syntopica-codeality-py 0.2.0__py3-none-any.whl

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 (169) hide show
  1. codeality_py/__init__.py +1 -0
  2. codeality_py/assets/codeality-py-ci.yml +51 -0
  3. codeality_py/assets/codeality-py.toml +19 -0
  4. codeality_py/assets/importlinter.ini +12 -0
  5. codeality_py/assets/mypy.ini +7 -0
  6. codeality_py/assets/pyproject-sections.toml +34 -0
  7. codeality_py/assets/ruff.toml +51 -0
  8. codeality_py/baseline/__init__.py +1 -0
  9. codeality_py/baseline/baseline_file.py +15 -0
  10. codeality_py/baseline/classified_findings.py +14 -0
  11. codeality_py/baseline/classify_findings.py +18 -0
  12. codeality_py/baseline/finding_context.py +19 -0
  13. codeality_py/baseline/fingerprint.py +21 -0
  14. codeality_py/baseline/fingerprint_context.py +27 -0
  15. codeality_py/baseline/read_baseline.py +32 -0
  16. codeality_py/baseline/write_baseline.py +18 -0
  17. codeality_py/cli.py +22 -0
  18. codeality_py/commands/__init__.py +1 -0
  19. codeality_py/commands/baseline_check_command.py +36 -0
  20. codeality_py/commands/baseline_command.py +15 -0
  21. codeality_py/commands/baseline_update_command.py +24 -0
  22. codeality_py/commands/check_command.py +45 -0
  23. codeality_py/commands/checked_project.py +26 -0
  24. codeality_py/commands/gate_command.py +33 -0
  25. codeality_py/commands/init_command.py +53 -0
  26. codeality_py/commands/project_option.py +12 -0
  27. codeality_py/config/__init__.py +1 -0
  28. codeality_py/config/baseline_config.py +27 -0
  29. codeality_py/config/check_schema_version.py +16 -0
  30. codeality_py/config/config_error.py +5 -0
  31. codeality_py/config/default_generated_globs.py +10 -0
  32. codeality_py/config/discover_source_roots.py +30 -0
  33. codeality_py/config/discover_test_roots.py +12 -0
  34. codeality_py/config/limits.py +11 -0
  35. codeality_py/config/load_config.py +64 -0
  36. codeality_py/config/module_role.py +18 -0
  37. codeality_py/config/override.py +14 -0
  38. codeality_py/config/parse_overrides.py +32 -0
  39. codeality_py/config/parse_roles.py +33 -0
  40. codeality_py/config/parse_rule_codes.py +17 -0
  41. codeality_py/config/pyproject_layout_hints.py +36 -0
  42. codeality_py/config/read_config_document.py +17 -0
  43. codeality_py/config/reject_duplicate_role_claims.py +20 -0
  44. codeality_py/config/reject_unknown_keys.py +15 -0
  45. codeality_py/engine/__init__.py +1 -0
  46. codeality_py/engine/check_project.py +32 -0
  47. codeality_py/engine/check_result.py +15 -0
  48. codeality_py/engine/check_source_file.py +25 -0
  49. codeality_py/engine/fingerprint_findings.py +33 -0
  50. codeality_py/engine/rule_registry.py +24 -0
  51. codeality_py/gate/__init__.py +1 -0
  52. codeality_py/gate/build_stage_result.py +20 -0
  53. codeality_py/gate/gate_result.py +23 -0
  54. codeality_py/gate/gate_stages.py +50 -0
  55. codeality_py/gate/run_gate.py +27 -0
  56. codeality_py/gate/run_stage.py +52 -0
  57. codeality_py/gate/stage.py +14 -0
  58. codeality_py/gate/stage_kind.py +11 -0
  59. codeality_py/gate/stage_result.py +19 -0
  60. codeality_py/gate/stage_status.py +12 -0
  61. codeality_py/init/__init__.py +1 -0
  62. codeality_py/init/add_missing_rules.py +33 -0
  63. codeality_py/init/apply_init.py +19 -0
  64. codeality_py/init/current_branch.py +24 -0
  65. codeality_py/init/first_party_packages.py +23 -0
  66. codeality_py/init/has_ruff_table.py +21 -0
  67. codeality_py/init/managed_asset_files.py +9 -0
  68. codeality_py/init/managed_file.py +16 -0
  69. codeality_py/init/merge_pyproject_sections.py +35 -0
  70. codeality_py/init/merge_quality_group.py +14 -0
  71. codeality_py/init/merge_ruff_config.py +28 -0
  72. codeality_py/init/plan_asset_file.py +25 -0
  73. codeality_py/init/plan_disposition.py +12 -0
  74. codeality_py/init/plan_init.py +28 -0
  75. codeality_py/init/plan_pyproject.py +32 -0
  76. codeality_py/init/plan_ruff.py +21 -0
  77. codeality_py/init/plan_shadowed_ruff.py +28 -0
  78. codeality_py/init/python_floor_minor.py +15 -0
  79. codeality_py/init/read_asset.py +8 -0
  80. codeality_py/init/read_requires_python.py +16 -0
  81. codeality_py/init/render_asset.py +55 -0
  82. codeality_py/init/render_nested_workflow.py +25 -0
  83. codeality_py/init/repository_root.py +15 -0
  84. codeality_py/init/workflow_target.py +21 -0
  85. codeality_py/model/__init__.py +1 -0
  86. codeality_py/model/finding.py +34 -0
  87. codeality_py/model/location.py +14 -0
  88. codeality_py/model/rule_code.py +30 -0
  89. codeality_py/model/severity.py +10 -0
  90. codeality_py/parsing/__init__.py +1 -0
  91. codeality_py/parsing/infrastructure_error.py +5 -0
  92. codeality_py/parsing/parse_source_file.py +32 -0
  93. codeality_py/parsing/read_source_text.py +16 -0
  94. codeality_py/parsing/source_file.py +18 -0
  95. codeality_py/parsing/syntax_error_finding.py +16 -0
  96. codeality_py/report/__init__.py +1 -0
  97. codeality_py/report/exit_code.py +12 -0
  98. codeality_py/report/finding_document.py +24 -0
  99. codeality_py/report/findings_summary.py +11 -0
  100. codeality_py/report/render_gate_report.py +21 -0
  101. codeality_py/report/render_json_report.py +24 -0
  102. codeality_py/report/render_text_report.py +16 -0
  103. codeality_py/report/stage_document.py +16 -0
  104. codeality_py/roles/__init__.py +1 -0
  105. codeality_py/roles/assign_module_role.py +36 -0
  106. codeality_py/roles/compile_glob.py +17 -0
  107. codeality_py/roles/matches_any_pattern.py +8 -0
  108. codeality_py/roles/matches_glob.py +14 -0
  109. codeality_py/roles/test_file_patterns.py +18 -0
  110. codeality_py/rules/__init__.py +1 -0
  111. codeality_py/rules/barrel_only_init.py +38 -0
  112. codeality_py/rules/count_code_lines.py +29 -0
  113. codeality_py/rules/data_unit_findings.py +16 -0
  114. codeality_py/rules/docstring_line_spans.py +26 -0
  115. codeality_py/rules/file_matches_unit.py +33 -0
  116. codeality_py/rules/grab_bag_names.py +3 -0
  117. codeality_py/rules/is_allowed_barrel_statement.py +16 -0
  118. codeality_py/rules/is_column_list.py +15 -0
  119. codeality_py/rules/is_docstring_statement.py +12 -0
  120. codeality_py/rules/is_sql_literal.py +15 -0
  121. codeality_py/rules/is_static_dunder_assignment.py +24 -0
  122. codeality_py/rules/is_type_checking_block.py +15 -0
  123. codeality_py/rules/joined_string_text.py +12 -0
  124. codeality_py/rules/looks_like_sql.py +31 -0
  125. codeality_py/rules/max_file_lines.py +35 -0
  126. codeality_py/rules/no_grab_bag_names.py +36 -0
  127. codeality_py/rules/no_inline_sql.py +52 -0
  128. codeality_py/rules/one_primary_unit.py +22 -0
  129. codeality_py/rules/ordinary_unit_findings.py +26 -0
  130. codeality_py/rules/sql_subject.py +10 -0
  131. codeality_py/rules/unit_finding.py +23 -0
  132. codeality_py/suppression/__init__.py +1 -0
  133. codeality_py/suppression/apply_suppressions.py +31 -0
  134. codeality_py/suppression/build_suppression.py +15 -0
  135. codeality_py/suppression/covering_suppression.py +25 -0
  136. codeality_py/suppression/is_overridden.py +14 -0
  137. codeality_py/suppression/parse_suppression_line.py +25 -0
  138. codeality_py/suppression/parse_suppressions.py +17 -0
  139. codeality_py/suppression/suppression.py +14 -0
  140. codeality_py/traversal/__init__.py +1 -0
  141. codeality_py/traversal/collect_source_files.py +17 -0
  142. codeality_py/traversal/default_excludes.py +25 -0
  143. codeality_py/traversal/gitignore_matcher.py +44 -0
  144. codeality_py/traversal/walk_root.py +27 -0
  145. codeality_py/units/__init__.py +1 -0
  146. codeality_py/units/coalesce_overloads.py +17 -0
  147. codeality_py/units/coalesce_singledispatch.py +25 -0
  148. codeality_py/units/collect_declarations.py +15 -0
  149. codeality_py/units/count_primary_declarations.py +18 -0
  150. codeality_py/units/declaration.py +18 -0
  151. codeality_py/units/declaration_for.py +33 -0
  152. codeality_py/units/declaration_kind.py +11 -0
  153. codeality_py/units/decorator_name.py +14 -0
  154. codeality_py/units/decorator_names.py +12 -0
  155. codeality_py/units/is_main_guard.py +11 -0
  156. codeality_py/units/is_private_local_handler.py +13 -0
  157. codeality_py/units/legacy_type_alias.py +33 -0
  158. codeality_py/units/literal_string_names.py +15 -0
  159. codeality_py/units/merge_stray_letters.py +20 -0
  160. codeality_py/units/named_declaration.py +23 -0
  161. codeality_py/units/overload_family_length.py +22 -0
  162. codeality_py/units/resolve_exported_names.py +22 -0
  163. codeality_py/units/to_snake_case.py +20 -0
  164. codeality_py/units/top_level_statements.py +20 -0
  165. syntopica_codeality_py-0.2.0.dist-info/METADATA +96 -0
  166. syntopica_codeality_py-0.2.0.dist-info/RECORD +169 -0
  167. syntopica_codeality_py-0.2.0.dist-info/WHEEL +4 -0
  168. syntopica_codeality_py-0.2.0.dist-info/entry_points.txt +2 -0
  169. syntopica_codeality_py-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ """Structural linter and config scaffolder for Python projects."""
@@ -0,0 +1,51 @@
1
+ name: Quality
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ gate:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ['3.11', '3.13']
18
+ steps:
19
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
20
+
21
+ - name: Install uv
22
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
23
+ with:
24
+ enable-cache: true
25
+
26
+ - name: Check the lockfile is current
27
+ run: uv lock --check
28
+
29
+ # Every extra and every group: the gate audits and tests the environment
30
+ # it runs in, and a group-only install leaves out the extras the tests
31
+ # import.
32
+ - name: Install from the lockfile
33
+ run: uv sync --locked --all-extras --all-groups
34
+ env:
35
+ UV_PYTHON: ${{ matrix.python-version }}
36
+
37
+ - name: Run the quality gate
38
+ run: uv run codeality-py gate --json
39
+
40
+ - name: Type-check with pyrefly, without blocking
41
+ continue-on-error: true
42
+ run: |
43
+ uv run --with pyrefly pyrefly check --output-format json > pyrefly.json || true
44
+
45
+ - name: Upload the pyrefly shadow report
46
+ if: always()
47
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
48
+ with:
49
+ name: pyrefly-shadow-${{ matrix.python-version }}
50
+ path: pyrefly.json
51
+ retention-days: 90
@@ -0,0 +1,19 @@
1
+ # codeality-py configuration - https://github.com/syntopica/codeality
2
+ schema-version = 1
3
+ source-roots = ["src"]
4
+ test-roots = ["tests"]
5
+ respect-gitignore = true
6
+ coverage-threshold = 80
7
+
8
+ [limits]
9
+ max-file-lines = 150
10
+ test-max-file-lines = 300
11
+
12
+ # A role states what a file is. Prefer a role over a suppression: it explains
13
+ # the file instead of silencing an alarm.
14
+ [roles]
15
+ data = []
16
+ registry = []
17
+ entrypoint = []
18
+ generated = []
19
+ namespace-init = []
@@ -0,0 +1,12 @@
1
+ ; Managed by codeality-py, scaffolded only with --profile app. Declare the real
2
+ ; layers of the project; an empty contract enforces nothing.
3
+ [importlinter]
4
+ root_package = CHANGE_ME
5
+
6
+ [importlinter:contract:layers]
7
+ name = Layered architecture
8
+ type = layers
9
+ layers =
10
+ cli
11
+ services
12
+ domain
@@ -0,0 +1,7 @@
1
+ ; Managed by codeality-py.
2
+ [mypy]
3
+ strict = True
4
+ warn_unreachable = True
5
+ show_error_codes = True
6
+ pretty = True
7
+ exclude = (?x)(^build/|^dist/|/migrations/)
@@ -0,0 +1,34 @@
1
+ # Sections codeality-py merges into an existing pyproject.toml. It owns these
2
+ # tables and nothing else; comments and key order elsewhere are preserved.
3
+
4
+ # Without known_first_party, deptry reports every internal import as DEP003.
5
+ [tool.deptry]
6
+ known_first_party = ["CHANGE_ME"]
7
+
8
+ [tool.deptry.per_rule_ignores]
9
+ DEP002 = []
10
+ # Test tooling legitimately lives in a dev group and is imported only by tests.
11
+ DEP004 = ["pytest", "pytest_asyncio", "pytest_cov"]
12
+
13
+ [tool.pytest.ini_options]
14
+ addopts = "-ra"
15
+ testpaths = ["tests"]
16
+
17
+ [tool.coverage.run]
18
+ branch = true
19
+
20
+ [tool.coverage.report]
21
+ show_missing = true
22
+ skip_covered = true
23
+ fail_under = 80
24
+
25
+ [dependency-groups]
26
+ quality = [
27
+ "syntopica-codeality-py>=0.1,<1",
28
+ "deptry>=0.20,<1",
29
+ "mypy>=1.11,<2",
30
+ "pip-audit>=2.7,<3",
31
+ "pytest>=8,<10",
32
+ "pytest-cov>=5,<8",
33
+ "ruff>=0.15,<1",
34
+ ]
@@ -0,0 +1,51 @@
1
+ # Managed by codeality-py. Rules are listed explicitly: ALL expands silently on
2
+ # every ruff upgrade, turning a dependency bump into a lint failure.
3
+ line-length = 100
4
+ target-version = "py311"
5
+
6
+ [lint]
7
+ select = [
8
+ "E", # pycodestyle errors
9
+ "W", # pycodestyle warnings
10
+ "F", # pyflakes
11
+ "I", # import sorting
12
+ "N", # pep8-naming
13
+ "UP", # pyupgrade
14
+ "B", # bugbear
15
+ "A", # builtin shadowing
16
+ "C4", # comprehensions
17
+ "DTZ", # naive datetimes
18
+ "T20", # print in library code
19
+ "SIM", # simplifiable code
20
+ "RET", # return control flow
21
+ "ARG", # unused arguments
22
+ "PTH", # pathlib over os.path
23
+ "ERA", # commented-out code
24
+ "PL", # pylint: complexity, branches, statements
25
+ "RUF", # ruff-specific
26
+ "D", # docstrings; replaces interrogate
27
+ # bandit, curated: the full S set is intentionally noisy in tests and in
28
+ # subprocess-heavy code.
29
+ "S102", "S103", "S104", "S105", "S106", "S107", "S108",
30
+ "S301", "S302", "S303", "S304", "S305", "S306",
31
+ "S324", "S501", "S506", "S508", "S509",
32
+ "S608", "S609", "S612",
33
+ ]
34
+ ignore = [
35
+ # Conflicts with the formatter; keeping these on fights ruff format forever.
36
+ "E501", "W191", "E111", "E114", "E117", "D206", "D300", "COM812", "ISC001",
37
+ "D203", # incompatible with D211
38
+ "D107", # an __init__ that stores its arguments has nothing to add to the class docstring
39
+ "D213", # incompatible with D212
40
+ ]
41
+
42
+ [lint.per-file-ignores]
43
+ # **/ so the pattern also reaches nested test roots such as host/tests.
44
+ "**/tests/**" = ["D", "S101", "S105", "S106", "S108", "ARG", "PLR"]
45
+ "**/conftest.py" = ["D"]
46
+
47
+ [lint.pydocstyle]
48
+ convention = "google"
49
+
50
+ [format]
51
+ docstring-code-format = true
@@ -0,0 +1 @@
1
+ """Recording and classifying migration debt."""
@@ -0,0 +1,15 @@
1
+ """The recorded debt of one project."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ BASELINE_SCHEMA_VERSION = 1
6
+ BASELINE_FILENAME = ".codeality-py-baseline.json"
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class BaselineFile:
11
+ """Fingerprints of the findings a project has agreed to carry."""
12
+
13
+ entries: frozenset[str] = field(default_factory=frozenset)
14
+ schema_version: int = BASELINE_SCHEMA_VERSION
15
+ tool_version: str = ""
@@ -0,0 +1,14 @@
1
+ """How current findings relate to the recorded baseline."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from codeality_py.model.finding import Finding
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ClassifiedFindings:
10
+ """New findings block; known ones are tracked debt; resolved ones are stale."""
11
+
12
+ new: tuple[Finding, ...]
13
+ known: tuple[Finding, ...]
14
+ resolved: tuple[str, ...]
@@ -0,0 +1,18 @@
1
+ """Split current findings into new, known and resolved."""
2
+
3
+ from codeality_py.baseline.baseline_file import BaselineFile
4
+ from codeality_py.baseline.classified_findings import ClassifiedFindings
5
+ from codeality_py.model.finding import Finding
6
+
7
+
8
+ def classify_findings(findings: tuple[Finding, ...], baseline: BaselineFile) -> ClassifiedFindings:
9
+ """Return the findings split against the baseline.
10
+
11
+ A baseline entry with no current finding is resolved, and reporting it is
12
+ what stops dead debt being carried forever.
13
+ """
14
+ new = tuple(finding for finding in findings if finding.fingerprint not in baseline.entries)
15
+ known = tuple(finding for finding in findings if finding.fingerprint in baseline.entries)
16
+ current = {finding.fingerprint for finding in findings}
17
+ resolved = tuple(sorted(baseline.entries - current))
18
+ return ClassifiedFindings(new=new, known=known, resolved=resolved)
@@ -0,0 +1,19 @@
1
+ """Describe what a finding is about, independently of where it sits."""
2
+
3
+ import re
4
+
5
+ _WHITESPACE = re.compile(r"\s+")
6
+
7
+
8
+ def finding_context(text: str, line: int) -> str:
9
+ """Return the normalised source of the offending line.
10
+
11
+ The line number itself must not enter a fingerprint - inserting a line
12
+ above a violation would make every finding below it look new. The content
13
+ of the offending line is stable under that edit and still tells two
14
+ different violations in one file apart.
15
+ """
16
+ lines = text.splitlines()
17
+ if not 1 <= line <= len(lines):
18
+ return ""
19
+ return _WHITESPACE.sub(" ", lines[line - 1]).strip()
@@ -0,0 +1,21 @@
1
+ """Identify a finding by what it is, not by where it sits."""
2
+
3
+ import hashlib
4
+
5
+ from codeality_py.model.finding import Finding
6
+
7
+ FINGERPRINT_VERSION = 1
8
+
9
+
10
+ def fingerprint(finding: Finding, context: str) -> str:
11
+ """Return a stable identity for this finding.
12
+
13
+ The line number is deliberately excluded: inserting a line above a query
14
+ must not make every finding in the file look new. The context carries the
15
+ offending line's content and its occurrence, so a genuinely new violation
16
+ still gets its own fingerprint.
17
+ """
18
+ material = "|".join(
19
+ [str(FINGERPRINT_VERSION), finding.code.value, finding.location.path, context]
20
+ )
21
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
@@ -0,0 +1,27 @@
1
+ """Decide what identifies a finding, per rule."""
2
+
3
+ from codeality_py.baseline.finding_context import finding_context
4
+ from codeality_py.model.finding import Finding
5
+ from codeality_py.model.rule_code import RuleCode
6
+
7
+ #: Rules that report once per file. Their anchor is line 1, whose content says
8
+ #: nothing about the violation, and the subject of a size finding is a count
9
+ #: that moves with every edit.
10
+ FILE_LEVEL = frozenset({RuleCode.BPY001, RuleCode.BPY004})
11
+
12
+ #: Rules whose subject names the thing that is wrong and is unique in the file.
13
+ SUBJECT_LEVEL = frozenset({RuleCode.BPY002, RuleCode.BPY003})
14
+
15
+
16
+ def fingerprint_context(finding: Finding, text: str) -> str:
17
+ """Return the text identifying this finding within its file.
18
+
19
+ A file-level rule identifies itself. A naming rule identifies the symbol.
20
+ Everything else can fire many times in one file, so it is identified by the
21
+ offending line's content, which survives edits elsewhere in the file.
22
+ """
23
+ if finding.code in FILE_LEVEL:
24
+ return ""
25
+ if finding.code in SUBJECT_LEVEL:
26
+ return finding.subject or ""
27
+ return finding_context(text, finding.location.line)
@@ -0,0 +1,32 @@
1
+ """Read the recorded baseline, refusing an incompatible one."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from codeality_py.baseline.baseline_file import BASELINE_SCHEMA_VERSION, BaselineFile
7
+ from codeality_py.config.config_error import ConfigError
8
+
9
+
10
+ def read_baseline(path: Path) -> BaselineFile:
11
+ """Return the baseline, or an empty one when the file is absent.
12
+
13
+ An incompatible schema is a configuration error rather than a silent
14
+ reclassification of every finding as new or known.
15
+ """
16
+ if not path.is_file():
17
+ return BaselineFile()
18
+ try:
19
+ document = json.loads(path.read_text(encoding="utf-8"))
20
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
21
+ raise ConfigError(f"{path.name} could not be read: {error}") from error
22
+ version = document.get("schema_version")
23
+ if version != BASELINE_SCHEMA_VERSION:
24
+ raise ConfigError(
25
+ f"baseline schema_version must be {BASELINE_SCHEMA_VERSION}, found {version!r}; "
26
+ "run 'codeality-py baseline update' to rewrite it"
27
+ )
28
+ return BaselineFile(
29
+ entries=frozenset(document.get("entries", ())),
30
+ schema_version=version,
31
+ tool_version=document.get("tool_version", ""),
32
+ )
@@ -0,0 +1,18 @@
1
+ """Write the baseline atomically and deterministically."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from codeality_py.baseline.baseline_file import BaselineFile
7
+
8
+
9
+ def write_baseline(path: Path, baseline: BaselineFile) -> None:
10
+ """Write the baseline through a temporary file, sorted for a clean diff."""
11
+ document = {
12
+ "schema_version": baseline.schema_version,
13
+ "tool_version": baseline.tool_version,
14
+ "entries": sorted(baseline.entries),
15
+ }
16
+ temporary = path.with_suffix(f"{path.suffix}.tmp")
17
+ temporary.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
18
+ temporary.replace(path)
codeality_py/cli.py ADDED
@@ -0,0 +1,22 @@
1
+ """Command-line entry point for codeality-py."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ import click
6
+
7
+ from codeality_py.commands.baseline_command import baseline_command
8
+ from codeality_py.commands.check_command import check_command
9
+ from codeality_py.commands.gate_command import gate_command
10
+ from codeality_py.commands.init_command import init_command
11
+
12
+
13
+ @click.group(name="codeality-py")
14
+ @click.version_option(version("syntopica-codeality-py"), prog_name="codeality-py")
15
+ def cli() -> None:
16
+ """Structural linter and config scaffolder for Python."""
17
+
18
+
19
+ cli.add_command(baseline_command)
20
+ cli.add_command(check_command)
21
+ cli.add_command(gate_command)
22
+ cli.add_command(init_command)
@@ -0,0 +1 @@
1
+ """The CLI commands."""
@@ -0,0 +1,36 @@
1
+ """Report current findings against the recorded baseline."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from codeality_py.baseline.baseline_file import BASELINE_FILENAME
9
+ from codeality_py.baseline.classify_findings import classify_findings
10
+ from codeality_py.baseline.read_baseline import read_baseline
11
+ from codeality_py.commands.checked_project import checked_project
12
+ from codeality_py.commands.project_option import PROJECT_OPTION
13
+ from codeality_py.report.exit_code import ExitCode
14
+
15
+
16
+ @click.command(name="check")
17
+ @PROJECT_OPTION
18
+ @click.option("--check-stale", is_flag=True, help="Also fail on entries nothing reports any more.")
19
+ def baseline_check_command(project: Path, check_stale: bool) -> None:
20
+ """Fail on new findings, and on stale debt when asked."""
21
+ root, result = checked_project(project)
22
+ classified = classify_findings(result.findings, read_baseline(root / BASELINE_FILENAME))
23
+ click.echo(
24
+ f"{len(classified.new)} new, {len(classified.known)} known, "
25
+ f"{len(classified.resolved)} resolved"
26
+ )
27
+ for finding in classified.new:
28
+ click.echo(
29
+ f"{finding.location.path}:{finding.location.line}: "
30
+ f"{finding.code.value} {finding.message}"
31
+ )
32
+ sys.exit(
33
+ ExitCode.FINDINGS
34
+ if classified.new or (check_stale and classified.resolved)
35
+ else ExitCode.OK
36
+ )
@@ -0,0 +1,15 @@
1
+ """The ``baseline`` command group: the only writer of recorded debt."""
2
+
3
+ import click
4
+
5
+ from codeality_py.commands.baseline_check_command import baseline_check_command
6
+ from codeality_py.commands.baseline_update_command import baseline_update_command
7
+
8
+
9
+ @click.group(name="baseline")
10
+ def baseline_command() -> None:
11
+ """Record and inspect migration debt. Only this group ever writes one."""
12
+
13
+
14
+ baseline_command.add_command(baseline_check_command)
15
+ baseline_command.add_command(baseline_update_command)
@@ -0,0 +1,24 @@
1
+ """Record every current finding as accepted debt."""
2
+
3
+ from importlib.metadata import version
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from codeality_py.baseline.baseline_file import BASELINE_FILENAME, BaselineFile
9
+ from codeality_py.baseline.write_baseline import write_baseline
10
+ from codeality_py.commands.checked_project import checked_project
11
+ from codeality_py.commands.project_option import PROJECT_OPTION
12
+
13
+
14
+ @click.command(name="update")
15
+ @PROJECT_OPTION
16
+ def baseline_update_command(project: Path) -> None:
17
+ """Write the baseline. This and nothing else records debt."""
18
+ root, result = checked_project(project)
19
+ recorded = BaselineFile(
20
+ entries=frozenset(finding.fingerprint for finding in result.findings),
21
+ tool_version=version("syntopica-codeality-py"),
22
+ )
23
+ write_baseline(root / BASELINE_FILENAME, recorded)
24
+ click.echo(f"recorded {len(recorded.entries)} findings as baseline debt")
@@ -0,0 +1,45 @@
1
+ """The read-only ``check`` command."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from codeality_py.config.config_error import ConfigError
9
+ from codeality_py.config.load_config import load_config
10
+ from codeality_py.engine.check_project import check_project
11
+ from codeality_py.parsing.infrastructure_error import InfrastructureError
12
+ from codeality_py.report.exit_code import ExitCode
13
+ from codeality_py.report.render_json_report import render_json_report
14
+ from codeality_py.report.render_text_report import render_text_report
15
+
16
+
17
+ @click.command(name="check")
18
+ @click.option(
19
+ "--project",
20
+ "project",
21
+ type=click.Path(file_okay=False, exists=True, path_type=Path),
22
+ default=Path(),
23
+ help="Project root to check. Defaults to the working directory.",
24
+ )
25
+ @click.option(
26
+ "--format",
27
+ "output_format",
28
+ type=click.Choice(["text", "json"]),
29
+ default="text",
30
+ help="Report format. JSON goes to stdout alone.",
31
+ )
32
+ def check_command(project: Path, output_format: str) -> None:
33
+ """Run the structural rules. This command never writes."""
34
+ try:
35
+ config = load_config(project.resolve())
36
+ result = check_project(config)
37
+ except ConfigError as error:
38
+ click.echo(f"configuration error: {error}", err=True)
39
+ sys.exit(ExitCode.CONFIGURATION)
40
+ except InfrastructureError as error:
41
+ click.echo(f"infrastructure error: {error}", err=True)
42
+ sys.exit(ExitCode.INFRASTRUCTURE)
43
+ rendered = render_json_report(result) if output_format == "json" else render_text_report(result)
44
+ click.echo(rendered)
45
+ sys.exit(ExitCode.FINDINGS if result.findings else ExitCode.OK)
@@ -0,0 +1,26 @@
1
+ """Load a project's configuration and run the checker over it."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from codeality_py.config.config_error import ConfigError
9
+ from codeality_py.config.load_config import load_config
10
+ from codeality_py.engine.check_project import check_project
11
+ from codeality_py.engine.check_result import CheckResult
12
+ from codeality_py.parsing.infrastructure_error import InfrastructureError
13
+ from codeality_py.report.exit_code import ExitCode
14
+
15
+
16
+ def checked_project(project: Path) -> tuple[Path, CheckResult]:
17
+ """Return the resolved root and its findings, exiting on any failure."""
18
+ root = project.resolve()
19
+ try:
20
+ return root, check_project(load_config(root))
21
+ except ConfigError as error:
22
+ click.echo(f"configuration error: {error}", err=True)
23
+ sys.exit(ExitCode.CONFIGURATION)
24
+ except InfrastructureError as error:
25
+ click.echo(f"infrastructure error: {error}", err=True)
26
+ sys.exit(ExitCode.INFRASTRUCTURE)
@@ -0,0 +1,33 @@
1
+ """The ``gate`` command: the whole quality chain, defined once."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from codeality_py.config.config_error import ConfigError
9
+ from codeality_py.config.load_config import load_config
10
+ from codeality_py.gate.run_gate import run_gate
11
+ from codeality_py.report.exit_code import ExitCode
12
+ from codeality_py.report.render_gate_report import render_gate_report
13
+
14
+
15
+ @click.command(name="gate")
16
+ @click.option(
17
+ "--project",
18
+ type=click.Path(file_okay=False, exists=True, path_type=Path),
19
+ default=Path(),
20
+ help="Project root to gate. Defaults to the working directory.",
21
+ )
22
+ @click.option("--fail-fast", is_flag=True, help="Stop at the first blocking failure.")
23
+ @click.option("--json", "as_json", is_flag=True, help="Emit the machine-readable summary.")
24
+ def gate_command(project: Path, fail_fast: bool, as_json: bool) -> None:
25
+ """Run ruff, mypy, the structural rules, deptry, pip-audit and pytest."""
26
+ try:
27
+ config = load_config(project.resolve())
28
+ except ConfigError as error:
29
+ click.echo(f"configuration error: {error}", err=True)
30
+ sys.exit(ExitCode.CONFIGURATION)
31
+ result = run_gate(config, fail_fast)
32
+ click.echo(render_gate_report(result, as_json))
33
+ sys.exit(result.exit_code())
@@ -0,0 +1,53 @@
1
+ """The ``init`` command: plan by default, merge on request, never shadow."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from codeality_py.init.apply_init import apply_init
9
+ from codeality_py.init.plan_disposition import PlanDisposition
10
+ from codeality_py.init.plan_init import plan_init
11
+ from codeality_py.report.exit_code import ExitCode
12
+
13
+
14
+ @click.command(name="init")
15
+ @click.option(
16
+ "--project",
17
+ type=click.Path(file_okay=False, exists=True, path_type=Path),
18
+ default=Path(),
19
+ help="Project root to scaffold. Defaults to the working directory.",
20
+ )
21
+ @click.option("--check", "check_only", is_flag=True, help="Plan only; exit 2 on any conflict.")
22
+ @click.option("--apply", "should_apply", is_flag=True, help="Write creates and merges.")
23
+ @click.option("--force", is_flag=True, help="Also replace conflicting managed files.")
24
+ @click.option("--ci", "with_ci", is_flag=True, help="Also scaffold the CI workflow.")
25
+ @click.option(
26
+ "--profile",
27
+ type=click.Choice(["lib", "app"]),
28
+ default="lib",
29
+ help="app additionally scaffolds an import-linter contract.",
30
+ )
31
+ def init_command( # noqa: PLR0913, PLR0917 - one parameter per click option
32
+ project: Path,
33
+ check_only: bool,
34
+ should_apply: bool,
35
+ force: bool,
36
+ with_ci: bool,
37
+ profile: str,
38
+ ) -> None:
39
+ """Scaffold the quality configuration by merging, never by shadowing."""
40
+ root = project.resolve()
41
+ plan = plan_init(root, profile, with_ci)
42
+ for managed in plan:
43
+ # A nested project's workflow lives at the repository root, outside it.
44
+ path = managed.path.resolve()
45
+ shown = path.relative_to(root) if path.is_relative_to(root) else path
46
+ click.echo(f"{managed.disposition.value:>9} {shown} ({managed.detail})")
47
+ if should_apply or force:
48
+ written = apply_init(plan, force)
49
+ click.echo(f"wrote {len(written)} files")
50
+ conflicts = [item for item in plan if item.disposition is PlanDisposition.CONFLICT]
51
+ if conflicts and check_only:
52
+ sys.exit(ExitCode.CONFIGURATION)
53
+ sys.exit(ExitCode.OK)
@@ -0,0 +1,12 @@
1
+ """The --project option shared by every command."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ PROJECT_OPTION = click.option(
8
+ "--project",
9
+ type=click.Path(file_okay=False, exists=True, path_type=Path),
10
+ default=Path(),
11
+ help="Project root. Defaults to the working directory.",
12
+ )
@@ -0,0 +1 @@
1
+ """Configuration loading and validation."""