Repository navigation
fix(modules): name a module in a directory without __init__.py after its package - #276
Merged
Merged
Conversation
Pin what naming modules after their packages must keep: a module in packages is named after them, one in no package from its own directory, and no directory above the project root names a module; a script reaches the module beside it by its short name, a module beside it named like a library leaves the library modelled, and each importer reaches the helper of its own directory.
The module name of a file and the directory an import of it starts from were both found by walking up the packages above it, in two places. A file loaded from disk now carries the names it can be imported by, each with that directory, and the module graph reads its import root from them. Behaviour is unchanged.
…m the importer's roots in order A module may be imported by more than one name, each from its own directory. The graph indexes every name a module has, and resolves a name several files share from the first of the importer's roots that holds exactly one, its last name's root first, as a script finds what lies beside it before what its package's root holds. It also records which module each import name of an importer reached. With one name per module, behaviour is unchanged.
…s reached by A component's members refer to the modules they import by the names they resolved them by, which may differ from a module's name in the graph. Their summaries go under each of those names, a member's own resolution first, and a member registers its own functions under each of its import names that no other member shares; worker processes receive the names with each module. A module is still analysed once, whatever the number of names. With one name per module, behaviour is unchanged.
Under another name, what the modules importing a module reach, and the symbols of its own functions, change, so a module's names are part of its key. CACHE_FORMAT goes to 19: caches built before miss once.
A script at the project root that imports a name two directories hold, neither beside it, is noted and not followed: pin the note before the names of those modules change.
…ts package Python takes a directory without __init__.py inside a regular package for a portion of that package (PEP 420), but such a module was named from that directory: its relative imports were rejected as beyond the top-level package, and the file was not analysed (flask's sansio, pygoat's playground). A module of a project is now named from the parent of the outermost directory holding an __init__.py, searched up through directories without one as far as the project root. The name a script run from the module's directory gives it stays a second name: an import from such a script still reaches the module beside it first. Files sharing only that second name are told apart by the importer's root and no longer noted as ambiguous; an import no root decides is still noted.
A module beside a script is imported by its short name from the script and by its package name from the rest of the package: it stays one module of the graph, covered once, and both importers reach its summaries.
A project mixing relative imports from directories without __init__.py, scripts beside their helpers, a module reached by both its names and two helpers of one short name gives the same findings and coverage without a cache, with a cold and a warm one, and in two processes, with or without a cache.
A script finds the helper beside it before a top-level module of the same name, as Python's import path puts its directory first, and keeps the helper it resolved when another directory's helper, imported under its package name, also has that short name.
introduction/playground/A9/api.py and archive.py import .main relatively: named after their package, they are analysed (two syntax-error notes gone, four files and eight functions more), and the two main.py files no longer share a module name (two ambiguous-module notes gone). No other finding changes.
…ctory A module in a directory without __init__.py is also named, as a script run there names it, from that directory. Unique, that name answered an import from anywhere: pkg/sub/os.py took main.py's "import os" at the project root, and its summary hid the model of os.system. A name a module has only from its own directory now answers only an importer with that directory among its roots; an import that only such names match names no module of the project, as Python finds none there (ModuleNotFoundError), and is no longer noted as ambiguous. The test of that note now pins that the import is neither followed nor noted.
…ph resolved A component's summaries took every name of every module it reaches: a module reached by its full name was also put under its short name, though the graph had left that name unresolved for the importer, and the importer's calls through it read that module's summary, a constant that hid an injection. A module now goes under the names the graph resolved to it, a member's own resolutions first, then those of the modules the component reaches; a name two of them resolved to different files goes under neither, and a name a member left unresolved under none.
…le name only A component's summaries took, for a module reached through another, the names that other module resolved: pkg/sub/loader.py's "import os", which from pkg/sub reaches pkg/sub/os.py, put that file under "os" for main.py too, whose "import os" is the library, and the summary of its system hid the injection. A module a member imports goes under the name that member resolved it by; a module reached through another now goes under its module name only, the name the whole project knows it by, as pkg.helpers is reached through "import pkg". What a callee resolved no longer changes what its caller's names mean.
…another main.py and x/a.py import each other. From x, x/a.py's helper is x/helper.py; main.py, at the project root, cannot tell x/helper.py from y/helper.py and leaves helper unresolved. The two files are one component, and main.py's helper.execute must still read neither.
…h module by The names a component read a module by were decided in two places: those of the modules it imports in _imported_names, those of its own members in _analyse_managers. _imported_names now gives both, and _analyse_managers only builds the index from them. A worker receives them, with the spool key of each module the component imports, instead of naming the members itself. Behaviour is unchanged.
The members of an import cycle shared one summary index: each member's functions went under all its names, and each module the cycle imports under the names any member had resolved. A name one member resolved from its own directory thus changed what another member's code meant. With main.py and pkg/sub/os.py importing each other, pkg/sub/os.py's system took the place of the library's os.system in main.py and hid the injection; pkg/sub/loader.py's "import os" did the same from such a cycle. Each member now reads the modules of its component, members included, in an index of its own: itself under its own names, which name it from its own roots, another module under the names it resolved it by, and otherwise under its module name only.
pkg/a/run.py and pkg/b/tool.py import each other, and each the helper.py of its own directory. The shared index put helper under neither; with an index of its own, run.py's helper.execute reaches the sink.
… by its module name main.py, pkg/__init__.py and pkg/helpers.py import one another in turn. main.py's pkg.helpers.run is that of pkg/helpers.py, a member it does not import by that name.
main.py, run from src, reaches src/pkg/helpers.py through its pkg, and lib/pkg/helpers.py through lib/tool.py. Its pkg.helpers is src's, harmless; lib's, which reaches a sink, must not take its place.
…at read alike With an index of its own for each member, built and compared at every round of the fixpoint, Django 5.1.4, whose largest cycle holds 144 modules, took 185 s to analyse instead of 176 s. In a regular package every member reads every module under the same names: the members that do now share one index, and the analysis takes 175 s again, with the same findings.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #264.
A module in a directory without
__init__.pyinside a regular package was named from that directory. Its relative imports were rejected asrelative import beyond top-level package, and the file was not analysed. Python, however, takes such a directory for a portion of the package (PEP 420):pkg/sub/app.pyispkg.sub.app.A module now has one identity, its name after its packages, and may be imported by more than one name, each from its own root:
__init__.py. That directory is searched up through directories without one as far as the project root; above the root, it climbs only while each directory holds an__init__.py. This name is the module's node in the graph, the symbol of its functions, and the base of its relative imports.sys.path[0]being that directory, which is the name the engine gave it until now. It is kept as a second import name, rooted at that directory.The name a module had depends on nothing in its content.
test(modules): cover the names and imports that must survive #264refactor(source): name a file's module and its import root in one placeSourceFile.import_names; the two climbs become one; behaviour unchangedrefactor(modulegraph): resolve an import by any name of a module, from the importer's roots in orderrefactor(engine): register a module's summaries under every name it is reached byfix(cache): key a module by the names it is imported byCACHE_FORMAT19test(engine): cover the note on an import no root decidesfix(source): name a module in a directory without __init__.py after its packagetest(modules): a module reached by both its names keeps one identitytest(modules): the result does not depend on the cache or the processestest(modules): a script's helper comes first, under the name it resolveddocs: state how a module in a directory without __init__.py is nameddocs/usage.mddocs(changelog): announce the names of modules in directories without __init__.pyCHANGELOG.mdtest(regression): record pygoat with its playground modules analysedfix(modulegraph): answer a module's short name only from its own directoryfix(engine): register an imported module only under the names the graph resolveddocs: state that a module's short name answers only its own directorydocs/usage.md,CHANGELOG.mdfix(engine): register a module reached through another under its module name onlytest(modules): a name one member of a cycle resolved does not change anotherrefactor(engine): decide in one place the names a component reads each module byfix(engine): give each member of a cycle the names it resolvedtest(modules): each member of a cycle reads the helper it resolvedtest(modules): follow a member of a cycle reached through its package by its module namepkg.helpersinside a cycletest(modules): take a module name two reached files share for neithersrcandlib, each with apkg.helpersperf(engine): share one summary index among the members of a cycle that read alikemain'sResults
The requirements
script-siblingandscript-shadowkeep their alertstest_a_script_reaches_the_module_beside_it_by_its_short_name,test_a_module_beside_a_script_named_like_a_library_leaves_the_library_modelledhelper.py, each use their owntest_each_importer_reaches_the_helper_of_its_own_directory, with the sink in either;test_a_script_reaches_the_helper_beside_it_before_one_at_the_project_root;test_a_script_keeps_the_helper_it_resolved_beside_another_of_that_short_nametest_a_module_reached_by_both_its_names_keeps_one_identity: one modulepkg.tools.helper, one coverage entry, both importers report the flowtest_the_result_does_not_depend_on_the_cache_or_the_processes: no cache, cold cache, warm cache,jobs=2, andjobs=2with a cacheProjects
ab814966, withouttests/):sansio/app.py,blueprints.pyandscaffold.pyare analysed. The threesyntax-errornotes are gone, files analysed go from 37 to 40 and functions from 413 to 531, and no other finding changes.A9/api.pyandA9/archive.pyare analysed and reachA9/main.py. The twomain.pyfiles have distinct module names, so neither carries anambiguous-modulenote.The pygoat snapshot
"coverage": { "files": 80, - "files_analysed": 74, - "functions": 295, - "functions_analysed": 295 + "files_analysed": 78, + "functions": 303, + "functions_analysed": 303 },Four findings are removed and none added:
ambiguous-moduleondockerized_labs/insec_des_lab/main.pyand onintroduction/playground/A9/main.py;syntax-erroronintroduction/playground/A9/api.pyand onintroduction/playground/A9/archive.py.No vulnerability finding changes.
Review
Four false negatives, each reported by
mainand lost with or without a cache and in two processes: three on the first thirteen commits, one on the fixes of the first three.A short name hid the library. The project had:
pkg/__init__.py;pkg/config.py, definingVALUE;pkg/sub/os.py, which importsVALUEfrom..configand definessystem(command), returningNone;main.py, at the project root, which importsosand callsos.system(input()).osis the short name ofpkg/sub/os.pyfrompkg/subonly. Unique, it still answered main.py'simport os, and the summary ofsystemhid the model ofos.system, whereas Python runs the library. A short name now answers only an importer with its directory among its roots, so main.py reportscommand-injection. The same file without its relative import was analysed onmaintoo, as a top-level module ofpkg/sub, and took the import there already; it no longer does.An unresolved short name was registered. The project had
pkg/a/helper.py,pkg/b/helper.pyand amain.pythat importshelper, importsnoopfrompkg.a.helper, and callsos.system(helper.execute(input())). Reached by its full name,pkg.a.helperwas also registered underhelper, which the graph had not resolved for main.py, so main.py'shelper.executeread pkg/a's, which returns a constant.A name resolved in between was registered. Beside
pkg/sub/os.py, apkg/sub/loader.pyimportsos, and main.py importsosandnoopfrompkg.sub.loader. Frompkg/sub, loader.py'sosispkg/sub/os.py, which the component of main.py then took for main.py'sostoo.A short name was seen across a cycle. main.py imports
osandnoopfrompkg.sub.os, andpkg/sub/os.pyimportsmain. The two files are one component, whose members shared one index:pkg/sub/os.py's functions went under all its names,osamong them, and main.py'sos.systemread the summary of itssystem. Python runs the library:main.osis the standardos. A name one member resolved from its own directory changed the others' the same way, aspkg/sub/loader.py'sosdid from such a cycle.The rule now. Each module reads the others in an index of its own, a member of a cycle too:
pkg.helpersis reached throughimport pkgwhen its__init__.pyimports it;What a callee, or another member of a cycle, resolved never changes what a module's names mean. Two members that resolve one short name to two files now each read their own, where the shared index put it under neither.
What changes for the ambiguity note. From the project root,
helperis now no module of the project, since only other directories' short names match it. Python agrees: run from the root,import helperraisesModuleNotFoundError. That import is neither followed nor noted asambiguous-moduleany more, and the test of that note pins this instead. Files that share a module name are still noted, as is an import of such a name that no root decides.What the tests cover. The new file
tests/test_namespace_module_visibility.pyhas thirteen tests, each run without a cache, with a cold and a warm one, and in two processes:pkg.helpersfollowed throughimport pkg, and through it inside a cycle;On the commit before each fix, its tests of the new behaviour fail in all four runs, and the guards pass.
Each of these mutations makes at least one of them fail:
Cost. Django 5.1.4 (879 files, a cycle of 144 modules) takes 176 s on the commit before the fix. With an index for each member, it took 185 s; members that read alike now share one, and it takes 175 s, with the same 42 findings. The regression corpus, whose largest cycle has 14 modules, keeps its times.
Compatibility
CACHE_FORMATgoes to 19, so caches miss once.python.pkg.sub.app.viewinstead ofpython.app.view. A model declared on the former symbol must follow.ambiguous-modulenow notes the files that share a module name, and an import of such a name no root decides. Files that share only their script name are told apart by the importer's directory, and an import that only other directories' script names match names no module of the project.Not in this change
__init__.py, since a single-file check has no project root.Tests
Three new files,
tests/test_namespace_module_names.py,tests/test_namespace_module_imports.pyandtests/test_namespace_module_visibility.py, with 69 tests: 5, 12 and 52, the last file's thirteen tests each run four ways. Apart from the pygoat snapshot, no test file existing onmainchanges.test_python_imports_every_module_by_its_nameimports every module by its name in a subprocess, with the project'ssrcon the import path.Each of these mutations makes at least one test fail:
ambiguous-modulenoting names shared only as script names;Verification
Python 3.12, on
mainatf7afd78:mypyandruff check .: clean;b1c4ed3: 978 passed.