assert-python-definition-is-used 20260829003946__tar.gz → 20260830020719__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. assert_python_definition_is_used-20260830020719/.claude/memories/pylint-refactor-messages-are-hard-failures.md +9 -0
  2. {assert_python_definition_is_used-20260829003946/src/assert_python_definition_is_used.egg-info → assert_python_definition_is_used-20260830020719}/PKG-INFO +64 -2
  3. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/README.md +63 -1
  4. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/action.yml +6 -0
  5. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/cli.py +45 -3
  6. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/scanner.py +45 -5
  7. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719/src/assert_python_definition_is_used.egg-info}/PKG-INFO +64 -2
  8. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/SOURCES.txt +1 -0
  9. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/scm_file_list.json +1 -0
  10. assert_python_definition_is_used-20260830020719/src/assert_python_definition_is_used.egg-info/scm_version.json +8 -0
  11. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/integration/test_cli.py +44 -0
  12. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/integration/test_helpers.py +59 -0
  13. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/samples.py +52 -0
  14. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/unit/test_cli.py +87 -0
  15. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/unit/test_scanner.py +114 -0
  16. assert_python_definition_is_used-20260829003946/src/assert_python_definition_is_used.egg-info/scm_version.json +0 -8
  17. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/ask-gh-before-saying-github-cannot.md +0 -0
  18. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/commit-straight-to-main.md +0 -0
  19. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/cover-new-source-in-both-tiers.md +0 -0
  20. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/do-not-offer-a-pointless-choice.md +0 -0
  21. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/do-what-the-issue-says-and-stop.md +0 -0
  22. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/jscpd-reads-a-docstring-as-ordinary-tokens.md +0 -0
  23. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/let-ci-run-the-checks.md +0 -0
  24. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/name-a-conftest-fixture-apart-from-what-it-asks-for.md +0 -0
  25. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/name-a-job-after-the-package-it-runs.md +0 -0
  26. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/run-the-tool-before-writing-the-claim.md +0 -0
  27. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/start-over-when-the-user-does-not-understand.md +0 -0
  28. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.github/workflows/documentation.yml +0 -0
  29. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.github/workflows/release.yml +0 -0
  30. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.gitignore +0 -0
  31. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/CLAUDE.md +0 -0
  32. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/LICENSE.txt +0 -0
  33. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/pyproject.toml +0 -0
  34. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/setup.cfg +0 -0
  35. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/__init__.py +0 -0
  36. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/__main__.py +0 -0
  37. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/py.typed +0 -0
  38. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/dependency_links.txt +0 -0
  39. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/entry_points.txt +0 -0
  40. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/top_level.txt +0 -0
  41. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/__init__.py +0 -0
  42. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/conftest.py +0 -0
  43. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/e2e/__init__.py +0 -0
  44. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/e2e/conftest.py +0 -0
  45. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/e2e/test_e2e.py +0 -0
  46. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/integration/__init__.py +0 -0
  47. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/runners.py +0 -0
  48. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/unit/__init__.py +0 -0
  49. {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/unit/test_main.py +0 -0
@@ -0,0 +1,9 @@
1
+ # pylint refactor messages are hard failures
2
+
3
+ `--fail-on=C,R,W` means pylint's R messages fail the job, so its default
4
+ limits are hard limits here: 5 arguments, 15 locals, 12 branches, 50
5
+ statements per function. Inline directives are banned, so the only route
6
+ is to split the function.
7
+
8
+ That is why `main` hands its work to `_findings_for` rather than growing
9
+ another local for each mode it grew.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assert-python-definition-is-used
3
- Version: 20260829003946
3
+ Version: 20260830020719
4
4
  Summary: CLI tool to assert that every public Python definition in a tree is used somewhere else
5
5
  Author-email: 10U Labs <dev@10ulabs.com>
6
6
  License-Expression: Apache-2.0
@@ -76,6 +76,7 @@ tests written for it, which is what a coverage gate hides.
76
76
  | `--exclude PATTERNS` | Comma-separated globs to leave out of both trees. |
77
77
  | `--assume-used-matching PATTERNS` | Globs of names a runtime invokes. |
78
78
  | `--assume-used-decorated-with PATHS` | Decorators a runtime invokes. |
79
+ | `--unimported-packages` | Report packages nothing imports, not definitions. |
79
80
  | `--quiet` | Print nothing; report through the exit code. |
80
81
  | `--count` | Print only how many findings there were. |
81
82
  | `--verbose` | Print each file scanned, each one read as text, and a summary. |
@@ -120,7 +121,8 @@ file rather than failing, and `--verbose` names each file that fell back.
120
121
 
121
122
  Reading a name is still not resolving it. A file that calls its own
122
123
  unrelated function of the same name counts as a use, so the count is a
123
- lower bound rather than an exact figure.
124
+ lower bound rather than an exact figure. `--unimported-packages` below
125
+ asks a question that survives that, one package at a time.
124
126
 
125
127
  Two dead functions in one file that call each other still read as used,
126
128
  because those calls are real code. Finding those needs reachability from
@@ -192,6 +194,53 @@ the run altogether, so its own definitions go unchecked too. A file left
192
194
  out of the search is still read for the definitions it holds; it just
193
195
  does not get a vote on whether anything else is used.
194
196
 
197
+ ## Packages nothing imports
198
+
199
+ A function is reached by being named, so asking what names it is the
200
+ right question. A package is reached by being imported, which is a
201
+ different question with a different answer. A package can be entirely
202
+ dead while every one of its names still appears somewhere, because the
203
+ caller that reimplemented it picked the same ordinary words.
204
+
205
+ The name search is blind in proportion to how ordinary a package's names
206
+ are. A package of `get_client`, `set_client` and `reset_clients` is
207
+ nearly invisible to it, those words appearing everywhere; a package of
208
+ `resolve_terraform_interpolation` is fully visible, that word appearing
209
+ nowhere else. That is backwards, because the packages most likely to be
210
+ quietly reimplemented are exactly the ones whose names are ordinary
211
+ enough for a caller to pick the same ones.
212
+
213
+ `--unimported-packages` asks the other question. It reads every directory
214
+ directly below a definition tree that holds an `__init__.py`, collects
215
+ the top-level module of every `import` and `from ... import` in the
216
+ searched files, and reports each package nothing imports. The finding is
217
+ anchored on the package's `__init__.py` at line 1, in the same
218
+ `path:line:name` form as the rest:
219
+
220
+ ```text
221
+ lib/python/aws_clients/__init__.py:1:aws_clients
222
+ ```
223
+
224
+ `--dont-search-in` applies here as it does elsewhere, so a package
225
+ imported only by its own tests is reported:
226
+
227
+ ```bash
228
+ assert-python-definition-is-used lib/python \
229
+ --search-in lib/python --search-in src --search-in test \
230
+ --dont-search-in 'test/lib/python/test_{package}' \
231
+ --unimported-packages
232
+ ```
233
+
234
+ It is a mode rather than a check folded into every run, because the two
235
+ questions have different answers for a directory with no `__init__.py`,
236
+ and because a repository may want one without the other. Run it as a
237
+ second job beside the definition run.
238
+
239
+ A relative import names no top-level module, so `from . import helper`
240
+ inside a package does not make that package imported. A file that will
241
+ not parse credits nothing here either: a bare word in text is not an
242
+ import, and guessing one from text would be worse than staying quiet.
243
+
195
244
  ## GitHub Actions
196
245
 
197
246
  ```yaml
@@ -204,6 +253,19 @@ does not get a vote on whether anything else is used.
204
253
  verbose: true
205
254
  ```
206
255
 
256
+ Beside it, ask whether the packages are imported at all:
257
+
258
+ ```yaml
259
+ - name: Assert every package is imported outside its own tests
260
+ uses: 10U-Labs/assert-python-definition-is-used@latest
261
+ with:
262
+ dont-search-in: test/lib/python/test_{package}
263
+ search-in: lib/python scripts src test
264
+ trees: lib/python
265
+ unimported-packages: true
266
+ verbose: true
267
+ ```
268
+
207
269
  Over a test tree, name what pytest invokes:
208
270
 
209
271
  ```yaml
@@ -52,6 +52,7 @@ tests written for it, which is what a coverage gate hides.
52
52
  | `--exclude PATTERNS` | Comma-separated globs to leave out of both trees. |
53
53
  | `--assume-used-matching PATTERNS` | Globs of names a runtime invokes. |
54
54
  | `--assume-used-decorated-with PATHS` | Decorators a runtime invokes. |
55
+ | `--unimported-packages` | Report packages nothing imports, not definitions. |
55
56
  | `--quiet` | Print nothing; report through the exit code. |
56
57
  | `--count` | Print only how many findings there were. |
57
58
  | `--verbose` | Print each file scanned, each one read as text, and a summary. |
@@ -96,7 +97,8 @@ file rather than failing, and `--verbose` names each file that fell back.
96
97
 
97
98
  Reading a name is still not resolving it. A file that calls its own
98
99
  unrelated function of the same name counts as a use, so the count is a
99
- lower bound rather than an exact figure.
100
+ lower bound rather than an exact figure. `--unimported-packages` below
101
+ asks a question that survives that, one package at a time.
100
102
 
101
103
  Two dead functions in one file that call each other still read as used,
102
104
  because those calls are real code. Finding those needs reachability from
@@ -168,6 +170,53 @@ the run altogether, so its own definitions go unchecked too. A file left
168
170
  out of the search is still read for the definitions it holds; it just
169
171
  does not get a vote on whether anything else is used.
170
172
 
173
+ ## Packages nothing imports
174
+
175
+ A function is reached by being named, so asking what names it is the
176
+ right question. A package is reached by being imported, which is a
177
+ different question with a different answer. A package can be entirely
178
+ dead while every one of its names still appears somewhere, because the
179
+ caller that reimplemented it picked the same ordinary words.
180
+
181
+ The name search is blind in proportion to how ordinary a package's names
182
+ are. A package of `get_client`, `set_client` and `reset_clients` is
183
+ nearly invisible to it, those words appearing everywhere; a package of
184
+ `resolve_terraform_interpolation` is fully visible, that word appearing
185
+ nowhere else. That is backwards, because the packages most likely to be
186
+ quietly reimplemented are exactly the ones whose names are ordinary
187
+ enough for a caller to pick the same ones.
188
+
189
+ `--unimported-packages` asks the other question. It reads every directory
190
+ directly below a definition tree that holds an `__init__.py`, collects
191
+ the top-level module of every `import` and `from ... import` in the
192
+ searched files, and reports each package nothing imports. The finding is
193
+ anchored on the package's `__init__.py` at line 1, in the same
194
+ `path:line:name` form as the rest:
195
+
196
+ ```text
197
+ lib/python/aws_clients/__init__.py:1:aws_clients
198
+ ```
199
+
200
+ `--dont-search-in` applies here as it does elsewhere, so a package
201
+ imported only by its own tests is reported:
202
+
203
+ ```bash
204
+ assert-python-definition-is-used lib/python \
205
+ --search-in lib/python --search-in src --search-in test \
206
+ --dont-search-in 'test/lib/python/test_{package}' \
207
+ --unimported-packages
208
+ ```
209
+
210
+ It is a mode rather than a check folded into every run, because the two
211
+ questions have different answers for a directory with no `__init__.py`,
212
+ and because a repository may want one without the other. Run it as a
213
+ second job beside the definition run.
214
+
215
+ A relative import names no top-level module, so `from . import helper`
216
+ inside a package does not make that package imported. A file that will
217
+ not parse credits nothing here either: a bare word in text is not an
218
+ import, and guessing one from text would be worse than staying quiet.
219
+
171
220
  ## GitHub Actions
172
221
 
173
222
  ```yaml
@@ -180,6 +229,19 @@ does not get a vote on whether anything else is used.
180
229
  verbose: true
181
230
  ```
182
231
 
232
+ Beside it, ask whether the packages are imported at all:
233
+
234
+ ```yaml
235
+ - name: Assert every package is imported outside its own tests
236
+ uses: 10U-Labs/assert-python-definition-is-used@latest
237
+ with:
238
+ dont-search-in: test/lib/python/test_{package}
239
+ search-in: lib/python scripts src test
240
+ trees: lib/python
241
+ unimported-packages: true
242
+ verbose: true
243
+ ```
244
+
183
245
  Over a test tree, name what pytest invokes:
184
246
 
185
247
  ```yaml
@@ -27,6 +27,10 @@ inputs:
27
27
  trees:
28
28
  description: Trees holding the definitions to check (space-separated)
29
29
  required: true
30
+ unimported-packages:
31
+ default: "false"
32
+ description: Report packages nothing imports, instead of definitions
33
+ required: false
30
34
  verbose:
31
35
  default: "false"
32
36
  description: Show trees read, definitions found, findings and a summary
@@ -46,6 +50,8 @@ runs:
46
50
  [ "${{ inputs.verbose }}" = "true" ] && ARGS="$ARGS --verbose"
47
51
  [ "${{ inputs.fail-fast }}" = "true" ] && ARGS="$ARGS --fail-fast"
48
52
  [ "${{ inputs.warn-only }}" = "true" ] && ARGS="$ARGS --warn-only"
53
+ [ "${{ inputs.unimported-packages }}" = "true" ] &&
54
+ ARGS="$ARGS --unimported-packages"
49
55
  if [ -n "${{ inputs.exclude }}" ]; then
50
56
  ARGS="$ARGS --exclude '${{ inputs.exclude }}'"
51
57
  fi
@@ -15,6 +15,7 @@ from .scanner import (
15
15
  assumed_used,
16
16
  public_definitions,
17
17
  read_searched,
18
+ unimported_packages,
18
19
  unused_definitions,
19
20
  )
20
21
 
@@ -106,6 +107,16 @@ def create_parser() -> argparse.ArgumentParser:
106
107
  ),
107
108
  )
108
109
 
110
+ parser.add_argument(
111
+ "--unimported-packages",
112
+ action="store_true",
113
+ help=(
114
+ "Ask the package question instead: report every package under the trees "
115
+ "that nothing outside its own tests imports. A package is reached by being "
116
+ "imported, so a name match says nothing about whether it is still wanted."
117
+ ),
118
+ )
119
+
109
120
  output_group = parser.add_mutually_exclusive_group()
110
121
  output_group.add_argument(
111
122
  "--quiet",
@@ -183,6 +194,14 @@ def package_of(path: str, tree: str) -> str | None:
183
194
  return parts[0]
184
195
 
185
196
 
197
+ def package_root(path: str, tree: str) -> str | None:
198
+ package = package_of(path, tree)
199
+ if package is None:
200
+ return None
201
+ anchor = os.path.join(os.path.normpath(tree), package, "__init__.py")
202
+ return package if os.path.normpath(path) == anchor else None
203
+
204
+
186
205
  def _tree_root(path: str) -> str:
187
206
  if os.path.isdir(path):
188
207
  return path
@@ -261,6 +280,15 @@ def read_definitions(
261
280
  return definitions
262
281
 
263
282
 
283
+ def read_packages(paths: dict[str, str]) -> list[Definition]:
284
+ packages: list[Definition] = []
285
+ for path in sorted(paths):
286
+ package = package_root(path, paths[path])
287
+ if package is not None:
288
+ packages.append(Definition(path=path, line_number=1, name=package, package=package))
289
+ return packages
290
+
291
+
264
292
  def report_unparsed(searched: Searched) -> None:
265
293
  for path in searched.unparsed:
266
294
  print(f"Searching as text (will not parse): {path}")
@@ -316,6 +344,22 @@ def _report(result: ScanResult, args: argparse.Namespace) -> None:
316
344
  output_findings(result.findings, args.count)
317
345
 
318
346
 
347
+ def _findings_for(
348
+ args: argparse.Namespace,
349
+ result: ScanResult,
350
+ definition_paths: dict[str, str],
351
+ sources: dict[str, str],
352
+ searched: Searched,
353
+ ) -> list[Finding]:
354
+ if args.unimported_packages:
355
+ packages = read_packages(definition_paths)
356
+ result.files_scanned = result.definitions_read = len(packages)
357
+ return unimported_packages(packages, searched, args.dont_search_in)
358
+ definitions = read_definitions(definition_paths, sources, result, args.verbose)
359
+ checked = assume_used(definitions, args, result)
360
+ return unused_definitions(checked, searched, args.dont_search_in)
361
+
362
+
319
363
  def main(argv: Sequence[str] | None = None) -> None:
320
364
  args = create_parser().parse_args(argv)
321
365
  exclude_patterns = parse_patterns(args.exclude)
@@ -340,14 +384,12 @@ def main(argv: Sequence[str] | None = None) -> None:
340
384
  print()
341
385
 
342
386
  sources = read_sources({**search_paths, **definition_paths}, result, args.verbose)
343
- definitions = read_definitions(definition_paths, sources, result, args.verbose)
344
- definitions = assume_used(definitions, args, result)
345
387
  to_search = {path: content for path, content in sources.items() if path in search_paths}
346
388
  searched = read_searched(to_search)
347
389
  if args.verbose:
348
390
  report_unparsed(searched)
349
391
 
350
- findings = unused_definitions(definitions, searched, args.dont_search_in)
392
+ findings = _findings_for(args, result, definition_paths, sources, searched)
351
393
  result.findings = findings[:1] if (args.fail_fast and findings) else findings
352
394
 
353
395
  _report(result, args)
@@ -8,7 +8,7 @@ from functools import lru_cache
8
8
  from typing import TYPE_CHECKING
9
9
 
10
10
  if TYPE_CHECKING:
11
- from collections.abc import Sequence
11
+ from collections.abc import Callable, Sequence
12
12
 
13
13
  DEFINITION_NODES = (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef)
14
14
  DOCSTRING_HOLDERS = (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef, ast.Module)
@@ -54,6 +54,7 @@ class Finding:
54
54
  class Searched:
55
55
  uses: dict[str, frozenset[str]]
56
56
  unparsed: dict[str, str]
57
+ imports: dict[str, frozenset[str]]
57
58
 
58
59
 
59
60
  def is_public(name: str) -> bool:
@@ -163,8 +164,19 @@ def names_used(tree: ast.Module) -> frozenset[str]:
163
164
  return frozenset(names)
164
165
 
165
166
 
167
+ def imported_modules(tree: ast.Module) -> frozenset[str]:
168
+ modules: set[str] = set()
169
+ for node in ast.walk(tree):
170
+ if isinstance(node, ast.Import):
171
+ modules.update(alias.name.split(".")[0] for alias in node.names)
172
+ elif isinstance(node, ast.ImportFrom) and not node.level and node.module:
173
+ modules.add(node.module.split(".")[0])
174
+ return frozenset(modules)
175
+
176
+
166
177
  def read_searched(sources: dict[str, str]) -> Searched:
167
178
  uses: dict[str, frozenset[str]] = {}
179
+ imports: dict[str, frozenset[str]] = {}
168
180
  unparsed: dict[str, str] = {}
169
181
  for path in sorted(sources):
170
182
  try:
@@ -173,7 +185,8 @@ def read_searched(sources: dict[str, str]) -> Searched:
173
185
  unparsed[path] = sources[path]
174
186
  continue
175
187
  uses[path] = names_used(tree)
176
- return Searched(uses=uses, unparsed=unparsed)
188
+ imports[path] = imported_modules(tree)
189
+ return Searched(uses=uses, unparsed=unparsed, imports=imports)
177
190
 
178
191
 
179
192
  def is_assumed_used_by_name(name: str, patterns: Sequence[str]) -> bool:
@@ -228,14 +241,41 @@ def is_used(
228
241
  return False
229
242
 
230
243
 
231
- def unused_definitions(
244
+ def is_imported(
245
+ definition: Definition, searched: Searched, unsearched: str | None = None
246
+ ) -> bool:
247
+ return any(
248
+ definition.name in imported
249
+ for path, imported in searched.imports.items()
250
+ if not _is_unsearched(path, unsearched)
251
+ )
252
+
253
+
254
+ def _findings_over(
232
255
  definitions: list[Definition],
233
256
  searched: Searched,
234
- unsearched_template: str | None = None,
257
+ unsearched_template: str | None,
258
+ reached: Callable[[Definition, Searched, str | None], bool],
235
259
  ) -> list[Finding]:
236
260
  findings = []
237
261
  for definition in definitions:
238
262
  unsearched = unsearched_directory(unsearched_template, definition.package)
239
- if not is_used(definition, searched, unsearched):
263
+ if not reached(definition, searched, unsearched):
240
264
  findings.append(Finding(definition=definition))
241
265
  return findings
266
+
267
+
268
+ def unused_definitions(
269
+ definitions: list[Definition],
270
+ searched: Searched,
271
+ unsearched_template: str | None = None,
272
+ ) -> list[Finding]:
273
+ return _findings_over(definitions, searched, unsearched_template, is_used)
274
+
275
+
276
+ def unimported_packages(
277
+ packages: list[Definition],
278
+ searched: Searched,
279
+ unsearched_template: str | None = None,
280
+ ) -> list[Finding]:
281
+ return _findings_over(packages, searched, unsearched_template, is_imported)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assert-python-definition-is-used
3
- Version: 20260829003946
3
+ Version: 20260830020719
4
4
  Summary: CLI tool to assert that every public Python definition in a tree is used somewhere else
5
5
  Author-email: 10U Labs <dev@10ulabs.com>
6
6
  License-Expression: Apache-2.0
@@ -76,6 +76,7 @@ tests written for it, which is what a coverage gate hides.
76
76
  | `--exclude PATTERNS` | Comma-separated globs to leave out of both trees. |
77
77
  | `--assume-used-matching PATTERNS` | Globs of names a runtime invokes. |
78
78
  | `--assume-used-decorated-with PATHS` | Decorators a runtime invokes. |
79
+ | `--unimported-packages` | Report packages nothing imports, not definitions. |
79
80
  | `--quiet` | Print nothing; report through the exit code. |
80
81
  | `--count` | Print only how many findings there were. |
81
82
  | `--verbose` | Print each file scanned, each one read as text, and a summary. |
@@ -120,7 +121,8 @@ file rather than failing, and `--verbose` names each file that fell back.
120
121
 
121
122
  Reading a name is still not resolving it. A file that calls its own
122
123
  unrelated function of the same name counts as a use, so the count is a
123
- lower bound rather than an exact figure.
124
+ lower bound rather than an exact figure. `--unimported-packages` below
125
+ asks a question that survives that, one package at a time.
124
126
 
125
127
  Two dead functions in one file that call each other still read as used,
126
128
  because those calls are real code. Finding those needs reachability from
@@ -192,6 +194,53 @@ the run altogether, so its own definitions go unchecked too. A file left
192
194
  out of the search is still read for the definitions it holds; it just
193
195
  does not get a vote on whether anything else is used.
194
196
 
197
+ ## Packages nothing imports
198
+
199
+ A function is reached by being named, so asking what names it is the
200
+ right question. A package is reached by being imported, which is a
201
+ different question with a different answer. A package can be entirely
202
+ dead while every one of its names still appears somewhere, because the
203
+ caller that reimplemented it picked the same ordinary words.
204
+
205
+ The name search is blind in proportion to how ordinary a package's names
206
+ are. A package of `get_client`, `set_client` and `reset_clients` is
207
+ nearly invisible to it, those words appearing everywhere; a package of
208
+ `resolve_terraform_interpolation` is fully visible, that word appearing
209
+ nowhere else. That is backwards, because the packages most likely to be
210
+ quietly reimplemented are exactly the ones whose names are ordinary
211
+ enough for a caller to pick the same ones.
212
+
213
+ `--unimported-packages` asks the other question. It reads every directory
214
+ directly below a definition tree that holds an `__init__.py`, collects
215
+ the top-level module of every `import` and `from ... import` in the
216
+ searched files, and reports each package nothing imports. The finding is
217
+ anchored on the package's `__init__.py` at line 1, in the same
218
+ `path:line:name` form as the rest:
219
+
220
+ ```text
221
+ lib/python/aws_clients/__init__.py:1:aws_clients
222
+ ```
223
+
224
+ `--dont-search-in` applies here as it does elsewhere, so a package
225
+ imported only by its own tests is reported:
226
+
227
+ ```bash
228
+ assert-python-definition-is-used lib/python \
229
+ --search-in lib/python --search-in src --search-in test \
230
+ --dont-search-in 'test/lib/python/test_{package}' \
231
+ --unimported-packages
232
+ ```
233
+
234
+ It is a mode rather than a check folded into every run, because the two
235
+ questions have different answers for a directory with no `__init__.py`,
236
+ and because a repository may want one without the other. Run it as a
237
+ second job beside the definition run.
238
+
239
+ A relative import names no top-level module, so `from . import helper`
240
+ inside a package does not make that package imported. A file that will
241
+ not parse credits nothing here either: a bare word in text is not an
242
+ import, and guessing one from text would be worse than staying quiet.
243
+
195
244
  ## GitHub Actions
196
245
 
197
246
  ```yaml
@@ -204,6 +253,19 @@ does not get a vote on whether anything else is used.
204
253
  verbose: true
205
254
  ```
206
255
 
256
+ Beside it, ask whether the packages are imported at all:
257
+
258
+ ```yaml
259
+ - name: Assert every package is imported outside its own tests
260
+ uses: 10U-Labs/assert-python-definition-is-used@latest
261
+ with:
262
+ dont-search-in: test/lib/python/test_{package}
263
+ search-in: lib/python scripts src test
264
+ trees: lib/python
265
+ unimported-packages: true
266
+ verbose: true
267
+ ```
268
+
207
269
  Over a test tree, name what pytest invokes:
208
270
 
209
271
  ```yaml
@@ -13,6 +13,7 @@ pyproject.toml
13
13
  .claude/memories/let-ci-run-the-checks.md
14
14
  .claude/memories/name-a-conftest-fixture-apart-from-what-it-asks-for.md
15
15
  .claude/memories/name-a-job-after-the-package-it-runs.md
16
+ .claude/memories/pylint-refactor-messages-are-hard-failures.md
16
17
  .claude/memories/run-the-tool-before-writing-the-claim.md
17
18
  .claude/memories/start-over-when-the-user-does-not-understand.md
18
19
  .github/workflows/documentation.yml
@@ -9,6 +9,7 @@
9
9
  ".claude/memories/let-ci-run-the-checks.md",
10
10
  ".claude/memories/name-a-conftest-fixture-apart-from-what-it-asks-for.md",
11
11
  ".claude/memories/name-a-job-after-the-package-it-runs.md",
12
+ ".claude/memories/pylint-refactor-messages-are-hard-failures.md",
12
13
  ".claude/memories/run-the-tool-before-writing-the-claim.md",
13
14
  ".claude/memories/start-over-when-the-user-does-not-understand.md",
14
15
  ".github/workflows/documentation.yml",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "20260830020719",
3
+ "distance": 0,
4
+ "node": "g5ff0194533d61d4bcd419fc6b86fb2e931ee4b26",
5
+ "dirty": false,
6
+ "branch": "main",
7
+ "node_date": "2026-08-30"
8
+ }
@@ -9,11 +9,15 @@ from test.samples import (
9
9
  LIB_AND_TEST_RUN,
10
10
  NOTED_PROJECT,
11
11
  OUTER_BOUND,
12
+ PACKAGED_PROJECT,
13
+ PACKAGES_RUN,
12
14
  PROJECT,
13
15
  PROSE,
14
16
  RUNTIME_ASSUMED,
15
17
  RUNTIME_PROJECT,
16
18
  RUNTIME_RUN,
19
+ SHADOWED_PACKAGES_RUN,
20
+ SHADOWING_PROJECT,
17
21
  )
18
22
  from typing import TYPE_CHECKING
19
23
 
@@ -301,3 +305,43 @@ class TestAgainstARuntimeInvokedTree:
301
305
  def test_the_verbose_summary_is_unchanged_without_the_inputs(self, stdout_of: StdoutOf) -> None:
302
306
  stdout = stdout_of(RUNTIME_PROJECT, [*RUNTIME_RUN, "--verbose"])
303
307
  assert "Definitions read: 4\nFindings: 4\n" in stdout
308
+
309
+
310
+ @pytest.mark.integration
311
+ class TestAgainstATreeShadowingALibraryName:
312
+ def test_the_definition_check_credits_the_shadowed_name(self, stdout_of: StdoutOf) -> None:
313
+ assert "get_ssm_client" not in stdout_of(SHADOWING_PROJECT, CLEAN_RUN)
314
+
315
+ def test_the_definition_check_reports_what_nothing_shadows(self, stdout_of: StdoutOf) -> None:
316
+ assert "reset_clients" in stdout_of(SHADOWING_PROJECT, CLEAN_RUN)
317
+
318
+ def test_the_package_nothing_imports_is_reported(self, stdout_of: StdoutOf) -> None:
319
+ anchor = os.path.join("lib", "python", "aws_clients", "__init__.py")
320
+ stdout = stdout_of(SHADOWING_PROJECT, SHADOWED_PACKAGES_RUN)
321
+ assert stdout.splitlines() == [f"{anchor}:1:aws_clients"]
322
+
323
+ def test_the_package_check_fails_the_run(self, exit_code_of: ExitCodeOf) -> None:
324
+ assert exit_code_of(SHADOWING_PROJECT, SHADOWED_PACKAGES_RUN) == EXIT_FINDINGS
325
+
326
+
327
+ @pytest.mark.integration
328
+ class TestAgainstPackagesAndTheirImporters:
329
+ def test_reports_the_package_only_its_own_tests_import(self, stdout_of: StdoutOf) -> None:
330
+ anchor = os.path.join("lib", "python", "dead", "__init__.py")
331
+ assert stdout_of(PACKAGED_PROJECT, PACKAGES_RUN).splitlines() == [f"{anchor}:1:dead"]
332
+
333
+ def test_leaves_the_imported_package_alone(self, stdout_of: StdoutOf) -> None:
334
+ assert "live" not in stdout_of(PACKAGED_PROJECT, PACKAGES_RUN)
335
+
336
+ def test_a_loose_module_is_no_package(self, stdout_of: StdoutOf) -> None:
337
+ assert "loose" not in stdout_of(PACKAGED_PROJECT, PACKAGES_RUN)
338
+
339
+ def test_a_nested_package_is_not_reported_on_its_own(self, stdout_of: StdoutOf) -> None:
340
+ assert "inner" not in stdout_of(PACKAGED_PROJECT, PACKAGES_RUN)
341
+
342
+ def test_the_own_tests_count_without_the_template(self, stdout_of: StdoutOf) -> None:
343
+ assert stdout_of(PACKAGED_PROJECT, [*OUTER_BOUND, "--unimported-packages"]) == ""
344
+
345
+ def test_a_fully_imported_tree_exits_zero(self, exit_code_of: ExitCodeOf) -> None:
346
+ run = [*OUTER_BOUND, "--unimported-packages"]
347
+ assert exit_code_of(PACKAGED_PROJECT, run) == EXIT_SUCCESS
@@ -6,8 +6,11 @@ import runpy
6
6
  import sys
7
7
  from test.samples import (
8
8
  CALLER,
9
+ HANDLER,
9
10
  LIBRARY,
11
+ PACKAGED_PROJECT,
10
12
  PROJECT,
13
+ RELATIVE,
11
14
  RUNTIME_ASSUMED,
12
15
  RUNTIME_INVOKED,
13
16
  RUNTIME_PROJECT,
@@ -36,8 +39,10 @@ from assert_python_definition_is_used.cli import (
36
39
  determine_exit_code,
37
40
  output_findings,
38
41
  package_of,
42
+ package_root,
39
43
  parse_patterns,
40
44
  read_definitions,
45
+ read_packages,
41
46
  read_sources,
42
47
  should_skip,
43
48
  )
@@ -45,13 +50,16 @@ from assert_python_definition_is_used.scanner import (
45
50
  Definition,
46
51
  Finding,
47
52
  assumed_used,
53
+ imported_modules,
48
54
  is_assumed_used_by_decorator,
49
55
  is_assumed_used_by_name,
56
+ is_imported,
50
57
  is_public,
51
58
  is_used,
52
59
  names_in_text,
53
60
  names_used,
54
61
  read_searched,
62
+ unimported_packages,
55
63
  unsearched_directory,
56
64
  public_definitions,
57
65
  unused_definitions,
@@ -347,3 +355,54 @@ class TestAssumptionsOverRealFiles:
347
355
  def test_the_parser_defaults_both_to_none(self) -> None:
348
356
  parsed = create_parser().parse_args(RUNTIME_RUN)
349
357
  assert (parsed.assume_used_matching, parsed.assume_used_decorated_with) == (None, None)
358
+
359
+
360
+ @pytest.mark.integration
361
+ class TestPackagesOverRealFiles:
362
+ def test_collect_finds_the_package_roots(self, write_tree: WriteTree) -> None:
363
+ write_tree(PACKAGED_PROJECT)
364
+ collected, _ = _collect(["lib/python"], [])
365
+ assert sorted(item.name for item in read_packages(collected)) == ["dead", "live"]
366
+
367
+ def test_a_nested_package_is_not_a_root(self) -> None:
368
+ assert package_root(os.path.join("lib", "python", "a", "b", "__init__.py"), "lib") is None
369
+
370
+ def test_a_package_init_is_a_root(self) -> None:
371
+ assert package_root(os.path.join("lib", "python", "__init__.py"), "lib") == "python"
372
+
373
+ def test_read_packages_anchors_on_line_one(self, write_tree: WriteTree) -> None:
374
+ write_tree(PACKAGED_PROJECT)
375
+ collected, _ = _collect(["lib/python"], [])
376
+ assert {item.line_number for item in read_packages(collected)} == {1}
377
+
378
+ def test_imported_modules_reads_an_import(self) -> None:
379
+ assert imported_modules(ast.parse(HANDLER)) == frozenset({"boto3"})
380
+
381
+ def test_imported_modules_reads_a_from_import(self) -> None:
382
+ assert imported_modules(ast.parse(CALLER)) == frozenset({"pkg"})
383
+
384
+ def test_imported_modules_skips_a_relative_import(self) -> None:
385
+ assert not imported_modules(ast.parse(RELATIVE))
386
+
387
+ def test_read_searched_keeps_the_imports(self) -> None:
388
+ assert read_searched({"src/app.py": CALLER}).imports["src/app.py"] == frozenset({"pkg"})
389
+
390
+ def test_is_imported_reads_the_sources(self) -> None:
391
+ package = Definition(path="lib/python/pkg/__init__.py", line_number=1, name="pkg",
392
+ package="pkg")
393
+ assert is_imported(package, read_searched({"src/app.py": CALLER}))
394
+
395
+ def test_a_bare_name_is_not_an_import(self) -> None:
396
+ package = Definition(path="lib/python/pkg/__init__.py", line_number=1, name="pkg",
397
+ package="pkg")
398
+ assert not is_imported(package, read_searched({"src/app.py": "pkg = 1\n"}))
399
+
400
+ def test_unimported_packages_reports(self) -> None:
401
+ package = Definition(path="lib/python/pkg/__init__.py", line_number=1, name="pkg",
402
+ package="pkg")
403
+ found = unimported_packages([package], read_searched({"src/app.py": "pkg = 1\n"}))
404
+ assert len(found) == 1
405
+
406
+ def test_the_parser_reads_the_unimported_packages_flag(self) -> None:
407
+ parsed = create_parser().parse_args(["lib", "--unimported-packages"])
408
+ assert parsed.unimported_packages is True
@@ -149,3 +149,55 @@ RUNTIME_ASSUMED = [
149
149
  "--assume-used-decorated-with",
150
150
  "pytest.fixture",
151
151
  ]
152
+
153
+ CLIENTS = """\
154
+ def get_client(service_name):
155
+ return service_name
156
+
157
+
158
+ def get_ssm_client():
159
+ return get_client("ssm")
160
+
161
+
162
+ def reset_clients():
163
+ return None
164
+ """
165
+
166
+ HANDLER = """\
167
+ import boto3
168
+
169
+
170
+ def get_ssm_client():
171
+ return boto3.client("ssm")
172
+
173
+
174
+ def handler(event, context):
175
+ return get_ssm_client()
176
+ """
177
+
178
+ RELATIVE = """\
179
+ from .helper import gadget
180
+
181
+
182
+ def ring():
183
+ return gadget()
184
+ """
185
+
186
+ SHADOWING_PROJECT = {
187
+ "lib/python/aws_clients/__init__.py": CLIENTS,
188
+ "src/handler.py": HANDLER,
189
+ }
190
+
191
+ PACKAGED_PROJECT = {
192
+ "lib/python/live/__init__.py": "def widget():\n return 1\n",
193
+ "lib/python/live/inner/__init__.py": "def spin():\n return 2\n",
194
+ "lib/python/dead/__init__.py": RELATIVE,
195
+ "lib/python/dead/helper.py": "def gadget():\n return 3\n",
196
+ "lib/python/loose.py": "def solo():\n return 4\n",
197
+ "src/app.py": "import live\n\nlive.widget()\n",
198
+ "test/lib/python/test_dead/test_it.py": "import dead\n\ndead.ring()\n",
199
+ }
200
+
201
+ PACKAGES_RUN = [*FULL_RUN, "--unimported-packages"]
202
+
203
+ SHADOWED_PACKAGES_RUN = [*CLEAN_RUN, "--unimported-packages"]
@@ -22,8 +22,10 @@ from assert_python_definition_is_used.cli import (
22
22
  determine_exit_code,
23
23
  output_findings,
24
24
  package_of,
25
+ package_root,
25
26
  parse_patterns,
26
27
  read_definitions,
28
+ read_packages,
27
29
  read_sources,
28
30
  should_skip,
29
31
  )
@@ -47,12 +49,22 @@ RUNTIME_TREE = {
47
49
  ),
48
50
  }
49
51
 
52
+ PACKAGE_TREE = {
53
+ "lib/python/held/__init__.py": "def widget():\n pass\n",
54
+ "lib/python/adrift/__init__.py": "def gadget():\n pass\n",
55
+ "src/app.py": "import held\n\nheld.widget()\ngadget()\n",
56
+ }
57
+
50
58
  ASSUMED = ["--assume-used-matching", "test_*", "--assume-used-decorated-with", "pytest.fixture"]
51
59
 
52
60
  SEARCH_EVERYWHERE = ["--search-in", "lib/python", "--search-in", "src", "--search-in", "test"]
53
61
 
54
62
  FULL_RUN = ["lib/python", *SEARCH_EVERYWHERE, "--dont-search-in", "test/lib/python/test_{package}"]
55
63
 
64
+ PACKAGE_SEARCH = ["lib/python", "--search-in", "lib/python", "--search-in", "src"]
65
+
66
+ PACKAGE_RUN = [*PACKAGE_SEARCH, "--unimported-packages"]
67
+
56
68
 
57
69
  def _arguments(extra: list[str]) -> argparse.Namespace:
58
70
  return create_parser().parse_args(["lib/python", *extra])
@@ -118,6 +130,13 @@ class TestCreateParser:
118
130
  with pytest.raises(SystemExit):
119
131
  create_parser().parse_args(["lib", "--quiet", "--verbose"])
120
132
 
133
+ def test_reads_the_unimported_packages_flag(self) -> None:
134
+ parsed = create_parser().parse_args(["lib", "--unimported-packages"])
135
+ assert parsed.unimported_packages is True
136
+
137
+ def test_unimported_packages_defaults_to_false(self) -> None:
138
+ assert create_parser().parse_args(["lib"]).unimported_packages is False
139
+
121
140
  def test_reads_the_fail_fast_flag(self) -> None:
122
141
  assert create_parser().parse_args(["lib", "--fail-fast"]).fail_fast is True
123
142
 
@@ -180,6 +199,49 @@ class TestPackageOf:
180
199
  assert package_of("lib/python/pkg/__init__.py", "lib/python/") == "pkg"
181
200
 
182
201
 
202
+ @pytest.mark.unit
203
+ class TestPackageRoot:
204
+ def test_names_a_package_at_its_init(self) -> None:
205
+ assert package_root("lib/python/pkg/__init__.py", "lib/python") == "pkg"
206
+
207
+ def test_a_module_beside_the_init_is_not_a_root(self) -> None:
208
+ assert package_root("lib/python/pkg/mod.py", "lib/python") is None
209
+
210
+ def test_a_nested_package_is_not_a_root(self) -> None:
211
+ assert package_root("lib/python/pkg/inner/__init__.py", "lib/python") is None
212
+
213
+ def test_a_loose_file_is_not_a_root(self) -> None:
214
+ assert package_root("lib/python/mod.py", "lib/python") is None
215
+
216
+ def test_normalises_a_trailing_separator(self) -> None:
217
+ assert package_root("lib/python/pkg/__init__.py", "lib/python/") == "pkg"
218
+
219
+
220
+ @pytest.mark.unit
221
+ class TestReadPackages:
222
+ def test_names_the_package(self) -> None:
223
+ found = read_packages({"lib/python/pkg/__init__.py": "lib/python"})
224
+ assert [item.name for item in found] == ["pkg"]
225
+
226
+ def test_anchors_on_the_first_line(self) -> None:
227
+ found = read_packages({"lib/python/pkg/__init__.py": "lib/python"})
228
+ assert found[0].line_number == 1
229
+
230
+ def test_carries_the_package_through(self) -> None:
231
+ found = read_packages({"lib/python/pkg/__init__.py": "lib/python"})
232
+ assert found[0].package == "pkg"
233
+
234
+ def test_skips_a_module_that_is_not_an_init(self) -> None:
235
+ assert not read_packages({"lib/python/pkg/mod.py": "lib/python"})
236
+
237
+ def test_reads_the_packages_in_path_order(self) -> None:
238
+ paths = {
239
+ "lib/python/b/__init__.py": "lib/python",
240
+ "lib/python/a/__init__.py": "lib/python",
241
+ }
242
+ assert [item.name for item in read_packages(paths)] == ["a", "b"]
243
+
244
+
183
245
  @pytest.mark.unit
184
246
  class TestTreeRoot:
185
247
  def test_a_directory_is_its_own_root(self, write_tree: WriteTree) -> None:
@@ -526,3 +588,28 @@ class TestMainWithAssumptions:
526
588
  def test_a_name_outside_the_patterns_is_reported(self, exit_code_of: ExitCodeOf) -> None:
527
589
  tree = {"test/pkg/helpers.py": "def spare():\n pass\n"}
528
590
  assert exit_code_of(tree, ["test", *ASSUMED]) == EXIT_FINDINGS
591
+
592
+
593
+ @pytest.mark.unit
594
+ class TestMainOverPackages:
595
+ def test_reports_the_package_nothing_imports(self, stdout_of: StdoutOf) -> None:
596
+ assert "adrift" in stdout_of(PACKAGE_TREE, PACKAGE_RUN)
597
+
598
+ def test_leaves_the_imported_package_alone(self, stdout_of: StdoutOf) -> None:
599
+ assert "held" not in stdout_of(PACKAGE_TREE, PACKAGE_RUN)
600
+
601
+ def test_anchors_the_finding_on_the_init_file(self, stdout_of: StdoutOf) -> None:
602
+ anchor = os.path.join("lib", "python", "adrift", "__init__.py")
603
+ assert stdout_of(PACKAGE_TREE, PACKAGE_RUN).splitlines() == [f"{anchor}:1:adrift"]
604
+
605
+ def test_the_finding_fails_the_run(self, exit_code_of: ExitCodeOf) -> None:
606
+ assert exit_code_of(PACKAGE_TREE, PACKAGE_RUN) == EXIT_FINDINGS
607
+
608
+ def test_the_definition_check_is_quiet_about_the_same_tree(self, stdout_of: StdoutOf) -> None:
609
+ assert stdout_of(PACKAGE_TREE, [*PACKAGE_SEARCH, "--count"]) == "0\n"
610
+
611
+ def test_verbose_counts_the_packages_read(self, stdout_of: StdoutOf) -> None:
612
+ assert "Definitions read: 2" in stdout_of(PACKAGE_TREE, [*PACKAGE_RUN, "--verbose"])
613
+
614
+ def test_verbose_counts_the_files_scanned(self, stdout_of: StdoutOf) -> None:
615
+ assert "Files scanned: 2" in stdout_of(PACKAGE_TREE, [*PACKAGE_RUN, "--verbose"])
@@ -8,13 +8,16 @@ from assert_python_definition_is_used.scanner import (
8
8
  Definition,
9
9
  Finding,
10
10
  assumed_used,
11
+ imported_modules,
11
12
  is_assumed_used_by_decorator,
12
13
  is_assumed_used_by_name,
14
+ is_imported,
13
15
  is_public,
14
16
  is_used,
15
17
  names_in_text,
16
18
  names_used,
17
19
  read_searched,
20
+ unimported_packages,
18
21
  unsearched_directory,
19
22
  public_definitions,
20
23
  unused_definitions,
@@ -27,6 +30,10 @@ def _uses(name: str, content: str) -> bool:
27
30
  return name in names_used(ast.parse(content))
28
31
 
29
32
 
33
+ def _imports(content: str) -> frozenset[str]:
34
+ return imported_modules(ast.parse(content))
35
+
36
+
30
37
  def _definition(
31
38
  name: str = "widget",
32
39
  line: int = 1,
@@ -283,6 +290,13 @@ class TestReadSearched:
283
290
  searched = read_searched({"src/b.py": "", "src/a.py": ""})
284
291
  assert list(searched.uses) == ["src/a.py", "src/b.py"]
285
292
 
293
+ def test_reads_the_imports_of_a_file(self) -> None:
294
+ searched = read_searched({"src/app.py": "import pkg\n"})
295
+ assert searched.imports == {"src/app.py": frozenset({"pkg"})}
296
+
297
+ def test_a_file_that_will_not_parse_has_no_imports(self) -> None:
298
+ assert not read_searched({"src/bad.py": "def widget(:\n"}).imports
299
+
286
300
 
287
301
  @pytest.mark.unit
288
302
  class TestIsAssumedUsedByName:
@@ -477,3 +491,103 @@ class TestUnusedDefinitions:
477
491
 
478
492
  def test_no_definitions_means_no_findings(self) -> None:
479
493
  assert not unused_definitions([], read_searched({MODULE: ""}))
494
+
495
+
496
+ @pytest.mark.unit
497
+ class TestImportedModules:
498
+ def test_reads_a_plain_import(self) -> None:
499
+ assert _imports("import pkg\n") == frozenset({"pkg"})
500
+
501
+ def test_reads_only_the_top_level_of_a_dotted_import(self) -> None:
502
+ assert _imports("import pkg.inner.mod\n") == frozenset({"pkg"})
503
+
504
+ def test_reads_a_renamed_import(self) -> None:
505
+ assert _imports("import pkg as shortcut\n") == frozenset({"pkg"})
506
+
507
+ def test_reads_every_name_in_one_import(self) -> None:
508
+ assert _imports("import one, two\n") == frozenset({"one", "two"})
509
+
510
+ def test_reads_a_from_import(self) -> None:
511
+ assert _imports("from pkg import widget\n") == frozenset({"pkg"})
512
+
513
+ def test_reads_only_the_top_level_of_a_dotted_from_import(self) -> None:
514
+ assert _imports("from pkg.inner import widget\n") == frozenset({"pkg"})
515
+
516
+ def test_reads_an_import_buried_in_a_function(self) -> None:
517
+ assert _imports("def entry():\n import pkg\n") == frozenset({"pkg"})
518
+
519
+ def test_a_bare_relative_import_names_no_package(self) -> None:
520
+ assert not _imports("from . import widget\n")
521
+
522
+ def test_a_relative_module_import_names_no_package(self) -> None:
523
+ assert not _imports("from .inner import widget\n")
524
+
525
+ def test_naming_a_module_is_not_importing_it(self) -> None:
526
+ assert not _imports("pkg.widget()\n")
527
+
528
+ def test_an_empty_file_imports_nothing(self) -> None:
529
+ assert not _imports("")
530
+
531
+
532
+ @pytest.mark.unit
533
+ class TestIsImported:
534
+ def test_a_sibling_module_importing_it_counts(self) -> None:
535
+ sources = {MODULE: "", "src/app.py": "import pkg\n"}
536
+ assert is_imported(_definition(name="pkg"), read_searched(sources)) is True
537
+
538
+ def test_a_from_import_counts(self) -> None:
539
+ sources = {MODULE: "", "src/app.py": "from pkg import widget\n"}
540
+ assert is_imported(_definition(name="pkg"), read_searched(sources)) is True
541
+
542
+ def test_a_name_written_as_a_bare_word_is_not_an_import(self) -> None:
543
+ sources = {MODULE: "", "src/app.py": "def get_client():\n pass\n"}
544
+ package = _definition(name="get_client", package="get_client")
545
+ assert is_imported(package, read_searched(sources)) is False
546
+
547
+ def test_a_file_that_will_not_parse_does_not_import(self) -> None:
548
+ sources = {MODULE: "", "src/bad.py": "import pkg(\n"}
549
+ assert is_imported(_definition(name="pkg"), read_searched(sources)) is False
550
+
551
+ def test_an_unsearched_directory_is_discounted(self) -> None:
552
+ sources = {MODULE: "", "test/pkg/test_it.py": "import pkg\n"}
553
+ found = is_imported(_definition(name="pkg"), read_searched(sources), "test/pkg/")
554
+ assert found is False
555
+
556
+ def test_another_directory_still_counts(self) -> None:
557
+ sources = {MODULE: "", "test/other/test_it.py": "import pkg\n"}
558
+ found = is_imported(_definition(name="pkg"), read_searched(sources), "test/pkg/")
559
+ assert found is True
560
+
561
+ def test_nothing_to_search_means_unimported(self) -> None:
562
+ assert is_imported(_definition(name="pkg"), read_searched({})) is False
563
+
564
+
565
+ @pytest.mark.unit
566
+ class TestUnimportedPackages:
567
+ def test_reports_a_package_nothing_imports(self) -> None:
568
+ sources = {MODULE: "def widget():\n pass\n"}
569
+ found = unimported_packages([_definition(name="pkg")], read_searched(sources))
570
+ assert len(found) == 1
571
+
572
+ def test_stays_quiet_about_a_package_a_sibling_imports(self) -> None:
573
+ sources = {MODULE: "", "src/app.py": "import pkg\n"}
574
+ assert not unimported_packages([_definition(name="pkg")], read_searched(sources))
575
+
576
+ def test_reports_a_package_only_its_own_tests_import(self) -> None:
577
+ sources = {MODULE: "", "test/lib/python/test_pkg/test_it.py": "import pkg\n"}
578
+ found = unimported_packages(
579
+ [_definition(name="pkg")], read_searched(sources), "test/lib/python/test_{package}"
580
+ )
581
+ assert len(found) == 1
582
+
583
+ def test_reports_the_package_whose_name_is_only_ever_a_bare_word(self) -> None:
584
+ sources = {MODULE: "", "src/app.py": "def get_client():\n return 1\n"}
585
+ packages = [_definition(name="get_client", package="get_client")]
586
+ assert len(unimported_packages(packages, read_searched(sources))) == 1
587
+
588
+ def test_anchors_the_finding_on_the_init_file(self) -> None:
589
+ found = unimported_packages([_definition(name="pkg")], read_searched({MODULE: ""}))
590
+ assert str(found[0]) == f"{MODULE}:1:pkg"
591
+
592
+ def test_no_packages_means_no_findings(self) -> None:
593
+ assert not unimported_packages([], read_searched({MODULE: ""}))
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "20260829003946",
3
- "distance": 0,
4
- "node": "g8d74654a1663da2f08adbf9ca0278d7f2554fbd0",
5
- "dirty": false,
6
- "branch": "main",
7
- "node_date": "2026-08-29"
8
- }