sarj-python-lint 0.24.0__tar.gz → 0.25.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.
Files changed (84) hide show
  1. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/PKG-INFO +2 -1
  2. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/README.md +1 -0
  3. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_registry.py +4 -0
  5. sarj_python_lint-0.25.0/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +144 -0
  6. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/.gitignore +0 -0
  7. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  10. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/_secret_names.py +0 -0
  11. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/_version.py +0 -0
  12. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/py.typed +0 -0
  13. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/ratchet.py +0 -0
  14. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rule_base.py +0 -0
  15. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  16. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  17. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  18. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  19. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  20. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  21. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  22. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  23. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  24. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  25. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  26. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  27. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  28. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  29. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  30. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  31. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  32. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  33. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  34. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  35. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  36. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  37. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  38. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  39. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  40. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  41. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  42. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  43. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
  44. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  45. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  46. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  47. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  48. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  49. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  50. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  51. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  52. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  53. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  54. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  55. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  56. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  57. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  58. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  59. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  60. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  61. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  62. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  63. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  64. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  65. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  66. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  67. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  68. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  69. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  70. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  71. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  72. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  73. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  74. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  75. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  76. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  77. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  78. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  79. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  80. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  81. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  82. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  83. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  84. {sarj_python_lint-0.24.0 → sarj_python_lint-0.25.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.24.0
3
+ Version: 0.25.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
@@ -40,6 +40,7 @@ uv tool install sarj-python-lint
40
40
  - id: sarj-prefer-struct-over-namedtuple
41
41
  - id: sarj-no-comment-cruft
42
42
  - id: sarj-no-fstring-in-log
43
+ - id: sarj-prefer-non-nullable-collection # SARJ074
43
44
  ```
44
45
 
45
46
  ### Test-quality rules (0.15.0)
@@ -22,6 +22,7 @@ uv tool install sarj-python-lint
22
22
  - id: sarj-prefer-struct-over-namedtuple
23
23
  - id: sarj-no-comment-cruft
24
24
  - id: sarj-no-fstring-in-log
25
+ - id: sarj-prefer-non-nullable-collection # SARJ074
25
26
  ```
26
27
 
27
28
  ### Test-quality rules (0.15.0)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.24.0"
3
+ version = "0.25.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -63,6 +63,9 @@ from sarj_python_lint.rules.prefer_module_level_constant import (
63
63
  from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
64
64
  PreferNamedtupleOverTupleReturn,
65
65
  )
66
+ from sarj_python_lint.rules.prefer_non_nullable_collection import (
67
+ PreferNonNullableCollection,
68
+ )
66
69
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
67
70
  from sarj_python_lint.rules.prefer_real_store_in_tests import PreferRealStoreInTests
68
71
  from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
@@ -186,6 +189,7 @@ REGISTRY: dict[str, type[Rule]] = {
186
189
  PreferMatchPatternDestructuring.id: PreferMatchPatternDestructuring,
187
190
  PreferOrPattern.id: PreferOrPattern,
188
191
  RequirePortForService.id: RequirePortForService,
192
+ PreferNonNullableCollection.id: PreferNonNullableCollection,
189
193
  }
190
194
 
191
195
  __all__ = ["REGISTRY"]
@@ -0,0 +1,144 @@
1
+ """SARJ074: prefer non-null list fields in declared data shapes.
2
+
3
+ Nullable list fields create two representations of an empty collection: ``None``
4
+ and ``[]``. When the project convention is that absence means empty, every
5
+ consumer inherits an unnecessary nullable type and null guard.
6
+
7
+ # flagged
8
+ class CallSettings(BaseModel):
9
+ organization_ids: list[OrganizationId] | None = None
10
+
11
+ # preferred when omission means "empty"
12
+ class CallSettings(BaseModel):
13
+ organization_ids: list[OrganizationId] = Field(default_factory=list)
14
+
15
+ The rule applies to annotated fields on every class data shape, including
16
+ Pydantic models, dataclasses, attrs classes, and ordinary typed classes. It does
17
+ not inspect function defaults: ``None`` is the safe Python idiom there because
18
+ ``[]`` would be shared mutable state. Tests and generated sources are exempt.
19
+ ``Optional[list[T]]`` and ``Union[list[T], None]`` are recognized alongside PEP
20
+ 604 unions. A field is reported whether it defaults to ``None``, uses
21
+ ``Field(default=None)``, or has no default at all.
22
+
23
+ This is an opinionated application convention, not a Python type-system fact.
24
+ When ``None`` is a meaningful third state (for example, "inherit this
25
+ constraint" rather than "allow no values"), keep the union and suppress the
26
+ line with ``# sarj-noqa: SARJ074 — None means ...``.
27
+
28
+ Corpus sweep (2026-07-27): FastAPI, Pydantic, SQLModel, Zod, and React Router;
29
+ 2,901 Python/TypeScript files total. The final rule reported 30 explicit Python
30
+ nullable-list fields. Every match had the advertised AST shape; the sweep also
31
+ confirmed the meaningful-third-state suppression boundary on public framework
32
+ contracts such as Pydantic's ``UrlConstraints.allowed_schemes``.
33
+
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import ast
39
+ from typing import TYPE_CHECKING, override
40
+
41
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
42
+ from sarj_python_lint.rules._paths import is_generated_source, is_test_path
43
+
44
+
45
+ if TYPE_CHECKING:
46
+ from pathlib import Path
47
+
48
+
49
+ _UNION_NAMES = frozenset({"Optional", "Union"})
50
+
51
+
52
+ class PreferNonNullableCollection(Rule):
53
+ """Nullable list field -- use a non-null list with an empty default."""
54
+
55
+ id: str = "prefer-non-nullable-collection"
56
+ code: str = "SARJ074"
57
+ description: str = (
58
+ "List fields should use a non-null list and an empty default instead of "
59
+ "two equivalent empty states."
60
+ )
61
+
62
+ @override
63
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
64
+ if is_test_path(path) or _is_generated_or_vendored_path(path) or is_generated_source(source):
65
+ return []
66
+ tree = parse_or_none(path, source)
67
+ if tree is None:
68
+ return []
69
+
70
+ diags: list[Diagnostic] = []
71
+ for cls in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)):
72
+ for statement in cls.body:
73
+ if not isinstance(statement, ast.AnnAssign):
74
+ continue
75
+ if not isinstance(statement.target, ast.Name):
76
+ continue
77
+ if not _is_nullable_list(statement.annotation):
78
+ continue
79
+ name = statement.target.id
80
+ diags.append(
81
+ Diagnostic(
82
+ path=path,
83
+ line=statement.lineno,
84
+ col=statement.col_offset + 1,
85
+ code=self.code,
86
+ message=(
87
+ f"`{name}` is a nullable list field, so `None` and `[]` "
88
+ "represent the same empty collection; use a non-null list "
89
+ "with `Field(default_factory=list)` / "
90
+ "`field(default_factory=list)` (or make it required)."
91
+ ),
92
+ )
93
+ )
94
+ return diags
95
+
96
+
97
+ def _is_generated_or_vendored_path(path: Path) -> bool:
98
+ return any(part.lower() in {"generated", "vendor", "vendored"} for part in path.parts)
99
+
100
+
101
+ def _qualified_name(node: ast.expr) -> str:
102
+ if isinstance(node, ast.Name):
103
+ return node.id
104
+ if isinstance(node, ast.Attribute):
105
+ parent = _qualified_name(node.value)
106
+ return f"{parent}.{node.attr}" if parent else node.attr
107
+ if isinstance(node, ast.Subscript):
108
+ return _qualified_name(node.value)
109
+ return ""
110
+
111
+
112
+ def _is_nullable_list(annotation: ast.expr) -> bool:
113
+ members = _union_members(annotation)
114
+ if members is None:
115
+ return False
116
+ non_none = [member for member in members if not _is_none_type(member)]
117
+ return len(non_none) > 0 and len(non_none) < len(members) and all(_is_list_type(member) for member in non_none)
118
+
119
+
120
+ def _union_members(annotation: ast.expr) -> list[ast.expr] | None:
121
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
122
+ left = _union_members(annotation.left) or [annotation.left]
123
+ right = _union_members(annotation.right) or [annotation.right]
124
+ return [*left, *right]
125
+ if isinstance(annotation, ast.Subscript) and _qualified_name(annotation.value).split(".")[-1] in _UNION_NAMES:
126
+ if _qualified_name(annotation.value).endswith("Optional"):
127
+ return [annotation.slice, ast.Constant(value=None)]
128
+ if isinstance(annotation.slice, ast.Tuple):
129
+ return list(annotation.slice.elts)
130
+ return [annotation.slice]
131
+ return None
132
+
133
+
134
+ def _is_none_type(node: ast.expr) -> bool:
135
+ return (isinstance(node, ast.Constant) and node.value is None) or (
136
+ isinstance(node, ast.Name) and node.id == "None"
137
+ )
138
+
139
+
140
+ def _is_list_type(node: ast.expr) -> bool:
141
+ return isinstance(node, ast.Subscript) and _qualified_name(node.value).split(".")[-1] in {
142
+ "List",
143
+ "list",
144
+ }