Repository: mwouts/jupytext
Pull request #508 (2020-05-21 19:20:46)

Jupytext config file
This implements #384


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90be50fc..09e8a774 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 ------------------
 
 **Added**
+- Jupytext can use a local or global [configuration file](https://github.com/mwouts/jupytext/blob/master/docs/config.md) (#508)
 - Jupytext is tested in `pip` and `conda` environments, on Linux, Mac OS and Windows, using Github actions (#487)
 - Pre-commit checks and automatic reformatting of Jupytext's code with `pre-commit`, `black` and `flake8` (#483)
 - Groovy and Java are now supported, thanks to Przemek Wesołek's contribution (#500)
diff --git a/docs/config.md b/docs/config.md
new file mode 100644
index 00000000..deaab405
--- /dev/null
+++ b/docs/config.md
@@ -0,0 +1,95 @@
+## Per-notebook configuration
+
+The pairing information for one or multiple notebooks can be set on the command line:
+```
+jupytext --set-formats ipynb,py [--sync] notebook.ipynb
+```
+You can pair a notebook to as many text representations as you want (see our _World population_ notebook in the demo folder). Format specifications are of the form
+```
+[[path/][prefix]/][suffix.]ext[:format_name]
+```
+where
+- `ext` is one of `ipynb`, `md`, `Rmd`, `jl`, `py`, `R`, `sh`, `cpp`, `q`. Use the `auto` extension to have the script extension chosen according to the Jupyter kernel.
+- `format_name` (optional) is either `light` (default for scripts), `nomarker`, `percent`, `hydrogen`, `sphinx` (Python only), `spin` (R only) &mdash; see the [format specifications](formats.md).
+- `path`, `prefix` and `suffix` allow to save the text representation to files with different names, or in a different folder.
+
+If you want to pair a notebook to a python script in a subfolder named `scripts`, set the formats metadata to `ipynb,scripts//py`. If the notebook is in a `notebooks` folder and you want the text representation to be in a `scripts` folder at the same level, set the Jupytext formats to `notebooks//ipynb,scripts//py`.
+
+Jupytext accepts a few additional options. These options should be added to the `"jupytext"` section in the metadata &mdash; use either the metadata editor or the `--opt/--format-options` argument on the command line.
+- `comment_magics`: By default, Jupyter magics are commented when notebooks are exported to any other format than markdown. If you prefer otherwise, use this boolean option, or is global counterpart (see below).
+- `notebook_metadata_filter`: By default, Jupytext only exports the `kernelspec` and `jupytext` metadata to the text files. Set `"jupytext": {"notebook_metadata_filter": "-all"}` if you want that the script has no notebook metadata at all. The value for `notebook_metadata_filter` is a comma separated list of additional/excluded (negated) entries, with `all` a keyword that allows to exclude all entries. Use dots to filter recursively the metadata. For instance, use `notebook_metadata_filter="-jupytext.text_representation.jupytext_version"` to remove the `jupytext_version` field in the `jupytext.text_representation` metadata.
+- `cell_metadata_filter`: By default, cell metadata `autoscroll`, `collapsed`, `scrolled`, `trusted` and `ExecuteTime` are not included in the text representation. Add or exclude more cell metadata with this option.
+
+## Jupytext configuration file
+
+Jupytext's contents manager, and the command line interface, can load some configuration options
+from a configuration file.
+
+The configuration file should be either in the local or a parent directory, or in any directory listed in
+```python
+from jupytext.config import global_jupytext_configuration_directories
+global_jupytext_configuration_directories()
+```
+which include `XDG_CONFIG_HOME` (defaults to `$HOME/.config`) and `XDG_CONFIG_DIR`.
+
+The name for the configuration file can be any of `jupytext.config.JUPYTEXT_CONFIG_FILES`, i.e. `.jupytext` (in TOML), `jupytext.toml`, `jupytext.yml`, `jupytext.yaml`, `jupytext.json` or `jupytext.py`, and their dot-file versions.
+
+If you want to know, for a given directory, which configuration file Jupytext is using, please execute:
+```python
+from jupytext.config import find_jupytext_configuration_file
+find_jupytext_configuration_file('.')
+```
+
+If you want to limit the search for a configuration file to a given parent directory, you can create an empty `.jupytext` configuration file in that directory. Alternatively, you can set the search boundaries with an environment variable `JUPYTEXT_CEILING_DIRECTORIES` - a colon-separated list of absolute paths.
+
+If `JUPYTEXT_CEILING_DIRECTORIES` is defined, Jupytext will stop searching for configuration files when it meets one of these path. This can be helpful to avoid searching for configuration files on slow filesystems. It can also be useful if you don't want to use a global configuration - for instance, when running `pytest` on Jupytext, we use `JUPYTEXT_CEILING_DIRECTORIES="/tmp"`.
+
+## Configuring paired notebooks globally
+
+Say you want to always associate every `.ipynb` notebook with a `.md` file  (and reciprocally). This is done by adding the following to your `jupytext.toml` or `.jupytext.toml` Jupyter configuration file:
+```
+# Always pair ipynb notebooks to md files
+default_jupytext_formats = "ipynb,md"
+```
+
+If you prefer using a default `ipynb` - `py:percent` pairing, then that would be:
+```
+# Always pair ipynb notebooks to py:percent files
+default_jupytext_formats = "ipynb,py:percent"
+```
+
+And if you prefer to use `jupytext.yml`, in YAML format, as your configuration file, then you could use:
+```
+# Always pair ipynb notebooks to py:percent files
+default_jupytext_formats: "ipynb,py:percent"
+```
+
+To disable global pairing for an individual notebook, set formats to a single format, with e.g.:
+```bash
+jupytext --set-formats ipynb notebook.ipynb
+```
+
+## Metadata filtering
+
+You can specify which metadata to include or exclude in the text files created by Jupytext by setting `default_notebook_metadata_filter` (notebook metadata) and `default_cell_metadata_filter` (cell metadata) in the configuration file. They accept a string of comma separated keywords. A minus sign `-` in front of a keyword means exclusion.
+
+Suppose you want to keep all the notebook metadata but `widgets` and `varInspector` in the YAML header. For cell metadata, you want to allow `ExecuteTime` and `autoscroll`, but not `hide_output`. You can set
+```python
+default_notebook_metadata_filter = "all,-widgets,-varInspector"
+default_cell_metadata_filter = "ExecuteTime,autoscroll,-hide_output"
+```
+
+If you want that the text files created by Jupytext have no metadata, you may use the global metadata filters below. Please note that with this setting, the metadata is only preserved in the `.ipynb` file.
+```python
+default_notebook_metadata_filter = "-all"
+default_cell_metadata_filter = "-all"
+```
+
+It is possible to filter nested metadata. For example, if you want to preserve the Jupytext metadata, but not the Jupytext version number, you can use:
+```python
+default_notebook_metadata_filter = "-jupytext.text_representation.jupytext_version"
+```
+
+## More options
+
+There are a couple more options available - please have a look at the `JupytextConfiguration` class in [config.py](https://github.com/mwouts/jupytext/blob/master/jupytext/config.py).
diff --git a/docs/examples.md b/docs/examples.md
index 651c43f1..b9056d08 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -2,7 +2,7 @@
 
 ## Writing notebooks as plain text
 
-You like to work with scripts? The good news is that plain scripts, which you can draft and test in your favorite IDE, open transparently as notebooks in Jupyter when using Jupytext. Run the notebook in Jupyter to generate the outputs, [associate](using-server.html#paired-notebooks) an `.ipynb` representation, save and share your research as either a plain script or as a traditional Jupyter notebook with outputs.
+You like to work with scripts? The good news is that plain scripts, which you can draft and test in your favorite IDE, open transparently as notebooks in Jupyter when using Jupytext. Run the notebook in Jupyter to generate the outputs, [associate](config.html#paired-notebooks) an `.ipynb` representation, save and share your research as either a plain script or as a traditional Jupyter notebook with outputs.
 
 ## Collaborating on Jupyter Notebooks
 
@@ -10,7 +10,7 @@ With Jupytext, collaborating on Jupyter notebooks with Git becomes as easy as co
 
 The setup is straightforward:
 - Open your favorite notebook in Jupyter notebook
-- [Associate](using-server.html#paired-notebooks) a `.py` representation (for instance) to that notebook
+- [Associate](paired-notebooks.html#) a `.py` representation (for instance) to that notebook
 - Save the notebook, and put the Python script under Git control. Sharing the `.ipynb` file is possible, but not required.
 
 Collaborating then works as follows:
diff --git a/docs/faq.md b/docs/faq.md
index feaa2d2b..af90ad57 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -14,7 +14,7 @@ Open the notebook that you want to version control. _Pair_ the notebook to a scr
 
 Save the notebook, and you get two copies of the notebook: the original `*.ipynb` file, together with its paired text representation.
 
-Read more about how to use Jupytext in the [documentation](using-server.md).
+Read more about how to use Jupytext in the [documentation](config.md).
 
 ## Which Jupytext format do you recommend?
 
diff --git a/docs/index.md b/docs/index.md
index 33ccd223..4b90d4ab 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -7,8 +7,9 @@
 * [Introduction](introduction.md)
 * [Usecases for Jupytext](examples.md)
 * [Installation](install.md)
-* [Using Jupytext in Jupyter](using-server.md)
+* [Paired notebooks](paired-notebook.md)
 * [Using at the command line](using-cli.md)
+* [Format options and configuration](config.md)
 * [Using as a Python libary](using-library.md)
 * [Supported document formats](formats.md)
 * [Frequently Asked Questions](faq.md)
diff --git a/docs/paired-notebooks.md b/docs/paired-notebooks.md
new file mode 100644
index 00000000..16102e75
--- /dev/null
+++ b/docs/paired-notebooks.md
@@ -0,0 +1,42 @@
+# Paired notebooks
+
+Jupytext can write a given notebook to multiple files. In addition to the original notebook file, Jupytext can save the input cells to a text file &mdash; either a script or a Markdown document. Put the text file under version control for a clear commit history. Or refactor the paired script, and reimport the updated input cells by simply refreshing the notebook in Jupyter.
+
+Select the pairing for a given notebook using either the [Jupytext menu](install.html#jupytext-menu-in-jupyter-notebook) in Jupyter Notebook, or the [Jupytext commands](install.html#jupytext-commands-in-jupyterlab) in JupyterLab.
+
+These command simply add a `"jupytext": {"formats": "ipynb,md"}`-like entry in the notebook metadata. You could also set that metadata yourself with _Edit/Edit Notebook Metadata_ in Jupyter Notebook. In JupyterLab, use [this extension](https://github.com/yuvipanda/jupyterlab-nbmetadata).
+
+## Can I edit a notebook simultaneously in Jupyter and in a text editor?
+
+When saving a paired notebook using Jupytext's contents manager, Jupyter updates both the `.ipynb` and its text representation. The text representation can be edited outside of Jupyter. When the notebook is refreshed in Jupyter, the input cells are read from the text file, and the output cells from the `.ipynb` file.
+
+It is possible (and convenient) to leave the notebook open in Jupyter while you edit its text representation. However, you don't want that the two editors save the notebook simultaneously. To avoid this:
+- deactivate Jupyter's autosave, by either toggling the `"Autosave notebook"` menu entry or run `%autosave 0` in a cell of the notebook (see in the [faq](https://github.com/mwouts/jupytext/blob/master/docs/faq.md#jupyter-warns-me-that-the-file-has-changed-on-disk) how to deactivate autosave permanently)
+- and refresh the notebook when you switch back from the editor to Jupyter.
+
+In case you forgot to refresh, and saved the Jupyter notebook while the text representation had changed, no worries: Jupyter will ask you which version you want to keep:
+
+![](https://github.com/mwouts/jupytext-screenshots/raw/master/JupytextDocumentation/NotebookChanged.png)
+
+When that occurs, please choose the version in which you made the latest changes. And give a second look to our advice to deactivate the autosaving of notebooks in Jupyter.
+
+## How to open scripts with either the text or notebook view in Jupyter?
+
+With Jupytext's contents manager for Jupyter, scripts and Markdown documents gain a notebook icon. If you don't see the notebook icon, double check the [contents manager configuration](https://github.com/mwouts/jupytext/blob/master/README.md#jupytexts-contents-manager).
+
+By default, Jupyter Notebook open scripts and Markdown documents as notebooks. If you want to open them with the text editor, select the document and click on _edit_:
+
+![](https://github.com/mwouts/jupytext-screenshots/raw/master/JupytextDocumentation/OpenAsText.png)
+
+
+In JupyterLab this is slightly different. Scripts and Markdown document also have a notebook icon. But they open as text by default. Open them as notebooks with the  _Open With -> Notebook_ context menu (available in JupyterLab 0.35 and above):
+
+![](https://github.com/mwouts/jupytext-screenshots/raw/master/JupytextDocumentation/ContextMenuLab.png)
+
+If do not want to classify scripts or Markdown documents as notebooks, please use the `notebook_extension` option. For instance, if you want to get the notebook icon only for `.ipynb` and `.Rmd` files, set
+
+```python
+c.ContentsManager.notebook_extensions = "ipynb,Rmd"
+```
+
+Please note that, with the above setting, Jupyter will not let you open scripts as notebooks. If you still want to do so, use Jupytext command line (see below) to first convert or pair the script to an `.ipynb` notebook.
diff --git a/docs/using-cli.md b/docs/using-cli.md
index 19c10ef7..db534b6d 100644
--- a/docs/using-cli.md
+++ b/docs/using-cli.md
@@ -60,7 +60,7 @@ If you want to preserve (or filter out) certain notebook or cell metadata, chang
 jupytext --to md --update-metadata '{"jupytext": {"notebook_metadata_filter":"all"}}' notebook.ipynb
 ```
 
-Read more on the default and possible values for the metadata filters in [this section](using-server.html#metadata-filtering).
+Read more on the default and possible values for the metadata filters in [this section](config.html#metadata-filtering).
 
 
 ## Jupytext as a Git pre-commit hook
@@ -139,7 +139,7 @@ jupytext --test --update notebook.ipynb --to py:percent
 Note that `jupytext --test` compares the resulting notebooks according to its expectations. If you wish to proceed to a strict comparison of the two notebooks, use `jupytext --test-strict`, and use the flag `-x` to report with more details on the first difference, if any.
 
 Please note that
-- Scripts opened with Jupyter have a default [metadata filter](using-server.html#metadata-filtering) that prevents additional notebook or cell
+- Scripts opened with Jupyter have a default [metadata filter](config.html#metadata-filtering) that prevents additional notebook or cell
 metadata to be added back to the script. Remove the filter if you want to store Jupytext's settings, or the kernel information, in the text file.
 - Cell metadata are available in the `light` and `percent` formats, as well as in the Markdown and R Markdown formats. R scripts in `spin` format support cell metadata for code cells only. Sphinx Gallery scripts in `sphinx` format do not support cell metadata.
-- By default, a few cell metadata are not included in the text representation of the notebook. And only the most standard notebook metadata are exported. Learn more on this in the sections for [notebook specific](using-server.html#per-notebook-configuration) and [global settings](using-server.html#metadata-filtering) for metadata filtering.
+- By default, a few cell metadata are not included in the text representation of the notebook. And only the most standard notebook metadata are exported. Learn more on this in the sections for [notebook specific](config.html#per-notebook-configuration) and [global settings](config.html#metadata-filtering) for metadata filtering.
diff --git a/docs/using-server.md b/docs/using-server.md
deleted file mode 100644
index 07a0aa2e..00000000
--- a/docs/using-server.md
+++ /dev/null
@@ -1,113 +0,0 @@
-# Using Jupytext in Jupyter
-
-## Paired notebooks
-
-Jupytext can write a given notebook to multiple files. In addition to the original notebook file, Jupytext can save the input cells to a text file &mdash; either a script or a Markdown document. Put the text file under version control for a clear commit history. Or refactor the paired script, and reimport the updated input cells by simply refreshing the notebook in Jupyter.
-
-## Per-notebook configuration
-
-Select the pairing for a given notebook using either the [Jupytext menu](install.html#jupytext-menu-in-jupyter-notebook) in Jupyter Notebook, or the [Jupytext commands](install.html#jupytext-commands-in-jupyterlab) in JupyterLab.
-
-These command simply add a `"jupytext": {"formats": "ipynb,md"}`-like entry in the notebook metadata. You could also set that metadata yourself with _Edit/Edit Notebook Metadata_ in Jupyter Notebook. In JupyterLab, use [this extension](https://github.com/yuvipanda/jupyterlab-nbmetadata).
-
-The pairing information for one or multiple notebooks can be set on the command line:
-```
-jupytext --set-formats ipynb,py [--sync] notebook.ipynb
-```
-You can pair a notebook to as many text representations as you want (see our _World population_ notebook in the demo folder). Format specifications are of the form
-```
-[[path/][prefix]/][suffix.]ext[:format_name]
-```
-where
-- `ext` is one of `ipynb`, `md`, `Rmd`, `jl`, `py`, `R`, `sh`, `cpp`, `q`. Use the `auto` extension to have the script extension chosen according to the Jupyter kernel.
-- `format_name` (optional) is either `light` (default for scripts), `nomarker`, `percent`, `hydrogen`, `sphinx` (Python only), `spin` (R only) &mdash; see the [format specifications](formats.md).
-- `path`, `prefix` and `suffix` allow to save the text representation to files with different names, or in a different folder.
-
-If you want to pair a notebook to a python script in a subfolder named `scripts`, set the formats metadata to `ipynb,scripts//py`. If the notebook is in a `notebooks` folder and you want the text representation to be in a `scripts` folder at the same level, set the Jupytext formats to `notebooks//ipynb,scripts//py`.
-
-Jupytext accepts a few additional options. These options should be added to the `"jupytext"` section in the metadata &mdash; use either the metadata editor or the `--opt/--format-options` argument on the command line.
-- `comment_magics`: By default, Jupyter magics are commented when notebooks are exported to any other format than markdown. If you prefer otherwise, use this boolean option, or is global counterpart (see below).
-- `notebook_metadata_filter`: By default, Jupytext only exports the `kernelspec` and `jupytext` metadata to the text files. Set `"jupytext": {"notebook_metadata_filter": "-all"}` if you want that the script has no notebook metadata at all. The value for `notebook_metadata_filter` is a comma separated list of additional/excluded (negated) entries, with `all` a keyword that allows to exclude all entries. Use dots to filter recursively the metadata. For instance, use `notebook_metadata_filter="-jupytext.text_representation.jupytext_version"` to remove the `jupytext_version` field in the `jupytext.text_representation` metadata.
-- `cell_metadata_filter`: By default, cell metadata `autoscroll`, `collapsed`, `scrolled`, `trusted` and `ExecuteTime` are not included in the text representation. Add or exclude more cell metadata with this option.
-
-## Global configuration
-
-Jupytext's contents manager also accepts global options. These options have to be set on Jupytext's contents manager, so please first include the following line in your `.jupyter/jupyter_notebook_config.py` file:
-```python
-c.NotebookApp.contents_manager_class = "jupytext.TextFileContentsManager"
-```
-
-We start with the default format pairing. Say you want to always associate every `.ipynb` notebook with a `.md` file  (and reciprocally). This is simply done by adding the following to your Jupyter configuration file:
-```python
-# Always pair ipynb notebooks to md files
-c.ContentsManager.default_jupytext_formats = "ipynb,md"
-```
-(and similarly for the other formats).
-
-In case the [`percent`](formats.html#the-percent-format) format is your favorite, add the following to your `.jupyter/jupyter_notebook_config.py` file:
-```python
-# Use the percent format when saving as py
-c.ContentsManager.preferred_jupytext_formats_save = "py:percent"
-```
-and then, Jupytext will understand `"jupytext": {"formats": "ipynb,py"}` as an instruction to create the paired Python script in the `percent` format.
-
-To disable global pairing for an individual notebook, set formats to a single format, e.g.:
-`"jupytext": {"formats": "ipynb"}`
-
-## Metadata filtering
-
-You can specify which metadata to include or exclude in the text files created by Jupytext by setting `c.ContentsManager.default_notebook_metadata_filter` (notebook metadata) and `c.ContentsManager.default_cell_metadata_filter` (cell metadata). They accept a string of comma separated keywords. A minus sign `-` in front of a keyword means exclusion.
-
-Suppose you want to keep all the notebook metadata but `widgets` and `varInspector` in the YAML header. For cell metadata, you want to allow `ExecuteTime` and `autoscroll`, but not `hide_output`. You can set
-```python
-c.ContentsManager.default_notebook_metadata_filter = "all,-widgets,-varInspector"
-c.ContentsManager.default_cell_metadata_filter = "ExecuteTime,autoscroll,-hide_output"
-```
-
-If you want that the text files created by Jupytext have no metadata, you may use the global metadata filters below. Please note that with this setting, the metadata is only preserved in the `.ipynb` file.
-```python
-c.ContentsManager.default_notebook_metadata_filter = "-all"
-c.ContentsManager.default_cell_metadata_filter = "-all"
-```
-
-It is possible to filter nested metadata. For example, if you want to preserve the Jupytext metadata, but not the Jupytext version number, you can use:
-```python
-c.ContentsManager.default_notebook_metadata_filter = "-jupytext.text_representation.jupytext_version"
-```
-
-NB: All these global options (and more) are documented [here](https://github.com/mwouts/jupytext/blob/master/jupytext/contentsmanager.py).
-
-## Can I edit a notebook simultaneously in Jupyter and in a text editor?
-
-When saving a paired notebook using Jupytext's contents manager, Jupyter updates both the `.ipynb` and its text representation. The text representation can be edited outside of Jupyter. When the notebook is refreshed in Jupyter, the input cells are read from the text file, and the output cells from the `.ipynb` file.
-
-It is possible (and convenient) to leave the notebook open in Jupyter while you edit its text representation. However, you don't want that the two editors save the notebook simultaneously. To avoid this:
-- deactivate Jupyter's autosave, by either toggling the `"Autosave notebook"` menu entry or run `%autosave 0` in a cell of the notebook (see in the [faq](https://github.com/mwouts/jupytext/blob/master/docs/faq.md#jupyter-warns-me-that-the-file-has-changed-on-disk) how to deactivate autosave permanently)
-- and refresh the notebook when you switch back from the editor to Jupyter.
-
-In case you forgot to refresh, and saved the Jupyter notebook while the text representation had changed, no worries: Jupyter will ask you which version you want to keep:
-
-![](https://github.com/mwouts/jupytext-screenshots/raw/master/JupytextDocumentation/NotebookChanged.png)
-
-When that occurs, please choose the version in which you made the latest changes. And give a second look to our advice to deactivate the autosaving of notebooks in Jupyter.
-
-## How to open scripts with either the text or notebook view in Jupyter?
-
-With Jupytext's contents manager for Jupyter, scripts and Markdown documents gain a notebook icon. If you don't see the notebook icon, double check the [contents manager configuration](https://github.com/mwouts/jupytext/blob/master/README.md#jupytexts-contents-manager).
-
-By default, Jupyter Notebook open scripts and Markdown documents as notebooks. If you want to open them with the text editor, select the document and click on _edit_:
-
-![](https://github.com/mwouts/jupytext-screenshots/raw/master/JupytextDocumentation/OpenAsText.png)
-
-
-In JupyterLab this is slightly different. Scripts and Markdown document also have a notebook icon. But they open as text by default. Open them as notebooks with the  _Open With -> Notebook_ context menu (available in JupyterLab 0.35 and above):
-
-![](https://github.com/mwouts/jupytext-screenshots/raw/master/JupytextDocumentation/ContextMenuLab.png)
-
-If do not want to classify scripts or Markdown documents as notebooks, please use the `notebook_extension` option. For instance, if you want to get the notebook icon only for `.ipynb` and `.Rmd` files, set
-
-```python
-c.ContentsManager.notebook_extensions = "ipynb,Rmd"
-```
-
-Please note that, with the above setting, Jupyter will not let you open scripts as notebooks. If you still want to do so, use Jupytext command line (see below) to first convert or pair the script to an `.ipynb` notebook.
diff --git a/environment.yml b/environment.yml
index 7f388f04..f6e7bcdc 100644
--- a/environment.yml
+++ b/environment.yml
@@ -7,6 +7,7 @@ dependencies:
   - jupyter
   - jupyterlab
   - pyyaml
+  - toml
   - nbconvert
   - jupyter_contrib_nbextensions
   - pytest
diff --git a/jupytext/cli.py b/jupytext/cli.py
index 8ab52b0d..c745fcf0 100644
--- a/jupytext/cli.py
+++ b/jupytext/cli.py
@@ -20,9 +20,17 @@ from .formats import (
 )
 from .languages import _SCRIPT_EXTENSIONS
 from .header import recursive_update
-from .paired_paths import paired_paths, base_path, full_path, InconsistentPath
+from .paired_paths import (
+    paired_paths,
+    base_path,
+    full_path,
+    InconsistentPath,
+    find_base_path_and_format,
+)
+from .pairs import write_pair, latest_inputs_and_outputs, read_pair
 from .combine import combine_inputs_with_outputs
 from .compare import test_round_trip_conversion, NotebookDifference, compare
+from .config import load_jupytext_config, prepare_notebook_for_save
 from .kernels import kernelspec_from_language, find_kernel_specs, get_kernel_spec
 from .reraise import reraise
 from .version import __version__
@@ -370,6 +378,7 @@ def jupytext(args=None):
     notebooks = []
     for pattern in args.notebooks:
         if "*" in pattern or "?" in pattern:
+            # Exclude the .jupytext.py configuration file
             notebooks.extend(glob.glob(pattern))
         else:
             notebooks.append(pattern)
@@ -403,6 +412,8 @@ def jupytext_single_file(nb_file, args, log):
         )
     )
 
+    config = load_jupytext_config(os.path.abspath(nb_file))
+
     # Just acting on metadata / pipe => save in place
     if not nb_dest and not args.sync:
         nb_dest = nb_file
@@ -412,6 +423,8 @@ def jupytext_single_file(nb_file, args, log):
 
     # I. ### Read the notebook ###
     fmt = copy(args.input_format) or {}
+    if config:
+        config.set_default_format_options(fmt)
     set_format_options(fmt, args.format_options)
     log(
         "[jupytext] Reading {}{}".format(
@@ -437,6 +450,11 @@ def jupytext_single_file(nb_file, args, log):
         elif ext:
             fmt = {"extension": ext}
 
+    if config and "formats" not in notebook.metadata.get("jupytext", {}):
+        default_formats = config.default_formats(nb_file)
+        if default_formats:
+            notebook.metadata.setdefault("jupytext", {})["formats"] = default_formats
+
     # Compute actual extension when using script/auto, and update nb_dest if necessary
     dest_fmt = args.to
     if dest_fmt and dest_fmt["extension"] == ".auto":
@@ -540,6 +558,7 @@ def jupytext_single_file(nb_file, args, log):
         log("[jupytext] Executing notebook with kernel {}".format(kernel_name))
         exec_proc = ExecutePreprocessor(timeout=None, kernel_name=kernel_name)
         if nb_dest is not None and nb_dest != "-":
+
             resources = {"metadata": {"path": str(os.path.dirname(nb_dest))}}
         elif nb_file != "-":
             resources = {"metadata": {"path": str(os.path.dirname(nb_file))}}
@@ -638,22 +657,19 @@ def jupytext_single_file(nb_file, args, log):
         # Also update the original notebook if the notebook was modified
         if modified:
             inputs_nb_file = outputs_nb_file = None
-        formats = notebook.metadata["jupytext"]["formats"]
-
-        for ipynb in [True, False]:
-            # Write first format last so that it is the most recent file
-            for alt_path, alt_fmt in paired_paths(nb_file, fmt, formats)[::-1]:
-                # Write ipynb first for compatibility with our contents manager
-                if alt_path.endswith(".ipynb") != ipynb:
-                    continue
-                # Do not write the ipynb file if it was not modified
-                # But, always write text representations to make sure they are the most recent
-                if alt_path == inputs_nb_file and alt_path == outputs_nb_file:
-                    continue
-                log("[jupytext] Updating '{}'".format(alt_path))
-                write(notebook, alt_path, fmt=alt_fmt)
-                if args.pre_commit:
-                    system("git", "add", alt_path)
+
+        def write_function(path, fmt):
+            # Do not write the ipynb file if it was not modified
+            # But, always write text representations to make sure they are the most recent
+            if path == inputs_nb_file and path == outputs_nb_file:
+                return
+            log("[jupytext] Updating '{}'".format(path))
+            write(notebook, path, fmt=fmt)
+            if args.pre_commit:
+                system("git", "add", path)
+
+        formats = prepare_notebook_for_save(notebook, config, nb_file)
+        write_pair(nb_file, formats, write_function)
     elif (
         os.path.isfile(nb_file)
         and nb_dest.endswith(".ipynb")
@@ -750,38 +766,26 @@ def load_paired_notebook(notebook, fmt, nb_file, log):
     if not formats:
         raise NotAPairedNotebook("'{}' is not a paired notebook".format(nb_file))
 
-    max_mtime_inputs = None
-    max_mtime_outputs = None
-    latest_inputs = None
-    latest_outputs = None
-    for alt_path, alt_fmt in paired_paths(nb_file, fmt, formats):
-        if not os.path.isfile(alt_path):
-            continue
-        info = os.lstat(alt_path)
-        if not max_mtime_inputs or info.st_mtime > max_mtime_inputs:
-            max_mtime_inputs = info.st_mtime
-            latest_inputs, input_fmt = alt_path, alt_fmt
-
-        if alt_path.endswith(".ipynb"):
-            if not max_mtime_outputs or info.st_mtime > max_mtime_outputs:
-                max_mtime_outputs = info.st_mtime
-                latest_outputs = alt_path
-
-    if latest_outputs and latest_outputs != latest_inputs:
-        log("[jupytext] Loading input cells from '{}'".format(latest_inputs))
-        inputs = (
-            notebook if latest_inputs == nb_file else read(latest_inputs, fmt=input_fmt)
-        )
-        check_file_version(inputs, latest_inputs, latest_outputs)
-        log("[jupytext] Loading output cells from '{}'".format(latest_outputs))
-        outputs = notebook if latest_outputs == nb_file else read(latest_outputs)
-        combine_inputs_with_outputs(inputs, outputs, fmt=input_fmt)
-        return inputs, latest_inputs, latest_outputs
-
-    log("[jupytext] Loading notebook from '{}'".format(latest_inputs))
-    if latest_inputs != nb_file:
-        notebook = read(latest_inputs, fmt=input_fmt)
-    return notebook, latest_inputs, latest_outputs
+    formats = long_form_multiple_formats(formats)
+    _, fmt_with_prefix_suffix = find_base_path_and_format(nb_file, formats)
+    fmt.update(fmt_with_prefix_suffix)
+
+    def get_timestamp(path):
+        if not os.path.isfile(path):
+            return None
+        return os.lstat(path).st_mtime
+
+    def read_one_file(path, fmt):
+        if path == nb_file:
+            return notebook
+
+        log("[jupytext] Loading '{}'".format(path))
+        return read(path, fmt=fmt)
+
+    inputs, outputs = latest_inputs_and_outputs(nb_file, fmt, formats, get_timestamp)
+    notebook = read_pair(inputs, outputs, read_one_file)
+
+    return notebook, inputs.path, outputs.path
 
 
 def exec_command(command, input=None, capture=False):
diff --git a/jupytext/config.py b/jupytext/config.py
new file mode 100644
index 00000000..acd177c0
--- /dev/null
+++ b/jupytext/config.py
@@ -0,0 +1,304 @@
+"""Find and read Jupytext configuration files"""
+import os
+import yaml
+from traitlets import Unicode, Float, Bool, Enum
+from traitlets.config import Configurable
+from traitlets.config.loader import JSONFileConfigLoader, PyFileConfigLoader
+from .formats import (
+    NOTEBOOK_EXTENSIONS,
+    long_form_one_format,
+    long_form_multiple_formats,
+    rearrange_jupytext_metadata,
+)
+from .paired_paths import (
+    base_path,
+    InconsistentPath,
+)
+
+JUPYTEXT_CONFIG_FILES = [
+    "jupytext",
+    "jupytext.toml",
+    "jupytext.yml",
+    "jupytext.yaml",
+    "jupytext.json",
+    "jupytext.py",
+]
+
+JUPYTEXT_CONFIG_FILES.extend(["." + filename for filename in JUPYTEXT_CONFIG_FILES])
+JUPYTEXT_CEILING_DIRECTORIES = [
+    path
+    for path in os.environ.get("JUPYTEXT_CEILING_DIRECTORIES", "").split(":")
+    if path
+]
+
+
+class JupytextConfiguration(Configurable):
+    """Jupytext Configuration's options"""
+
+    default_jupytext_formats = Unicode(
+        u"",
+        help="Save notebooks to these file extensions. "
+        "Can be any of ipynb,Rmd,md,jl,py,R,nb.jl,nb.py,nb.R "
+        "comma separated. If you want another format than the "
+        "default one, append the format name to the extension, "
+        "e.g. ipynb,py:percent to save the notebook to "
+        "hydrogen/spyder/vscode compatible scripts",
+        config=True,
+    )
+
+    preferred_jupytext_formats_save = Unicode(
+        u"",
+        help="Preferred format when saving notebooks as text, per extension. "
+        'Use "jl:percent,py:percent,R:percent" if you want to save '
+        "Julia, Python and R scripts in the double percent format and "
+        'only write "jupytext_formats": "py" in the notebook metadata.',
+        config=True,
+    )
+
+    preferred_jupytext_formats_read = Unicode(
+        u"",
+        help="Preferred format when reading notebooks from text, per "
+        'extension. Use "py:sphinx" if you want to read all python '
+        "scripts as Sphinx gallery scripts.",
+        config=True,
+    )
+
+    default_notebook_metadata_filter = Unicode(
+        u"",
+        help="Cell metadata that should be save in the text representations. "
+        "Examples: 'all', '-all', 'widgets,nteract', 'kernelspec,jupytext-all'",
+        config=True,
+    )
+
+    default_cell_metadata_filter = Unicode(
+        u"",
+        help="Notebook metadata that should be saved in the text representations. "
+        "Examples: 'all', 'hide_input,hide_output'",
+        config=True,
+    )
+
+    comment_magics = Enum(
+        values=[True, False],
+        allow_none=True,
+        help="Should Jupyter magic commands be commented out in the text representation?",
+        config=True,
+    )
+
+    split_at_heading = Bool(
+        False,
+        help="Split markdown cells on headings (Markdown and R Markdown formats only)",
+        config=True,
+    )
+
+    sphinx_convert_rst2md = Bool(
+        False,
+        help="When opening a Sphinx Gallery script, convert the reStructuredText to markdown",
+        config=True,
+    )
+
+    outdated_text_notebook_margin = Float(
+        1.0,
+        help="Refuse to overwrite inputs of a ipynb notebooks with those of a "
+        "text notebook when the text notebook plus margin is older than "
+        "the ipynb notebook (NB: This option is ignored by Jupytext CLI)",
+        config=True,
+    )
+
+    default_cell_markers = Unicode(
+        u"",
+        help='Start and end cell markers for the light format, comma separated. Use "{{{,}}}" to mark cells'
+        'as foldable regions in Vim, and "region,endregion" to mark cells as Vscode/PyCharm regions',
+        config=True,
+    )
+
+    notebook_extensions = Unicode(
+        u",".join(NOTEBOOK_EXTENSIONS),
+        help="A comma separated list of notebook extensions",
+        config=True,
+    )
+
+    def set_default_format_options(self, format_options, read=False):
+        """Set default format option"""
+        if self.default_notebook_metadata_filter:
+            format_options.setdefault(
+                "notebook_metadata_filter", self.default_notebook_metadata_filter
+            )
+        if self.default_cell_metadata_filter:
+            format_options.setdefault(
+                "cell_metadata_filter", self.default_cell_metadata_filter
+            )
+        if self.comment_magics is not None:
+            format_options.setdefault("comment_magics", self.comment_magics)
+        if self.split_at_heading:
+            format_options.setdefault("split_at_heading", self.split_at_heading)
+        if not read and self.default_cell_markers:
+            format_options.setdefault("cell_markers", self.default_cell_markers)
+        if read and self.sphinx_convert_rst2md:
+            format_options.setdefault("rst2md", self.sphinx_convert_rst2md)
+
+    def default_formats(self, path):
+        """Return the default formats, if they apply to the current path #157"""
+        formats = long_form_multiple_formats(self.default_jupytext_formats)
+        for fmt in formats:
+            try:
+                base_path(path, fmt)
+                return self.default_jupytext_formats
+            except InconsistentPath:
+                continue
+
+        return None
+
+
+def preferred_format(incomplete_format, preferred_formats):
+    """Return the preferred format for the given extension"""
+    incomplete_format = long_form_one_format(incomplete_format)
+    if "format_name" in incomplete_format:
+        return incomplete_format
+
+    for fmt in long_form_multiple_formats(preferred_formats):
+        if (
+            (
+                incomplete_format["extension"] == fmt["extension"]
+                or (
+                    fmt["extension"] == ".auto"
+                    and incomplete_format["extension"]
+                    not in [".md", ".markdown", ".Rmd", ".ipynb"]
+                )
+            )
+            and incomplete_format.get("suffix")
+            == fmt.get("suffix", incomplete_format.get("suffix"))
+            and incomplete_format.get("prefix")
+            == fmt.get("prefix", incomplete_format.get("prefix"))
+        ):
+            fmt.update(incomplete_format)
+            return fmt
+
+    return incomplete_format
+
+
+def global_jupytext_configuration_directories():
+    """Return the directories in which Jupytext will search for a configuration file"""
+
+    config_dirs = []
+
+    if "XDG_CONFIG_HOME" in os.environ:
+        config_dirs.extend(os.environ["XDG_CONFIG_HOME"].split(":"))
+    elif "HOME" in os.environ:
+        config_dirs.append(os.path.join(os.environ["HOME"], ".config"))
+        config_dirs.append(os.environ["HOME"])
+
+    if "XDG_CONFIG_DIRS" in os.environ:
+        config_dirs.extend(os.environ["XDG_CONFIG_DIRS"].split(":"))
+    else:
+        config_dirs.extend(["/usr/local/share/", "/usr/share/"])
+
+    for config_dir in config_dirs:
+        for config_dir_jupytext_or_not in [
+            os.path.join(config_dir, "jupytext"),
+            config_dir,
+        ]:
+            yield config_dir_jupytext_or_not
+
+
+def find_global_jupytext_configuration_file():
+    """Return the global Jupytext configuration file, if any"""
+
+    for config_dir in global_jupytext_configuration_directories():
+        config_file = find_jupytext_configuration_file(config_dir, False)
+        if config_file:
+            return config_file
+
+    return None
+
+
+def find_jupytext_configuration_file(path, search_parent_dirs=True):
+    """Return the first jupytext configuration file in the current directory, or any parent directory"""
+    if os.path.isdir(path):
+        for filename in JUPYTEXT_CONFIG_FILES:
+            full_path = os.path.join(path, filename)
+            if os.path.isfile(full_path):
+                return full_path
+
+    if not search_parent_dirs:
+        return None
+
+    if JUPYTEXT_CEILING_DIRECTORIES and os.path.isdir(path):
+        for ceiling_dir in JUPYTEXT_CEILING_DIRECTORIES:
+            if os.path.isdir(ceiling_dir) and os.path.samefile(path, ceiling_dir):
+                return None
+
+    parent_dir = os.path.dirname(path)
+    if parent_dir == path:
+        return find_global_jupytext_configuration_file()
+
+    return find_jupytext_configuration_file(parent_dir)
+
+
+def load_jupytext_configuration_file(jupytext_config_file):
+    """Read a Jupytext config file, and return a JupytextConfig object"""
+
+    if jupytext_config_file.endswith((".toml", "jupytext")):
+        import toml
+
+        config = toml.load(jupytext_config_file)
+        return JupytextConfiguration(**config)
+
+    if jupytext_config_file.endswith((".yml", ".yaml")):
+        with open(jupytext_config_file) as stream:
+            config = yaml.safe_load(stream)
+        return JupytextConfiguration(**config)
+
+    if jupytext_config_file.endswith(".py"):
+        return JupytextConfiguration(
+            **PyFileConfigLoader(jupytext_config_file).load_config()
+        )
+
+    return JupytextConfiguration(
+        **JSONFileConfigLoader(jupytext_config_file).load_config()
+    )
+
+
+def load_jupytext_config(nb_file):
+    """Return the jupytext configuration file in the same folder, or in a parent folder, of the current file, if any"""
+    config_file = find_jupytext_configuration_file(nb_file)
+    if config_file is None:
+        return None
+    if os.path.isfile(nb_file) and os.path.samefile(config_file, nb_file):
+        return None
+    return load_jupytext_configuration_file(config_file)
+
+
+def prepare_notebook_for_save(nbk, config, path):
+    """Apply the given configuration to the notebook before saving it"""
+    metadata = nbk.get("metadata")
+    rearrange_jupytext_metadata(metadata)
+    jupytext_metadata = metadata.setdefault("jupytext", {})
+    formats = jupytext_metadata.get("formats") or (
+        config.default_formats(path) if config else None
+    )
+
+    if not formats:
+        text_repr = jupytext_metadata.get("text_representation", {})
+        ext = os.path.splitext(path)[1]
+        fmt = {"extension": ext}
+
+        if ext == text_repr.get("extension") and text_repr.get("format_name"):
+            fmt["format_name"] = text_repr.get("format_name")
+
+        formats = [fmt]
+
+    formats = long_form_multiple_formats(
+        formats, metadata, auto_ext_requires_language_info=False
+    )
+
+    # Set preferred formats if not format name is given yet
+    if config:
+        formats = [
+            preferred_format(f, config.preferred_jupytext_formats_save) for f in formats
+        ]
+        config.set_default_format_options(jupytext_metadata)
+
+    if not jupytext_metadata:
+        metadata.pop("jupytext")
+
+    return formats
diff --git a/jupytext/contentsmanager.py b/jupytext/contentsmanager.py
index 407d9ec4..c86c15db 100644
--- a/jupytext/contentsmanager.py
+++ b/jupytext/contentsmanager.py
@@ -1,7 +1,8 @@
 """ContentsManager that allows to open Rmd, py, R and ipynb files as notebooks
 """
 import os
-from datetime import timedelta
+from datetime import timedelta, datetime
+from collections import namedtuple
 import nbformat
 
 try:
@@ -9,8 +10,6 @@ try:
 except ImportError:
     import mock
 from tornado.web import HTTPError
-from traitlets import Unicode, Float, Bool, Enum
-from traitlets.config import Configurable
 
 # import notebook.transutils before notebook.services.contents.filemanager #75
 try:
@@ -20,13 +19,7 @@ except ImportError:
 
 from .jupytext import reads, writes
 from .jupytext import create_prefix_dir as create_prefix_dir_from_path
-from .combine import combine_inputs_with_outputs
-from .formats import rearrange_jupytext_metadata, check_file_version
-from .formats import (
-    NOTEBOOK_EXTENSIONS,
-    long_form_one_format,
-    long_form_multiple_formats,
-)
+from .formats import long_form_multiple_formats
 from .formats import short_form_one_format, short_form_multiple_formats
 from .paired_paths import (
     paired_paths,
@@ -35,34 +28,14 @@ from .paired_paths import (
     full_path,
     InconsistentPath,
 )
+from .pairs import write_pair, read_pair, latest_inputs_and_outputs
 from .kernels import set_kernelspec_from_language
-
-
-def preferred_format(incomplete_format, preferred_formats):
-    """Return the preferred format for the given extension"""
-    incomplete_format = long_form_one_format(incomplete_format)
-    if "format_name" in incomplete_format:
-        return incomplete_format
-
-    for fmt in long_form_multiple_formats(preferred_formats):
-        if (
-            (
-                incomplete_format["extension"] == fmt["extension"]
-                or (
-                    fmt["extension"] == ".auto"
-                    and incomplete_format["extension"]
-                    not in [".md", ".markdown", ".Rmd", ".ipynb"]
-                )
-            )
-            and incomplete_format.get("suffix")
-            == fmt.get("suffix", incomplete_format.get("suffix"))
-            and incomplete_format.get("prefix")
-            == fmt.get("prefix", incomplete_format.get("prefix"))
-        ):
-            fmt.update(incomplete_format)
-            return fmt
-
-    return incomplete_format
+from .config import (
+    JupytextConfiguration,
+    preferred_format,
+    load_jupytext_config,
+    prepare_notebook_for_save,
+)
 
 
 def _jupytext_writes(fmt):
@@ -82,15 +55,22 @@ def _jupytext_reads(fmt):
 def build_jupytext_contents_manager_class(base_contents_manager_class):
     """Derives a TextFileContentsManager class from the given base class"""
 
-    class JupytextContentsManager(base_contents_manager_class, Configurable):
+    class JupytextContentsManager(base_contents_manager_class, JupytextConfiguration):
         """
         A FileContentsManager Class that reads and stores notebooks to classical
         Jupyter notebooks (.ipynb), R Markdown notebooks (.Rmd), Julia (.jl),
         Python (.py) or R scripts (.R)
         """
 
-        # Dictionary: notebook path => (fmt, formats) where fmt is the current format, and formats the paired formats.
-        paired_notebooks = dict()
+        def __init__(self, **kwargs):
+            # Dictionary: notebook path => (fmt, formats) where
+            # fmt is the current format, and formats the paired formats.
+            self.paired_notebooks = dict()
+
+            # Configuration cache, useful when notebooks are listed in a given directory
+            self.cached_config = namedtuple("cached_config", "path timestamp config")
+
+            super(JupytextContentsManager, self).__init__(**kwargs)
 
         def all_nb_extensions(self):
             """All extensions that should be classified as notebooks"""
@@ -99,88 +79,6 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                 for ext in self.notebook_extensions.split(",")
             ]
 
-        default_jupytext_formats = Unicode(
-            u"",
-            help="Save notebooks to these file extensions. "
-            "Can be any of ipynb,Rmd,md,jl,py,R,nb.jl,nb.py,nb.R "
-            "comma separated. If you want another format than the "
-            "default one, append the format name to the extension, "
-            "e.g. ipynb,py:percent to save the notebook to "
-            "hydrogen/spyder/vscode compatible scripts",
-            config=True,
-        )
-
-        preferred_jupytext_formats_save = Unicode(
-            u"",
-            help="Preferred format when saving notebooks as text, per extension. "
-            'Use "jl:percent,py:percent,R:percent" if you want to save '
-            "Julia, Python and R scripts in the double percent format and "
-            'only write "jupytext_formats": "py" in the notebook metadata.',
-            config=True,
-        )
-
-        preferred_jupytext_formats_read = Unicode(
-            u"",
-            help="Preferred format when reading notebooks from text, per "
-            'extension. Use "py:sphinx" if you want to read all python '
-            "scripts as Sphinx gallery scripts.",
-            config=True,
-        )
-
-        default_notebook_metadata_filter = Unicode(
-            u"",
-            help="Cell metadata that should be save in the text representations. "
-            "Examples: 'all', '-all', 'widgets,nteract', 'kernelspec,jupytext-all'",
-            config=True,
-        )
-
-        default_cell_metadata_filter = Unicode(
-            u"",
-            help="Notebook metadata that should be saved in the text representations. "
-            "Examples: 'all', 'hide_input,hide_output'",
-            config=True,
-        )
-
-        comment_magics = Enum(
-            values=[True, False],
-            allow_none=True,
-            help="Should Jupyter magic commands be commented out in the text representation?",
-            config=True,
-        )
-
-        split_at_heading = Bool(
-            False,
-            help="Split markdown cells on headings (Markdown and R Markdown formats only)",
-            config=True,
-        )
-
-        sphinx_convert_rst2md = Bool(
-            False,
-            help="When opening a Sphinx Gallery script, convert the reStructuredText to markdown",
-            config=True,
-        )
-
-        outdated_text_notebook_margin = Float(
-            1.0,
-            help="Refuse to overwrite inputs of a ipynb notebooks with those of a "
-            "text notebook when the text notebook plus margin is older than "
-            "the ipynb notebook",
-            config=True,
-        )
-
-        default_cell_markers = Unicode(
-            u"",
-            help='Start and end cell markers for the light format, comma separated. Use "{{{,}}}" to mark cells'
-            'as foldable regions in Vim, and "region,endregion" to mark cells as Vscode/PyCharm regions',
-            config=True,
-        )
-
-        notebook_extensions = Unicode(
-            u",".join(NOTEBOOK_EXTENSIONS),
-            help="A comma separated list of notebook extensions",
-            config=True,
-        )
-
         def drop_paired_notebook(self, path):
             """Remove the current notebook from the list of paired notebooks"""
             if path not in self.paired_notebooks:
@@ -192,18 +90,19 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                 if alt_path in self.paired_notebooks:
                     self.drop_paired_notebook(alt_path)
 
-        def update_paired_notebooks(self, path, fmt, formats):
+        def update_paired_notebooks(self, path, formats):
             """Update the list of paired notebooks to include/update the current pair"""
             if not formats:
                 self.drop_paired_notebook(path)
                 return
 
+            formats = long_form_multiple_formats(formats)
+            _, fmt = find_base_path_and_format(path, formats)
             new_paired_paths = paired_paths(path, fmt, formats)
             for alt_path, _ in new_paired_paths:
                 self.drop_paired_notebook(alt_path)
 
-            long_formats = long_form_multiple_formats(formats)
-            if len(long_formats) == 1 and set(long_formats[0]) <= {"extension"}:
+            if len(formats) == 1 and set(formats[0]) <= {"extension"}:
                 return
 
             short_formats = short_form_multiple_formats(formats)
@@ -213,37 +112,6 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                     short_formats,
                 )
 
-        def set_default_format_options(self, format_options, read=False):
-            """Set default format option"""
-            if self.default_notebook_metadata_filter:
-                format_options.setdefault(
-                    "notebook_metadata_filter", self.default_notebook_metadata_filter
-                )
-            if self.default_cell_metadata_filter:
-                format_options.setdefault(
-                    "cell_metadata_filter", self.default_cell_metadata_filter
-                )
-            if self.comment_magics is not None:
-                format_options.setdefault("comment_magics", self.comment_magics)
-            if self.split_at_heading:
-                format_options.setdefault("split_at_heading", self.split_at_heading)
-            if not read and self.default_cell_markers:
-                format_options.setdefault("cell_markers", self.default_cell_markers)
-            if read and self.sphinx_convert_rst2md:
-                format_options.setdefault("rst2md", self.sphinx_convert_rst2md)
-
-        def default_formats(self, path):
-            """Return the default formats, if they apply to the current path #157"""
-            formats = long_form_multiple_formats(self.default_jupytext_formats)
-            for fmt in formats:
-                try:
-                    base_path(path, fmt)
-                    return self.default_jupytext_formats
-                except InconsistentPath:
-                    continue
-
-            return None
-
         def create_prefix_dir(self, path, fmt):
             """Create the prefix dir, if missing"""
             create_prefix_dir_from_path(self._get_os_path(path.strip("/")), fmt)
@@ -256,66 +124,11 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
             path = path.strip("/")
             nbk = model["content"]
             try:
-                metadata = nbk.get("metadata")
-                rearrange_jupytext_metadata(metadata)
-                jupytext_metadata = metadata.setdefault("jupytext", {})
-                jupytext_formats = jupytext_metadata.get(
-                    "formats"
-                ) or self.default_formats(path)
-
-                if not jupytext_formats:
-                    text_representation = jupytext_metadata.get(
-                        "text_representation", {}
-                    )
-                    ext = os.path.splitext(path)[1]
-                    fmt = {"extension": ext}
-
-                    if ext == text_representation.get(
-                        "extension"
-                    ) and text_representation.get("format_name"):
-                        fmt["format_name"] = text_representation.get("format_name")
-
-                    jupytext_formats = [fmt]
+                config = self.get_config(path)
+                jupytext_formats = prepare_notebook_for_save(nbk, config, path)
+                self.update_paired_notebooks(path, jupytext_formats)
 
-                jupytext_formats = long_form_multiple_formats(
-                    jupytext_formats, metadata, auto_ext_requires_language_info=False
-                )
-
-                # Set preferred formats if not format name is given yet
-                jupytext_formats = [
-                    preferred_format(f, self.preferred_jupytext_formats_save)
-                    for f in jupytext_formats
-                ]
-
-                base, fmt = find_base_path_and_format(path, jupytext_formats)
-                self.update_paired_notebooks(path, fmt, jupytext_formats)
-                self.set_default_format_options(jupytext_metadata)
-
-                if not jupytext_metadata:
-                    metadata.pop("jupytext")
-
-                # Save as ipynb first
-                return_value = None
-                value = None
-                for fmt in jupytext_formats[::-1]:
-                    if fmt["extension"] != ".ipynb":
-                        continue
-
-                    alt_path = full_path(base, fmt)
-                    self.create_prefix_dir(alt_path, fmt)
-                    self.log.info("Saving %s", os.path.basename(alt_path))
-                    value = super(JupytextContentsManager, self).save(model, alt_path)
-                    if alt_path == path:
-                        return_value = value
-
-                # And then to the other formats, in reverse order so that
-                # the first format is the most recent
-                for fmt in jupytext_formats[::-1]:
-                    if fmt["extension"] == ".ipynb":
-                        continue
-
-                    alt_path = full_path(base, fmt)
-                    self.create_prefix_dir(alt_path, fmt)
+                def save_one_file(path, fmt):
                     if "format_name" in fmt and fmt["extension"] not in [
                         ".md",
                         ".markdown",
@@ -323,22 +136,21 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                     ]:
                         self.log.info(
                             "Saving %s in format %s:%s",
-                            os.path.basename(alt_path),
+                            os.path.basename(path),
                             fmt["extension"][1:],
                             fmt["format_name"],
                         )
                     else:
-                        self.log.info("Saving %s", os.path.basename(alt_path))
+                        self.log.info("Saving %s", os.path.basename(path))
+
+                    self.create_prefix_dir(path, fmt)
+                    if fmt["extension"] == ".ipynb":
+                        return super(JupytextContentsManager, self).save(model, path)
+
                     with mock.patch("nbformat.writes", _jupytext_writes(fmt)):
-                        value = super(JupytextContentsManager, self).save(
-                            model, alt_path
-                        )
-                        if alt_path == path:
-                            return_value = value
+                        return super(JupytextContentsManager, self).save(model, path)
 
-                # Update modified timestamp to match that of the pair #207
-                return_value["last_modified"] = value["last_modified"]
-                return return_value
+                return write_pair(path, jupytext_formats, save_one_file)
 
             except Exception as err:
                 raise HTTPError(400, str(err))
@@ -367,11 +179,12 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                     path, content, type, format
                 )
 
-            fmt = preferred_format(ext, self.preferred_jupytext_formats_read)
+            config = self.get_config(path, use_cache=content is False)
+            fmt = preferred_format(ext, config.preferred_jupytext_formats_read)
             if ext == ".ipynb":
                 model = self._notebook_model(path, content=content)
             else:
-                self.set_default_format_options(fmt, read=True)
+                config.set_default_format_options(fmt, read=True)
                 with mock.patch("nbformat.reads", _jupytext_reads(fmt)):
                     model = self._notebook_model(path, content=content)
 
@@ -395,97 +208,67 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
 
             # We will now read a second file if this is a paired notebooks.
             nbk = model["content"]
-            jupytext_formats = nbk.metadata.get("jupytext", {}).get(
+            formats = nbk.metadata.get("jupytext", {}).get(
                 "formats"
-            ) or self.default_formats(path)
-            jupytext_formats = long_form_multiple_formats(
-                jupytext_formats, nbk.metadata, auto_ext_requires_language_info=False
+            ) or config.default_formats(path)
+            formats = long_form_multiple_formats(
+                formats, nbk.metadata, auto_ext_requires_language_info=False
             )
 
             # Compute paired notebooks from formats
             alt_paths = [(path, fmt)]
-            if jupytext_formats:
+            if formats:
                 try:
-                    _, fmt = find_base_path_and_format(path, jupytext_formats)
-                    alt_paths = paired_paths(path, fmt, jupytext_formats)
-                    self.update_paired_notebooks(path, fmt, jupytext_formats)
+                    _, fmt = find_base_path_and_format(path, formats)
+                    alt_paths = paired_paths(path, fmt, formats)
+                    self.update_paired_notebooks(path, formats)
                 except InconsistentPath as err:
                     self.log.info("Unable to read paired notebook: %s", str(err))
             else:
                 if path in self.paired_notebooks:
                     fmt, formats = self.paired_notebooks.get(path)
                     alt_paths = paired_paths(path, fmt, formats)
+                    formats = long_form_multiple_formats(formats)
 
             if len(alt_paths) > 1 and ext == ".ipynb":
                 # Apply default options (like saving and reloading would do)
                 jupytext_metadata = model["content"]["metadata"].get("jupytext", {})
-                self.set_default_format_options(jupytext_metadata, read=True)
+                config.set_default_format_options(jupytext_metadata, read=True)
                 if jupytext_metadata:
                     model["content"]["metadata"]["jupytext"] = jupytext_metadata
 
-            org_model = model
-            fmt_inputs = fmt
-            path_inputs = path_outputs = path
-            model_outputs = None
-
-            # Source format is the most recent non ipynb format found on disk
-            if path.endswith(".ipynb"):
-                source_timestamps = {}
-                for alt_path, alt_fmt in alt_paths:
-                    if not alt_path.endswith(".ipynb") and self.exists(alt_path):
-                        source_timestamps[alt_path] = self._notebook_model(
-                            alt_path, content=False
-                        )["last_modified"]
-
-                most_recent_timestamp = None
-                for alt_path in source_timestamps:
-                    alt_ts = source_timestamps[alt_path]
-                    if len(source_timestamps) > 1:
-                        self.log.info(
-                            u"File {} was last modified at {}".format(alt_path, alt_ts)
-                        )
-                    if most_recent_timestamp is None or alt_ts > most_recent_timestamp:
-                        most_recent_timestamp = alt_ts
-                        model_outputs = model
-                        path_inputs = alt_path
-                        fmt_inputs = alt_fmt
-
-                if most_recent_timestamp is not None:
-                    self.log.info(u"Reading SOURCE from {}".format(path_inputs))
-                    model = self.get(
-                        path_inputs,
-                        content=content,
-                        type="notebook",
-                        format=format,
-                        load_alternative_format=False,
-                    )
-
-            # Outputs taken from ipynb if in group, if file exists
-            else:
-                for alt_path, _ in alt_paths:
-                    if alt_path.endswith(".ipynb") and self.exists(alt_path):
-                        self.log.info(u"Reading OUTPUTS from {}".format(alt_path))
-                        path_outputs = alt_path
-                        model_outputs = self.get(
-                            alt_path,
-                            content=content,
-                            type="notebook",
-                            format=format,
-                            load_alternative_format=False,
-                        )
-                        break
-
-            try:
-                check_file_version(model["content"], path_inputs, path_outputs)
-            except Exception as err:
-                raise HTTPError(400, str(err))
+            def get_timestamp(alt_path):
+                if not self.exists(alt_path):
+                    return None
+                if alt_path == path:
+                    return model["last_modified"]
+                return self._notebook_model(alt_path, content=False)["last_modified"]
+
+            def read_one_file(alt_path, alt_fmt):
+                if alt_path == path:
+                    return model["content"]
+                if alt_path.endswith(".ipynb"):
+                    self.log.info(u"Reading OUTPUTS from {}".format(alt_path))
+                    return self._notebook_model(alt_path, content=True)["content"]
+
+                self.log.info(u"Reading SOURCE from {}".format(alt_path))
+                config.set_default_format_options(alt_fmt, read=True)
+                with mock.patch("nbformat.reads", _jupytext_reads(alt_fmt)):
+                    return self._notebook_model(alt_path, content=True)["content"]
+
+            inputs, outputs = latest_inputs_and_outputs(
+                path, fmt, formats, get_timestamp, contents_manager_mode=True
+            )
 
             # Before we combine the two files, we make sure we're not overwriting ipynb cells
             # with an outdated text file
             try:
-                if model_outputs and model_outputs["last_modified"] > model[
-                    "last_modified"
-                ] + timedelta(seconds=self.outdated_text_notebook_margin):
+                if (
+                    outputs.timestamp
+                    and outputs.timestamp
+                    > inputs.timestamp
+                    + timedelta(seconds=config.outdated_text_notebook_margin)
+                ):
                     raise HTTPError(
                         400,
                         """{out} (last modified {out_last})
@@ -497,20 +280,21 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                             c.ContentsManager.outdated_text_notebook_margin = 5 # in seconds # or float("inf")
                         to your .jupyter/jupyter_notebook_config.py file
                         """.format(
-                            src=path_inputs,
-                            src_last=model["last_modified"],
-                            out=path_outputs,
-                            out_last=model_outputs["last_modified"],
+                            src=inputs.path,
+                            src_last=inputs.timestamp,
+                            out=outputs.path,
+                            out_last=outputs.timestamp,
                         ),
                     )
             except OverflowError:
                 pass
 
-            if model_outputs:
-                combine_inputs_with_outputs(
-                    model["content"], model_outputs["content"], fmt_inputs
-                )
-            elif not path.endswith(".ipynb"):
+            try:
+                model["content"] = read_pair(inputs, outputs, read_one_file)
+            except Exception as err:
+                raise HTTPError(400, str(err))
+
+            if not outputs.timestamp:
                 set_kernelspec_from_language(model["content"])
 
             # Trust code cells when they have no output
@@ -522,10 +306,6 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                 ):
                     cell.metadata["trusted"] = True
 
-            # Path and name of the notebook is the one of the original path
-            model["path"] = org_model["path"]
-            model["name"] = org_model["name"]
-
             return model
 
         def trust_notebook(self, path):
@@ -570,7 +350,27 @@ def build_jupytext_contents_manager_class(base_contents_manager_class):
                     )
 
             self.drop_paired_notebook(old_path)
-            self.update_paired_notebooks(new_path, fmt, formats)
+            self.update_paired_notebooks(new_path, formats)
+
+        def get_config(self, path, use_cache=False):
+            """Return the Jupytext configuration for the given path"""
+            nb_file = self._get_os_path(path.strip("/"))
+            parent_dir = os.path.dirname(nb_file)
+
+            # When listing the notebooks for the tree view, we use a one-second
+            # cache for the configuration file
+            if (
+                not use_cache
+                or parent_dir != self.cached_config.path
+                or (
+                    self.cached_config.timestamp + timedelta(seconds=1) < datetime.now()
+                )
+            ):
+                self.cached_config.path = parent_dir
+                self.cached_config.timestamp = datetime.now()
+                self.cached_config.config = load_jupytext_config(parent_dir)
+
+            return self.cached_config.config or self
 
     return JupytextContentsManager
 
diff --git a/jupytext/paired_paths.py b/jupytext/paired_paths.py
index 8c77d610..905687b1 100644
--- a/jupytext/paired_paths.py
+++ b/jupytext/paired_paths.py
@@ -62,13 +62,32 @@ def base_path(main_path, fmt):
         notebook_file_name = notebook_file_name[len(prefix_file_name) :]
 
     if prefix_dir:
-        if not notebook_dir.endswith(prefix_dir):
-            raise InconsistentPath(
-                u"Notebook directory '{}' was expected to end with directory prefix '{}'".format(
-                    notebook_dir, prefix_dir
+        parent_notebook_dir = notebook_dir
+        parent_prefix_dir = prefix_dir
+        actual_folders = list()
+        while parent_prefix_dir:
+            parent_prefix_dir, expected_folder = os.path.split(parent_prefix_dir)
+            if expected_folder == "..":
+                if not actual_folders:
+                    raise InconsistentPath(
+                        u"Notebook directory '{}' does not match prefix '{}'".format(
+                            notebook_dir, prefix_dir
+                        )
+                    )
+                parent_notebook_dir = os.path.join(
+                    parent_notebook_dir, actual_folders.pop()
                 )
-            )
-        notebook_dir = notebook_dir[: -len(prefix_dir)]
+            else:
+                parent_notebook_dir, actual_folder = os.path.split(parent_notebook_dir)
+                actual_folders.append(actual_folder)
+
+                if actual_folder != expected_folder:
+                    raise InconsistentPath(
+                        u"Notebook directory '{}' does not match prefix '{}'".format(
+                            notebook_dir, prefix_dir
+                        )
+                    )
+        notebook_dir = parent_notebook_dir
 
     if not notebook_dir:
         return notebook_file_name
@@ -100,9 +119,15 @@ def full_path(base, fmt):
             notebook_file_name = prefix_file_name + notebook_file_name
 
         if prefix_dir:
+            dotdot = ".." + sep
+            while prefix_dir.startswith(dotdot):
+                prefix_dir = prefix_dir[len(dotdot) :]
+                notebook_dir = os.path.dirname(notebook_dir)
+
             # Do not add a path separator when notebook_dir is '/'
             if notebook_dir and not notebook_dir.endswith(sep):
                 notebook_dir = notebook_dir + sep
+
             notebook_dir = notebook_dir + prefix_dir
 
         if notebook_dir and not notebook_dir.endswith(sep):
diff --git a/jupytext/pairs.py b/jupytext/pairs.py
new file mode 100644
index 00000000..3b3acb3e
--- /dev/null
+++ b/jupytext/pairs.py
@@ -0,0 +1,119 @@
+"""Functions to read or write paired notebooks"""
+
+from collections import namedtuple
+from .formats import (
+    long_form_one_format,
+    long_form_multiple_formats,
+    check_file_version,
+)
+from .paired_paths import find_base_path_and_format, full_path, paired_paths
+from .combine import combine_inputs_with_outputs
+
+NotebookFile = namedtuple("notebook_file", "path fmt timestamp")
+
+
+def write_pair(path, formats, write_one_file):
+    """
+    Call the function 'write_one_file' on each of the paired path/formats
+    """
+    formats = long_form_multiple_formats(formats)
+    base, _ = find_base_path_and_format(path, formats)
+
+    # Save as ipynb first
+    return_value = None
+    value = None
+    for fmt in formats[::-1]:
+        if fmt["extension"] != ".ipynb":
+            continue
+
+        alt_path = full_path(base, fmt)
+        value = write_one_file(alt_path, fmt)
+        if alt_path == path:
+            return_value = value
+
+    # And then to the other formats, in reverse order so that
+    # the first format is the most recent
+    for fmt in formats[::-1]:
+        if fmt["extension"] == ".ipynb":
+            continue
+
+        alt_path = full_path(base, fmt)
+        value = write_one_file(alt_path, fmt)
+        if alt_path == path:
+            return_value = value
+
+    # Update modified timestamp to match that of the pair #207
+    if isinstance(return_value, dict) and "last_modified" in return_value:
+        return_value["last_modified"] = value["last_modified"]
+
+    return return_value
+
+
+def latest_inputs_and_outputs(
+    path, fmt, formats, get_timestamp, contents_manager_mode=False
+):
+    """Given a notebook path, its format and paired formats, and a function that
+    returns the timestamp for each (or None if the file does not exist), return
+    the most recent notebook for the inputs and outputs, respectively"""
+
+    timestamp_inputs = None
+    timestamp_outputs = None
+    inputs_path = None
+    outputs_path = None
+    input_fmt = None
+    output_fmt = None
+
+    fmt = long_form_one_format(fmt)
+    formats = long_form_multiple_formats(formats)
+
+    for alt_path, alt_fmt in paired_paths(path, fmt, formats):
+        # In the contents manager, we don't read another text file if the current notebook is in already in text mode
+        if (
+            contents_manager_mode
+            and alt_fmt["extension"] != ".ipynb"
+            and fmt["extension"] != ".ipynb"
+        ):
+            if any(
+                alt_fmt.get(key) != fmt.get(key)
+                for key in ["extension", "suffix", "prefix"]
+            ):
+                continue
+
+        timestamp = get_timestamp(alt_path)
+        if timestamp is None:
+            continue
+
+        if alt_fmt["extension"] == ".ipynb":
+            if not timestamp_outputs or timestamp > timestamp_outputs:
+                timestamp_outputs = timestamp
+                outputs_path, output_fmt = alt_path, alt_fmt
+        elif not timestamp_inputs or timestamp > timestamp_inputs:
+            timestamp_inputs = timestamp
+            inputs_path, input_fmt = alt_path, alt_fmt
+
+    if not timestamp_inputs or (
+        not contents_manager_mode
+        and timestamp_outputs
+        and timestamp_outputs > timestamp_inputs
+    ):
+        timestamp_inputs = timestamp_outputs
+        inputs_path, input_fmt = outputs_path, output_fmt
+
+    return (
+        NotebookFile(inputs_path, input_fmt, timestamp_inputs),
+        NotebookFile(outputs_path, output_fmt, timestamp_outputs),
+    )
+
+
+def read_pair(inputs, outputs, read_one_file):
+    """Read a notebook given its inputs and outputs path and formats"""
+    if not outputs.path or outputs.path == inputs.path:
+        return read_one_file(inputs.path, inputs.fmt)
+
+    notebook = read_one_file(inputs.path, inputs.fmt)
+    check_file_version(notebook, inputs.path, outputs.path)
+
+    outputs = read_one_file(outputs.path, outputs.fmt)
+    combine_inputs_with_outputs(notebook, outputs, fmt=inputs.fmt)
+
+    return notebook
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f7c983a5..85c9bb0b 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -7,6 +7,7 @@ jupyter_client
 nbconvert
 sphinx-gallery
 setuptools
+toml
 
 # Python 2
 pathlib
diff --git a/setup.py b/setup.py
index 26520d9a..5442be17 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,10 @@ setup(
     entry_points={"console_scripts": ["jupytext = jupytext.cli:jupytext"]},
     tests_require=["pytest"],
     install_requires=["nbformat>=4.0.0", "pyyaml", 'mock;python_version<"3"'],
-    extras_require={"myst": ["myst-parser~=0.8; python_version >= '3.6'"]},
+    extras_require={
+        "myst": ["myst-parser~=0.8; python_version >= '3.6'"],
+        "toml": ["toml"],
+    },
     license="MIT",
     classifiers=[
         "Development Status :: 5 - Production/Stable",


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e77210e9..c546dc6c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
 import pytest
+import jupytext
 
 try:
     import unittest.mock as mock
@@ -10,3 +11,8 @@ except ImportError:
 def no_jupytext_version_number():
     with mock.patch("jupytext.header.INSERT_AND_CHECK_VERSION_NUMBER", False):
         yield
+
+
+# Pytest's tmpdir is in /tmp (at least for me), so this helps avoiding interferences between
+# global configuration on HOME and the test collection
+jupytext.config.JUPYTEXT_CEILING_DIRECTORIES = ["/tmp/"]
diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py
new file mode 100644
index 00000000..decb3a03
--- /dev/null
+++ b/tests/test_cli_config.py
@@ -0,0 +1,91 @@
+import pytest
+from nbformat.v4.nbbase import new_notebook, new_code_cell
+from jupytext.cli import jupytext
+from jupytext.jupytext import read, write
+from jupytext.header import header_to_metadata_and_cell
+
+
+def test_default_jupytext_formats(tmpdir):
+    tmpdir.join(".jupytext").write(
+        '''# Default pairing
+default_jupytext_formats = "ipynb,py"'''
+    )
+    test = tmpdir.join("test.py")
+    test.write("1 + 1\n")
+
+    jupytext([str(test), "--sync"])
+
+    assert tmpdir.join("test.ipynb").isfile()
+
+
+def test_default_jupytext_formats_with_suffix(tmpdir):
+    tmpdir.join(".jupytext").write('default_jupytext_formats = "ipynb,.nb.py"')
+    test = tmpdir.join("test.py")
+    test.write("1 + 1\n")
+
+    test_nb = tmpdir.join("test.nb.py")
+    test_nb.write("1 + 1\n")
+
+    jupytext([str(test), "--sync"])
+    assert not tmpdir.join("test.ipynb").isfile()
+
+    jupytext([str(test_nb), "--sync"])
+    assert tmpdir.join("test.ipynb").isfile()
+
+
+def test_default_jupytext_formats_does_not_apply_to_config_file(tmpdir):
+    config = tmpdir.join(".jupytext.py")
+    config.write('c.default_jupytext_formats = "ipynb,py"')
+    test = tmpdir.join("test.py")
+    test.write("1 + 1\n")
+
+    jupytext([str(test), str(config), "--sync"])
+
+    assert tmpdir.join("test.ipynb").isfile()
+    assert not tmpdir.join(".jupytext.ipynb").isfile()
+
+
+def test_preferred_jupytext_formats_save(tmpdir):
+    tmpdir.join(".jupytext.yml").write("preferred_jupytext_formats_save: jl:percent")
+    tmp_ipynb = tmpdir.join("notebook.ipynb")
+    tmp_jl = tmpdir.join("notebook.jl")
+
+    nb = new_notebook(
+        cells=[new_code_cell("1 + 1")], metadata={"jupytext": {"formats": "ipynb,jl"}}
+    )
+
+    write(nb, str(tmp_ipynb))
+    jupytext([str(tmp_ipynb), "--sync"])
+
+    with open(str(tmp_jl)) as stream:
+        text_jl = stream.read()
+
+    # Parse the YAML header
+    metadata, _, _, _ = header_to_metadata_and_cell(text_jl.splitlines(), "#")
+    assert metadata["jupytext"]["formats"] == "ipynb,jl:percent"
+
+
+@pytest.mark.parametrize(
+    "config",
+    [
+        # Way 1: preferred_jupytext_formats_save + default_jupytext_formats
+        """preferred_jupytext_formats_save: "python//py:percent"
+default_jupytext_formats: "ipynb,python//py"
+""",
+        # Way 2: default_jupytext_formats
+        "default_jupytext_formats: ipynb,python//py:percent",
+    ],
+)
+def test_save_using_preferred_and_default_format(config, tmpdir):
+    tmpdir.join(".jupytext.yml").write(config)
+    tmp_ipynb = tmpdir.join("notebook.ipynb")
+    tmp_py = tmpdir.join("python").join("notebook.py")
+
+    nb = new_notebook(cells=[new_code_cell("1 + 1")])
+
+    write(nb, str(tmp_ipynb))
+    jupytext([str(tmp_ipynb), "--sync"])
+
+    # read py file
+    nb_py = read(str(tmp_py))
+    assert nb_py.metadata["jupytext"]["text_representation"]["format_name"] == "percent"
diff --git a/tests/test_cm_config.py b/tests/test_cm_config.py
new file mode 100644
index 00000000..799aa6a5
--- /dev/null
+++ b/tests/test_cm_config.py
@@ -0,0 +1,48 @@
+import os
+
+try:
+    import unittest.mock as mock
+except ImportError:
+    import mock
+from nbformat.v4.nbbase import new_notebook, new_markdown_cell, new_code_cell
+import jupytext
+
+SAMPLE_NOTEBOOK = new_notebook(
+    cells=[new_markdown_cell("A Markdown cell"), new_code_cell("# A code cell\n1 + 1")]
+)
+
+
+def test_local_config_overrides_cm_config(tmpdir):
+    cm = jupytext.TextFileContentsManager()
+    cm.root_dir = str(tmpdir)
+    cm.default_jupytext_formats = "ipynb,py"
+
+    nested = tmpdir.mkdir("nested")
+    with open(str(nested.join(".jupytext.yml")), "w") as fp:
+        fp.write("default_jupytext_formats: ''\n")
+
+    cm.save(dict(type="notebook", content=SAMPLE_NOTEBOOK), "notebook.ipynb")
+    assert os.path.isfile(str(tmpdir.join("notebook.ipynb")))
+    assert os.path.isfile(str(tmpdir.join("notebook.py")))
+
+    cm.save(dict(type="notebook", content=SAMPLE_NOTEBOOK), "nested/notebook.ipynb")
+    assert os.path.isfile(str(nested.join("notebook.ipynb")))
+    assert not os.path.isfile(str(nested.join("notebook.py")))
+
+
+def test_config_file_is_called_just_once(tmpdir, n=2):
+    cm = jupytext.TextFileContentsManager()
+    cm.root_dir = str(tmpdir)
+    nb_files = [str(tmpdir.join("notebook{}.ipynb".format(i))) for i in range(n)]
+
+    for nb_file in nb_files:
+        jupytext.write(SAMPLE_NOTEBOOK, nb_file)
+
+    mock_config = mock.MagicMock(return_value=None)
+
+    with mock.patch("jupytext.contentsmanager.load_jupytext_config", mock_config):
+        for i in range(n):
+            cm.get("notebook{}.ipynb".format(i), content=False)
+
+    # Listing the contents should not call the config more than once
+    assert mock_config.call_count == 1
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 00000000..f34c255e
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,76 @@
+import os
+import pytest
+from jupytext.config import (
+    find_jupytext_configuration_file,
+    load_jupytext_configuration_file,
+)
+
+
+def test_find_jupytext_configuration_file(tmpdir):
+    nested = tmpdir.mkdir("nested")
+
+    # Start with no configuration
+    assert find_jupytext_configuration_file(str(nested)) is None
+
+    # Configuration file in the parent directory
+    root_config = tmpdir.join("jupytext.yml")
+    root_config.write("\n")
+    assert os.path.samefile(
+        find_jupytext_configuration_file(str(tmpdir)), str(root_config)
+    )
+    assert os.path.samefile(
+        find_jupytext_configuration_file(str(nested)), str(root_config)
+    )
+
+    # Local configuration file
+    local_config = nested.join(".jupytext")
+    local_config.write("\n")
+    assert os.path.samefile(
+        find_jupytext_configuration_file(str(tmpdir)), str(root_config)
+    )
+    assert os.path.samefile(
+        find_jupytext_configuration_file(str(nested)), str(local_config)
+    )
+
+
+@pytest.mark.parametrize(
+    "config_file",
+    ["jupytext", "jupytext.toml", "jupytext.yml", "jupytext.json", "jupytext.py"],
+)
+def test_load_jupytext_configuration_file(tmpdir, config_file):
+    full_config_path = tmpdir.join(config_file)
+
+    if config_file.endswith(("jupytext", ".toml")):
+        full_config_path.write(
+            """default_jupytext_formats = "ipynb,py:percent"
+default_notebook_metadata_filter = "all"
+default_cell_metadata_filter = "all"
+"""
+        )
+    elif config_file.endswith(".yml"):
+        full_config_path.write(
+            """default_jupytext_formats: ipynb,py:percent
+default_notebook_metadata_filter: all
+default_cell_metadata_filter: all
+"""
+        )
+    elif config_file.endswith(".json"):
+        full_config_path.write(
+            """{"default_jupytext_formats": "ipynb,py:percent",
+"default_notebook_metadata_filter": "all",
+"default_cell_metadata_filter": "all"
+}
+"""
+        )
+    elif config_file.endswith(".py"):
+        full_config_path.write(
+            """c.default_jupytext_formats = "ipynb,py:percent"
+c.default_notebook_metadata_filter = "all"
+c.default_cell_metadata_filter = "all"
+"""
+        )
+
+    config = load_jupytext_configuration_file(str(full_config_path))
+    assert config.default_jupytext_formats == "ipynb,py:percent"
+    assert config.default_notebook_metadata_filter == "all"
+    assert config.default_cell_metadata_filter == "all"
diff --git a/tests/test_paired_paths.py b/tests/test_paired_paths.py
index b8f68594..7f9905e6 100644
--- a/tests/test_paired_paths.py
+++ b/tests/test_paired_paths.py
@@ -1,7 +1,7 @@
 import pytest
 from jupytext.compare import compare
 from jupytext.contentsmanager import TextFileContentsManager
-from jupytext.paired_paths import InconsistentPath, paired_paths, base_path
+from jupytext.paired_paths import InconsistentPath, paired_paths, base_path, full_path
 from jupytext.formats import (
     long_form_one_format,
     long_form_multiple_formats,
@@ -28,6 +28,16 @@ def test_base_path():
         base_path("dir/incorrect_prefix_NAME.ipynb", fmt)
 
 
+def test_base_path_dotdot():
+    fmt = long_form_one_format("../scripts//py")
+    assert base_path("scripts/test.py", fmt=fmt) == "scripts/test"
+
+
+def test_full_path_dotdot():
+    fmt = long_form_one_format("../scripts//py")
+    assert full_path("scripts/test", fmt=fmt) == "scripts/test.py"
+
+
 def test_many_and_suffix():
     formats = long_form_multiple_formats("ipynb,.pct.py,_lgt.py")
     expected_paths = ["notebook.ipynb", "notebook.pct.py", "notebook_lgt.py"]
@@ -106,15 +116,15 @@ def test_cm_paired_paths():
     cm.paired_notebooks = dict()
 
     three = "ipynb,py,md"
-    cm.update_paired_notebooks("nb.ipynb", "ipynb", three)
+    cm.update_paired_notebooks("nb.ipynb", three)
     assert cm.paired_notebooks == {
         "nb." + fmt: (fmt, three) for fmt in three.split(",")
     }
 
     two = "ipynb,Rmd"
-    cm.update_paired_notebooks("nb.ipynb", "ipynb", two)
+    cm.update_paired_notebooks("nb.ipynb", two)
     assert cm.paired_notebooks == {"nb." + fmt: (fmt, two) for fmt in two.split(",")}
 
     one = "ipynb"
-    cm.update_paired_notebooks("nb.ipynb", "ipynb", one)
+    cm.update_paired_notebooks("nb.ipynb", one)
     assert cm.paired_notebooks == {}