pycmdcheck 0.1.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 (41) hide show
  1. pycmdcheck/__init__.py +104 -0
  2. pycmdcheck/__main__.py +6 -0
  3. pycmdcheck/ast_cache.py +45 -0
  4. pycmdcheck/checks/__init__.py +1 -0
  5. pycmdcheck/checks/base.py +143 -0
  6. pycmdcheck/checks/build.py +101 -0
  7. pycmdcheck/checks/changelog.py +101 -0
  8. pycmdcheck/checks/ci.py +87 -0
  9. pycmdcheck/checks/citation.py +102 -0
  10. pycmdcheck/checks/community.py +108 -0
  11. pycmdcheck/checks/dependencies.py +309 -0
  12. pycmdcheck/checks/docs.py +305 -0
  13. pycmdcheck/checks/doctests.py +129 -0
  14. pycmdcheck/checks/formatting.py +200 -0
  15. pycmdcheck/checks/imports.py +239 -0
  16. pycmdcheck/checks/license.py +304 -0
  17. pycmdcheck/checks/linting.py +304 -0
  18. pycmdcheck/checks/metadata.py +233 -0
  19. pycmdcheck/checks/py_typed.py +207 -0
  20. pycmdcheck/checks/python_versions.py +188 -0
  21. pycmdcheck/checks/structure.py +282 -0
  22. pycmdcheck/checks/tests.py +230 -0
  23. pycmdcheck/checks/typing.py +257 -0
  24. pycmdcheck/checks/urls.py +124 -0
  25. pycmdcheck/checks/version.py +184 -0
  26. pycmdcheck/cli.py +296 -0
  27. pycmdcheck/config.py +194 -0
  28. pycmdcheck/constants.py +29 -0
  29. pycmdcheck/discovery.py +127 -0
  30. pycmdcheck/package_layout.py +234 -0
  31. pycmdcheck/profiles.py +158 -0
  32. pycmdcheck/py.typed +0 -0
  33. pycmdcheck/pyproject_reader.py +226 -0
  34. pycmdcheck/results.py +320 -0
  35. pycmdcheck/runner.py +236 -0
  36. pycmdcheck/subprocess_runner.py +166 -0
  37. pycmdcheck-0.1.0.dist-info/METADATA +346 -0
  38. pycmdcheck-0.1.0.dist-info/RECORD +41 -0
  39. pycmdcheck-0.1.0.dist-info/WHEEL +4 -0
  40. pycmdcheck-0.1.0.dist-info/entry_points.txt +24 -0
  41. pycmdcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,184 @@
1
+ """Version consistency check."""
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pycmdcheck.ast_cache import parse_file
8
+ from pycmdcheck.checks.base import BaseCheck
9
+ from pycmdcheck.package_layout import PackageLayout
10
+ from pycmdcheck.pyproject_reader import get_effective_project_table, read_pyproject
11
+ from pycmdcheck.results import CheckResult, CheckStatus
12
+
13
+
14
+ class VersionCheck(BaseCheck):
15
+ """Check that __version__ in code matches pyproject.toml version."""
16
+
17
+ name = "version"
18
+ description = "Verify version consistency"
19
+
20
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
21
+ """Compare __version__ in code with pyproject.toml version."""
22
+ # Distinguish "no pyproject.toml" from "pyproject present but no
23
+ # [project] table" (e.g. legacy Poetry / setup.py metadata).
24
+ if read_pyproject(package_path) is None:
25
+ return CheckResult(
26
+ name=self.name,
27
+ status=CheckStatus.NOTE,
28
+ message="No pyproject.toml found",
29
+ )
30
+
31
+ project = get_effective_project_table(package_path)
32
+ if not project:
33
+ return CheckResult(
34
+ name=self.name,
35
+ status=CheckStatus.NOTE,
36
+ message="No [project] table (legacy/setup.py metadata)",
37
+ details=["Version consistency not checked"],
38
+ )
39
+
40
+ # Check for dynamic version
41
+ dynamic = project.get("dynamic", [])
42
+ if "version" in dynamic:
43
+ return CheckResult(
44
+ name=self.name,
45
+ status=CheckStatus.SKIPPED,
46
+ message="Version is dynamic (set at build time)",
47
+ )
48
+
49
+ pyproject_version = project.get("version")
50
+ if not pyproject_version:
51
+ return CheckResult(
52
+ name=self.name,
53
+ status=CheckStatus.WARNING,
54
+ message="No version in pyproject.toml [project] table",
55
+ )
56
+
57
+ # Find __version__ in code (package __init__.py or single-module file)
58
+ raw_name = project.get("name")
59
+ fs_name = raw_name.replace("-", "_") if raw_name else None
60
+ layout = PackageLayout(package_path, package_name=fs_name)
61
+ version_source = self._find_version_source(layout, fs_name)
62
+ if version_source is None:
63
+ return CheckResult(
64
+ name=self.name,
65
+ status=CheckStatus.WARNING,
66
+ message="No __init__.py or module found to check __version__",
67
+ )
68
+
69
+ code_version = self._extract_version(version_source)
70
+ if code_version is None:
71
+ return CheckResult(
72
+ name=self.name,
73
+ status=CheckStatus.WARNING,
74
+ message="No __version__ defined in code",
75
+ details=[
76
+ f"pyproject.toml version: {pyproject_version}",
77
+ f'Consider adding __version__ = "{pyproject_version}"'
78
+ f" to {version_source.name}",
79
+ ],
80
+ )
81
+
82
+ if code_version == self._DYNAMIC_VERSION:
83
+ return CheckResult(
84
+ name=self.name,
85
+ status=CheckStatus.OK,
86
+ message="Version set dynamically (e.g. importlib.metadata)",
87
+ details=[
88
+ f"pyproject.toml version: {pyproject_version}",
89
+ "__version__ is set via function call at runtime",
90
+ ],
91
+ )
92
+
93
+ if code_version == pyproject_version:
94
+ return CheckResult(
95
+ name=self.name,
96
+ status=CheckStatus.OK,
97
+ message=f"Version {pyproject_version} is consistent",
98
+ details=[
99
+ f"pyproject.toml: {pyproject_version}",
100
+ f"__version__: {code_version}",
101
+ ],
102
+ )
103
+
104
+ return CheckResult(
105
+ name=self.name,
106
+ status=CheckStatus.ERROR,
107
+ message="Version mismatch",
108
+ details=[
109
+ f"pyproject.toml: {pyproject_version}",
110
+ f"__version__: {code_version}",
111
+ ],
112
+ )
113
+
114
+ # Sentinel returned when __version__ is set via a function call
115
+ _DYNAMIC_VERSION = "<dynamic>"
116
+
117
+ def _find_version_source(
118
+ self, layout: PackageLayout, fs_name: str | None
119
+ ) -> Path | None:
120
+ """Return the file that should declare ``__version__``.
121
+
122
+ Prefers the primary package's ``__init__.py``; falls back to a
123
+ single-module package (``foo.py`` at the import root) when there is no
124
+ package directory.
125
+ """
126
+ pkg_dir = layout.primary_package
127
+ if pkg_dir is not None:
128
+ init_file = pkg_dir / "__init__.py"
129
+ if init_file.exists():
130
+ return init_file
131
+
132
+ modules = layout.top_level_modules()
133
+ if fs_name:
134
+ for module in modules:
135
+ if module.stem == fs_name:
136
+ return module
137
+ if len(modules) == 1:
138
+ return modules[0]
139
+ return None
140
+
141
+ def _extract_version(self, init_file: Path) -> str | None:
142
+ """Extract __version__ from a Python file using AST.
143
+
144
+ Returns the literal version string, the sentinel ``_DYNAMIC_VERSION``
145
+ if the version is set via a function call (e.g.
146
+ ``importlib.metadata.version()``), or ``None`` if no ``__version__``
147
+ assignment is found.
148
+ """
149
+ tree = parse_file(init_file)
150
+ if tree is None:
151
+ return None
152
+
153
+ for node in ast.walk(tree):
154
+ # ``from ._version import __version__`` re-exports the version from
155
+ # another module — treat it like a runtime/dynamic source rather
156
+ # than "missing", since it cannot be compared statically here.
157
+ if isinstance(node, ast.ImportFrom):
158
+ for alias in node.names:
159
+ if "__version__" in (alias.name, alias.asname):
160
+ return self._DYNAMIC_VERSION
161
+ if isinstance(node, ast.Assign):
162
+ for target in node.targets:
163
+ if isinstance(target, ast.Name) and target.id == "__version__":
164
+ if isinstance(node.value, ast.Constant) and isinstance(
165
+ node.value.value, str
166
+ ):
167
+ return node.value.value
168
+ if isinstance(node.value, ast.Call):
169
+ return self._DYNAMIC_VERSION
170
+ elif isinstance(node, ast.AnnAssign):
171
+ if (
172
+ isinstance(node.target, ast.Name)
173
+ and node.target.id == "__version__"
174
+ ):
175
+ if (
176
+ node.value
177
+ and isinstance(node.value, ast.Constant)
178
+ and isinstance(node.value.value, str)
179
+ ):
180
+ return node.value.value
181
+ if node.value and isinstance(node.value, ast.Call):
182
+ return self._DYNAMIC_VERSION
183
+
184
+ return None
pycmdcheck/cli.py ADDED
@@ -0,0 +1,296 @@
1
+ """Command-line interface for pycmdcheck."""
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from pycmdcheck.config import load_config
14
+ from pycmdcheck.discovery import list_available_checks
15
+ from pycmdcheck.profiles import PROFILES, get_profile, list_profiles
16
+ from pycmdcheck.results import CheckStatus, Report
17
+ from pycmdcheck.runner import run_checks
18
+
19
+ console = Console()
20
+
21
+
22
+ @click.command()
23
+ @click.argument("path", default=".", type=click.Path(exists=True))
24
+ @click.option(
25
+ "-c",
26
+ "--check",
27
+ multiple=True,
28
+ help="Run only specific check(s). Can be specified multiple times.",
29
+ )
30
+ @click.option(
31
+ "-s",
32
+ "--skip",
33
+ multiple=True,
34
+ help="Skip specific check(s). Can be specified multiple times.",
35
+ )
36
+ @click.option(
37
+ "--fail-on",
38
+ multiple=True,
39
+ type=click.Choice(["error", "warning", "note"]),
40
+ default=["error"],
41
+ help="Exit with non-zero code on these statuses. Default: error",
42
+ )
43
+ @click.option(
44
+ "--list",
45
+ "list_checks",
46
+ is_flag=True,
47
+ help="List available checks and exit.",
48
+ )
49
+ @click.option(
50
+ "-v",
51
+ "--verbose",
52
+ is_flag=True,
53
+ help="Show detailed output for each check.",
54
+ )
55
+ @click.option(
56
+ "--json",
57
+ "json_output",
58
+ is_flag=True,
59
+ help="Output results as JSON.",
60
+ )
61
+ @click.option(
62
+ "--no-parallel",
63
+ is_flag=True,
64
+ help="Run checks sequentially instead of in parallel.",
65
+ )
66
+ @click.option(
67
+ "-x",
68
+ "--fail-fast",
69
+ "fail_fast",
70
+ is_flag=True,
71
+ help="Stop after first check failure.",
72
+ )
73
+ @click.option(
74
+ "--profile",
75
+ type=click.Choice(list(PROFILES)),
76
+ default=None,
77
+ help="Use a predefined check profile.",
78
+ )
79
+ @click.option(
80
+ "--list-profiles",
81
+ "list_profiles_flag",
82
+ is_flag=True,
83
+ help="List available profiles and exit.",
84
+ )
85
+ @click.option("--debug", is_flag=True, help="Enable debug logging output.")
86
+ @click.version_option(package_name="pycmdcheck")
87
+ def main(
88
+ path: str,
89
+ check: tuple[str, ...],
90
+ skip: tuple[str, ...],
91
+ fail_on: tuple[str, ...],
92
+ list_checks: bool,
93
+ verbose: bool,
94
+ json_output: bool,
95
+ no_parallel: bool,
96
+ fail_fast: bool,
97
+ profile: str | None,
98
+ list_profiles_flag: bool,
99
+ debug: bool,
100
+ ) -> None:
101
+ """Check Python package quality.
102
+
103
+ PATH is the directory to check (default: current directory).
104
+
105
+ Examples:
106
+
107
+ pycmdcheck # Check current directory
108
+
109
+ pycmdcheck /path/to/package # Check specific directory
110
+
111
+ pycmdcheck -c tests -c linting # Run only tests and linting
112
+
113
+ pycmdcheck -s typing # Skip type checking
114
+
115
+ pycmdcheck --fail-on error --fail-on warning # Fail on warnings too
116
+ """
117
+ if debug:
118
+ logging.basicConfig(
119
+ level=logging.DEBUG,
120
+ format="%(name)s: %(message)s",
121
+ )
122
+
123
+ if list_profiles_flag:
124
+ _show_profiles()
125
+ return
126
+
127
+ if list_checks:
128
+ _show_available_checks()
129
+ return
130
+
131
+ # Resolve profile
132
+ profile_config: dict[str, dict[str, object]] = {}
133
+ if profile:
134
+ prof = get_profile(profile)
135
+ if prof:
136
+ profile_config = prof.config_overrides
137
+ if not check:
138
+ # Use profile's check set (converted to tuple for consistency)
139
+ check = tuple(sorted(prof.checks))
140
+
141
+ # Validate check names
142
+ if check:
143
+ from pycmdcheck.discovery import discover_checks
144
+
145
+ available = set(discover_checks().keys())
146
+ invalid = set(check) - available
147
+ if invalid:
148
+ console.print(
149
+ f"[bold red]Unknown check(s): {', '.join(sorted(invalid))}[/bold red]"
150
+ )
151
+ console.print(f"Available checks: {', '.join(sorted(available))}")
152
+ sys.exit(2)
153
+
154
+ package_path = Path(path).resolve()
155
+
156
+ if not json_output:
157
+ profile_label = f" (profile: {profile})" if profile else ""
158
+ console.print(
159
+ f"\n[bold]Checking package:[/bold] {package_path}{profile_label}\n"
160
+ )
161
+
162
+ # Load config from pyproject.toml and merge profile overrides
163
+ merged_config = load_config(package_path)
164
+ if profile_config:
165
+ checks_section = merged_config.setdefault("checks", {})
166
+ for check_name, overrides in profile_config.items():
167
+ existing = checks_section.get(check_name, {})
168
+ if isinstance(existing, bool):
169
+ existing = {"enabled": existing}
170
+ existing.update(overrides)
171
+ checks_section[check_name] = existing
172
+
173
+ # Run checks
174
+ if not json_output and console.is_terminal:
175
+ with console.status("[bold]Running checks...[/bold]"):
176
+ report = run_checks(
177
+ package_path=package_path,
178
+ checks=list(check) if check else None,
179
+ skip=list(skip),
180
+ config=merged_config,
181
+ parallel=not no_parallel,
182
+ fail_fast=fail_fast,
183
+ )
184
+ else:
185
+ report = run_checks(
186
+ package_path=package_path,
187
+ checks=list(check) if check else None,
188
+ skip=list(skip),
189
+ config=merged_config,
190
+ parallel=not no_parallel,
191
+ fail_fast=fail_fast,
192
+ )
193
+
194
+ # Output results
195
+ if json_output:
196
+ _output_json(report)
197
+ else:
198
+ _output_rich(report, verbose)
199
+
200
+ # Determine exit code
201
+ if report.failed_on(list(fail_on)):
202
+ sys.exit(1)
203
+
204
+
205
+ def _show_profiles(con: Console | None = None) -> None:
206
+ """Display list of available profiles."""
207
+ con = con or console
208
+ profiles = list_profiles()
209
+
210
+ table = Table(title="Available Profiles")
211
+ table.add_column("Name", style="cyan")
212
+ table.add_column("Description")
213
+
214
+ for name, description in profiles:
215
+ table.add_row(name, description)
216
+
217
+ con.print(table)
218
+
219
+
220
+ def _show_available_checks(con: Console | None = None) -> None:
221
+ """Display list of available checks."""
222
+ con = con or console
223
+ checks = list_available_checks()
224
+
225
+ table = Table(title="Available Checks")
226
+ table.add_column("Name", style="cyan")
227
+ table.add_column("Description")
228
+
229
+ for name, description in checks:
230
+ table.add_row(name, description)
231
+
232
+ con.print(table)
233
+
234
+
235
+ def _output_json(report: Report) -> None:
236
+ """Output report as JSON."""
237
+ print(json.dumps(report.to_dict(), indent=2))
238
+
239
+
240
+ def _output_rich(report: Report, verbose: bool, con: Console | None = None) -> None:
241
+ """Output report with rich formatting."""
242
+ con = con or console
243
+ if not report.results:
244
+ con.print("[yellow]No checks were run.[/yellow]")
245
+ return
246
+
247
+ # Create results table
248
+ table = Table(show_header=True, header_style="bold")
249
+ table.add_column("Status", width=3, justify="center")
250
+ table.add_column("Check", style="cyan")
251
+ table.add_column("Message")
252
+ if verbose:
253
+ table.add_column("Time", justify="right")
254
+
255
+ for result in report.results:
256
+ status_text = Text(result.status.symbol)
257
+ status_text.stylize(result.status.color)
258
+
259
+ if verbose:
260
+ table.add_row(
261
+ status_text, result.name, result.message, f"{result.duration:.2f}s"
262
+ )
263
+ else:
264
+ table.add_row(status_text, result.name, result.message)
265
+
266
+ con.print(table)
267
+
268
+ # Show details if verbose
269
+ if verbose:
270
+ for result in report.results:
271
+ if result.details:
272
+ con.print(f"\n[bold]{result.name}[/bold] details:")
273
+ for detail in result.details:
274
+ con.print(f" {detail}")
275
+
276
+ # Summary
277
+ con.print()
278
+ counts = report.count_by_status()
279
+ summary_parts: list[str] = []
280
+
281
+ for status in CheckStatus:
282
+ count = counts[status]
283
+ if count > 0:
284
+ colored = f"[{status.color}]{count} {status.value}[/{status.color}]"
285
+ summary_parts.append(colored)
286
+
287
+ con.print(" | ".join(summary_parts))
288
+
289
+ if report.passed:
290
+ con.print("\n[bold green]All checks passed![/bold green]")
291
+ else:
292
+ con.print("\n[bold red]Some checks failed.[/bold red]")
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()
pycmdcheck/config.py ADDED
@@ -0,0 +1,194 @@
1
+ """Configuration loading for pycmdcheck."""
2
+
3
+ import copy
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pycmdcheck.pyproject_reader import get_tool_table
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ VALID_FAIL_ON = {"error", "warning", "note"}
13
+
14
+
15
+ DEFAULT_CONFIG: dict[str, Any] = {
16
+ "fail_on": ["error"],
17
+ "checks": {
18
+ "metadata": True,
19
+ "structure": True,
20
+ "tests": {"enabled": True, "runner": "pytest"},
21
+ "linting": {"enabled": True, "tool": "ruff"},
22
+ "typing": {"enabled": True, "tool": "mypy"},
23
+ "imports": True,
24
+ "license": True,
25
+ "docs": True,
26
+ "dependencies": True,
27
+ "build": True,
28
+ "formatting": {"enabled": True, "tool": "auto"},
29
+ "version": True,
30
+ "py_typed": True,
31
+ },
32
+ }
33
+ """Default configuration applied when no pyproject.toml is found.
34
+
35
+ This configuration enables all built-in checks with sensible defaults:
36
+ - tests: Uses pytest as the test runner
37
+ - linting: Uses ruff as the linter
38
+ - typing: Uses mypy as the type checker
39
+ """
40
+
41
+
42
+ def load_config(package_path: Path) -> dict[str, Any]:
43
+ """Load pycmdcheck configuration from pyproject.toml.
44
+
45
+ Reads the [tool.pycmdcheck] section from the package's pyproject.toml
46
+ and merges it with default configuration. If no pyproject.toml exists
47
+ or it doesn't contain pycmdcheck configuration, returns defaults.
48
+
49
+ Args:
50
+ package_path: Path to the package directory containing pyproject.toml.
51
+
52
+ Returns:
53
+ Configuration dictionary with the following structure:
54
+ - fail_on: List of statuses to fail on (default: ["error"])
55
+ - checks: Dictionary of check configurations
56
+
57
+ Examples:
58
+ >>> from pathlib import Path
59
+ >>> config = load_config(Path("."))
60
+ >>> config["fail_on"]
61
+ ['error']
62
+ >>> config["checks"]["tests"]["runner"]
63
+ 'pytest'
64
+ """
65
+ config = copy.deepcopy(DEFAULT_CONFIG)
66
+ tool_config = get_tool_table(package_path, "pycmdcheck")
67
+ if tool_config:
68
+ config = _merge_config(config, tool_config)
69
+ config_warnings = validate_config(config)
70
+ for w in config_warnings:
71
+ logger.warning("Config: %s", w)
72
+ logger.debug("Loaded config from %s", package_path)
73
+ return config
74
+
75
+
76
+ def get_check_config(config: dict[str, Any], check_name: str) -> dict[str, Any]:
77
+ """Get configuration for a specific check.
78
+
79
+ Extracts the configuration for a named check from the full configuration.
80
+ Handles both boolean shorthand (e.g., `metadata = true`) and dictionary
81
+ configuration (e.g., `tests = { enabled = true, runner = "pytest" }`).
82
+
83
+ Args:
84
+ config: Full pycmdcheck configuration dictionary, as returned by
85
+ load_config().
86
+ check_name: Name of the check to get configuration for (e.g.,
87
+ "metadata", "tests", "linting").
88
+
89
+ Returns:
90
+ Configuration dictionary for the check, always containing an
91
+ "enabled" key. Additional keys depend on the check type.
92
+
93
+ Examples:
94
+ Boolean config is converted to dict:
95
+
96
+ >>> config = {"checks": {"metadata": True}}
97
+ >>> get_check_config(config, "metadata")
98
+ {'enabled': True}
99
+
100
+ Dict config is passed through with enabled defaulting to True:
101
+
102
+ >>> config = {"checks": {"tests": {"runner": "pytest"}}}
103
+ >>> get_check_config(config, "tests")
104
+ {'runner': 'pytest', 'enabled': True}
105
+
106
+ Unknown checks default to enabled:
107
+
108
+ >>> config = {"checks": {}}
109
+ >>> get_check_config(config, "unknown")
110
+ {'enabled': True}
111
+ """
112
+ checks_config = config.get("checks", {})
113
+ check_config = checks_config.get(check_name, True)
114
+
115
+ # Handle boolean shorthand
116
+ if isinstance(check_config, bool):
117
+ return {"enabled": check_config}
118
+
119
+ # Handle dict config (ensure 'enabled' key exists)
120
+ if isinstance(check_config, dict):
121
+ result = dict(check_config)
122
+ result.setdefault("enabled", True)
123
+ return result
124
+
125
+ # Default to enabled
126
+ return {"enabled": True}
127
+
128
+
129
+ def is_check_enabled(config: dict[str, Any], check_name: str) -> bool:
130
+ """Check if a specific check is enabled.
131
+
132
+ Convenience function to determine if a check should run based on
133
+ the configuration.
134
+
135
+ Args:
136
+ config: Full pycmdcheck configuration dictionary.
137
+ check_name: Name of the check to query.
138
+
139
+ Returns:
140
+ True if the check is enabled, False otherwise.
141
+
142
+ Examples:
143
+ >>> config = {"checks": {"metadata": True, "linting": False}}
144
+ >>> is_check_enabled(config, "metadata")
145
+ True
146
+ >>> is_check_enabled(config, "linting")
147
+ False
148
+ >>> is_check_enabled(config, "unknown") # Defaults to enabled
149
+ True
150
+ """
151
+ check_config = get_check_config(config, check_name)
152
+ return bool(check_config.get("enabled", True))
153
+
154
+
155
+ def validate_config(config: dict[str, Any]) -> list[str]:
156
+ """Validate config values and return a list of warning messages."""
157
+ warnings: list[str] = []
158
+
159
+ fail_on = config.get("fail_on", [])
160
+ if not isinstance(fail_on, list):
161
+ warnings.append(f"'fail_on' should be a list, got {type(fail_on).__name__}")
162
+ else:
163
+ for value in fail_on:
164
+ if value not in VALID_FAIL_ON:
165
+ warnings.append(
166
+ f"Invalid fail_on value '{value}'. "
167
+ f"Valid values: {', '.join(sorted(VALID_FAIL_ON))}"
168
+ )
169
+
170
+ return warnings
171
+
172
+
173
+ def _merge_config(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
174
+ """Recursively merge override into base configuration.
175
+
176
+ Values in override take precedence. Nested dictionaries are merged
177
+ recursively rather than replaced.
178
+
179
+ Args:
180
+ base: Base configuration dictionary.
181
+ override: Override configuration dictionary.
182
+
183
+ Returns:
184
+ Merged configuration dictionary.
185
+ """
186
+ result = copy.deepcopy(base)
187
+
188
+ for key, value in override.items():
189
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
190
+ result[key] = _merge_config(result[key], value)
191
+ else:
192
+ result[key] = value
193
+
194
+ return result
@@ -0,0 +1,29 @@
1
+ """Named constants used across pycmdcheck."""
2
+
3
+ # Subprocess execution
4
+ DEFAULT_TIMEOUT: int = 120
5
+ LONG_TIMEOUT: int = 300
6
+
7
+ # Output limits
8
+ MAX_DETAIL_LINES: int = 10
9
+ MAX_WORKERS: int = 4
10
+
11
+ # Documentation thresholds
12
+ MIN_README_WORDS: int = 50
13
+ MIN_LICENSE_LENGTH: int = 50
14
+ MAX_DOCSTRING_FILES: int = 20
15
+ MAX_TYPE_SCAN_FILES: int = 20
16
+
17
+ # URL validation
18
+ URL_TIMEOUT: int = 10
19
+ MAX_URL_CHECKS: int = 20
20
+
21
+ # Changelog validation
22
+ MIN_CHANGELOG_LENGTH: int = 50
23
+
24
+ # Python version EOL dates (year, month) — versions whose EOL has passed
25
+ # Source: https://devguide.python.org/versions/
26
+ PYTHON_EOL_VERSIONS: dict[str, tuple[int, int]] = {
27
+ "3.8": (2024, 10),
28
+ "3.9": (2025, 10),
29
+ }