sarj-python-lint 0.33.0__tar.gz → 0.35.0__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.
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/PKG-INFO +18 -17
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/README.md +17 -16
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/__main__.py +32 -9
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/_ratchet_cli.py +3 -18
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/_secret_names.py +2 -19
- sarj_python_lint-0.35.0/src/sarj_python_lint/_version.py +9 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/ratchet.py +10 -74
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rule_base.py +43 -16
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_ast_index.py +1 -16
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_comments.py +5 -59
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_docstrings.py +4 -36
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_first_party.py +129 -28
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_logging.py +0 -3
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_paths.py +1 -21
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_pytest.py +0 -6
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_registry.py +0 -2
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_sql.py +8 -19
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -4
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +23 -333
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/docstring_args_restate_signature.py +94 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/duplicate_test_body.py +405 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/duplicated_override_docstring.py +107 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +7 -64
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +15 -135
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/interaction_only_test.py +19 -266
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +185 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +69 -189
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/mock_without_spec.py +15 -235
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +9 -62
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_comment_cruft.py +19 -250
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +6 -35
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +10 -147
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +85 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +8 -75
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_first_party_private_import.py +171 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +14 -158
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +89 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +459 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +8 -84
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_offset_pagination.py +82 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -74
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +4 -20
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +99 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +150 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_restated_comment.py +71 -115
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_secret_in_log.py +6 -60
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_select_star.py +6 -36
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +39 -378
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_sequential_await.py +15 -138
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +8 -77
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +12 -101
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_tautological_expect.py +248 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +8 -52
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/over_mocked_test.py +614 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +7 -81
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_class_row.py +5 -35
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +11 -95
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +311 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_library_fake.py +18 -236
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +18 -251
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +289 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +360 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +28 -263
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +15 -163
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +4 -34
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_or_pattern.py +165 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +17 -256
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +6 -36
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_str_enum.py +26 -296
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +67 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +103 -133
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +5 -20
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +6 -30
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +6 -31
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +13 -150
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/redundant_class_docstring.py +116 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/redundant_docstring.py +136 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/require_port_for_service.py +400 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/single_public_export.py +6 -60
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +7 -55
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/stepdown.py +55 -148
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +86 -0
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +412 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +7 -65
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/trailing_value_narration.py +5 -57
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/trivially_true_assertion.py +356 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/unused_mock_setup.py +18 -210
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +8 -86
- sarj_python_lint-0.35.0/src/sarj_python_lint/rules/zero_assertion_test.py +355 -0
- sarj_python_lint-0.33.0/src/sarj_python_lint/_version.py +0 -20
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -172
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/duplicate_test_body.py +0 -711
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -188
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -202
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -150
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -185
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -145
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -177
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_offset_pagination.py +0 -128
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -801
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -161
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -247
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_tautological_expect.py +0 -463
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/over_mocked_test.py +0 -1055
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -574
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -488
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -287
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -370
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -112
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -201
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/redundant_docstring.py +0 -147
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/require_port_for_service.py +0 -783
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -150
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -704
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -648
- sarj_python_lint-0.33.0/src/sarj_python_lint/rules/zero_assertion_test.py +0 -354
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/.gitignore +0 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.35.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -76,7 +76,7 @@ package inside your own project. Third-party privates are never flagged.
|
|
|
76
76
|
|
|
77
77
|
**It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
|
|
78
78
|
*same top-level package* — a different question, and one that cannot separate
|
|
79
|
-
`from
|
|
79
|
+
`from app.stores.order_store import _row_to_order` (real; export it) from
|
|
80
80
|
`from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
|
|
81
81
|
`sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
|
|
82
82
|
reason; if you take that config, turn this hook on, or you lose the check
|
|
@@ -100,7 +100,7 @@ each guard was built from are recorded in the rule module docstrings.
|
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
`redundant-docstring` finds real volume on a codebase that has never had it
|
|
103
|
-
(105 in
|
|
103
|
+
(105 in one first-party repo), so the same baseline ratchet applies.
|
|
104
104
|
|
|
105
105
|
### Docstring-ceremony rules (0.31.0)
|
|
106
106
|
|
|
@@ -161,10 +161,11 @@ to WARNING — usually vanish in production while looking fine locally.
|
|
|
161
161
|
The one legitimate reason to touch stdlib logging in a loguru house is to
|
|
162
162
|
*bridge* it, and the bridge cannot be written without naming both loggers, so a
|
|
163
163
|
module importing loguru is exempt. Measured across two production repos that
|
|
164
|
-
exemption is exact: all four sites that import stdlib logging
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
other module in either repo imports
|
|
164
|
+
exemption is exact: all four sites that import stdlib logging (three in the
|
|
165
|
+
first repo — its package `__init__.py`, a dedicated `configure_logging.py`, and
|
|
166
|
+
a service entrypoint `main.py` — plus a `common/logging.py` in the second) are
|
|
167
|
+
bridges, all four import loguru, and no other module in either repo imports
|
|
168
|
+
stdlib logging at all. Tests, `scripts/`,
|
|
168
169
|
`notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
|
|
169
170
|
|
|
170
171
|
**This is a house-convention rule, not a universal one.** A *library* should log
|
|
@@ -176,7 +177,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
|
|
|
176
177
|
(Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
|
|
177
178
|
policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
|
|
178
179
|
and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
|
|
179
|
-
files (41 sites in
|
|
180
|
+
files (41 sites in one first-party repo, 14 in another, all of them a
|
|
181
|
+
primary-key `DEFAULT`).
|
|
180
182
|
A literal only counts when it is SQL-shaped, so prose naming the function is not
|
|
181
183
|
a finding.
|
|
182
184
|
|
|
@@ -195,7 +197,7 @@ flagged — measured across five repos those are the entire population.
|
|
|
195
197
|
The second test-quality wave. Where the 0.15.0 family asks "does this test assert
|
|
196
198
|
anything?", this one asks "does it exercise anything real?" — it pushes suites off
|
|
197
199
|
hand-rolled doubles and onto the real store, the real database and a maintained fake
|
|
198
|
-
library. Measured against
|
|
200
|
+
library. Measured against seven first-party repos and fourteen
|
|
199
201
|
OSS corpora; two candidates were dropped outright when the corpus showed they only
|
|
200
202
|
duplicated ruff.
|
|
201
203
|
|
|
@@ -203,7 +205,6 @@ duplicated ruff.
|
|
|
203
205
|
- id: sarj-prefer-real-store-in-tests # SARJ058
|
|
204
206
|
- id: sarj-prefer-library-fake # SARJ059
|
|
205
207
|
- id: sarj-tautological-mock-assertion # SARJ060
|
|
206
|
-
- id: sarj-no-patching-system-under-test # SARJ061
|
|
207
208
|
- id: sarj-over-mocked-test # SARJ062
|
|
208
209
|
- id: sarj-interaction-only-test # SARJ063
|
|
209
210
|
- id: sarj-trivially-true-assertion # SARJ064
|
|
@@ -255,8 +256,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
|
|
|
255
256
|
pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
|
|
256
257
|
consumer. An AST port of each was built and measured, and the boundary each
|
|
257
258
|
encodes turned out to be repo-specific rather than shared: "shared fake" flagged
|
|
258
|
-
9/9 single-use test doubles in
|
|
259
|
-
"raw connection in a test" flagged 46 sites in
|
|
259
|
+
9/9 single-use test doubles in one first-party repo that are idiomatic where
|
|
260
|
+
they sit, and "raw connection in a test" flagged 46 sites in another of which every one is
|
|
260
261
|
already an intentional exemption (store tests asserting DB state, pool-lifecycle
|
|
261
262
|
tests, retention tests where physical deletion is the subject). SARJ036
|
|
262
263
|
`no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
|
|
@@ -303,11 +304,11 @@ an omitted filter.
|
|
|
303
304
|
|
|
304
305
|
Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
|
|
305
306
|
attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
|
|
306
|
-
column, so the rule is silent by construction — and 0
|
|
307
|
-
|
|
307
|
+
column, so the rule is silent by construction — and 0 across four other
|
|
308
|
+
first-party repos. In the fifth it finds 10 sites, all genuine fail-open
|
|
308
309
|
compositions, two of which were reachable cross-tenant reads at the time of
|
|
309
|
-
writing (
|
|
310
|
-
|
|
310
|
+
writing (two paginated list endpoints, both of which composed `WHERE 1=1` for a
|
|
311
|
+
user whose `organization_id` was NULL).
|
|
311
312
|
|
|
312
313
|
### Assertions that can never fail (0.23.0)
|
|
313
314
|
|
|
@@ -343,7 +344,7 @@ ran* — as is anything inside a pytest-benchmark test.
|
|
|
343
344
|
|
|
344
345
|
Measured before shipping: **4 findings across 28,608 files** — 26,346 of
|
|
345
346
|
pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
|
|
346
|
-
|
|
347
|
+
across five first-party repos. All 4 are true positives
|
|
347
348
|
(Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
|
|
348
349
|
`tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
|
|
349
350
|
`providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
|
|
@@ -58,7 +58,7 @@ package inside your own project. Third-party privates are never flagged.
|
|
|
58
58
|
|
|
59
59
|
**It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
|
|
60
60
|
*same top-level package* — a different question, and one that cannot separate
|
|
61
|
-
`from
|
|
61
|
+
`from app.stores.order_store import _row_to_order` (real; export it) from
|
|
62
62
|
`from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
|
|
63
63
|
`sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
|
|
64
64
|
reason; if you take that config, turn this hook on, or you lose the check
|
|
@@ -82,7 +82,7 @@ each guard was built from are recorded in the rule module docstrings.
|
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
`redundant-docstring` finds real volume on a codebase that has never had it
|
|
85
|
-
(105 in
|
|
85
|
+
(105 in one first-party repo), so the same baseline ratchet applies.
|
|
86
86
|
|
|
87
87
|
### Docstring-ceremony rules (0.31.0)
|
|
88
88
|
|
|
@@ -143,10 +143,11 @@ to WARNING — usually vanish in production while looking fine locally.
|
|
|
143
143
|
The one legitimate reason to touch stdlib logging in a loguru house is to
|
|
144
144
|
*bridge* it, and the bridge cannot be written without naming both loggers, so a
|
|
145
145
|
module importing loguru is exempt. Measured across two production repos that
|
|
146
|
-
exemption is exact: all four sites that import stdlib logging
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
other module in either repo imports
|
|
146
|
+
exemption is exact: all four sites that import stdlib logging (three in the
|
|
147
|
+
first repo — its package `__init__.py`, a dedicated `configure_logging.py`, and
|
|
148
|
+
a service entrypoint `main.py` — plus a `common/logging.py` in the second) are
|
|
149
|
+
bridges, all four import loguru, and no other module in either repo imports
|
|
150
|
+
stdlib logging at all. Tests, `scripts/`,
|
|
150
151
|
`notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
|
|
151
152
|
|
|
152
153
|
**This is a house-convention rule, not a universal one.** A *library* should log
|
|
@@ -158,7 +159,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
|
|
|
158
159
|
(Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
|
|
159
160
|
policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
|
|
160
161
|
and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
|
|
161
|
-
files (41 sites in
|
|
162
|
+
files (41 sites in one first-party repo, 14 in another, all of them a
|
|
163
|
+
primary-key `DEFAULT`).
|
|
162
164
|
A literal only counts when it is SQL-shaped, so prose naming the function is not
|
|
163
165
|
a finding.
|
|
164
166
|
|
|
@@ -177,7 +179,7 @@ flagged — measured across five repos those are the entire population.
|
|
|
177
179
|
The second test-quality wave. Where the 0.15.0 family asks "does this test assert
|
|
178
180
|
anything?", this one asks "does it exercise anything real?" — it pushes suites off
|
|
179
181
|
hand-rolled doubles and onto the real store, the real database and a maintained fake
|
|
180
|
-
library. Measured against
|
|
182
|
+
library. Measured against seven first-party repos and fourteen
|
|
181
183
|
OSS corpora; two candidates were dropped outright when the corpus showed they only
|
|
182
184
|
duplicated ruff.
|
|
183
185
|
|
|
@@ -185,7 +187,6 @@ duplicated ruff.
|
|
|
185
187
|
- id: sarj-prefer-real-store-in-tests # SARJ058
|
|
186
188
|
- id: sarj-prefer-library-fake # SARJ059
|
|
187
189
|
- id: sarj-tautological-mock-assertion # SARJ060
|
|
188
|
-
- id: sarj-no-patching-system-under-test # SARJ061
|
|
189
190
|
- id: sarj-over-mocked-test # SARJ062
|
|
190
191
|
- id: sarj-interaction-only-test # SARJ063
|
|
191
192
|
- id: sarj-trivially-true-assertion # SARJ064
|
|
@@ -237,8 +238,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
|
|
|
237
238
|
pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
|
|
238
239
|
consumer. An AST port of each was built and measured, and the boundary each
|
|
239
240
|
encodes turned out to be repo-specific rather than shared: "shared fake" flagged
|
|
240
|
-
9/9 single-use test doubles in
|
|
241
|
-
"raw connection in a test" flagged 46 sites in
|
|
241
|
+
9/9 single-use test doubles in one first-party repo that are idiomatic where
|
|
242
|
+
they sit, and "raw connection in a test" flagged 46 sites in another of which every one is
|
|
242
243
|
already an intentional exemption (store tests asserting DB state, pool-lifecycle
|
|
243
244
|
tests, retention tests where physical deletion is the subject). SARJ036
|
|
244
245
|
`no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
|
|
@@ -285,11 +286,11 @@ an omitted filter.
|
|
|
285
286
|
|
|
286
287
|
Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
|
|
287
288
|
attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
|
|
288
|
-
column, so the rule is silent by construction — and 0
|
|
289
|
-
|
|
289
|
+
column, so the rule is silent by construction — and 0 across four other
|
|
290
|
+
first-party repos. In the fifth it finds 10 sites, all genuine fail-open
|
|
290
291
|
compositions, two of which were reachable cross-tenant reads at the time of
|
|
291
|
-
writing (
|
|
292
|
-
|
|
292
|
+
writing (two paginated list endpoints, both of which composed `WHERE 1=1` for a
|
|
293
|
+
user whose `organization_id` was NULL).
|
|
293
294
|
|
|
294
295
|
### Assertions that can never fail (0.23.0)
|
|
295
296
|
|
|
@@ -325,7 +326,7 @@ ran* — as is anything inside a pytest-benchmark test.
|
|
|
325
326
|
|
|
326
327
|
Measured before shipping: **4 findings across 28,608 files** — 26,346 of
|
|
327
328
|
pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
|
|
328
|
-
|
|
329
|
+
across five first-party repos. All 4 are true positives
|
|
329
330
|
(Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
|
|
330
331
|
`tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
|
|
331
332
|
`providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
|
|
@@ -84,6 +84,9 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
|
|
|
84
84
|
class _Args(argparse.Namespace):
|
|
85
85
|
cmd: str | None
|
|
86
86
|
rule: list[str]
|
|
87
|
+
# `explain` takes exactly one rule, so it cannot share `--rule`'s list slot
|
|
88
|
+
# without widening the type and losing the check on every `check` call site.
|
|
89
|
+
which: str
|
|
87
90
|
files: list[Path]
|
|
88
91
|
baseline: Path | None
|
|
89
92
|
update_baseline: Path | None
|
|
@@ -92,11 +95,33 @@ class _Args(argparse.Namespace):
|
|
|
92
95
|
super().__init__()
|
|
93
96
|
self.cmd = None
|
|
94
97
|
self.rule = []
|
|
98
|
+
self.which = ""
|
|
95
99
|
self.files = []
|
|
96
100
|
self.baseline = None
|
|
97
101
|
self.update_baseline = None
|
|
98
102
|
|
|
99
103
|
|
|
104
|
+
def _explain(wanted: str) -> int:
|
|
105
|
+
"""Print a rule's summary plus the derived links to its examples and evidence.
|
|
106
|
+
|
|
107
|
+
This is the runtime consumer of the one-line module docstring: it is what
|
|
108
|
+
makes an inaccurate summary visible rather than merely unread.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
key = wanted.strip()
|
|
112
|
+
cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
|
|
113
|
+
if cls is None:
|
|
114
|
+
sys.stderr.write(f"unknown rule: {wanted}\navailable: {', '.join(sorted(REGISTRY))}\n")
|
|
115
|
+
return 2
|
|
116
|
+
summary = (sys.modules[cls.__module__].__doc__ or "").strip().splitlines()
|
|
117
|
+
sys.stdout.write(
|
|
118
|
+
f"{cls.code} {cls.id}\n{summary[0] if summary else cls.description}\nexamples: {cls.examples_url()}\n"
|
|
119
|
+
)
|
|
120
|
+
if cls.has_evidence:
|
|
121
|
+
sys.stdout.write(f"evidence: {cls.evidence_url()}\n")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
100
125
|
def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
|
|
101
126
|
counts: dict[str, dict[str, int]] = {}
|
|
102
127
|
for d in diags:
|
|
@@ -112,9 +137,6 @@ def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
|
|
|
112
137
|
asserted — a hand-edited baseline should degrade to "not baselined" rather
|
|
113
138
|
than crash the run or silently suppress on a malformed entry.
|
|
114
139
|
|
|
115
|
-
Returns:
|
|
116
|
-
The baseline counts, with any entry of the wrong shape dropped.
|
|
117
|
-
|
|
118
140
|
"""
|
|
119
141
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
120
142
|
path.read_text(encoding="utf-8")
|
|
@@ -134,12 +156,7 @@ def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
|
|
|
134
156
|
|
|
135
157
|
|
|
136
158
|
def _apply_baseline(diags: list[Diagnostic], baseline: dict[str, dict[str, int]]) -> list[Diagnostic]:
|
|
137
|
-
"""Suppress up to the baselined count per (path, code); excess diags survive.
|
|
138
|
-
|
|
139
|
-
Returns:
|
|
140
|
-
The diagnostics that exceed the baselined count for their (path, code).
|
|
141
|
-
|
|
142
|
-
"""
|
|
159
|
+
"""Suppress up to the baselined count per (path, code); excess diags survive."""
|
|
143
160
|
seen: Counter[tuple[str, str]] = Counter()
|
|
144
161
|
out: list[Diagnostic] = []
|
|
145
162
|
for d in diags:
|
|
@@ -179,6 +196,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
179
196
|
|
|
180
197
|
sub.add_parser("list-rules", help="List available rule IDs.")
|
|
181
198
|
|
|
199
|
+
explain_p = sub.add_parser("explain", help="Print one rule's summary and the links to its examples and evidence.")
|
|
200
|
+
explain_p.add_argument("which", metavar="rule", help="Rule ID or SARJ code.")
|
|
201
|
+
|
|
182
202
|
args = parser.parse_args(argv, namespace=_Args())
|
|
183
203
|
|
|
184
204
|
if args.cmd == "list-rules":
|
|
@@ -187,6 +207,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
187
207
|
sys.stdout.write(f"{inst.code:8} {rid:40} {inst.description}\n")
|
|
188
208
|
return 0
|
|
189
209
|
|
|
210
|
+
if args.cmd == "explain":
|
|
211
|
+
return _explain(args.which)
|
|
212
|
+
|
|
190
213
|
diags = _check(args.rule, args.files)
|
|
191
214
|
if args.update_baseline is not None:
|
|
192
215
|
args.update_baseline.write_text(json.dumps(_baseline_counts(diags), indent=2, sort_keys=True) + "\n")
|
|
@@ -49,12 +49,7 @@ class _Args(argparse.Namespace):
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def _build_parser() -> argparse.ArgumentParser:
|
|
52
|
-
"""Assemble the argument parser.
|
|
53
|
-
|
|
54
|
-
Returns:
|
|
55
|
-
The configured parser.
|
|
56
|
-
|
|
57
|
-
"""
|
|
52
|
+
"""Assemble the argument parser."""
|
|
58
53
|
parser = argparse.ArgumentParser(
|
|
59
54
|
prog="sarj-ratchet",
|
|
60
55
|
description=(
|
|
@@ -99,12 +94,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
99
94
|
|
|
100
95
|
|
|
101
96
|
def main(argv: list[str] | None = None) -> int:
|
|
102
|
-
"""Run the ratchet.
|
|
103
|
-
|
|
104
|
-
Returns:
|
|
105
|
-
0 when every ceiling holds (or the baseline was re-seeded), 1 otherwise.
|
|
106
|
-
|
|
107
|
-
"""
|
|
97
|
+
"""Run the ratchet."""
|
|
108
98
|
args = _build_parser().parse_args(argv, namespace=_Args())
|
|
109
99
|
root = args.root.resolve()
|
|
110
100
|
baseline_path = args.baseline if args.baseline is not None else root / _DEFAULT_BASELINE_NAME
|
|
@@ -162,12 +152,7 @@ def _update(
|
|
|
162
152
|
*,
|
|
163
153
|
allow_increase: bool,
|
|
164
154
|
) -> int:
|
|
165
|
-
"""Re-seed the baseline, refusing raises unless they were explicitly reviewed.
|
|
166
|
-
|
|
167
|
-
Returns:
|
|
168
|
-
0 when the baseline was written, 1 when the re-seed was refused.
|
|
169
|
-
|
|
170
|
-
"""
|
|
155
|
+
"""Re-seed the baseline, refusing raises unless they were explicitly reviewed."""
|
|
171
156
|
would_raise = gate(measurement, baseline)
|
|
172
157
|
if would_raise and not allow_increase:
|
|
173
158
|
sys.stderr.write("REFUSED: --update would raise ceilings; pass --allow-increase if this was reviewed:\n")
|
|
@@ -130,9 +130,6 @@ def identifier_tokens(identifier: str) -> list[str]:
|
|
|
130
130
|
mixed-case single word like `ToKeN` (which camel-splitting shreds into
|
|
131
131
|
`to`/`ke`/`n`) still surfaces its intended `token` form.
|
|
132
132
|
|
|
133
|
-
Returns:
|
|
134
|
-
The ordered lowercase tokens decomposed from `identifier`.
|
|
135
|
-
|
|
136
133
|
"""
|
|
137
134
|
tokens: list[str] = []
|
|
138
135
|
for segment in _SEGMENT_RE.split(identifier):
|
|
@@ -152,10 +149,6 @@ def leading_word(identifier: str) -> str | None:
|
|
|
152
149
|
useless `hassecret` rather than `has`. Splitting the leading segment with the
|
|
153
150
|
same camel regex makes `has_secret` and `hasSecret` both yield `has`.
|
|
154
151
|
|
|
155
|
-
Returns:
|
|
156
|
-
The lowercased leading word, or None when `identifier` has no word
|
|
157
|
-
characters at all.
|
|
158
|
-
|
|
159
152
|
"""
|
|
160
153
|
for segment in _SEGMENT_RE.split(identifier):
|
|
161
154
|
if not segment:
|
|
@@ -166,12 +159,7 @@ def leading_word(identifier: str) -> str | None:
|
|
|
166
159
|
|
|
167
160
|
|
|
168
161
|
def is_secret_name(identifier: str) -> bool:
|
|
169
|
-
"""Report whether `identifier` names raw secret material (a credential, not metadata).
|
|
170
|
-
|
|
171
|
-
Returns:
|
|
172
|
-
True when `identifier` denotes a credential rather than metadata.
|
|
173
|
-
|
|
174
|
-
"""
|
|
162
|
+
"""Report whether `identifier` names raw secret material (a credential, not metadata)."""
|
|
175
163
|
tokens = identifier_tokens(identifier)
|
|
176
164
|
if tokens and tokens[-1] in _INNOCUOUS_WORDS:
|
|
177
165
|
return False
|
|
@@ -183,10 +171,5 @@ def is_secret_name(identifier: str) -> bool:
|
|
|
183
171
|
|
|
184
172
|
|
|
185
173
|
def _has_api_key(tokens: list[str]) -> bool:
|
|
186
|
-
"""Report whether `api` is immediately followed by `key` (the split form of `api_key`).
|
|
187
|
-
|
|
188
|
-
Returns:
|
|
189
|
-
True when an `api` token is directly followed by a `key` token.
|
|
190
|
-
|
|
191
|
-
"""
|
|
174
|
+
"""Report whether `api` is immediately followed by `key` (the split form of `api_key`)."""
|
|
192
175
|
return any(a == "api" and b == "key" for a, b in pairwise(tokens))
|
|
@@ -94,12 +94,7 @@ class Measurement:
|
|
|
94
94
|
|
|
95
95
|
@property
|
|
96
96
|
def total(self) -> int:
|
|
97
|
-
"""Total suppressions counted.
|
|
98
|
-
|
|
99
|
-
Returns:
|
|
100
|
-
The sum over every code key.
|
|
101
|
-
|
|
102
|
-
"""
|
|
97
|
+
"""Total suppressions counted."""
|
|
103
98
|
return sum(self.codes.values())
|
|
104
99
|
|
|
105
100
|
|
|
@@ -123,12 +118,7 @@ class Failure:
|
|
|
123
118
|
actual: int
|
|
124
119
|
|
|
125
120
|
def format(self) -> str:
|
|
126
|
-
"""Render the failure with the remediation for its dimension.
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
A multi-sentence message naming the key, both numbers and the fix.
|
|
130
|
-
|
|
131
|
-
"""
|
|
121
|
+
"""Render the failure with the remediation for its dimension."""
|
|
132
122
|
head = f"FAIL[{self.dimension}] {self.key}: {self.actual} suppressions, ceiling {self.ceiling}."
|
|
133
123
|
return f"{head} {_REMEDIATION[self.dimension]}"
|
|
134
124
|
|
|
@@ -164,9 +154,6 @@ def measure(
|
|
|
164
154
|
contributes 0 rather than raising, so a baseline outliving a rename fails
|
|
165
155
|
loudly on the package's disappearance rather than crashing.
|
|
166
156
|
|
|
167
|
-
Returns:
|
|
168
|
-
The measurement.
|
|
169
|
-
|
|
170
157
|
"""
|
|
171
158
|
codes: Counter[str] = Counter()
|
|
172
159
|
package_counts: Counter[str] = Counter()
|
|
@@ -189,12 +176,7 @@ def measure(
|
|
|
189
176
|
|
|
190
177
|
|
|
191
178
|
def count_source(source: str) -> Counter[str]:
|
|
192
|
-
"""Count the suppressions in one file's text, keyed by dialect and code.
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
The per-key counts for this file.
|
|
196
|
-
|
|
197
|
-
"""
|
|
179
|
+
"""Count the suppressions in one file's text, keyed by dialect and code."""
|
|
198
180
|
counts: Counter[str] = Counter()
|
|
199
181
|
for line in source.splitlines():
|
|
200
182
|
_count_line(line, counts)
|
|
@@ -202,13 +184,7 @@ def count_source(source: str) -> Counter[str]:
|
|
|
202
184
|
|
|
203
185
|
|
|
204
186
|
def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
|
|
205
|
-
"""Compare a measurement against the baseline's three ceilings.
|
|
206
|
-
|
|
207
|
-
Returns:
|
|
208
|
-
Every exceeded ceiling, ordered code → package → file, each dimension
|
|
209
|
-
sorted by key so output is stable across runs.
|
|
210
|
-
|
|
211
|
-
"""
|
|
187
|
+
"""Compare a measurement against the baseline's three ceilings."""
|
|
212
188
|
failures = [
|
|
213
189
|
Failure(dimension="code", key=key, ceiling=c, actual=n)
|
|
214
190
|
for key, n in sorted(measurement.codes.items())
|
|
@@ -228,13 +204,7 @@ def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
|
|
|
228
204
|
|
|
229
205
|
|
|
230
206
|
def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, tuple[int, int]]:
|
|
231
|
-
"""Find the code keys now below their ceiling — the wins worth locking in.
|
|
232
|
-
|
|
233
|
-
Returns:
|
|
234
|
-
`{key: (ceiling, actual)}` for every code that shrank, including codes
|
|
235
|
-
that reached zero and are no longer present at all.
|
|
236
|
-
|
|
237
|
-
"""
|
|
207
|
+
"""Find the code keys now below their ceiling — the wins worth locking in."""
|
|
238
208
|
out: dict[str, tuple[int, int]] = {}
|
|
239
209
|
for key, ceiling in baseline.codes.items():
|
|
240
210
|
actual = measurement.codes.get(key, 0)
|
|
@@ -250,9 +220,6 @@ def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
|
|
|
250
220
|
that dropped to or below the global ceiling loses its exception, so the
|
|
251
221
|
allowance cannot outlive the debt it was granted for.
|
|
252
222
|
|
|
253
|
-
Returns:
|
|
254
|
-
The re-seeded baseline.
|
|
255
|
-
|
|
256
223
|
"""
|
|
257
224
|
exceptions = {path: n for path, n in measurement.files.items() if n > baseline.per_file_ceiling}
|
|
258
225
|
return Baseline(
|
|
@@ -269,9 +236,6 @@ def load_baseline(path: Path) -> Baseline:
|
|
|
269
236
|
A hand-edited baseline degrades to "not baselined" (ceiling 0) for the
|
|
270
237
|
malformed entry rather than crashing the gate or silently passing.
|
|
271
238
|
|
|
272
|
-
Returns:
|
|
273
|
-
The parsed baseline.
|
|
274
|
-
|
|
275
239
|
"""
|
|
276
240
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
|
|
277
241
|
path.read_text(encoding="utf-8")
|
|
@@ -289,24 +253,14 @@ def load_baseline(path: Path) -> Baseline:
|
|
|
289
253
|
|
|
290
254
|
|
|
291
255
|
def _get(mapping: object, key: str) -> object:
|
|
292
|
-
"""Read one key out of a value that may or may not be a JSON object.
|
|
293
|
-
|
|
294
|
-
Returns:
|
|
295
|
-
The value at `key`, or None when `mapping` is not an object or lacks it.
|
|
296
|
-
|
|
297
|
-
"""
|
|
256
|
+
"""Read one key out of a value that may or may not be a JSON object."""
|
|
298
257
|
if not isinstance(mapping, dict):
|
|
299
258
|
return None
|
|
300
259
|
return mapping.get(key) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] — json leaves are Any
|
|
301
260
|
|
|
302
261
|
|
|
303
262
|
def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
|
|
304
|
-
"""Render a baseline as the JSON text to write.
|
|
305
|
-
|
|
306
|
-
Returns:
|
|
307
|
-
The serialized baseline, newline-terminated.
|
|
308
|
-
|
|
309
|
-
"""
|
|
263
|
+
"""Render a baseline as the JSON text to write."""
|
|
310
264
|
payload = {
|
|
311
265
|
"_comment": (
|
|
312
266
|
"Suppression ceilings, written by `sarj-ratchet --update`. Counts may "
|
|
@@ -332,9 +286,6 @@ def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_E
|
|
|
332
286
|
Used when neither `--package` nor a baseline names the packages, so the
|
|
333
287
|
first run needs no configuration.
|
|
334
288
|
|
|
335
|
-
Returns:
|
|
336
|
-
The package names, sorted.
|
|
337
|
-
|
|
338
289
|
"""
|
|
339
290
|
return sorted(
|
|
340
291
|
name
|
|
@@ -347,12 +298,7 @@ def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_E
|
|
|
347
298
|
|
|
348
299
|
|
|
349
300
|
def _int_map(value: object) -> dict[str, int]:
|
|
350
|
-
"""Narrow a JSON value to `{str: int}`, dropping anything else.
|
|
351
|
-
|
|
352
|
-
Returns:
|
|
353
|
-
The well-formed entries only.
|
|
354
|
-
|
|
355
|
-
"""
|
|
301
|
+
"""Narrow a JSON value to `{str: int}`, dropping anything else."""
|
|
356
302
|
if not isinstance(value, dict):
|
|
357
303
|
return {}
|
|
358
304
|
return {
|
|
@@ -363,12 +309,7 @@ def _int_map(value: object) -> dict[str, int]:
|
|
|
363
309
|
|
|
364
310
|
|
|
365
311
|
def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Path]:
|
|
366
|
-
"""Yield every `.py` file under `root`, skipping excluded directories.
|
|
367
|
-
|
|
368
|
-
Yields:
|
|
369
|
-
Each Python file path.
|
|
370
|
-
|
|
371
|
-
"""
|
|
312
|
+
"""Yield every `.py` file under `root`, skipping excluded directories."""
|
|
372
313
|
if not root.is_dir():
|
|
373
314
|
return
|
|
374
315
|
for path in sorted(root.rglob("*.py")):
|
|
@@ -377,12 +318,7 @@ def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Pa
|
|
|
377
318
|
|
|
378
319
|
|
|
379
320
|
def _read(path: Path) -> str:
|
|
380
|
-
"""Read a source file, treating undecodable bytes as empty.
|
|
381
|
-
|
|
382
|
-
Returns:
|
|
383
|
-
The file text, or `""` when it cannot be decoded.
|
|
384
|
-
|
|
385
|
-
"""
|
|
321
|
+
"""Read a source file, treating undecodable bytes as empty."""
|
|
386
322
|
try:
|
|
387
323
|
return path.read_text(encoding="utf-8")
|
|
388
324
|
except UnicodeDecodeError, OSError:
|