# export
from nbdev.imports import *
from fastcore.script import *
# default_exp export
# default_cls_lvl 3
#hide
#not a dep but we need it for show_doc
from nbdev.showdoc import show_doc
The functions that transform notebooks in a library
The most important function defined in this module is notebooks2script
, so you may want to jump to it before scrolling though the rest, which explain the details behind the scenes of the conversion from notebooks to library. The main things to remember are:
# export
flag on each cell you want exported# exports
flag (for export and show) on each cell you want exported with the source code shown in the docs# exporti
flag (for export internal) on each cell you want exported without it being added to __all__
, and without it showing up in the docs.# default_exp
flag followed by the name of the module (with points for submodules and without the py extension) everything should be exported in (if one specific cell needs to be exported in a different module, just indicate it after the # export
flag: # export special.module
)__all__
automatically__all__
if it's not picked automatically, write an exported cell with something like _all_ = ["my_name"]
(the single underscores are intentional)For bootstrapping nbdev
we have a few basic foundations defined in imports
, which we test a show here. First, a simple config file class, Config
that read the content of your settings.ini
file and make it accessible:
show_doc(Config, title_level=3)
create_config("github", "nbdev", user='fastai', path='..', tst_flags='tst', cfg_name='test_settings.ini')
cfg = Config(cfg_name='test_settings.ini')
test_eq(cfg.lib_name, 'nbdev')
test_eq(cfg.git_url, "https://github.com/fastai/nbdev/tree/master/")
test_eq(cfg.lib_path, Path.cwd().parent/'nbdev')
test_eq(cfg.nbs_path, Path.cwd())
test_eq(cfg.doc_path, Path.cwd().parent/'docs')
test_eq(cfg.custom_sidebar, 'False')
We derive some useful variables to check what environment we're in:
if not os.environ.get("IN_TEST", None):
assert IN_NOTEBOOK
assert not IN_COLAB
assert IN_IPYTHON
Then we have a few util functions.
show_doc(last_index)
last_index
[source]
last_index
(x
,o
)
Finds the last index of occurrence of x
in o
(returns -1 if no occurrence)
test_eq(last_index(1, [1,2,1,3,1,4]), 4)
test_eq(last_index(2, [1,2,1,3,1,4]), 1)
test_eq(last_index(5, [1,2,1,3,1,4]), -1)
show_doc(compose)
compose
[source]
compose
(***funcs
,order
*=None
*)
Create a function that composes all functions in funcs
, passing along remaining *args
and **kwargs
to all
f1 = lambda o,p=0: (o*2)+p
f2 = lambda o,p=1: (o+1)/p
test_eq(f2(f1(3)), compose(f1,f2)(3))
test_eq(f2(f1(3,p=3),p=3), compose(f1,f2)(3,p=3))
test_eq(f2(f1(3, 3), 3), compose(f1,f2)(3, 3))
show_doc(parallel)
parallel
[source]
parallel
(f
,items
, ***args
,n_workers
=None
, **kwargs
**)
Applies func
in parallel to items
, using n_workers
import time,random
def add_one(x, a=1):
time.sleep(random.random()/100)
return x+a
inp,exp = range(50),range(1,51)
test_eq(parallel(add_one, inp, n_workers=2), list(exp))
test_eq(parallel(add_one, inp, n_workers=0), list(exp))
test_eq(parallel(add_one, inp, n_workers=1, a=2), list(range(2,52)))
test_eq(parallel(add_one, inp, n_workers=0, a=2), list(range(2,52)))
#export
def first(x):
"First element of `x`, or None if missing"
try: return next(iter(x))
except StopIteration: return None
A jupyter notebook is a json file behind the scenes. We can just read it with the json module, which will return a nested dictionary of dictionaries/lists of dictionaries, but there are some small differences between reading the json and using the tools from nbformat
so we'll use this one.
#export
def read_nb(fname):
"Read the notebook in `fname`."
with open(Path(fname),'r', encoding='utf8') as f: return nbformat.reads(f.read(), as_version=4)
fname
can be a string or a pathlib object.
test_nb = read_nb('00_export.ipynb')
The root has four keys: cells
contains the cells of the notebook, metadata
some stuff around the version of python used to execute the notebook, nbformat
and nbformat_minor
the version of nbformat.
test_nb.keys()
dict_keys(['cells', 'metadata', 'nbformat', 'nbformat_minor'])
test_nb['metadata']
{'jupytext': {'split_at_heading': True}, 'kernelspec': {'display_name': 'Python 3', 'language': 'python', 'name': 'python3'}, 'language_info': {'codemirror_mode': {'name': 'ipython', 'version': 3}, 'file_extension': '.py', 'mimetype': 'text/x-python', 'name': 'python', 'nbconvert_exporter': 'python', 'pygments_lexer': 'ipython3', 'version': '3.7.7'}}
f"{test_nb['nbformat']}.{test_nb['nbformat_minor']}"
'4.4'
The cells key then contains a list of cells. Each one is a new dictionary that contains entries like the type (code or markdown), the source (what is written in the cell) and the output (for code cells).
test_nb['cells'][0]
{'cell_type': 'code', 'execution_count': None, 'metadata': {'hide_input': False}, 'outputs': [], 'source': '# export\nfrom nbdev.imports import *\nfrom fastcore.script import *'}
The following functions are used to catch the flags used in the code cells.
# export
def check_re(cell, pat, code_only=True):
"Check if `cell` contains a line with regex `pat`"
if code_only and cell['cell_type'] != 'code': return
if isinstance(pat, str): pat = re.compile(pat, re.IGNORECASE | re.MULTILINE)
return pat.search(cell['source'])
pat
can be a string or a compiled regex, if code_only=True
, this function ignores markdown cells.
cell = test_nb['cells'][0].copy()
assert check_re(cell, '# export') is not None
assert check_re(cell, re.compile('# export')) is not None
assert check_re(cell, '# bla') is None
cell['cell_type'] = 'markdown'
assert check_re(cell, '# export') is None
assert check_re(cell, '# export', code_only=False) is not None
# export
_re_blank_export = re.compile(r"""
# Matches any line with #export or #exports or #exporti without any module name:
^ # beginning of line (since re.MULTILINE is passed)
\s* # any number of whitespace
\#\s* # "#", then any number of whitespace
export[si]? # export or exports or exporti
\s* # any number of whitespace
$ # end of line (since re.MULTILINE is passed)
""", re.IGNORECASE | re.MULTILINE | re.VERBOSE)
# export
_re_mod_export = re.compile(r"""
# Matches any line with #export or #exports or #exporti with a module name and catches it in group 1:
^ # beginning of line (since re.MULTILINE is passed)
\s* # any number of whitespace
\#\s* # "#", then any number of whitespace
export[si]? # export or exports or exporti
\s+ # one or more whitespace chars
(\S+) # catch a group with any non-whitespace chars
\s* # any number of whitespace
$ # end of line (since re.MULTILINE is passed)
""", re.IGNORECASE | re.MULTILINE | re.VERBOSE)
# export
_re_internal_export = re.compile(r"""
# Matches any line with #export or #exports without any module name:
^ # beginning of line (since re.MULTILINE is passed)
\s* # any number of whitespace
\#\s* # "#", then any number of whitespace
exporti # export or exports or exporti
\s* # any number of whitespace
\S* # any number of non-whitespace chars
\s* # any number of whitespace
$ # end of line (since re.MULTILINE is passed)
""", re.IGNORECASE | re.MULTILINE | re.VERBOSE)
# export
def is_export(cell, default):
"Check if `cell` is to be exported and returns the name of the module to export it if provided"
tst = check_re(cell, _re_blank_export)
if tst:
if default is None:
print(f"This cell doesn't have an export destination and was ignored:\n{cell['source'][1]}")
return default, (_re_internal_export.search(tst.string) is None)
tst = check_re(cell, _re_mod_export)
if tst: return os.path.sep.join(tst.groups()[0].split('.')), (_re_internal_export.search(tst.string) is None)
else: return None
The cells to export are marked with an #export
, #exports
or #exporti
code, potentially with a module name where we want it exported. The default module is given in a cell of the form #default_exp bla
inside the notebook (usually at the top), though in this function, it needs the be passed (the final script will read the whole notebook to find it).
# export
, # exports
or # exporti
will be exported to the default module# export special.module
, # exports special.module
or # exporti special.module
will be exported in special.module (located in lib_name
/special/module.py)# export
will have it's signature added to the documentation# exports
will additionally have it's source code added to the documentation# exporti
will not show up in the documentation, and will also not be added to __all__
.cell = test_nb['cells'][0].copy()
test_eq(is_export(cell, 'export'), ('export', True))
cell['source'] = "# exports"
test_eq(is_export(cell, 'export'), ('export', True))
cell['source'] = "# exporti"
test_eq(is_export(cell, 'export'), ('export', False))
cell['source'] = "# export mod"
test_eq(is_export(cell, 'export'), ('mod', True))
cell['source'] = "# export mod.file"
test_eq(is_export(cell, 'export'), (f'mod{os.path.sep}file', True))
cell['source'] = "# exporti mod.file"
test_eq(is_export(cell, 'export'), (f'mod{os.path.sep}file', False))
cell['source'] = "# expt mod.file"
assert is_export(cell, 'export') is None
cell['source'] = "# exportmod.file"
assert is_export(cell, 'export') is None
cell['source'] = "# exportsmod.file"
assert is_export(cell, 'export') is None
# export
_re_default_exp = re.compile(r"""
# Matches any line with #default_exp with a module name and catches it in group 1:
^ # beginning of line (since re.MULTILINE is passed)
\s* # any number of whitespace
\#\s* # "#", then any number of whitespace
default_exp # default_exp
\s+ # one or more whitespace chars
(\S+) # catch a group with any non-whitespace chars
\s* # any number of whitespace
$ # end of line (since re.MULTILINE is passed)
""", re.IGNORECASE | re.MULTILINE | re.VERBOSE)
# export
def find_default_export(cells):
"Find in `cells` the default export module."
for cell in cells:
tst = check_re(cell, _re_default_exp)
if tst: return tst.groups()[0]
Stops at the first cell containing a # default_exp
flag (if there are several) and returns the value behind. Returns None
if there are no cell with that code.
test_eq(find_default_export(test_nb['cells']), 'export')
assert find_default_export(test_nb['cells'][2:]) is None
The following functions make a list of everything that is exported to prepare a proper __all__
for our exported module.
#export
_re_patch_func = re.compile(r"""
# Catches any function decorated with @patch, its name in group 1 and the patched class in group 2
@patch # At any place in the cell, something that begins with @patch
(?:\s*@.*)* # Any other decorator applied to the function
\s*def # Any number of whitespace (including a new line probably) followed by def
\s+ # One whitespace or more
([^\(\s]+) # Catch a group composed of anything but whitespace or an opening parenthesis (name of the function)
\s*\( # Any number of whitespace followed by an opening parenthesis
[^:]* # Any number of character different of : (the name of the first arg that is type-annotated)
:\s* # A column followed by any number of whitespace
(?: # Non-catching group with either
([^,\s\(\)]*) # a group composed of anything but a comma, a parenthesis or whitespace (name of the class)
| # or
(\([^\)]*\))) # a group composed of something between parenthesis (tuple of classes)
\s* # Any number of whitespace
(?:,|\)) # Non-catching group with either a comma or a closing parenthesis
""", re.VERBOSE)
tst = _re_patch_func.search("""
@patch
@log_args(a=1)
def func(obj:Class):""")
tst, tst.groups()
(<re.Match object; span=(1, 42), match='@patch\n@log_args(a=1)\ndef func(obj:Class)'>, ('func', 'Class', None))
#hide
tst = _re_patch_func.search("""
@patch
def func(obj:Class):""")
test_eq(tst.groups(), ("func", "Class", None))
tst = _re_patch_func.search("""
@patch
def func (obj:Class, a)""")
test_eq(tst.groups(), ("func", "Class", None))
tst = _re_patch_func.search("""
@patch
def func (obj:(Class1, Class2), a)""")
test_eq(tst.groups(), ("func", None, "(Class1, Class2)"))
tst = _re_patch_func.search("""
@patch
def func (obj:(Class1, Class2), a:int)->int:""")
test_eq(tst.groups(), ("func", None, "(Class1, Class2)"))
tst = _re_patch_func.search("""
@patch
@log_args(but='a,b')
@funcs_kwargs
def func (obj:(Class1, Class2), a:int)->int:""")
test_eq(tst.groups(), ("func", None, "(Class1, Class2)"))
tst = _re_patch_func.search("""
@patch
@contextmanager
def func (obj:Class, a:int)->int:""")
test_eq(tst.groups(), ("func", "Class", None))
#export
_re_typedispatch_func = re.compile(r"""
# Catches any function decorated with @typedispatch
(@typedispatch # At any place in the cell, catch a group with something that begins with @typedispatch
\s*def # Any number of whitespace (including a new line probably) followed by def
\s+ # One whitespace or more
[^\(]+ # Anything but whitespace or an opening parenthesis (name of the function)
\s*\( # Any number of whitespace followed by an opening parenthesis
[^\)]* # Any number of character different of )
\)[\s\S]*:) # A closing parenthesis followed by any number of characters and whitespace (type annotation) and :
""", re.VERBOSE)
#hide
assert _re_typedispatch_func.search("@typedispatch\ndef func(a, b):").groups() == ('@typedispatch\ndef func(a, b):',)
assert (_re_typedispatch_func.search("@typedispatch\ndef func(a:str, b:bool)->int:").groups() ==
('@typedispatch\ndef func(a:str, b:bool)->int:',))
#export
_re_class_func_def = re.compile(r"""
# Catches any 0-indented function or class definition with its name in group 1
^ # Beginning of a line (since re.MULTILINE is passed)
(?:async\sdef|def|class) # Non-catching group for def or class
\s+ # One whitespace or more
([^\(\s]+) # Catching group with any character except an opening parenthesis or a whitespace (name)
\s* # Any number of whitespace
(?:\(|:) # Non-catching group with either an opening parenthesis or a : (classes don't need ())
""", re.MULTILINE | re.VERBOSE)
#hide
test_eq(_re_class_func_def.search("class Class:").groups(), ('Class',))
test_eq(_re_class_func_def.search("def func(a, b):").groups(), ('func',))
test_eq(_re_class_func_def.search("def func(a:str, b:bool)->int:").groups(), ('func',))
test_eq(_re_class_func_def.search("async def func(a, b):").groups(), ('func',))
#export
_re_obj_def = re.compile(r"""
# Catches any 0-indented object definition (bla = thing) with its name in group 1
^ # Beginning of a line (since re.MULTILINE is passed)
([_a-zA-Z]+[a-zA-Z0-9_\.]*) # Catch a group which is a valid python variable name
\s* # Any number of whitespace
(?::\s*\S.*|)= # Non-catching group of either a colon followed by a type annotation, or nothing; followed by an =
""", re.MULTILINE | re.VERBOSE)
#hide
test_eq(_re_obj_def.search("a = 1").groups(), ('a',))
test_eq(_re_obj_def.search("a.b = 1").groups(), ('a.b',))
test_eq(_re_obj_def.search("_aA1=1").groups(), ('_aA1',))
test_eq(_re_obj_def.search("a : int =1").groups(), ('a',))
test_eq(_re_obj_def.search("a:f(':=')=1").groups(), ('a',))
assert _re_obj_def.search("@abc=2") is None
assert _re_obj_def.search("a a=2") is None
# export
def _not_private(n):
for t in n.split('.'):
if (t.startswith('_') and not t.startswith('__')) or t.startswith('@'): return False
return '\\' not in t and '^' not in t and '[' not in t and t != 'else'
def export_names(code, func_only=False):
"Find the names of the objects, functions or classes defined in `code` that are exported."
#Format monkey-patches with @patch
def _f(gps):
nm, cls, t = gps.groups()
if cls is not None: return f"def {cls}.{nm}():"
return '\n'.join([f"def {c}.{nm}():" for c in re.split(', *', t[1:-1])])
code = _re_typedispatch_func.sub('', code)
code = _re_patch_func.sub(_f, code)
names = _re_class_func_def.findall(code)
if not func_only: names += _re_obj_def.findall(code)
return [n for n in names if _not_private(n)]
This function only picks the zero-indented objects on the left side of an =, functions or classes (we don't want the class methods for instance) and excludes private names (that begin with _
) but no dunder names. It only returns func and class names (not the objects) when func_only=True
.
To work properly with fastai added python functionality, this function ignores function decorated with @typedispatch
(since they are defined multiple times) and unwraps properly functions decorated with @patch
.
test_eq(export_names("def my_func(x):\n pass\nclass MyClass():"), ["my_func", "MyClass"])
#Indented funcs are ignored (funcs inside a class)
test_eq(export_names(" def my_func(x):\n pass\nclass MyClass():"), ["MyClass"])
#Private funcs are ignored, dunder are not
test_eq(export_names("def _my_func():\n pass\nclass MyClass():"), ["MyClass"])
test_eq(export_names("__version__ = 1:\n pass\nclass MyClass():"), ["MyClass", "__version__"])
#trailing spaces
test_eq(export_names("def my_func ():\n pass\nclass MyClass():"), ["my_func", "MyClass"])
#class without parenthesis
test_eq(export_names("def my_func ():\n pass\nclass MyClass:"), ["my_func", "MyClass"])
#object and funcs
test_eq(export_names("def my_func ():\n pass\ndefault_bla=[]:"), ["my_func", "default_bla"])
test_eq(export_names("def my_func ():\n pass\ndefault_bla=[]:", func_only=True), ["my_func"])
#Private objects are ignored
test_eq(export_names("def my_func ():\n pass\n_default_bla = []:"), ["my_func"])
#Objects with dots are privates if one part is private
test_eq(export_names("def my_func ():\n pass\ndefault.bla = []:"), ["my_func", "default.bla"])
test_eq(export_names("def my_func ():\n pass\ndefault._bla = []:"), ["my_func"])
#Monkey-path with @patch are properly renamed
test_eq(export_names("@patch\ndef my_func(x:Class):\n pass"), ["Class.my_func"])
test_eq(export_names("@patch\ndef my_func(x:Class):\n pass", func_only=True), ["Class.my_func"])
test_eq(export_names("some code\n@patch\ndef my_func(x:Class, y):\n pass"), ["Class.my_func"])
test_eq(export_names("some code\n@patch\ndef my_func(x:(Class1,Class2), y):\n pass"), ["Class1.my_func", "Class2.my_func"])
#Check delegates
test_eq(export_names("@delegates(keep=True)\nclass someClass:\n pass"), ["someClass"])
#Typedispatch decorated functions shouldn't be added
test_eq(export_names("@patch\ndef my_func(x:Class):\n pass\n@typedispatch\ndef func(x: TensorImage): pass"), ["Class.my_func"])
#export
_re_all_def = re.compile(r"""
# Catches a cell with defines \_all\_ = [\*\*] and get that \*\* in group 1
^_all_ # Beginning of line (since re.MULTILINE is passed)
\s*=\s* # Any number of whitespace, =, any number of whitespace
\[ # Opening [
([^\n\]]*) # Catching group with anything except a ] or newline
\] # Closing ]
""", re.MULTILINE | re.VERBOSE)
#Same with __all__
_re__all__def = re.compile(r'^__all__\s*=\s*\[([^\]]*)\]', re.MULTILINE)
# export
def extra_add(code):
"Catch adds to `__all__` required by a cell with `_all_=`"
if _re_all_def.search(code):
names = _re_all_def.search(code).groups()[0]
names = re.sub('\s*,\s*', ',', names)
names = names.replace('"', "'")
code = _re_all_def.sub('', code)
code = re.sub(r'([^\n]|^)\n*$', r'\1', code)
return names.split(','),code
return [],code
Sometimes objects are not picked to be automatically added to the __all__
of the module so you will need to add them manually. To do so, create an exported cell with the following code _all_ = ["name"]
(the single underscores are intentional)>
test_eq(extra_add('_all_ = ["func", "func1", "func2"]'), (["'func'", "'func1'", "'func2'"],''))
test_eq(extra_add('_all_ = ["func", "func1" , "func2"]'), (["'func'", "'func1'", "'func2'"],''))
test_eq(extra_add("_all_ = ['func','func1', 'func2']\n"), (["'func'", "'func1'", "'func2'"],''))
test_eq(extra_add('code\n\n_all_ = ["func", "func1", "func2"]'), (["'func'", "'func1'", "'func2'"],'code'))
#export
def _add2add(fname, names, line_width=120):
if len(names) == 0: return
with open(fname, 'r', encoding='utf8') as f: text = f.read()
tw = TextWrapper(width=120, initial_indent='', subsequent_indent=' '*11, break_long_words=False)
re_all = _re__all__def.search(text)
start,end = re_all.start(),re_all.end()
text_all = tw.wrap(f"{text[start:end-1]}{'' if text[end-2]=='[' else ', '}{', '.join(names)}]")
with open(fname, 'w', encoding='utf8') as f: f.write(text[:start] + '\n'.join(text_all) + text[end:])
fname = 'test_add.txt'
with open(fname, 'w', encoding='utf8') as f: f.write("Bla\n__all__ = [my_file, MyClas]\nBli")
_add2add(fname, ['new_function'])
with open(fname, 'r', encoding='utf8') as f:
test_eq(f.read(), "Bla\n__all__ = [my_file, MyClas, new_function]\nBli")
_add2add(fname, [f'new_function{i}' for i in range(10)])
with open(fname, 'r', encoding='utf8') as f:
test_eq(f.read(), """Bla
__all__ = [my_file, MyClas, new_function, new_function0, new_function1, new_function2, new_function3, new_function4,
new_function5, new_function6, new_function7, new_function8, new_function9]
Bli""")
os.remove(fname)
# export
def relative_import(name, fname):
"Convert a module `name` to a name relative to `fname`"
mods = name.split('.')
splits = str(fname).split(os.path.sep)
if mods[0] not in splits: return name
i=len(splits)-1
while i>0 and splits[i] != mods[0]: i-=1
splits = splits[i:]
while len(mods)>0 and splits[0] == mods[0]: splits,mods = splits[1:],mods[1:]
return '.' * (len(splits)) + '.'.join(mods)
When we say from
from lib_name.module.submodule import bla
in a notebook, it needs to be converted to something like
from .module.submodule import bla
or
from .submodule import bla
depending on where we are. This function deals with those imports renaming.
Note that import of the form
import lib_name.module
are left as is as the syntax import module
does not work for relative imports.
test_eq(relative_import('nbdev.core', Path.cwd()/'nbdev'/'data.py'), '.core')
test_eq(relative_import('nbdev.core', Path('nbdev')/'vision'/'data.py'), '..core')
test_eq(relative_import('nbdev.vision.transform', Path('nbdev')/'vision'/'data.py'), '.transform')
test_eq(relative_import('nbdev.notebook.core', Path('nbdev')/'data'/'external.py'), '..notebook.core')
test_eq(relative_import('nbdev.vision', Path('nbdev')/'vision'/'learner.py'), '.')
#export
_re_import = ReLibName(r'^(\s*)from (LIB_NAME\.\S*) import (.*)$')
# export
def _deal_import(code_lines, fname):
def _replace(m):
sp,mod,obj = m.groups()
return f"{sp}from {relative_import(mod, fname)} import {obj}"
return [_re_import.re.sub(_replace,line) for line in code_lines]
#hide
lines = ["from nbdev.core import *",
"nothing to see",
" from nbdev.vision import bla1, bla2",
"from nbdev.vision import models",
"import nbdev.vision"]
test_eq(_deal_import(lines, Path.cwd()/'nbdev'/'data.py'), [
"from .core import *",
"nothing to see",
" from .vision import bla1, bla2",
"from .vision import models",
"import nbdev.vision"
])
To be able to build back a correspondence between functions and the notebooks they are defined in, we need to store an index. It's done in the private module _nbdev
inside your library, and the following function are used to define it.
#export
_re_index_custom = re.compile(r'def custom_doc_links\(name\):(.*)$', re.DOTALL)
#export
def reset_nbdev_module():
"Create a skeleton for <code>_nbdev</code>"
fname = Config().lib_path/'_nbdev.py'
fname.parent.mkdir(parents=True, exist_ok=True)
sep = '\n'* (int(Config().get('cell_spacing', '1'))+1)
if fname.is_file():
with open(fname, 'r') as f: search = _re_index_custom.search(f.read())
else: search = None
prev_code = search.groups()[0] if search is not None else ' return None\n'
with open(fname, 'w') as f:
f.write(f"# AUTOGENERATED BY NBDEV! DO NOT EDIT!")
f.write('\n\n__all__ = ["index", "modules", "custom_doc_links", "git_url"]')
f.write('\n\nindex = {}')
f.write('\n\nmodules = []')
f.write(f'\n\ndoc_url = "{Config().doc_host}{Config().doc_baseurl}"')
f.write(f'\n\ngit_url = "{Config().git_url}"')
f.write(f'{sep}def custom_doc_links(name):{prev_code}')
# export
class _EmptyModule():
def __init__(self):
self.index,self.modules = {},[]
self.doc_url,self.git_url = f"{Config().doc_host}{Config().doc_baseurl}",Config().git_url
def custom_doc_links(self, name): return None
# export
def get_nbdev_module():
"Reads <code>_nbdev</code>"
try:
spec = importlib.util.spec_from_file_location(f"{Config().lib_name}._nbdev", Config().lib_path/'_nbdev.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
except: return _EmptyModule()
#export
_re_index_idx = re.compile(r'index\s*=\s*{[^}]*}')
_re_index_mod = re.compile(r'modules\s*=\s*\[[^\]]*\]')
#export
def save_nbdev_module(mod):
"Save `mod` inside <code>_nbdev</code>"
fname = Config().lib_path/'_nbdev.py'
with open(fname, 'r') as f: code = f.read()
t = r',\n '.join([f'"{k}": "{v}"' for k,v in mod.index.items()])
code = _re_index_idx.sub("index = {"+ t +"}", code)
t = r',\n '.join(['"' + f.replace('\\','/') + '"' for f in mod.modules])
code = _re_index_mod.sub(f"modules = [{t}]", code)
with open(fname, 'w') as f: f.write(code)
#hide
ind,ind_bak = Config().lib_path/'_nbdev.py',Config().lib_path/'_nbdev.bak'
if ind.exists(): shutil.move(ind, ind_bak)
try:
reset_nbdev_module()
mod = get_nbdev_module()
test_eq(mod.index, {})
test_eq(mod.modules, [])
mod.index = {'foo':'bar'}
mod.modules.append('lala.bla')
save_nbdev_module(mod)
mod = get_nbdev_module()
test_eq(mod.index, {'foo':'bar'})
test_eq(mod.modules, ['lala.bla'])
finally:
if ind_bak.exists(): shutil.move(ind_bak, ind)
# export
def create_mod_file(fname, nb_path):
"Create a module file for `fname`."
fname.parent.mkdir(parents=True, exist_ok=True)
file_path = os.path.relpath(nb_path, Config().config_file.parent).replace('\\', '/')
with open(fname, 'w') as f:
f.write(f"# AUTOGENERATED! DO NOT EDIT! File to edit: {file_path} (unless otherwise specified).")
f.write('\n\n__all__ = []')
A new module filename is created each time a notebook has a cell marked with # default_exp
. In your collection of notebooks, you should only have one notebook that creates a given module since they are re-created each time you do a library build (to ensure the library is clean). Note that any file you create manually will never be overwritten (unless it has the same name as one of the modules defined in a # default_exp
cell) so you are responsible to clean up those yourself.
fname
is the notebook that contained the # default_exp
cell.
#export
def _notebook2script(fname, silent=False, to_dict=None):
"Finds cells starting with `#export` and puts them into a new module"
if os.environ.get('IN_TEST',0): return # don't export if running tests
sep = '\n'* (int(Config().get('cell_spacing', '1'))+1)
fname = Path(fname)
nb = read_nb(fname)
default = find_default_export(nb['cells'])
if default is not None:
default = os.path.sep.join(default.split('.'))
if to_dict is None: create_mod_file(Config().lib_path/f'{default}.py', Config().nbs_path/f'{fname}')
mod = get_nbdev_module()
exports = [is_export(c, default) for c in nb['cells']]
cells = [(i,c,e) for i,(c,e) in enumerate(zip(nb['cells'],exports)) if e is not None]
for i,c,(e,a) in cells:
fname_out = Config().lib_path/f'{e}.py'
orig = (f'# {"" if a else "Internal "}C' if e==default else f'# Comes from {fname.name}, c') + 'ell\n'
code = sep + orig + '\n'.join(_deal_import(c['source'].split('\n')[1:], fname_out))
names = export_names(code)
extra,code = extra_add(code)
if a:
if to_dict is None: _add2add(fname_out, [f"'{f}'" for f in names if '.' not in f and len(f) > 0] + extra)
mod.index.update({f: fname.name for f in names})
code = re.sub(r' +$', '', code, flags=re.MULTILINE)
if code != sep + orig[:-1]:
if to_dict is not None: to_dict[e].append((i, fname, code))
else:
with open(fname_out, 'a', encoding='utf8') as f: f.write(code)
if f'{e}.py' not in mod.modules: mod.modules.append(f'{e}.py')
save_nbdev_module(mod)
if not silent: print(f"Converted {fname.name}.")
return to_dict
#hide
_notebook2script('00_export.ipynb')
#hide
with open(Config().lib_path/('export.py')) as f: l = f.readline()
test_eq(l, '# AUTOGENERATED! DO NOT EDIT! File to edit: nbs/00_export.ipynb (unless otherwise specified).\n')
#export
def add_init(path):
"Add `__init__.py` in all subdirs of `path` containing python files if it's not there already"
for p,d,f in os.walk(path):
for f_ in f:
if f_.endswith('.py'):
if not (Path(p)/'__init__.py').exists(): (Path(p)/'__init__.py').touch()
break
with tempfile.TemporaryDirectory() as d:
os.makedirs(Path(d)/'a', exist_ok=True)
(Path(d)/'a'/'f.py').touch()
os.makedirs(Path(d)/'a/b', exist_ok=True)
(Path(d)/'a'/'b'/'f.py').touch()
add_init(d)
assert not (Path(d)/'__init__.py').exists()
for e in [Path(d)/'a', Path(d)/'a/b']:
assert (e/'__init__.py').exists()
#export
_re_version = re.compile('^__version__\s*=.*$', re.MULTILINE)
#export
def update_version():
"Add or update `__version__` in the main `__init__.py` of the library"
fname = Config().lib_path/'__init__.py'
if not fname.exists(): fname.touch()
version = f'__version__ = "{Config().version}"'
with open(fname, 'r') as f: code = f.read()
if _re_version.search(code) is None: code = version + "\n" + code
else: code = _re_version.sub(version, code)
with open(fname, 'w') as f: f.write(code)
#export
_re_baseurl = re.compile('^baseurl\s*:.*$', re.MULTILINE)
#export
def update_baseurl():
"Add or update `baseurl` in `_config.yml` for the docs"
fname = Config().doc_path/'_config.yml'
if not fname.exists(): return
with open(fname, 'r') as f: code = f.read()
if _re_baseurl.search(code) is None: code = code + f"\nbaseurl: {Config().doc_baseurl}"
else: code = _re_baseurl.sub(f"baseurl: {Config().doc_baseurl}", code)
with open(fname, 'w') as f: f.write(code)
#export
def notebook2script(fname=None, silent=False, to_dict=False):
"Convert notebooks matching `fname` to modules"
# initial checks
if os.environ.get('IN_TEST',0): return # don't export if running tests
if fname is None:
reset_nbdev_module()
update_version()
update_baseurl()
files = [f for f in Config().nbs_path.glob('*.ipynb') if not f.name.startswith('_')]
else: files = glob.glob(fname)
d = collections.defaultdict(list) if to_dict else None
for f in sorted(files): d = _notebook2script(f, silent=silent, to_dict=d)
if to_dict: return d
else: add_init(Config().lib_path)
Finds cells starting with #export
and puts them into the appropriate module. If fname
is not specified, this will convert all notebook not beginning with an underscore in the nb_folder
defined in setting.ini
. Otherwise fname
can be a single filename or a glob expression.
silent
makes the command not print any statement and to_dict
is used internally to convert the library to a dictionary.
#hide
#export
#for tests only
class DocsTestClass:
def test(): pass
#hide
notebook2script()