{ "cells": [ { "cell_type": "raw", "metadata": {}, "source": [ "---\n", "title: clean\n", "output-file: clean.html\n", "description: Strip superfluous metadata from notebooks\n", "---" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "hide_input": false }, "outputs": [], "source": [ "#|default_exp clean" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "import ast,warnings,stat\n", "from astunparse import unparse\n", "from textwrap import indent\n", "\n", "from execnb.nbio import *\n", "from fastcore.script import *\n", "from fastcore.basics import *\n", "from fastcore.imports import *\n", "\n", "from nbdev.imports import *\n", "from nbdev.config import *\n", "from nbdev.sync import *\n", "from nbdev.process import first_code_ln" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "from fastcore.test import *" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To avoid pointless conflicts while working with jupyter notebooks (with different execution counts or cell metadata), it is recommended to clean the notebooks before committing anything (done automatically if you install the git hooks with `nbdev_install_hooks`). The following functions are used to do that." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Trust" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@call_parse\n", "def nbdev_trust(\n", " fname:str=None, # A notebook name or glob to trust\n", " force_all:bool=False # Also trust notebooks that haven't changed\n", "):\n", " \"Trust notebooks matching `fname`\"\n", " try: from nbformat.sign import NotebookNotary\n", " except:\n", " import warnings\n", " warnings.warn(\"Please install jupyter and try again\")\n", " return\n", "\n", " fname = Path(fname if fname else get_config().path('nbs_path'))\n", " path = fname if fname.is_dir() else fname.parent\n", " check_fname = path/\".last_checked\"\n", " last_checked = os.path.getmtime(check_fname) if check_fname.exists() else None\n", " nbs = globtastic(fname, file_glob='*.ipynb', skip_folder_re='^[_.]') if fname.is_dir() else [fname]\n", " for fn in nbs:\n", " if last_checked and not force_all:\n", " last_changed = os.path.getmtime(fn)\n", " if last_changed < last_checked: continue\n", " nb = read_nb(fn)\n", " if not NotebookNotary().check_signature(nb): NotebookNotary().sign(nb)\n", " check_fname.touch(exist_ok=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Clean" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Utils -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "_repr_id_re = re.compile('(<.*?)( at 0x[0-9a-fA-F]+)(>)')\n", "\n", "def _clean_cell_output_id(lines):\n", " sub = partial(_repr_id_re.sub, r'\\1\\3')\n", " return sub(lines) if isinstance(lines,str) else [sub(o) for o in lines]" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "test_eq(_clean_cell_output_id(['Lambda(func=)',\n", " '[,\\n',\n", " '(, , )']),\n", " ['Lambda(func=)',\n", " '[,\\n',\n", " '(, , )'])\n", "test_eq(_clean_cell_output_id('foo\\n\\nbar'), 'foo\\n\\nbar')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def _clean_cell_output(cell, clean_ids):\n", " \"Remove `cell` output execution count and optionally ids from text reprs\"\n", " outputs = cell.get('outputs', [])\n", " for o in outputs:\n", " if 'execution_count' in o: o['execution_count'] = None\n", " data = o.get('data', {})\n", " data.pop(\"application/vnd.google.colaboratory.intrinsic+json\", None)\n", " if clean_ids:\n", " for k in data:\n", " if k.startswith('text'): data[k] = _clean_cell_output_id(data[k])\n", " if 'text' in o: o['text'] = _clean_cell_output_id(o['text'])\n", " o.get('metadata', {}).pop('tags', None)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def _clean_cell(cell, clear_all, allowed_metadata_keys, clean_ids):\n", " \"Clean `cell` by removing superfluous metadata or everything except the input if `clear_all`\"\n", " if 'execution_count' in cell: cell['execution_count'] = None\n", " if 'outputs' in cell:\n", " if clear_all: cell['outputs'] = []\n", " else: _clean_cell_output(cell, clean_ids)\n", " if cell['source'] == ['']: cell['source'] = []\n", " cell['metadata'] = {} if clear_all else {\n", " k:v for k,v in cell['metadata'].items() if k in allowed_metadata_keys}" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def clean_nb(\n", " nb, # The notebook to clean\n", " clear_all=False, # Remove all cell metadata and cell outputs\n", " allowed_metadata_keys:list=None, # Preserve the list of keys in the main notebook metadata\n", " allowed_cell_metadata_keys:list=None, # Preserve the list of keys in cell level metadata\n", " clean_ids=True, # Remove ids from plaintext reprs?\n", "):\n", " \"Clean `nb` from superfluous metadata\"\n", " metadata_keys = {\"kernelspec\", \"jekyll\", \"jupytext\", \"doc\"}\n", " if allowed_metadata_keys: metadata_keys.update(allowed_metadata_keys)\n", " cell_metadata_keys = {\"hide_input\"}\n", " if allowed_cell_metadata_keys: cell_metadata_keys.update(allowed_cell_metadata_keys)\n", " for c in nb['cells']: _clean_cell(c, clear_all, cell_metadata_keys, clean_ids)\n", " nb['metadata'] = {k:v for k,v in nb['metadata'].items() if k in metadata_keys}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The test notebook has metadata in both the main metadata section and contains cell level metadata in the second cell:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "test_nb = read_nb('../tests/metadata.ipynb')\n", "\n", "assert {'meta', 'jekyll', 'my_extra_key', 'my_removed_key'} <= test_nb.metadata.keys()\n", "assert {'meta', 'hide_input', 'my_extra_cell_key', 'my_removed_cell_key'} == test_nb.cells[1].metadata.keys()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "After cleaning the notebook, all extra metadata is removed, only some keys are allowed by default:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "clean_nb(test_nb)\n", "\n", "assert {'jekyll', 'kernelspec'} == test_nb.metadata.keys()\n", "assert {'hide_input'} == test_nb.cells[1].metadata.keys()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can preserve some additional keys at the notebook or cell levels:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "test_nb = read_nb('../tests/metadata.ipynb')\n", "clean_nb(test_nb, allowed_metadata_keys={'my_extra_key'}, allowed_cell_metadata_keys={'my_extra_cell_key'})\n", "\n", "assert {'jekyll', 'kernelspec', 'my_extra_key'} == test_nb.metadata.keys()\n", "assert {'hide_input', 'my_extra_cell_key'} == test_nb.cells[1].metadata.keys()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Passing `clear_all=True` removes everything from the cell metadata:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "test_nb = read_nb('../tests/metadata.ipynb')\n", "clean_nb(test_nb, clear_all=True)\n", "\n", "assert {'jekyll', 'kernelspec'} == test_nb.metadata.keys()\n", "test_eq(test_nb.cells[1].metadata, {})" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Passing `clean_ids=True` removes `id`s from plaintext repr outputs, to avoid notebooks whose contents change on each run since they often lead to git merge conflicts. For example:\n", "\n", "```\n", "\n", "```\n", "\n", "becomes:\n", "\n", "```\n", "\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Commands -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def _reconfigure(*strms):\n", " for s in strms:\n", " if hasattr(s,'reconfigure'): s.reconfigure(encoding='utf-8')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def process_write(warn_msg, proc_nb, f_in, f_out=None, disp=False):\n", " if not f_out: f_out = sys.stdout if disp else f_in\n", " if isinstance(f_in, (str,Path)): f_in = Path(f_in).open()\n", " try:\n", " _reconfigure(f_in, f_out)\n", " nb = loads(f_in.read())\n", " proc_nb(nb)\n", " write_nb(nb, f_out)\n", " except Exception as e:\n", " warn(f'{warn_msg}')\n", " warn(e)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def _nbdev_clean(nb, path=None, **kwargs):\n", " cfg = get_config(path=path)\n", " allowed_metadata_keys = cfg.get(\"allowed_metadata_keys\").split()\n", " allowed_cell_metadata_keys = cfg.get(\"allowed_cell_metadata_keys\").split()\n", " clean_ids = str2bool(cfg.get('clean_ids'))\n", " return clean_nb(nb, clean_ids=clean_ids, allowed_metadata_keys=allowed_metadata_keys,\n", " allowed_cell_metadata_keys=allowed_cell_metadata_keys, **kwargs)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@call_parse\n", "def nbdev_clean(\n", " fname:str=None, # A notebook name or glob to clean\n", " clear_all:bool=False, # Clean all metadata and outputs\n", " disp:bool=False, # Print the cleaned outputs\n", " stdin:bool=False # Read notebook from input stream\n", "):\n", " \"Clean all notebooks in `fname` to avoid merge conflicts\"\n", " # Git hooks will pass the notebooks in stdin\n", " _clean = partial(_nbdev_clean, clear_all=clear_all)\n", " _write = partial(process_write, warn_msg='Failed to clean notebook', proc_nb=_clean)\n", " if stdin: return _write(f_in=sys.stdin, f_out=sys.stdout)\n", " \n", " if fname is None: fname = get_config().path('nbs_path')\n", " for f in globtastic(fname, file_glob='*.ipynb', skip_folder_re='^[_.]'): _write(f_in=f, disp=disp)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "By default (`fname` left to `None`), all the notebooks in `lib_folder` are cleaned. You can opt in to fully clean the notebook by removing every bit of metadata and the cell outputs by passing `clear_all=True`.\n", "\n", "If you want to keep some keys in the main notebook metadata you can set `allowed_metadata_keys` in `settings.ini`.\n", "Similarly for cell level metadata use: `allowed_cell_metadata_keys`. For example, to preserve both `k1` and `k2` at both the notebook and cell level adding the following in `settings.ini`:\n", "```\n", "...\n", "allowed_metadata_keys = k1 k2\n", "allowed_cell_metadata_keys = k1 k2\n", "...\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Jupyter -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def clean_jupyter(path, model, **kwargs):\n", " \"Clean Jupyter `model` pre save to `path`\"\n", " if not (model['type']=='notebook' and model['content']['nbformat']==4): return\n", " get_config.cache_clear() # Allow config changes without restarting Jupyter\n", " jupyter_hooks = get_config(path=path).jupyter_hooks\n", " if jupyter_hooks in {'user','nbdev','none'}:\n", " warn((\"`jupyter_hooks` values in `{'user','nbdev','none'}` are deprecated. Use `True` or `False` instead.\\n\"\n", " \"See the docs for more: https://nbdev.fast.ai/clean.html#clean_jupyter\"), DeprecationWarning)\n", " jupyter_hooks = False if jupyter_hooks == 'none' else True\n", " else: jupyter_hooks = str2bool(jupyter_hooks)\n", " if jupyter_hooks: _nbdev_clean(model['content'], path=path)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This cleans notebooks on-save to avoid unnecessary merge conflicts. The easiest way to install it for both Jupyter Notebook and Lab is by running `nbdev_install_hooks`. It works by implementing a `pre_save_hook` from Jupyter's [file save hook API](https://jupyter-server.readthedocs.io/en/latest/developers/savehooks.html)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Hooks" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "_pre_save_hook_src = '''\n", "def nbdev_clean_jupyter(**kwargs):\n", " try: from nbdev.clean import clean_jupyter\n", " except ModuleNotFoundError: return\n", " clean_jupyter(**kwargs)\n", "\n", "c.ContentsManager.pre_save_hook = nbdev_clean_jupyter'''.strip()\n", "_pre_save_hook_re = re.compile(r'c\\.(File)?ContentsManager\\.pre_save_hook')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def _add_jupyter_hooks(src, path):\n", " if _pre_save_hook_src in src: return\n", " mod = ast.parse(src)\n", " for node in ast.walk(mod):\n", " if not isinstance(node,ast.Assign): continue\n", " target = only(node.targets)\n", " if _pre_save_hook_re.match(unparse(target)):\n", " pre = ' '*2\n", " old = indent(unparse(node), pre)\n", " new = indent(_pre_save_hook_src, pre)\n", " sys.stderr.write(f\"Can't install hook to '{path}' since it already contains:\\n{old}\\n\"\n", " f\"Manually update to the following (without indentation) for this functionality:\\n\\n{new}\\n\\n\")\n", " return\n", " src = src.rstrip()\n", " if src: src+='\\n\\n'\n", " return src+_pre_save_hook_src" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "# Returns None if hook is already installed\n", "res = _add_jupyter_hooks(_pre_save_hook_src, 'config.py')\n", "test_is(res, None)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "Can't install hook to 'config.py' since it already contains:\n", "\n", " c.ContentsManager.pre_save_hook = my_hook\n", "\n", "Manually update to the following (without indentation) for this functionality:\n", "\n", " def nbdev_clean_jupyter(**kwargs):\n", " try: from nbdev.clean import clean_jupyter\n", " except ModuleNotFoundError: return\n", " clean_jupyter(**kwargs)\n", "\n", " c.ContentsManager.pre_save_hook = nbdev_clean_jupyter\n", "\n" ] } ], "source": [ "#|hide\n", "# Returns None and warns if pre_save_hook is already set\n", "res = _add_jupyter_hooks(\"c.ContentsManager.pre_save_hook = my_hook\\n\", 'config.py')\n", "test_is(res, None)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "```python\n", "an_existing_line = True\n", "\n", "def nbdev_clean_jupyter(**kwargs):\n", " try: from nbdev.clean import clean_jupyter\n", " except ModuleNotFoundError: return\n", " clean_jupyter(**kwargs)\n", "\n", "c.ContentsManager.pre_save_hook = nbdev_clean_jupyter\n", "```" ], "text/plain": [ "" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "#|hide\n", "# Adds after existing source\n", "show_src(_add_jupyter_hooks('an_existing_line = True\\n', 'config.py'))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def _git_root(): \n", " try: return Path(run('git rev-parse --show-toplevel'))\n", " except OSError: return None" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "import tempfile" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "test_eq(_git_root().name, 'nbdev')\n", "with tempfile.TemporaryDirectory() as d, working_directory(d): test_is(_git_root(), None)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@call_parse\n", "def nbdev_install_hooks():\n", " \"Install Jupyter and git hooks to automatically clean, trust, and fix merge conflicts in notebooks\"\n", " cfg_path = Path.home()/'.jupyter'\n", " cfg_path.mkdir(exist_ok=True)\n", " cfg_fns = [cfg_path/f'jupyter_{o}_config.py' for o in ('notebook','server')]\n", " for fn in cfg_fns:\n", " src = fn.read_text() if fn.exists() else ''\n", " upd = _add_jupyter_hooks(src, fn)\n", " if upd is not None: fn.write_text(upd)\n", "\n", " repo_path = _git_root()\n", " if repo_path is None:\n", " sys.stderr.write('Not in a git repository, git hooks cannot be installed.\\n')\n", " return\n", " hook_path = repo_path/'.git'/'hooks'\n", " fn = hook_path/'post-merge'\n", " hook_path.mkdir(parents=True, exist_ok=True)\n", " fn.write_text(\"#!/bin/bash\\nnbdev_trust\")\n", " os.chmod(fn, os.stat(fn).st_mode | stat.S_IEXEC)\n", "\n", " cmd = 'git config --local include.path ../.gitconfig'\n", " (repo_path/'.gitconfig').write_text(f'''# Generated by nbdev_install_hooks\n", "#\n", "# If you need to disable this instrumentation do:\n", "# git config --local --unset include.path\n", "#\n", "# To restore:\n", "# {cmd}\n", "#\n", "[merge \"nbdev-merge\"]\n", "\tname = resolve conflicts with nbdev_fix\n", "\tdriver = nbdev_merge %O %A %B %P\n", "''')\n", " run(cmd)\n", "\n", " attrs_path = repo_path/'.gitattributes'\n", " nbdev_attr = '*.ipynb merge=nbdev-merge\\n'\n", " try:\n", " attrs = attrs_path.read_text()\n", " if nbdev_attr not in attrs:\n", " if not attrs.endswith('\\n'): attrs+='\\n'\n", " attrs_path.write_text(attrs+nbdev_attr)\n", " except FileNotFoundError: attrs_path.write_text(nbdev_attr)\n", "\n", " print(\"Hooks are installed.\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "See `clean_jupyter` and `nbdev_merge` for more about how each hook works." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## End-to-end git hooks test -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "def _git_brunch_current(): return run('git branch --show-current')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "meta = {'nbformat': 4,'metadata':{'kernelspec':{'display_name':'Python 3','language': 'python','name': 'python3'}}}\n", "base = dict2nb({'cells':[mk_cell('import random'),\n", " mk_cell('random.random()')], **meta})\n", "base.cells[-1].output = create_output('0.3314001088639852\\n0.20280244713400464', 'plain')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "from copy import deepcopy" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "ours = deepcopy(base)\n", "ours.cells[0].source+=',os' # Change first cell\n", "ours.cells.insert(1, mk_cell('Calculate a random number:', cell_type='markdown')) # New cell\n", "ours.cells[-1].output = create_output('0.3379097372590093\\n0.7379492349993123', 'plain') # Change outputs" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "thrs = deepcopy(base)\n", "thrs.cells[0].source+=',sys'# Also change first cell\n", "thrs.cells.insert(0, mk_cell('# Random numbers', cell_type='markdown')) # New cell\n", "thrs.cells[-1].output = create_output('0.6587181429602441\\n0.5962200692415515', 'plain') # Change outputs" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "import subprocess" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "def _run(cmd, check=True):\n", " proc = subprocess.run(cmd, shell=True, capture_output=True, text=True)\n", " if check and proc.returncode != 0:\n", " msg = f\"Command '{cmd}' returned non-zero exit status {proc.returncode}\"\n", " if proc.stdout.strip(): msg+=f'\\nstdout: {proc.stdout.strip()}'\n", " if proc.stderr.strip(): msg+=f'\\nstderr: {proc.stderr.strip()}'\n", " raise RuntimeError(msg)\n", " return proc" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "with tempfile.TemporaryDirectory() as d, working_directory(d):\n", " _run('git init')\n", " _run(\"git config user.email 'nbdev@fast.ai'\")\n", " _run(\"git config user.name 'nbdev'\")\n", "\n", " nbs_path = Path('nbs')\n", " nbs_path.mkdir()\n", " Config('.', 'settings.ini', create={'nbs_path':nbs_path,'author':'fastai'})\n", " _run('nbdev_install_hooks')\n", " \n", " fn = 'random.ipynb'\n", " p = nbs_path/fn\n", " write_nb(base, p)\n", " _run(f\"git add . && git commit -m 'add {fn}'\")\n", " default = _git_brunch_current()\n", "\n", " feature = 'add-heading'\n", " _run(f'git checkout -b {feature}')\n", " write_nb(thrs, p)\n", " _run(\"git commit -am 'heading'\")\n", "\n", " _run(f'git checkout {default}')\n", " write_nb(ours, p)\n", " _run(\"git commit -am 'docs'\")\n", "\n", " proc = _run(f'git merge {feature}', check=False)\n", " if proc.stderr: raise AssertionError(f'Git hook failed with:\\n\\n{proc.stderr}')\n", " assert proc.returncode != 0, proc.stdout.strip() # Should error since we can't autofix cell source change\n", " nb = read_nb(p)\n", "\n", "s = [o.source for o in nb.cells]\n", "test_eq(s, ['# Random numbers',\n", " '`<<<<<<< HEAD`',\n", " 'import random,os',\n", " 'Calculate a random number:',\n", " '`=======`',\n", " 'import random,sys',\n", " '`>>>>>>> add-heading`',\n", " 'random.random()'])\n", "test_eq(nb.cells[-1].output, ours.cells[-1].output)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Export -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "from nbdev import nbdev_export\n", "nbdev_export()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "jupytext": { "split_at_heading": true }, "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" } }, "nbformat": 4, "nbformat_minor": 4 }