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