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 +1 -0
- tq/cli/__init__.py +1 -0
- tq/cli/main.py +283 -0
- tq/config/__init__.py +1 -0
- tq/config/loader.py +356 -0
- tq/config/models.py +70 -0
- tq/discovery/__init__.py +1 -0
- tq/discovery/filesystem.py +47 -0
- tq/discovery/index.py +57 -0
- tq/engine/__init__.py +1 -0
- tq/engine/context.py +45 -0
- tq/engine/models.py +107 -0
- tq/engine/rule_id.py +36 -0
- tq/engine/runner.py +82 -0
- tq/reporting/__init__.py +1 -0
- tq/reporting/json.py +56 -0
- tq/reporting/terminal.py +73 -0
- tq/rules/__init__.py +1 -0
- tq/rules/contracts.py +29 -0
- tq/rules/file_too_large.py +96 -0
- tq/rules/mapping_missing_test.py +127 -0
- tq/rules/orphaned_test.py +115 -0
- tq/rules/qualifiers.py +47 -0
- tq/rules/structure_mismatch.py +139 -0
- tqlint-0.4.2.dist-info/METADATA +119 -0
- tqlint-0.4.2.dist-info/RECORD +28 -0
- tqlint-0.4.2.dist-info/WHEEL +4 -0
- tqlint-0.4.2.dist-info/entry_points.txt +4 -0
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)
|