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.
- assert_python_definition_is_used-20260830020719/.claude/memories/pylint-refactor-messages-are-hard-failures.md +9 -0
- {assert_python_definition_is_used-20260829003946/src/assert_python_definition_is_used.egg-info → assert_python_definition_is_used-20260830020719}/PKG-INFO +64 -2
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/README.md +63 -1
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/action.yml +6 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/cli.py +45 -3
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/scanner.py +45 -5
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719/src/assert_python_definition_is_used.egg-info}/PKG-INFO +64 -2
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used.egg-info/SOURCES.txt +1 -0
- {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
- assert_python_definition_is_used-20260830020719/src/assert_python_definition_is_used.egg-info/scm_version.json +8 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/integration/test_cli.py +44 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/integration/test_helpers.py +59 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/samples.py +52 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/unit/test_cli.py +87 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/unit/test_scanner.py +114 -0
- assert_python_definition_is_used-20260829003946/src/assert_python_definition_is_used.egg-info/scm_version.json +0 -8
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/ask-gh-before-saying-github-cannot.md +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/commit-straight-to-main.md +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/cover-new-source-in-both-tiers.md +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/do-not-offer-a-pointless-choice.md +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/do-what-the-issue-says-and-stop.md +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/jscpd-reads-a-docstring-as-ordinary-tokens.md +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/let-ci-run-the-checks.md +0 -0
- {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
- {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
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.claude/memories/run-the-tool-before-writing-the-claim.md +0 -0
- {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
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.github/workflows/documentation.yml +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.github/workflows/release.yml +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/.gitignore +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/CLAUDE.md +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/LICENSE.txt +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/pyproject.toml +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/setup.cfg +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/__init__.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/__main__.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/src/assert_python_definition_is_used/py.typed +0 -0
- {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
- {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
- {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
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/__init__.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/conftest.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/e2e/__init__.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/e2e/conftest.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/e2e/test_e2e.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/integration/__init__.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/runners.py +0 -0
- {assert_python_definition_is_used-20260829003946 → assert_python_definition_is_used-20260830020719}/test/unit/__init__.py +0 -0
- {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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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",
|
|
@@ -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: ""}))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|