diff --git a/README.md b/README.md index 3f5b26de8..a0b27c073 100755 --- a/README.md +++ b/README.md @@ -160,6 +160,13 @@ jupytext --to md --output - notebook.ipynb # display the markdown version o jupytext --from ipynb --to py:percent # read ipynb from stdin and write double percent script on stdout ``` +Jupytext is also available as a pre-commit hook. Use this if you want Jupytext to create and update the `.py` representation of your `.ipynb` notebooks on every commit. All you need is to create a `.git/hooks/pre-commit` file with the following content: +```bash +#!/bin/sh +jupytext --to py:light --pre-commit +``` +Jupytext does not offer a merge driver. If a conflict occurs, solve it on the text representation and then update or recreate the `.ipynb` notebook. Or give a try to nbdime and its [merge driver](https://nbdime.readthedocs.io/en/stable/vcs.html#merge-driver). + ## Reading notebooks in Python Manipulate notebooks in a Python shell or script using `jupytext`'s main functions: diff --git a/jupytext/cli.py b/jupytext/cli.py index e2fce7d89..6ed644f63 100644 --- a/jupytext/cli.py +++ b/jupytext/cli.py @@ -2,7 +2,9 @@ """ import os +import re import sys +import subprocess import argparse from .jupytext import readf, reads, writef, writes from .formats import NOTEBOOK_EXTENSIONS, JUPYTEXT_FORMATS, check_file_version, one_format_as_string, parse_one_format @@ -12,7 +14,7 @@ from .version import __version__ -def convert_notebook_files(nb_files, fmt, input_format=None, output=None, +def convert_notebook_files(nb_files, fmt, input_format=None, output=None, pre_commit=False, test_round_trip=False, test_round_trip_strict=False, stop_on_first_error=True, update=True, freeze_metadata=False, comment_magics=None): """ @@ -22,6 +24,7 @@ def convert_notebook_files(nb_files, fmt, input_format=None, output=None, :param input_format: input format, e.g. "py:percent" :param fmt: destination format, e.g. "py:percent" :param output: None, destination file, or '-' for stdout + :param pre_commit: convert notebooks in the git index? :param test_round_trip: should round trip conversion be tested? :param test_round_trip_strict: should round trip conversion be tested, with strict notebook comparison? :param stop_on_first_error: when testing, should we stop on first error, or compare the full notebook? @@ -36,6 +39,23 @@ def convert_notebook_files(nb_files, fmt, input_format=None, output=None, if ext not in NOTEBOOK_EXTENSIONS: raise TypeError('Destination extension {} is not a notebook'.format(ext)) + if pre_commit: + input_format = input_format or 'ipynb' + input_ext, _ = parse_one_format(input_format) + modified, deleted = modified_and_deleted_files(input_ext) + + for file in modified: + dest_file = file[:-len(input_ext)] + ext + nb = readf(file) + writef(nb, dest_file, format_name=format_name) + system('git', 'add', dest_file) + + for file in deleted: + dest_file = file[:-len(input_ext)] + ext + system('git', 'rm', dest_file) + + return + if not nb_files: if not input_format: raise ValueError('Reading notebook from the standard input requires the --from field.') @@ -116,6 +136,22 @@ def convert_notebook_files(nb_files, fmt, input_format=None, output=None, exit(notebooks_in_error) +def system(*args, **kwargs): + """Execute the given bash command""" + kwargs.setdefault('stdout', subprocess.PIPE) + proc = subprocess.Popen(args, **kwargs) + out, err = proc.communicate() + return out + + +def modified_and_deleted_files(ext): + """Return the list of modified and deleted ipynb files in the git index""" + re_modified = re.compile(r'^[AM]+\s+(?P.*{})'.format(ext.replace('.', r'\.')), re.MULTILINE) + re_deleted = re.compile(r'^[D]+\s+(?P.*{})'.format(ext.replace('.', r'\.')), re.MULTILINE) + files = system('git', 'status', '--porcelain').decode('utf-8') + return re_modified.findall(files), re_deleted.findall(files) + + def save_notebook_as(notebook, nb_file, nb_dest, format_name, combine): """Save notebook to file, in desired format""" if combine and os.path.isfile(nb_dest) and os.path.splitext(nb_dest)[1] == '.ipynb': @@ -187,6 +223,10 @@ def cli_jupytext(args=None): 'provided , but then the --from field is ' 'mandatory', nargs='*') + parser.add_argument('--pre-commit', action='store_true', + help="""Run Jupytext on the ipynb files in the git index. Use Jupytext + as a pre-commit hook with: echo '#!/bin/sh + jupytext --to py:light --pre-commit' > .git/hooks/pre-commit""") parser.add_argument('-o', '--output', help='Destination file. Defaults to original file, ' 'with extension changed to destination format. ' @@ -227,12 +267,18 @@ def cli_jupytext(args=None): args.output = '-' if not args.input_format: - if not args.notebooks: - raise ValueError('Please specificy either --from or notebooks') + if not args.notebooks and not args.pre_commit: + raise ValueError('Please specificy either --from, --pre-commit or notebooks') if args.update and not (args.test or args.test_strict) and args.to != 'ipynb': raise ValueError('--update works exclusively with --to notebook ') + if args.pre_commit: + if args.notebooks: + raise ValueError('--pre-commit takes notebooks from the git index. Do not pass any notebook here.') + if args.test or args.test_strict: + raise ValueError('--pre-commit cannot be used with --test or --test-strict') + return args @@ -249,6 +295,7 @@ def jupytext(args=None): fmt=args.to, input_format=args.input_format, output=args.output, + pre_commit=args.pre_commit, test_round_trip=args.test, test_round_trip_strict=args.test_strict, stop_on_first_error=args.stop_on_first_error, diff --git a/tests/test_cli.py b/tests/test_cli.py index 37ee0b945..d2759e0fa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ from nbformat.v4.nbbase import new_notebook from jupytext import header, __version__ from jupytext import readf, writef, writes -from jupytext.cli import convert_notebook_files, cli_jupytext, jupytext +from jupytext.cli import convert_notebook_files, cli_jupytext, jupytext, system from jupytext.compare import compare_notebooks from .utils import list_notebooks @@ -244,3 +244,76 @@ def test_convert_to_percent_format_and_keep_magics(nb_file, tmpdir): nb2 = readf(tmp_nbpy) compare_notebooks(nb1, nb2) + + +def test_pre_commit_hook(tmpdir): + tmp_ipynb = str(tmpdir.join('notebook.ipynb')) + tmp_py = str(tmpdir.join('notebook.py')) + nb = new_notebook(cells=[]) + + def git(*args): + print(system('git', *args, cwd=str(tmpdir))) + + git('init') + git('status') + with open(str(tmpdir.join('.git/hooks/pre-commit')), 'w') as fp: + fp.write('#!/bin/sh\n' + 'jupytext --to py:light --pre-commit\n') + + writef(nb, tmp_ipynb) + assert os.path.isfile(tmp_ipynb) + assert not os.path.isfile(tmp_py) + + git('add', 'notebook.ipynb') + git('status') + git('commit', '-m', 'created') + git('status') + + assert os.path.isfile(tmp_py) + + git('rm', 'notebook.ipynb') + git('status') + git('commit', '-m', 'deleted') + git('status') + + assert not os.path.isfile(tmp_ipynb) + assert not os.path.isfile(tmp_py) + + +def test_pre_commit_hook_py_to_ipynb_and_md(tmpdir): + tmp_ipynb = str(tmpdir.join('notebook.ipynb')) + tmp_py = str(tmpdir.join('notebook.py')) + tmp_md = str(tmpdir.join('notebook.md')) + nb = new_notebook(cells=[]) + + def git(*args): + print(system('git', *args, cwd=str(tmpdir))) + + git('init') + git('status') + with open(str(tmpdir.join('.git/hooks/pre-commit')), 'w') as fp: + fp.write('#!/bin/sh\n' + 'jupytext --from py:light --to ipynb --pre-commit\n' + 'jupytext --from py:light --to md --pre-commit\n') + + writef(nb, tmp_py) + assert os.path.isfile(tmp_py) + assert not os.path.isfile(tmp_ipynb) + assert not os.path.isfile(tmp_md) + + git('add', 'notebook.py') + git('status') + git('commit', '-m', 'created') + git('status') + + assert os.path.isfile(tmp_ipynb) + assert os.path.isfile(tmp_md) + + git('rm', 'notebook.py') + git('status') + git('commit', '-m', 'deleted') + git('status') + + assert not os.path.isfile(tmp_ipynb) + assert not os.path.isfile(tmp_py) + assert not os.path.isfile(tmp_md)