Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
jantic
GitHub Repository: jantic/deoldify
Path: blob/master/fastai/gen_doc/nbtest.py
781 views
1
"`gen_doc.nbtest` shows pytest documentation for module functions"
2
3
import inspect, os, re
4
from os.path import abspath, dirname, join
5
from collections import namedtuple
6
7
from fastai.gen_doc import nbdoc
8
from ..imports.core import *
9
from .core import ifnone
10
from .doctest import get_parent_func, relative_test_path, get_func_fq_name, DB_NAME
11
12
from nbconvert import HTMLExporter
13
from IPython.core import page
14
from IPython.core.display import display, Markdown, HTML
15
16
__all__ = ['show_test', 'doctest', 'find_related_tests', 'lookup_db', 'find_test_matches', 'find_test_files', 'fuzzy_test_match', 'get_pytest_html']
17
18
TestFunctionMatch = namedtuple('TestFunctionMatch', ['line_number', 'line'])
19
20
def show_test(elt)->str:
21
"Show associated tests for a fastai function/class"
22
md = build_tests_markdown(elt)
23
display(Markdown(md))
24
25
def doctest(elt):
26
"Inline notebook popup for `show_test`"
27
md = build_tests_markdown(elt)
28
output = nbdoc.md2html(md)
29
try: page.page({'text/html': output})
30
except: display(Markdown(md))
31
32
def build_tests_markdown(elt):
33
fn_name = nbdoc.fn_name(elt)
34
md = ''
35
db_matches = [get_links(t) for t in lookup_db(elt)]
36
md += tests2md(db_matches, '')
37
try:
38
related = [get_links(t) for t in find_related_tests(elt)]
39
other_tests = [k for k in OrderedDict.fromkeys(related) if k not in db_matches]
40
md += tests2md(other_tests, f'Some other tests where `{fn_name}` is used:')
41
except OSError as e: pass
42
43
if len(md.strip())==0:
44
return (f'No tests found for `{fn_name}`.'
45
' To contribute a test please refer to [this guide](/dev/test.html)'
46
' and [this discussion](https://forums.fast.ai/t/improving-expanding-functional-tests/32929).')
47
return (f'Tests found for `{fn_name}`: {md}'
48
'\n\nTo run tests please refer to this [guide](/dev/test.html#quick-guide).')
49
50
def tests2md(tests, type_label:str):
51
if not tests: return ''
52
md = [f'\n\n{type_label}'] + [f'* `{cmd}` {link}' for link,cmd in sorted(tests, key=lambda k: k[1])]
53
return '\n'.join(md)
54
55
def get_pytest_html(elt, anchor_id:str)->Tuple[str,str]:
56
md = build_tests_markdown(elt)
57
html = nbdoc.md2html(md).replace('\n','') # nbconverter fails to parse markdown if it has both html and '\n'
58
anchor_id = anchor_id.replace('.', '-') + '-pytest'
59
link, body = get_pytest_card(html, anchor_id)
60
return link, body
61
62
def get_pytest_card(html, anchor_id):
63
"creates a collapsible bootstrap card for `show_test`"
64
link = f'<a class="source_link" data-toggle="collapse" data-target="#{anchor_id}" style="float:right; padding-right:10px">[test]</a>'
65
body = (f'<div class="collapse" id="{anchor_id}"><div class="card card-body pytest_card">'
66
f'<a type="button" data-toggle="collapse" data-target="#{anchor_id}" class="close" aria-label="Close"><span aria-hidden="true">&times;</span></a>'
67
f'{html}'
68
'</div></div>')
69
return link, body
70
71
def lookup_db(elt)->List[Dict]:
72
"Finds `this_test` entries from test_registry.json"
73
db_file = Path(abspath(join(dirname( __file__ ), '..')))/DB_NAME
74
if not db_file.exists():
75
raise Exception(f'Could not find {db_file}. Please make sure it exists at "{db_file}" or run `make test`')
76
with open(db_file, 'r') as f:
77
db = json.load(f)
78
key = get_func_fq_name(elt)
79
return db.get(key, [])
80
81
def find_related_tests(elt)->Tuple[List[Dict],List[Dict]]:
82
"Searches `fastai/tests` folder for any test functions related to `elt`"
83
related_matches = []
84
for test_file in find_test_files(elt):
85
fuzzy_matches = find_test_matches(elt, test_file)
86
related_matches.extend(fuzzy_matches)
87
return related_matches
88
89
def get_tests_dir(elt)->Path:
90
"Absolute path of `fastai/tests` directory"
91
test_dir = Path(__file__).parent.parent.parent.resolve()/'tests'
92
if not test_dir.exists(): raise OSError('Could not find test directory at this location:', test_dir)
93
return test_dir
94
95
def get_file(elt)->str:
96
if hasattr(elt, '__wrapped__'): elt = elt.__wrapped__
97
if not nbdoc.is_fastai_class(elt): return None
98
return inspect.getfile(elt)
99
100
def find_test_files(elt, exact_match:bool=False)->List[Path]:
101
"Searches in `fastai/tests` directory for module tests"
102
test_dir = get_tests_dir(elt)
103
matches = [test_dir/o.name for o in os.scandir(test_dir) if _is_file_match(elt, o.name)]
104
# if len(matches) != 1: raise Error('Could not find exact file match:', matches)
105
return matches
106
107
def _is_file_match(elt, file_name:str, exact_match:bool=False)->bool:
108
fp = get_file(elt)
109
if fp is None: return False
110
subdir = ifnone(_submodule_name(elt), '')
111
exact_re = '' if exact_match else '\w*'
112
return re.match(f'test_{subdir}\w*{Path(fp).stem}{exact_re}\.py', file_name)
113
114
def _submodule_name(elt)->str:
115
"Returns submodule - utils, text, vision, imports, etc."
116
if inspect.ismodule(elt): return None
117
modules = elt.__module__.split('.')
118
if len(modules) > 2:
119
return modules[1]
120
return None
121
122
def find_test_matches(elt, test_file:Path)->Tuple[List[Dict],List[Dict]]:
123
"Find all functions in `test_file` related to `elt`"
124
lines = get_lines(test_file)
125
rel_path = relative_test_path(test_file)
126
fn_name = get_qualname(elt) if not inspect.ismodule(elt) else ''
127
return fuzzy_test_match(fn_name, lines, rel_path)
128
129
def get_qualname(elt):
130
return elt.__qualname__ if hasattr(elt, '__qualname__') else fn_name(elt)
131
132
def separate_comp(qualname:str):
133
if not isinstance(qualname, str): qualname = get_qualname(qualname)
134
parts = qualname.split('.')
135
parts[-1] = remove_underscore(parts[-1])
136
if len(parts) == 1: return [], parts[0]
137
return parts[:-1], parts[-1]
138
139
def remove_underscore(fn_name):
140
if fn_name and fn_name[0] == '_': return fn_name[1:] # remove private method underscore prefix
141
return fn_name
142
143
def fuzzy_test_match(fn_name:str, lines:List[Dict], rel_path:str)->List[TestFunctionMatch]:
144
"Find any lines where `fn_name` is invoked and return the parent test function"
145
fuzzy_line_matches = _fuzzy_line_match(fn_name, lines)
146
fuzzy_matches = [get_parent_func(lno, lines, ignore_missing=True) for lno,_ in fuzzy_line_matches]
147
fuzzy_matches = list(filter(None.__ne__, fuzzy_matches))
148
return [map_test(rel_path, lno, l) for lno,l in fuzzy_matches]
149
150
def _fuzzy_line_match(fn_name:str, lines)->List[TestFunctionMatch]:
151
"Find any lines where `fn_name` is called"
152
result = []
153
_,fn_name = separate_comp(fn_name)
154
for idx,line in enumerate(lines):
155
if re.match(f'.*[\s\.\(]{fn_name}[\.\(]', line):
156
result.append((idx,line))
157
return result
158
159
def get_lines(file:Path)->List[str]:
160
with open(file, 'r') as f: return f.readlines()
161
162
def map_test(test_file, line, line_text):
163
"Creates dictionary test format to match doctest api"
164
test_name = re.match(f'\s*def (test_\w*)', line_text).groups(0)[0]
165
return { 'file': test_file, 'line': line, 'test': test_name }
166
167
def get_links(metadata)->Tuple[str,str]:
168
"Returns source code link and pytest command"
169
return nbdoc.get_source_link(**metadata), pytest_command(**metadata)
170
171
def pytest_command(file:str, test:str, **kwargs)->str:
172
"Returns CLI command to run specific test function"
173
return f'pytest -sv {file}::{test}'
174
175