|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""Every ``pcapkit.const`` subpackage is re-exported and documented (#2656). |
| 3 | +
|
| 4 | +A subpackage whose ``from pcapkit.const.<name> import *`` and ``__all__`` entries leave |
| 5 | +:file:`pcapkit/const/__init__.py`, or whose line leaves the toctree in |
| 6 | +:file:`docs/source/pcapkit/const/index.rst`, fails here. The subpackages are read from |
| 7 | +the tree with :func:`pkgutil.iter_modules`, so one added to the tree is held to both |
| 8 | +rules without an edit here, as *Round-Trip Invariants* in |
| 9 | +:file:`docs/source/contributing/conventions/test-coverage.rst` asks of a sweep. |
| 10 | +
|
| 11 | +* :class:`ReaderTests` -- the toctree, ``__all__`` and star-import readers the sweeps use. |
| 12 | +* :class:`ConstPackageTests` -- each subpackage is star-imported by |
| 13 | + :mod:`pcapkit.const`, lists every name of its own ``__all__`` in |
| 14 | + :data:`pcapkit.const.__all__`, and is in the toctree of the index page. |
| 15 | +* :class:`VendorPackageTests` -- the toctree rule for :mod:`pcapkit.vendor`, which |
| 16 | + mirrors :mod:`pcapkit.const` (*Const-Vendor Pairing* in |
| 17 | + :file:`docs/source/contributing/conventions/const-vendor-pairing.rst`). Its |
| 18 | + re-export is held by ``CrawlerReachabilityTests`` in |
| 19 | + :file:`tests/vendor/test_crawler_reachability_unit.py`, so it is not repeated. |
| 20 | +
|
| 21 | +The check is textual and imports nothing. |
| 22 | +
|
| 23 | +""" |
| 24 | + |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +import ast |
| 28 | +import pathlib |
| 29 | +import pkgutil |
| 30 | +import re |
| 31 | +import tempfile |
| 32 | +import unittest |
| 33 | + |
| 34 | +#: Test tier (#1958): reads the package trees and the docs index pages, and imports nothing. |
| 35 | +__tier__ = 'LATEST' |
| 36 | + |
| 37 | +ROOT = pathlib.Path(__file__).resolve().parents[2] |
| 38 | + |
| 39 | +#: The docs pages of :mod:`pcapkit`'s packages. |
| 40 | +DOCS = ROOT / 'docs' / 'source' / 'pcapkit' |
| 41 | + |
| 42 | +#: Modules directly under a checked package that are no registry package, so owe no |
| 43 | +#: re-export and no toctree line, keyed by package and then module. Each reason is |
| 44 | +#: asserted by a test of its own, so an entry cannot outlive it. |
| 45 | +NOT_A_REGISTRY = { |
| 46 | + 'vendor': { |
| 47 | + '__main__': 'the pcapkit-vendor command line: it defines main, and the index page ' |
| 48 | + 'documents it under a module directive of its own', |
| 49 | + 'default': 'the Vendor base class every crawler subclasses: pcapkit.vendor imports it ' |
| 50 | + 'by name and keeps it out of __all__', |
| 51 | + }, |
| 52 | +} |
| 53 | + |
| 54 | +#: Registry packages of :mod:`pcapkit.const` that :file:`pcapkit/const/__init__.py` does not |
| 55 | +#: re-export, a recorded defect (#2661). Each reason names what is missing. Wiring a package |
| 56 | +#: deletes its entry, which ``test_unwired_entries_hold`` enforces. |
| 57 | +KNOWN_UNWIRED = { |
| 58 | + 'linux': 'no "from pcapkit.const.linux import *" in pcapkit/const/__init__.py, and the name ' |
| 59 | + 'is unprefixed: pcapkit.const.linux.__all__ lists ARPHRD, where the house prefix ' |
| 60 | + 'gives Linux_ARPHRD, as pcapkit.vendor.linux.__all__ lists it', |
| 61 | +} |
| 62 | + |
| 63 | +#: A ``.. toctree::`` directive, capturing its indentation. |
| 64 | +TOCTREE = re.compile(r'^(\s*)\.\. toctree::\s*$') |
| 65 | + |
| 66 | +#: The target of a ``Title <target>`` toctree entry. |
| 67 | +TITLED = re.compile(r'<([^>]+)>\s*$') |
| 68 | + |
| 69 | + |
| 70 | +def members(package: 'str') -> 'dict[str, bool]': |
| 71 | + """Every module directly under :mod:`pcapkit.<package>`, mapped to whether it is a package.""" |
| 72 | + return {info.name: info.ispkg |
| 73 | + for info in pkgutil.iter_modules([str(ROOT / 'pcapkit' / package)])} |
| 74 | + |
| 75 | + |
| 76 | +def subpackages(package: 'str') -> 'list[str]': |
| 77 | + """The packages directly under :mod:`pcapkit.<package>`, sorted.""" |
| 78 | + return sorted(name for name, ispkg in members(package).items() if ispkg) |
| 79 | + |
| 80 | + |
| 81 | +def parse(path: 'pathlib.Path') -> 'ast.Module': |
| 82 | + """The syntax tree of the module at ``path``.""" |
| 83 | + return ast.parse(path.read_text(encoding='utf-8'), filename=str(path)) |
| 84 | + |
| 85 | + |
| 86 | +def literal_all(path: 'pathlib.Path') -> 'list[str]': |
| 87 | + """The names in the literal ``__all__`` of the module at ``path``. |
| 88 | +
|
| 89 | + Raises: |
| 90 | + AssertionError: The module has no ``__all__``, or one that is not a literal list of |
| 91 | + names, which this module cannot read. |
| 92 | +
|
| 93 | + """ |
| 94 | + for node in parse(path).body: |
| 95 | + if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == '__all__' |
| 96 | + for target in node.targets): |
| 97 | + try: |
| 98 | + names = ast.literal_eval(node.value) |
| 99 | + except ValueError: |
| 100 | + names = None |
| 101 | + if not isinstance(names, (list, tuple)) or not all(isinstance(name, str) for name in names): |
| 102 | + raise AssertionError(f'{path} spells __all__ as something other than a literal list of names') |
| 103 | + return list(names) |
| 104 | + raise AssertionError(f'{path} defines no __all__') |
| 105 | + |
| 106 | + |
| 107 | +def star_imports(path: 'pathlib.Path') -> 'set[str]': |
| 108 | + """The modules the module at ``path`` star-imports, at any depth.""" |
| 109 | + return {node.module for node in ast.walk(parse(path)) |
| 110 | + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module is not None |
| 111 | + and any(alias.name == '*' for alias in node.names)} |
| 112 | + |
| 113 | + |
| 114 | +def toctree_entries(path: 'pathlib.Path') -> 'set[str]': |
| 115 | + """The documents listed by every toctree in the page at ``path``, as written. |
| 116 | +
|
| 117 | + A ``Title <target>`` entry counts as its target. Options and comments are skipped. |
| 118 | +
|
| 119 | + """ |
| 120 | + entries = set() # type: set[str] |
| 121 | + indent = None # type: int | None |
| 122 | + for line in path.read_text(encoding='utf-8').splitlines(): |
| 123 | + directive = TOCTREE.match(line) |
| 124 | + if directive: |
| 125 | + indent = len(directive.group(1)) |
| 126 | + continue |
| 127 | + if indent is None or not line.strip(): |
| 128 | + continue |
| 129 | + if len(line) - len(line.lstrip()) <= indent: |
| 130 | + indent = None |
| 131 | + continue |
| 132 | + text = line.strip() |
| 133 | + if text.startswith((':', '..')): |
| 134 | + continue |
| 135 | + titled = TITLED.search(text) |
| 136 | + entries.add(titled.group(1) if titled else text) |
| 137 | + return entries |
| 138 | + |
| 139 | + |
| 140 | +def index_page(package: 'str') -> 'pathlib.Path': |
| 141 | + """The page that documents :mod:`pcapkit.<package>`.""" |
| 142 | + return DOCS / package / 'index.rst' |
| 143 | + |
| 144 | + |
| 145 | +def unlisted(package: 'str') -> 'list[str]': |
| 146 | + """The subpackages of :mod:`pcapkit.<package>` that no toctree of its index page lists.""" |
| 147 | + listed = toctree_entries(index_page(package)) |
| 148 | + return [name for name in subpackages(package) if name not in listed] |
| 149 | + |
| 150 | + |
| 151 | +def unexcused(package: 'str') -> 'list[str]': |
| 152 | + """The plain modules directly under :mod:`pcapkit.<package>` that ``NOT_A_REGISTRY`` omits.""" |
| 153 | + plain = {name for name, ispkg in members(package).items() if not ispkg} |
| 154 | + return sorted(plain - set(NOT_A_REGISTRY.get(package, ()))) |
| 155 | + |
| 156 | + |
| 157 | +def stale_excuses(package: 'str') -> 'list[str]': |
| 158 | + """The ``NOT_A_REGISTRY`` entries of ``package`` that are not plain modules under it.""" |
| 159 | + plain = {name for name, ispkg in members(package).items() if not ispkg} |
| 160 | + return sorted(set(NOT_A_REGISTRY.get(package, ())) - plain) |
| 161 | + |
| 162 | + |
| 163 | +class ReaderTests(unittest.TestCase): |
| 164 | + """The readers find what the sweeps rely on, so a green sweep is not a reader that saw nothing.""" |
| 165 | + |
| 166 | + def write(self, text: 'str') -> 'pathlib.Path': |
| 167 | + """A scratch file holding ``text``.""" |
| 168 | + directory = tempfile.TemporaryDirectory() |
| 169 | + self.addCleanup(directory.cleanup) |
| 170 | + path = pathlib.Path(directory.name) / 'page' |
| 171 | + path.write_text(text, encoding='utf-8') |
| 172 | + return path |
| 173 | + |
| 174 | + def test_toctree_entries_read_every_form(self) -> 'None': |
| 175 | + """Plain, titled and nested entries count; options, comments and prose do not.""" |
| 176 | + path = self.write('Title\n=====\n\n' |
| 177 | + '.. toctree::\n :maxdepth: 2\n :caption: Layers\n\n' |
| 178 | + ' plain\n Shown <titled>\n .. commented\n\n' |
| 179 | + 'prose after the block\n\n' |
| 180 | + '.. only:: html\n\n .. toctree::\n\n nested\n\n' |
| 181 | + '.. note::\n\n not_in_a_toctree\n') |
| 182 | + self.assertEqual(toctree_entries(path), {'plain', 'titled', 'nested'}) |
| 183 | + |
| 184 | + def test_literal_all_reads_a_list_and_refuses_the_rest(self) -> 'None': |
| 185 | + """A missing, computed or non-string ``__all__`` raises instead of reading as empty.""" |
| 186 | + self.assertEqual(literal_all(self.write("__all__ = ['a', 'b']\n")), ['a', 'b']) |
| 187 | + for text in ('x = 1\n', '__all__ = sorted(names)\n', '__all__ = [1]\n'): |
| 188 | + with self.subTest(text=text), self.assertRaises(AssertionError): |
| 189 | + literal_all(self.write(text)) |
| 190 | + |
| 191 | + def test_star_imports_read_nested_absolute_ones(self) -> 'None': |
| 192 | + """A star import under ``if``/``else`` counts; a named or relative one does not.""" |
| 193 | + path = self.write('from a.b import *\nif x:\n from c import *\nfrom d import e\nfrom . import *\n') |
| 194 | + self.assertEqual(star_imports(path), {'a.b', 'c'}) |
| 195 | + |
| 196 | + |
| 197 | +class ConstPackageTests(unittest.TestCase): |
| 198 | + """Each :mod:`pcapkit.const` subpackage is re-exported and listed in its index page.""" |
| 199 | + |
| 200 | + init = ROOT / 'pcapkit' / 'const' / '__init__.py' |
| 201 | + |
| 202 | + def test_the_walk_found_subpackages_and_a_toctree(self) -> 'None': |
| 203 | + """An empty walk or toctree would let every loop below pass without checking a member.""" |
| 204 | + self.assertTrue(subpackages('const')) |
| 205 | + self.assertTrue(toctree_entries(index_page('const'))) |
| 206 | + |
| 207 | + def test_every_subpackage_is_star_imported(self) -> 'None': |
| 208 | + """Its ``from pcapkit.const.<name> import *`` is what puts the names on the package.""" |
| 209 | + stars = star_imports(self.init) |
| 210 | + missing = [name for name in subpackages('const') |
| 211 | + if name not in KNOWN_UNWIRED and f'pcapkit.const.{name}' not in stars] |
| 212 | + self.assertEqual(missing, [], 'add a "from pcapkit.const.<name> import *" for each') |
| 213 | + |
| 214 | + def test_every_subpackage_name_is_in_all(self) -> 'None': |
| 215 | + """Each name of its own ``__all__`` is in :data:`pcapkit.const.__all__`.""" |
| 216 | + exported = set(literal_all(self.init)) |
| 217 | + missing = {} # type: dict[str, list[str]] |
| 218 | + for name in subpackages('const'): |
| 219 | + if name in KNOWN_UNWIRED: |
| 220 | + continue |
| 221 | + names = literal_all(self.init.parent / name / '__init__.py') |
| 222 | + self.assertTrue(names, f'pcapkit.const.{name}.__all__ is empty') |
| 223 | + lacking = [item for item in names if item not in exported] |
| 224 | + if lacking: |
| 225 | + missing[name] = lacking |
| 226 | + self.assertEqual(missing, {}, 'add each name to __all__ in pcapkit/const/__init__.py') |
| 227 | + |
| 228 | + def test_every_subpackage_is_in_the_toctree(self) -> 'None': |
| 229 | + """A package with no toctree line is orphaned from the reference.""" |
| 230 | + self.assertEqual(unlisted('const'), [], |
| 231 | + f'add each to a toctree of {index_page("const").relative_to(ROOT)}') |
| 232 | + |
| 233 | + def test_every_plain_module_is_excused(self) -> 'None': |
| 234 | + """A module that is no package owes neither a re-export nor a line, and says why.""" |
| 235 | + self.assertEqual(unexcused('const'), [], |
| 236 | + 'make each a package, or add it to NOT_A_REGISTRY with the reason') |
| 237 | + self.assertEqual(stale_excuses('const'), []) |
| 238 | + |
| 239 | + def test_unwired_entries_hold(self) -> 'None': |
| 240 | + """An entry names a real package that is neither star-imported nor listed in ``__all__``.""" |
| 241 | + stars = star_imports(self.init) |
| 242 | + exported = set(literal_all(self.init)) |
| 243 | + for name in KNOWN_UNWIRED: |
| 244 | + with self.subTest(package=name): |
| 245 | + self.assertTrue(name in subpackages('const'), 'names no subpackage of pcapkit.const') |
| 246 | + self.assertFalse(f'pcapkit.const.{name}' in stars, 'is star-imported; delete the entry') |
| 247 | + listed = [item for item in literal_all(self.init.parent / name / '__init__.py') |
| 248 | + if item in exported] |
| 249 | + self.assertEqual(listed, [], 'is listed in __all__; delete the entry') |
| 250 | + |
| 251 | + |
| 252 | +class VendorPackageTests(unittest.TestCase): |
| 253 | + """The :mod:`pcapkit.vendor` index page lists every crawler package.""" |
| 254 | + |
| 255 | + vendor = ROOT / 'pcapkit' / 'vendor' |
| 256 | + |
| 257 | + def test_the_walk_found_subpackages_and_a_toctree(self) -> 'None': |
| 258 | + """An empty walk or toctree would let the loop below pass without checking a member.""" |
| 259 | + self.assertTrue(subpackages('vendor')) |
| 260 | + self.assertTrue(toctree_entries(index_page('vendor'))) |
| 261 | + |
| 262 | + def test_every_subpackage_is_in_the_toctree(self) -> 'None': |
| 263 | + """A crawler package with no toctree line is orphaned from the reference.""" |
| 264 | + self.assertEqual(unlisted('vendor'), [], |
| 265 | + f'add each to a toctree of {index_page("vendor").relative_to(ROOT)}') |
| 266 | + |
| 267 | + def test_every_plain_module_is_excused(self) -> 'None': |
| 268 | + """A module that is no package owes no toctree line, and says why.""" |
| 269 | + self.assertEqual(unexcused('vendor'), [], |
| 270 | + 'make each a package, or add it to NOT_A_REGISTRY with the reason') |
| 271 | + self.assertEqual(stale_excuses('vendor'), []) |
| 272 | + |
| 273 | + def command_line_is_no_registry(self) -> 'None': |
| 274 | + """``__main__`` defines ``main``, and the index page documents it by a module directive.""" |
| 275 | + defined = {node.name for node in parse(self.vendor / '__main__.py').body |
| 276 | + if isinstance(node, ast.FunctionDef)} |
| 277 | + self.assertIn('main', defined) |
| 278 | + self.assertIn('.. module:: pcapkit.vendor.__main__', |
| 279 | + index_page('vendor').read_text(encoding='utf-8')) |
| 280 | + |
| 281 | + def base_class_is_no_registry(self) -> 'None': |
| 282 | + """``default`` defines ``Vendor``, which the package imports by name and does not export.""" |
| 283 | + defined = {node.name for node in parse(self.vendor / 'default.py').body |
| 284 | + if isinstance(node, ast.ClassDef)} |
| 285 | + self.assertIn('Vendor', defined) |
| 286 | + init = self.vendor / '__init__.py' |
| 287 | + imported = [alias.name for node in ast.walk(parse(init)) |
| 288 | + if isinstance(node, ast.ImportFrom) and node.module == 'pcapkit.vendor.default' |
| 289 | + for alias in node.names] |
| 290 | + self.assertEqual(imported, ['Vendor']) |
| 291 | + self.assertNotIn('Vendor', literal_all(init)) |
| 292 | + |
| 293 | + def test_every_excuse_holds_its_reason(self) -> 'None': |
| 294 | + """Each ``NOT_A_REGISTRY`` entry has an assertion of its reason, and no assertion is orphaned.""" |
| 295 | + asserted = {'__main__': self.command_line_is_no_registry, |
| 296 | + 'default': self.base_class_is_no_registry} |
| 297 | + self.assertEqual(sorted(asserted), sorted(NOT_A_REGISTRY['vendor']), |
| 298 | + 'assert the reason of each excuse, and only of those') |
| 299 | + for name, check in asserted.items(): |
| 300 | + with self.subTest(module=name): |
| 301 | + check() |
| 302 | + |
| 303 | + |
| 304 | +if __name__ == '__main__': |
| 305 | + unittest.main() |
0 commit comments