assert-python-definition-is-used 20260829003639__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 (50) 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-20260829003639 → assert_python_definition_is_used-20260830020719}/.github/workflows/documentation.yml +2 -2
  3. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.github/workflows/release.yml +32 -32
  4. {assert_python_definition_is_used-20260829003639/src/assert_python_definition_is_used.egg-info → assert_python_definition_is_used-20260830020719}/PKG-INFO +64 -2
  5. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/README.md +63 -1
  6. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/action.yml +6 -0
  7. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/cli.py +45 -3
  8. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/scanner.py +45 -5
  9. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719/src/assert_python_definition_is_used.egg-info}/PKG-INFO +64 -2
  10. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/SOURCES.txt +1 -1
  11. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/scm_file_list.json +1 -1
  12. assert_python_definition_is_used-20260830020719/src/assert_python_definition_is_used.egg-info/scm_version.json +8 -0
  13. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/integration/test_cli.py +44 -0
  14. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/integration/test_helpers.py +59 -0
  15. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/samples.py +52 -0
  16. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/unit/test_cli.py +87 -0
  17. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/unit/test_scanner.py +114 -0
  18. assert_python_definition_is_used-20260829003639/.claude/memories/disable-pylint-docstring-checks-on-the-command-line.md +0 -8
  19. assert_python_definition_is_used-20260829003639/src/assert_python_definition_is_used.egg-info/scm_version.json +0 -8
  20. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/ask-gh-before-saying-github-cannot.md +0 -0
  21. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/commit-straight-to-main.md +0 -0
  22. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/cover-new-source-in-both-tiers.md +0 -0
  23. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/do-not-offer-a-pointless-choice.md +0 -0
  24. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/do-what-the-issue-says-and-stop.md +0 -0
  25. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/jscpd-reads-a-docstring-as-ordinary-tokens.md +0 -0
  26. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/let-ci-run-the-checks.md +0 -0
  27. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/name-a-conftest-fixture-apart-from-what-it-asks-for.md +0 -0
  28. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/name-a-job-after-the-package-it-runs.md +0 -0
  29. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/run-the-tool-before-writing-the-claim.md +0 -0
  30. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.claude/memories/start-over-when-the-user-does-not-understand.md +0 -0
  31. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/.gitignore +0 -0
  32. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/CLAUDE.md +0 -0
  33. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/LICENSE.txt +0 -0
  34. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/pyproject.toml +0 -0
  35. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/setup.cfg +0 -0
  36. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/__init__.py +0 -0
  37. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/__main__.py +0 -0
  38. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/py.typed +0 -0
  39. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/dependency_links.txt +0 -0
  40. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/entry_points.txt +0 -0
  41. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/top_level.txt +0 -0
  42. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/__init__.py +0 -0
  43. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/conftest.py +0 -0
  44. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/e2e/__init__.py +0 -0
  45. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/e2e/conftest.py +0 -0
  46. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/e2e/test_e2e.py +0 -0
  47. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/integration/__init__.py +0 -0
  48. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/runners.py +0 -0
  49. {assert_python_definition_is_used-20260829003639 → assert_python_definition_is_used-20260830020719}/test/unit/__init__.py +0 -0
  50. {assert_python_definition_is_used-20260829003639 → 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.
@@ -7,9 +7,9 @@ jobs:
7
7
  runs-on: ubuntu-latest
8
8
  steps:
9
9
  - name: Checkout code
10
- uses: actions/checkout@v4
10
+ uses: actions/checkout@v7
11
11
  - name: Set up Node
12
- uses: actions/setup-node@v4
12
+ uses: actions/setup-node@v7
13
13
  with:
14
14
  node-version: "20"
15
15
  - name: Install dependencies
@@ -7,9 +7,9 @@ jobs:
7
7
  runs-on: ubuntu-latest
8
8
  steps:
9
9
  - name: Checkout code
10
- uses: actions/checkout@v4
10
+ uses: actions/checkout@v7
11
11
  - name: Set up Python
12
- uses: actions/setup-python@v5
12
+ uses: actions/setup-python@v7
13
13
  with:
14
14
  python-version: "3.13"
15
15
  - name: Install dependencies
@@ -23,9 +23,9 @@ jobs:
23
23
  runs-on: ubuntu-latest
24
24
  steps:
25
25
  - name: Checkout code
26
- uses: actions/checkout@v4
26
+ uses: actions/checkout@v7
27
27
  - name: Set up Python
28
- uses: actions/setup-python@v5
28
+ uses: actions/setup-python@v7
29
29
  with:
30
30
  python-version: "3.13"
31
31
  - name: Install dependencies
@@ -40,9 +40,9 @@ jobs:
40
40
  runs-on: ubuntu-latest
41
41
  steps:
42
42
  - name: Checkout code
43
- uses: actions/checkout@v4
43
+ uses: actions/checkout@v7
44
44
  - name: Set up Python
45
- uses: actions/setup-python@v5
45
+ uses: actions/setup-python@v7
46
46
  with:
47
47
  python-version: "3.13"
48
48
  - name: Install dependencies
@@ -57,9 +57,9 @@ jobs:
57
57
  runs-on: ubuntu-latest
58
58
  steps:
59
59
  - name: Checkout code
60
- uses: actions/checkout@v4
60
+ uses: actions/checkout@v7
61
61
  - name: Set up Python
62
- uses: actions/setup-python@v5
62
+ uses: actions/setup-python@v7
63
63
  with:
64
64
  python-version: "3.13"
65
65
  - name: Install dependencies
@@ -73,9 +73,9 @@ jobs:
73
73
  runs-on: ubuntu-latest
74
74
  steps:
75
75
  - name: Checkout code
76
- uses: actions/checkout@v4
76
+ uses: actions/checkout@v7
77
77
  - name: Set up Python
78
- uses: actions/setup-python@v5
78
+ uses: actions/setup-python@v7
79
79
  with:
80
80
  python-version: "3.13"
81
81
  - name: Install dependencies
@@ -91,9 +91,9 @@ jobs:
91
91
  runs-on: ubuntu-latest
92
92
  steps:
93
93
  - name: Checkout code
94
- uses: actions/checkout@v4
94
+ uses: actions/checkout@v7
95
95
  - name: Set up Python
96
- uses: actions/setup-python@v5
96
+ uses: actions/setup-python@v7
97
97
  with:
98
98
  python-version: "3.13"
99
99
  - name: Install dependencies
@@ -104,9 +104,9 @@ jobs:
104
104
  runs-on: ubuntu-latest
105
105
  steps:
106
106
  - name: Checkout code
107
- uses: actions/checkout@v4
107
+ uses: actions/checkout@v7
108
108
  - name: Set up Python
109
- uses: actions/setup-python@v5
109
+ uses: actions/setup-python@v7
110
110
  with:
111
111
  python-version: "3.13"
112
112
  - name: Install dependencies
@@ -123,9 +123,9 @@ jobs:
123
123
  runs-on: ubuntu-latest
124
124
  steps:
125
125
  - name: Checkout code
126
- uses: actions/checkout@v4
126
+ uses: actions/checkout@v7
127
127
  - name: Set up Node
128
- uses: actions/setup-node@v4
128
+ uses: actions/setup-node@v7
129
129
  with:
130
130
  node-version: "20"
131
131
  - name: Install dependencies
@@ -136,9 +136,9 @@ jobs:
136
136
  runs-on: ubuntu-latest
137
137
  steps:
138
138
  - name: Checkout code
139
- uses: actions/checkout@v4
139
+ uses: actions/checkout@v7
140
140
  - name: Set up Node
141
- uses: actions/setup-node@v4
141
+ uses: actions/setup-node@v7
142
142
  with:
143
143
  node-version: "20"
144
144
  - name: Install dependencies
@@ -149,9 +149,9 @@ jobs:
149
149
  runs-on: ubuntu-latest
150
150
  steps:
151
151
  - name: Checkout code
152
- uses: actions/checkout@v4
152
+ uses: actions/checkout@v7
153
153
  - name: Set up Python
154
- uses: actions/setup-python@v5
154
+ uses: actions/setup-python@v7
155
155
  with:
156
156
  python-version: "3.13"
157
157
  - name: Install dependencies
@@ -162,9 +162,9 @@ jobs:
162
162
  runs-on: ubuntu-latest
163
163
  steps:
164
164
  - name: Checkout code
165
- uses: actions/checkout@v4
165
+ uses: actions/checkout@v7
166
166
  - name: Set up Python
167
- uses: actions/setup-python@v5
167
+ uses: actions/setup-python@v7
168
168
  with:
169
169
  python-version: "3.13"
170
170
  - name: Install dependencies
@@ -175,9 +175,9 @@ jobs:
175
175
  runs-on: ubuntu-latest
176
176
  steps:
177
177
  - name: Checkout code
178
- uses: actions/checkout@v4
178
+ uses: actions/checkout@v7
179
179
  - name: Set up Python
180
- uses: actions/setup-python@v5
180
+ uses: actions/setup-python@v7
181
181
  with:
182
182
  python-version: "3.13"
183
183
  - name: Install dependencies
@@ -194,9 +194,9 @@ jobs:
194
194
  runs-on: ubuntu-latest
195
195
  steps:
196
196
  - name: Checkout code
197
- uses: actions/checkout@v4
197
+ uses: actions/checkout@v7
198
198
  - name: Set up Python
199
- uses: actions/setup-python@v5
199
+ uses: actions/setup-python@v7
200
200
  with:
201
201
  python-version: "3.13"
202
202
  - name: Install dependencies
@@ -233,11 +233,11 @@ jobs:
233
233
  runs-on: ubuntu-latest
234
234
  steps:
235
235
  - name: Checkout code
236
- uses: actions/checkout@v4
236
+ uses: actions/checkout@v7
237
237
  with:
238
238
  fetch-depth: 0
239
239
  - name: Set up Python
240
- uses: actions/setup-python@v5
240
+ uses: actions/setup-python@v7
241
241
  with:
242
242
  python-version: "3.13"
243
243
  - id: version
@@ -267,9 +267,9 @@ jobs:
267
267
  runs-on: ubuntu-latest
268
268
  steps:
269
269
  - name: Checkout code
270
- uses: actions/checkout@v4
270
+ uses: actions/checkout@v7
271
271
  - name: Set up Python
272
- uses: actions/setup-python@v5
272
+ uses: actions/setup-python@v7
273
273
  with:
274
274
  python-version: "3.13"
275
275
  - name: Install dependencies
@@ -286,9 +286,9 @@ jobs:
286
286
  runs-on: ubuntu-latest
287
287
  steps:
288
288
  - name: Checkout code
289
- uses: actions/checkout@v4
289
+ uses: actions/checkout@v7
290
290
  - name: Set up Python
291
- uses: actions/setup-python@v5
291
+ uses: actions/setup-python@v7
292
292
  with:
293
293
  python-version: "3.13"
294
294
  - name: Install dependencies
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assert-python-definition-is-used
3
- Version: 20260829003639
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)