tqlint 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tqlint-0.4.2/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: tqlint
3
+ Version: 0.4.2
4
+ Summary: tq inspects a codebase's tests and enforces quality rules so tests remain discoverable, focused, actionable, and maintainable
5
+ Keywords: testing,lint,pytest,quality,developer-tools
6
+ Author: Stephen Lewis
7
+ Author-email: Stephen Lewis <31178492+stelewis@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: click>=8.3.1
22
+ Requires-Dist: pyyaml>=6.0.3
23
+ Requires-Dist: rich>=14.3.2
24
+ Requires-Python: >=3.11
25
+ Project-URL: Homepage, https://github.com/stelewis/tq
26
+ Project-URL: Repository, https://github.com/stelewis/tq
27
+ Project-URL: Issues, https://github.com/stelewis/tq/issues
28
+ Project-URL: Changelog, https://github.com/stelewis/tq/blob/main/CHANGELOG.md
29
+ Description-Content-Type: text/markdown
30
+
31
+ # `tq` - Test Quality Toolkit
32
+
33
+ `tq` inspects a codebase's tests and enforces quality rules so tests remain discoverable, focused, actionable, and maintainable.
34
+
35
+ ## Installation
36
+
37
+ PyPI distribution name: `tqlint`
38
+
39
+ Add to a project:
40
+
41
+ ```sh
42
+ uv add --dev tqlint
43
+ uv run tq check
44
+ ```
45
+
46
+ Run without installing (ephemeral):
47
+
48
+ ```sh
49
+ uvx tqlint check
50
+ ```
51
+
52
+ Install as a persistent global tool:
53
+
54
+ ```sh
55
+ uv tool install tqlint
56
+ tq check
57
+ ```
58
+
59
+ Note: `uvx tq check` is not available because the `tq` package name on PyPI is owned by another project.
60
+
61
+ ## Current scope
62
+
63
+ `tq` currently analyzes Python source and Python tests (`.py`) only.
64
+
65
+ ## Usage
66
+
67
+ Run checks:
68
+
69
+ ```sh
70
+ uv run tq check
71
+ ```
72
+
73
+ Emit machine-readable diagnostics:
74
+
75
+ ```sh
76
+ uv run tq check --output-format json
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ Configure `tq` in `pyproject.toml` under `[tool.tq]`:
82
+
83
+ ```toml
84
+ [tool.tq]
85
+ package = "tq"
86
+ source_root = "src"
87
+ test_root = "tests"
88
+ ignore_init_modules = true
89
+ max_test_file_non_blank_lines = 600
90
+ qualifier_strategy = "allowlist"
91
+ allowed_qualifiers = ["regression"]
92
+ ```
93
+
94
+ ## Documentation
95
+
96
+ - See [docs/developer/tools/tq_check.md](docs/developer/tools/tq_check.md) for tool usage and configuration details.
97
+ - See [docs/developer/tools/rules.md](docs/developer/tools/rules.md) for built-in rules.
98
+
99
+ ## Development
100
+
101
+ Contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md). For local development, follow below steps.
102
+
103
+ Install dependencies:
104
+
105
+ ```sh
106
+ uv sync
107
+ ```
108
+
109
+ Install pre-commit hooks (including `pre-commit`, `commit-msg`, and `pre-push`):
110
+
111
+ ```sh
112
+ uv run prek install
113
+ ```
114
+
115
+ Run tests:
116
+
117
+ ```sh
118
+ uv run pytest -q
119
+ ```
tqlint-0.4.2/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # `tq` - Test Quality Toolkit
2
+
3
+ `tq` inspects a codebase's tests and enforces quality rules so tests remain discoverable, focused, actionable, and maintainable.
4
+
5
+ ## Installation
6
+
7
+ PyPI distribution name: `tqlint`
8
+
9
+ Add to a project:
10
+
11
+ ```sh
12
+ uv add --dev tqlint
13
+ uv run tq check
14
+ ```
15
+
16
+ Run without installing (ephemeral):
17
+
18
+ ```sh
19
+ uvx tqlint check
20
+ ```
21
+
22
+ Install as a persistent global tool:
23
+
24
+ ```sh
25
+ uv tool install tqlint
26
+ tq check
27
+ ```
28
+
29
+ Note: `uvx tq check` is not available because the `tq` package name on PyPI is owned by another project.
30
+
31
+ ## Current scope
32
+
33
+ `tq` currently analyzes Python source and Python tests (`.py`) only.
34
+
35
+ ## Usage
36
+
37
+ Run checks:
38
+
39
+ ```sh
40
+ uv run tq check
41
+ ```
42
+
43
+ Emit machine-readable diagnostics:
44
+
45
+ ```sh
46
+ uv run tq check --output-format json
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ Configure `tq` in `pyproject.toml` under `[tool.tq]`:
52
+
53
+ ```toml
54
+ [tool.tq]
55
+ package = "tq"
56
+ source_root = "src"
57
+ test_root = "tests"
58
+ ignore_init_modules = true
59
+ max_test_file_non_blank_lines = 600
60
+ qualifier_strategy = "allowlist"
61
+ allowed_qualifiers = ["regression"]
62
+ ```
63
+
64
+ ## Documentation
65
+
66
+ - See [docs/developer/tools/tq_check.md](docs/developer/tools/tq_check.md) for tool usage and configuration details.
67
+ - See [docs/developer/tools/rules.md](docs/developer/tools/rules.md) for built-in rules.
68
+
69
+ ## Development
70
+
71
+ Contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md). For local development, follow below steps.
72
+
73
+ Install dependencies:
74
+
75
+ ```sh
76
+ uv sync
77
+ ```
78
+
79
+ Install pre-commit hooks (including `pre-commit`, `commit-msg`, and `pre-push`):
80
+
81
+ ```sh
82
+ uv run prek install
83
+ ```
84
+
85
+ Run tests:
86
+
87
+ ```sh
88
+ uv run pytest -q
89
+ ```
@@ -0,0 +1,153 @@
1
+ [project]
2
+ name = "tqlint"
3
+ version = "0.4.2"
4
+ description = "tq inspects a codebase's tests and enforces quality rules so tests remain discoverable, focused, actionable, and maintainable"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = ["testing", "lint", "pytest", "quality", "developer-tools"]
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Intended Audience :: Developers",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3 :: Only",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Software Development :: Quality Assurance",
19
+ "Topic :: Software Development :: Testing",
20
+ "Typing :: Typed",
21
+ ]
22
+ authors = [
23
+ { name = "Stephen Lewis", email = "31178492+stelewis@users.noreply.github.com" }
24
+ ]
25
+ requires-python = ">=3.11"
26
+ dependencies = [
27
+ "click>=8.3.1",
28
+ "pyyaml>=6.0.3",
29
+ "rich>=14.3.2",
30
+ ]
31
+
32
+ [project.scripts]
33
+ tq = "tq.cli.main:main"
34
+ tqlint = "tq.cli.main:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/stelewis/tq"
38
+ Repository = "https://github.com/stelewis/tq"
39
+ Issues = "https://github.com/stelewis/tq/issues"
40
+ Changelog = "https://github.com/stelewis/tq/blob/main/CHANGELOG.md"
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.10.4,<0.11.0"]
44
+ build-backend = "uv_build"
45
+
46
+ [tool.uv.build-backend]
47
+ module-name = "tq"
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "bandit>=1.9.3",
52
+ "commitizen>=4.9.1",
53
+ "detect-secrets>=1.5.0",
54
+ "pip-audit>=2.10.0",
55
+ "prek>=0.3.1",
56
+ "pytest>=9.0.2",
57
+ "pytest-mock>=3.15.1",
58
+ "ruff>=0.15.0",
59
+ "ty>=0.0.14",
60
+ ]
61
+
62
+ [tool.ruff]
63
+ line-length = 88
64
+ src = ["src"]
65
+ extend-exclude = ["tmp"]
66
+
67
+ [tool.ruff.format]
68
+ quote-style = "double"
69
+ indent-style = "space"
70
+ docstring-code-format = true
71
+
72
+ [tool.ruff.lint]
73
+ select = ["ALL"]
74
+ ignore = ["COM812"] # Per docs guidance: recommended to ignore when using ruff format as formatter enforces consistent use of trailing commas
75
+
76
+ [tool.ruff.lint.flake8-tidy-imports]
77
+ ban-relative-imports = "all"
78
+
79
+ [tool.ruff.lint.per-file-ignores]
80
+ "tests/**/*.py" = ["S101", "PLR2004"]
81
+
82
+ [tool.ruff.lint.pylint]
83
+ max-returns = 10
84
+
85
+ [tool.pytest.ini_options]
86
+ addopts = [
87
+ "--import-mode=importlib",
88
+ "-m",
89
+ "not integration and not e2e",
90
+ ]
91
+ testpaths = ["tests"]
92
+ markers = [
93
+ "e2e: mark end-to-end tests",
94
+ "golden: deterministic golden/snapshot fixture tests (offline)",
95
+ "integration: mark a test as an integration test",
96
+ "regression: mark tests that exercise known regressions",
97
+ "smoke: mark quick smoke tests",
98
+ "slow: mark slow tests that may be skipped in fast CI jobs",
99
+ ]
100
+
101
+ [tool.ruff.lint.pydocstyle]
102
+ convention = "google"
103
+
104
+ [tool.commitizen]
105
+ name = "cz_conventional_commits"
106
+ version_provider = "uv"
107
+ version_scheme = "semver"
108
+ tag_format = "$version"
109
+ update_changelog_on_bump = false
110
+ changelog_file = "CHANGELOG.md"
111
+ template = "CHANGELOG.md.j2"
112
+ change_type_order = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
113
+ gpg_sign = true
114
+ major_version_zero = true
115
+
116
+ [tool.commitizen.change_type_map]
117
+ feat = "Added"
118
+ fix = "Fixed"
119
+ perf = "Changed"
120
+ refactor = "Changed"
121
+ docs = "Changed"
122
+ chore = "Changed"
123
+ build = "Changed"
124
+ ci = "Changed"
125
+ test = "Changed"
126
+ style = "Changed"
127
+ revert = "Changed"
128
+ "breaking change" = "Changed"
129
+ "BREAKING CHANGE" = "Changed"
130
+
131
+ [tool.bandit]
132
+ exclude_dirs = [
133
+ "tests",
134
+ ]
135
+
136
+ [tool.ty.src]
137
+ exclude = [
138
+ "tmp/scripts/**",
139
+ ]
140
+
141
+ [tool.tq]
142
+ source_root = "src"
143
+ test_root = "tests"
144
+ package = "tq"
145
+ ignore_init_modules = true
146
+ max_test_file_non_blank_lines = 600
147
+ qualifier_strategy = "allowlist"
148
+ allowed_qualifiers = [
149
+ "fixtures_golden",
150
+ "fixtures_synthetic",
151
+ "regression",
152
+ "config",
153
+ ]
@@ -0,0 +1 @@
1
+ """tq package."""
@@ -0,0 +1 @@
1
+ """CLI package for tq command surface."""
@@ -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()
@@ -0,0 +1 @@
1
+ """Configuration domain for tq CLI and engine composition."""