tqlint 0.4.2__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.
tq/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """tq package."""
tq/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI package for tq command surface."""
tq/cli/main.py ADDED
@@ -0,0 +1,283 @@
1
+ """Command-line interface for tq."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import click
10
+ from rich.console import Console
11
+
12
+ from tq.config.loader import resolve_tq_config
13
+ from tq.config.models import CliOverrides, ConfigValidationError, TqConfig
14
+ from tq.discovery.filesystem import build_analysis_index
15
+ from tq.engine.context import AnalysisContext
16
+ from tq.engine.rule_id import RuleId
17
+ from tq.engine.runner import RuleEngine
18
+ from tq.reporting.json import print_json_report
19
+ from tq.reporting.terminal import print_report
20
+ from tq.rules.file_too_large import FileTooLargeRule
21
+ from tq.rules.mapping_missing_test import MappingMissingTestRule
22
+ from tq.rules.orphaned_test import OrphanedTestRule
23
+ from tq.rules.qualifiers import QualifierStrategy
24
+ from tq.rules.structure_mismatch import StructureMismatchRule
25
+
26
+ if TYPE_CHECKING:
27
+ from tq.rules.contracts import Rule
28
+
29
+ _BUILTIN_RULE_IDS = (
30
+ RuleId("mapping-missing-test"),
31
+ RuleId("structure-mismatch"),
32
+ RuleId("test-file-too-large"),
33
+ RuleId("orphaned-test"),
34
+ )
35
+
36
+
37
+ _HELP_OPTION_NAMES = {"help_option_names": ["-h", "--help"]}
38
+
39
+
40
+ @click.group(context_settings=_HELP_OPTION_NAMES)
41
+ def cli() -> None:
42
+ """Run tq commands."""
43
+
44
+
45
+ @cli.command("check", context_settings=_HELP_OPTION_NAMES)
46
+ @click.option(
47
+ "--config",
48
+ "config_path",
49
+ type=click.Path(path_type=Path, dir_okay=False, exists=True),
50
+ default=None,
51
+ help="Use this pyproject file instead of discovered configuration.",
52
+ )
53
+ @click.option(
54
+ "--isolated",
55
+ is_flag=True,
56
+ default=False,
57
+ help="Ignore discovered configuration files.",
58
+ )
59
+ @click.option("--package", type=str, default=None, help="Target package import path.")
60
+ @click.option("--source-root", type=str, default=None, help="Source tree root path.")
61
+ @click.option("--test-root", type=str, default=None, help="Test tree root path.")
62
+ @click.option(
63
+ "--max-test-file-non-blank-lines",
64
+ type=click.IntRange(min=1),
65
+ default=None,
66
+ help="Maximum non-blank, non-comment lines per test file.",
67
+ )
68
+ @click.option(
69
+ "--qualifier-strategy",
70
+ type=click.Choice([strategy.value for strategy in QualifierStrategy]),
71
+ default=None,
72
+ help="Module-name qualifier policy for qualified test files.",
73
+ )
74
+ @click.option(
75
+ "--allowed-qualifier",
76
+ "allowed_qualifiers",
77
+ multiple=True,
78
+ type=str,
79
+ help="Allowed qualifier suffix for allowlist strategy.",
80
+ )
81
+ @click.option(
82
+ "--ignore-init-modules",
83
+ "ignore_init_modules",
84
+ flag_value=True,
85
+ default=None,
86
+ help="Ignore __init__.py modules in mapping checks.",
87
+ )
88
+ @click.option(
89
+ "--no-ignore-init-modules",
90
+ "ignore_init_modules",
91
+ flag_value=False,
92
+ default=None,
93
+ help="Include __init__.py modules in mapping checks.",
94
+ )
95
+ @click.option(
96
+ "--select",
97
+ "select_rules",
98
+ multiple=True,
99
+ type=str,
100
+ help="Only run selected rule IDs.",
101
+ )
102
+ @click.option(
103
+ "--ignore",
104
+ "ignore_rules",
105
+ multiple=True,
106
+ type=str,
107
+ help="Skip listed rule IDs.",
108
+ )
109
+ @click.option(
110
+ "--exit-zero",
111
+ is_flag=True,
112
+ default=False,
113
+ help="Always exit with code 0 regardless of findings.",
114
+ )
115
+ @click.option(
116
+ "--show-suggestions",
117
+ is_flag=True,
118
+ default=False,
119
+ help="Render remediation suggestions in diagnostics output.",
120
+ )
121
+ @click.option(
122
+ "--output-format",
123
+ type=click.Choice(["text", "json"]),
124
+ default="text",
125
+ show_default=True,
126
+ help="Select output format.",
127
+ )
128
+ def check_command( # noqa: PLR0913
129
+ *,
130
+ config_path: Path | None,
131
+ isolated: bool,
132
+ package: str | None,
133
+ source_root: str | None,
134
+ test_root: str | None,
135
+ max_test_file_non_blank_lines: int | None,
136
+ qualifier_strategy: str | None,
137
+ allowed_qualifiers: tuple[str, ...],
138
+ ignore_init_modules: bool | None,
139
+ select_rules: tuple[str, ...],
140
+ ignore_rules: tuple[str, ...],
141
+ exit_zero: bool,
142
+ show_suggestions: bool,
143
+ output_format: str,
144
+ ) -> None:
145
+ """Run built-in tq quality rules against discovered modules and tests."""
146
+ cwd = Path.cwd()
147
+ console = Console(stderr=False)
148
+
149
+ try:
150
+ overrides = CliOverrides(
151
+ package=package,
152
+ source_root=source_root,
153
+ test_root=test_root,
154
+ ignore_init_modules=ignore_init_modules,
155
+ max_test_file_non_blank_lines=max_test_file_non_blank_lines,
156
+ qualifier_strategy=(
157
+ QualifierStrategy(qualifier_strategy)
158
+ if qualifier_strategy is not None
159
+ else None
160
+ ),
161
+ allowed_qualifiers=allowed_qualifiers or None,
162
+ select=_parse_rule_id_tuple(values=select_rules),
163
+ ignore=_parse_rule_id_tuple(values=ignore_rules),
164
+ )
165
+ config = resolve_tq_config(
166
+ cwd=cwd,
167
+ explicit_config_path=config_path,
168
+ isolated=isolated,
169
+ cli_overrides=overrides,
170
+ )
171
+ except (ConfigValidationError, ValueError, tomllib.TOMLDecodeError) as error:
172
+ raise click.UsageError(str(error)) from error
173
+
174
+ if not config.source_package_root.exists():
175
+ msg = (
176
+ "Configured source package root does not exist: "
177
+ f"{config.source_package_root}"
178
+ )
179
+ raise click.UsageError(
180
+ msg,
181
+ )
182
+ if not config.test_root.exists():
183
+ msg = f"Configured test root does not exist: {config.test_root}"
184
+ raise click.UsageError(
185
+ msg,
186
+ )
187
+
188
+ rules = _build_rules(config=config)
189
+ index = build_analysis_index(
190
+ source_root=config.source_package_root,
191
+ test_root=config.test_root,
192
+ )
193
+ context = AnalysisContext.create(index=index)
194
+ result = RuleEngine(rules=rules).run(context=context)
195
+
196
+ if output_format == "json":
197
+ print_json_report(result=result, console=console, cwd=cwd)
198
+ else:
199
+ print_report(
200
+ result=result,
201
+ console=console,
202
+ cwd=cwd,
203
+ include_suggestions=show_suggestions,
204
+ )
205
+
206
+ if exit_zero:
207
+ raise click.exceptions.Exit(0)
208
+
209
+ raise click.exceptions.Exit(1 if result.has_errors else 0)
210
+
211
+
212
+ def _build_rules(*, config: TqConfig) -> tuple[Rule, ...]:
213
+ """Build active built-in rule set using select/ignore resolution."""
214
+ selected_rule_ids = _resolve_rule_selection(config=config)
215
+ selected_set = set(selected_rule_ids)
216
+
217
+ builtins: dict[RuleId, Rule] = {
218
+ RuleId("mapping-missing-test"): MappingMissingTestRule(
219
+ ignore_init_modules=config.ignore_init_modules,
220
+ qualifier_strategy=config.qualifier_strategy,
221
+ allowed_qualifiers=config.allowed_qualifiers,
222
+ ),
223
+ RuleId("structure-mismatch"): StructureMismatchRule(),
224
+ RuleId("test-file-too-large"): FileTooLargeRule(
225
+ max_non_blank_lines=config.max_test_file_non_blank_lines,
226
+ ),
227
+ RuleId("orphaned-test"): OrphanedTestRule(
228
+ qualifier_strategy=config.qualifier_strategy,
229
+ allowed_qualifiers=config.allowed_qualifiers,
230
+ ),
231
+ }
232
+
233
+ return tuple(
234
+ builtins[rule_id] for rule_id in _BUILTIN_RULE_IDS if rule_id in selected_set
235
+ )
236
+
237
+
238
+ def _resolve_rule_selection(*, config: TqConfig) -> tuple[RuleId, ...]:
239
+ """Resolve active rule IDs deterministically from select/ignore."""
240
+ builtin_set = set(_BUILTIN_RULE_IDS)
241
+
242
+ requested_select = set(config.select)
243
+ requested_ignore = set(config.ignore)
244
+
245
+ unknown = (requested_select | requested_ignore) - builtin_set
246
+ if unknown:
247
+ unknown_ids = ", ".join(sorted(rule_id.value for rule_id in unknown))
248
+ msg = f"Unknown built-in rule ID(s): {unknown_ids}"
249
+ raise ConfigValidationError(msg)
250
+
251
+ if config.select:
252
+ selected = tuple(
253
+ rule_id for rule_id in _BUILTIN_RULE_IDS if rule_id in requested_select
254
+ )
255
+ else:
256
+ selected = _BUILTIN_RULE_IDS
257
+
258
+ return tuple(rule_id for rule_id in selected if rule_id not in requested_ignore)
259
+
260
+
261
+ def _parse_rule_id_tuple(*, values: tuple[str, ...]) -> tuple[RuleId, ...] | None:
262
+ """Parse optional CLI rule identifier list into RuleId values."""
263
+ if not values:
264
+ return None
265
+
266
+ rule_ids: list[RuleId] = []
267
+ for value in values:
268
+ try:
269
+ rule_ids.append(RuleId(value))
270
+ except ValueError as error:
271
+ msg = f"Invalid rule ID: {value}"
272
+ raise ConfigValidationError(msg) from error
273
+
274
+ return tuple(rule_ids)
275
+
276
+
277
+ def main() -> None:
278
+ """Run the tq command group."""
279
+ cli()
280
+
281
+
282
+ if __name__ == "__main__":
283
+ main()
tq/config/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Configuration domain for tq CLI and engine composition."""
tq/config/loader.py ADDED
@@ -0,0 +1,356 @@
1
+ """Strict configuration loading and precedence resolution for tq."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from tq.config.models import (
10
+ CliOverrides,
11
+ ConfigValidationError,
12
+ PartialTqConfig,
13
+ TqConfig,
14
+ )
15
+ from tq.engine.rule_id import RuleId
16
+ from tq.rules.qualifiers import QualifierStrategy
17
+
18
+ DEFAULT_IGNORE_INIT_MODULES = False
19
+ DEFAULT_MAX_TEST_FILE_NON_BLANK_LINES = 600
20
+ DEFAULT_QUALIFIER_STRATEGY = QualifierStrategy.ANY_SUFFIX
21
+
22
+ _CONFIG_KEYS = {
23
+ "package",
24
+ "source_root",
25
+ "test_root",
26
+ "ignore_init_modules",
27
+ "max_test_file_non_blank_lines",
28
+ "qualifier_strategy",
29
+ "allowed_qualifiers",
30
+ "select",
31
+ "ignore",
32
+ }
33
+
34
+
35
+ def resolve_tq_config(
36
+ *,
37
+ cwd: Path,
38
+ explicit_config_path: Path | None,
39
+ isolated: bool,
40
+ cli_overrides: CliOverrides,
41
+ ) -> TqConfig:
42
+ """Resolve final tq config with strict precedence and validation."""
43
+ discovered = PartialTqConfig()
44
+
45
+ if explicit_config_path is not None:
46
+ config_path = explicit_config_path.resolve()
47
+ discovered = _load_partial_from_pyproject(
48
+ path=config_path,
49
+ require_section=True,
50
+ )
51
+ elif not isolated:
52
+ user_config_path = Path.home() / ".config" / "tq" / "pyproject.toml"
53
+ project_config_path = _find_project_pyproject(cwd)
54
+
55
+ if user_config_path.exists():
56
+ user_partial = _load_partial_from_pyproject(
57
+ path=user_config_path,
58
+ require_section=False,
59
+ )
60
+ discovered = _merge_partial(discovered, user_partial)
61
+
62
+ if project_config_path is not None:
63
+ project_partial = _load_partial_from_pyproject(
64
+ path=project_config_path,
65
+ require_section=False,
66
+ )
67
+ discovered = _merge_partial(discovered, project_partial)
68
+
69
+ merged = _merge_partial(discovered, _partial_from_cli(cli_overrides))
70
+ return _materialize_config(cwd=cwd, partial=merged)
71
+
72
+
73
+ def _find_project_pyproject(cwd: Path) -> Path | None:
74
+ """Find nearest project ``pyproject.toml`` starting from cwd."""
75
+ for candidate_dir in (cwd, *cwd.parents):
76
+ candidate = candidate_dir / "pyproject.toml"
77
+ if candidate.exists():
78
+ return candidate
79
+ return None
80
+
81
+
82
+ def _load_partial_from_pyproject(
83
+ *,
84
+ path: Path,
85
+ require_section: bool,
86
+ ) -> PartialTqConfig:
87
+ """Load strict partial tq config from a pyproject file."""
88
+ if not path.exists():
89
+ msg = f"Config file not found: {path}"
90
+ raise ConfigValidationError(msg)
91
+
92
+ with path.open("rb") as handle:
93
+ document = tomllib.load(handle)
94
+
95
+ tool_section = document.get("tool", {})
96
+ tq_section = tool_section.get("tq")
97
+
98
+ if tq_section is None:
99
+ if require_section:
100
+ msg = f"Missing [tool.tq] section in config file: {path}"
101
+ raise ConfigValidationError(
102
+ msg,
103
+ )
104
+ return PartialTqConfig()
105
+
106
+ if not isinstance(tq_section, dict):
107
+ msg = "[tool.tq] must be a table"
108
+ raise ConfigValidationError(msg)
109
+
110
+ unknown_keys = set(tq_section) - _CONFIG_KEYS
111
+ if unknown_keys:
112
+ keys = ", ".join(sorted(unknown_keys))
113
+ msg = f"Unknown [tool.tq] key(s): {keys}"
114
+ raise ConfigValidationError(msg)
115
+
116
+ return PartialTqConfig(
117
+ package=_expect_optional_str(tq_section, "package"),
118
+ source_root=_expect_optional_str(tq_section, "source_root"),
119
+ test_root=_expect_optional_str(tq_section, "test_root"),
120
+ ignore_init_modules=_expect_optional_bool(tq_section, "ignore_init_modules"),
121
+ max_test_file_non_blank_lines=_expect_optional_positive_int(
122
+ tq_section,
123
+ "max_test_file_non_blank_lines",
124
+ ),
125
+ qualifier_strategy=_expect_optional_qualifier_strategy(
126
+ tq_section,
127
+ "qualifier_strategy",
128
+ ),
129
+ allowed_qualifiers=_expect_optional_string_tuple(
130
+ tq_section,
131
+ "allowed_qualifiers",
132
+ ),
133
+ select=_expect_optional_rule_ids(tq_section, "select"),
134
+ ignore=_expect_optional_rule_ids(tq_section, "ignore"),
135
+ )
136
+
137
+
138
+ def _partial_from_cli(overrides: CliOverrides) -> PartialTqConfig:
139
+ """Convert CLI overrides into a partial config representation."""
140
+ return PartialTqConfig(
141
+ package=overrides.package,
142
+ source_root=overrides.source_root,
143
+ test_root=overrides.test_root,
144
+ ignore_init_modules=overrides.ignore_init_modules,
145
+ max_test_file_non_blank_lines=overrides.max_test_file_non_blank_lines,
146
+ qualifier_strategy=overrides.qualifier_strategy,
147
+ allowed_qualifiers=overrides.allowed_qualifiers,
148
+ select=overrides.select,
149
+ ignore=overrides.ignore,
150
+ )
151
+
152
+
153
+ def _merge_partial(base: PartialTqConfig, override: PartialTqConfig) -> PartialTqConfig:
154
+ """Merge two partial configs where ``override`` takes precedence."""
155
+ return PartialTqConfig(
156
+ package=override.package if override.package is not None else base.package,
157
+ source_root=(
158
+ override.source_root
159
+ if override.source_root is not None
160
+ else base.source_root
161
+ ),
162
+ test_root=(
163
+ override.test_root if override.test_root is not None else base.test_root
164
+ ),
165
+ ignore_init_modules=(
166
+ override.ignore_init_modules
167
+ if override.ignore_init_modules is not None
168
+ else base.ignore_init_modules
169
+ ),
170
+ max_test_file_non_blank_lines=(
171
+ override.max_test_file_non_blank_lines
172
+ if override.max_test_file_non_blank_lines is not None
173
+ else base.max_test_file_non_blank_lines
174
+ ),
175
+ qualifier_strategy=(
176
+ override.qualifier_strategy
177
+ if override.qualifier_strategy is not None
178
+ else base.qualifier_strategy
179
+ ),
180
+ allowed_qualifiers=(
181
+ override.allowed_qualifiers
182
+ if override.allowed_qualifiers is not None
183
+ else base.allowed_qualifiers
184
+ ),
185
+ select=override.select if override.select is not None else base.select,
186
+ ignore=override.ignore if override.ignore is not None else base.ignore,
187
+ )
188
+
189
+
190
+ def _materialize_config(*, cwd: Path, partial: PartialTqConfig) -> TqConfig:
191
+ """Validate and materialize a final runtime config."""
192
+ if not partial.package:
193
+ msg = "Missing required configuration key: tool.tq.package"
194
+ raise ConfigValidationError(
195
+ msg,
196
+ )
197
+ if not partial.source_root:
198
+ msg = "Missing required configuration key: tool.tq.source_root"
199
+ raise ConfigValidationError(
200
+ msg,
201
+ )
202
+ if not partial.test_root:
203
+ msg = "Missing required configuration key: tool.tq.test_root"
204
+ raise ConfigValidationError(
205
+ msg,
206
+ )
207
+
208
+ allowed_qualifiers = tuple(sorted(set(partial.allowed_qualifiers or ())))
209
+ qualifier_strategy = partial.qualifier_strategy or DEFAULT_QUALIFIER_STRATEGY
210
+
211
+ if qualifier_strategy is QualifierStrategy.ALLOWLIST and not allowed_qualifiers:
212
+ msg = (
213
+ "tool.tq.allowed_qualifiers must be non-empty when "
214
+ "tool.tq.qualifier_strategy is 'allowlist'"
215
+ )
216
+ raise ConfigValidationError(
217
+ msg,
218
+ )
219
+
220
+ source_root = _resolve_path(cwd=cwd, value=partial.source_root)
221
+ test_root = _resolve_path(cwd=cwd, value=partial.test_root)
222
+
223
+ return TqConfig(
224
+ package=partial.package,
225
+ source_root=source_root,
226
+ test_root=test_root,
227
+ ignore_init_modules=(
228
+ partial.ignore_init_modules
229
+ if partial.ignore_init_modules is not None
230
+ else DEFAULT_IGNORE_INIT_MODULES
231
+ ),
232
+ max_test_file_non_blank_lines=(
233
+ partial.max_test_file_non_blank_lines
234
+ if partial.max_test_file_non_blank_lines is not None
235
+ else DEFAULT_MAX_TEST_FILE_NON_BLANK_LINES
236
+ ),
237
+ qualifier_strategy=qualifier_strategy,
238
+ allowed_qualifiers=allowed_qualifiers,
239
+ select=partial.select or (),
240
+ ignore=partial.ignore or (),
241
+ )
242
+
243
+
244
+ def _resolve_path(*, cwd: Path, value: str) -> Path:
245
+ """Resolve a config path relative to cwd."""
246
+ candidate = Path(value)
247
+ if candidate.is_absolute():
248
+ return candidate
249
+ return (cwd / candidate).resolve()
250
+
251
+
252
+ def _expect_optional_str(document: dict[str, Any], key: str) -> str | None:
253
+ """Read optional non-empty string field from config document."""
254
+ value = document.get(key)
255
+ if value is None:
256
+ return None
257
+ if not isinstance(value, str):
258
+ msg = f"tool.tq.{key} must be a string"
259
+ raise ConfigValidationError(msg)
260
+ if not value.strip():
261
+ msg = f"tool.tq.{key} must be non-empty"
262
+ raise ConfigValidationError(msg)
263
+ return value
264
+
265
+
266
+ def _expect_optional_bool(document: dict[str, Any], key: str) -> bool | None:
267
+ """Read optional boolean field from config document."""
268
+ value = document.get(key)
269
+ if value is None:
270
+ return None
271
+ if not isinstance(value, bool):
272
+ msg = f"tool.tq.{key} must be a boolean"
273
+ raise ConfigValidationError(msg)
274
+ return value
275
+
276
+
277
+ def _expect_optional_positive_int(document: dict[str, Any], key: str) -> int | None:
278
+ """Read optional positive integer field from config document."""
279
+ value = document.get(key)
280
+ if value is None:
281
+ return None
282
+ if not isinstance(value, int):
283
+ msg = f"tool.tq.{key} must be an integer"
284
+ raise ConfigValidationError(msg)
285
+ if value < 1:
286
+ msg = f"tool.tq.{key} must be >= 1"
287
+ raise ConfigValidationError(msg)
288
+ return value
289
+
290
+
291
+ def _expect_optional_qualifier_strategy(
292
+ document: dict[str, Any],
293
+ key: str,
294
+ ) -> QualifierStrategy | None:
295
+ """Read optional qualifier strategy enum value from config document."""
296
+ value = document.get(key)
297
+ if value is None:
298
+ return None
299
+ if not isinstance(value, str):
300
+ msg = f"tool.tq.{key} must be a string"
301
+ raise ConfigValidationError(msg)
302
+
303
+ try:
304
+ return QualifierStrategy(value)
305
+ except ValueError as error:
306
+ choices = ", ".join(strategy.value for strategy in QualifierStrategy)
307
+ msg = f"tool.tq.{key} must be one of: {choices}"
308
+ raise ConfigValidationError(
309
+ msg,
310
+ ) from error
311
+
312
+
313
+ def _expect_optional_string_tuple(
314
+ document: dict[str, Any],
315
+ key: str,
316
+ ) -> tuple[str, ...] | None:
317
+ """Read optional list of non-empty strings from config document."""
318
+ value = document.get(key)
319
+ if value is None:
320
+ return None
321
+ if not isinstance(value, list):
322
+ msg = f"tool.tq.{key} must be an array of strings"
323
+ raise ConfigValidationError(msg)
324
+
325
+ items: list[str] = []
326
+ for item in value:
327
+ if not isinstance(item, str) or not item.strip():
328
+ msg = f"tool.tq.{key} must contain only non-empty strings"
329
+ raise ConfigValidationError(
330
+ msg,
331
+ )
332
+ items.append(item)
333
+
334
+ return tuple(items)
335
+
336
+
337
+ def _expect_optional_rule_ids(
338
+ document: dict[str, Any],
339
+ key: str,
340
+ ) -> tuple[RuleId, ...] | None:
341
+ """Read optional list of rule identifiers from config document."""
342
+ values = _expect_optional_string_tuple(document, key)
343
+ if values is None:
344
+ return None
345
+
346
+ rule_ids: list[RuleId] = []
347
+ for value in values:
348
+ try:
349
+ rule_ids.append(RuleId(value))
350
+ except ValueError as error:
351
+ msg = f"tool.tq.{key} contains invalid rule id: {value}"
352
+ raise ConfigValidationError(
353
+ msg,
354
+ ) from error
355
+
356
+ return tuple(rule_ids)