testmap 0.2.0__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.
- testmap-0.2.0/PKG-INFO +14 -0
- testmap-0.2.0/README.md +5 -0
- testmap-0.2.0/pyproject.toml +15 -0
- testmap-0.2.0/src/testmap/__init__.py +1 -0
- testmap-0.2.0/src/testmap/__main__.py +6 -0
- testmap-0.2.0/src/testmap/cli.py +53 -0
- testmap-0.2.0/src/testmap/discover.py +73 -0
- testmap-0.2.0/src/testmap/report.py +142 -0
testmap-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: testmap
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Understand your test suite: a feature x test-kind validation matrix.
|
|
5
|
+
Author: Tyler Riccio
|
|
6
|
+
Author-email: Tyler Riccio <tylerriccio8@gmail.com>
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# testmap
|
|
11
|
+
|
|
12
|
+
Standalone core + CLI for testmap. Ingests test metadata (emitted by
|
|
13
|
+
`pytest-testmap`) and renders the feature x test-kind matrix and the report of
|
|
14
|
+
required-but-missing test kinds.
|
testmap-0.2.0/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "testmap"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Understand your test suite: a feature x test-kind validation matrix."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Tyler Riccio", email = "tylerriccio8@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.14"
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[project.scripts]
|
|
11
|
+
testmap = "testmap.cli:main"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["uv_build>=0.11.18,<0.12.0"]
|
|
15
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""testmap: a feature x test-kind validation matrix for your test suite."""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""`testmap report` — render the matrix, discovering tagged tests or ingesting JSON."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from testmap.discover import discover
|
|
9
|
+
from testmap.report import build_report, load_config, render
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _default_paths(pyproject: Path) -> list[Path]:
|
|
13
|
+
"""Testpaths from `[tool.pytest.ini_options]`, falling back to the cwd."""
|
|
14
|
+
if pyproject.is_file():
|
|
15
|
+
ini = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
16
|
+
testpaths = ini.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("testpaths")
|
|
17
|
+
if testpaths:
|
|
18
|
+
return [Path(p) for p in testpaths]
|
|
19
|
+
return [Path(".")]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_records(path: Path | None, config_path: Path) -> list[dict[str, str]]:
|
|
23
|
+
"""A JSON file is ingested as-is; a directory (or nothing) is scanned for @testmap."""
|
|
24
|
+
if path is not None and path.is_file() and path.suffix == ".json":
|
|
25
|
+
return json.loads(path.read_text(encoding="utf-8"))["tests"]
|
|
26
|
+
paths = [path] if path is not None else _default_paths(config_path)
|
|
27
|
+
return discover(paths)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
parser = argparse.ArgumentParser(prog="testmap")
|
|
32
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
report = sub.add_parser("report", help="render a testmap report from source or a JSON file")
|
|
34
|
+
report.add_argument(
|
|
35
|
+
"path",
|
|
36
|
+
type=Path,
|
|
37
|
+
nargs="?",
|
|
38
|
+
help="a JSON file emitted by --testmap-json, or a dir to scan "
|
|
39
|
+
"(default: scan testpaths from pyproject.toml)",
|
|
40
|
+
)
|
|
41
|
+
report.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
42
|
+
report.add_argument(
|
|
43
|
+
"--config",
|
|
44
|
+
type=Path,
|
|
45
|
+
default=Path("pyproject.toml"),
|
|
46
|
+
help="pyproject.toml holding [tool.testmap] (default: ./pyproject.toml)",
|
|
47
|
+
)
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
|
|
50
|
+
config = load_config(args.config)
|
|
51
|
+
tests = _load_records(args.path, args.config)
|
|
52
|
+
result = build_report(tests, config)
|
|
53
|
+
print(json.dumps(result, indent=2) if args.json else render(result, config))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Discover `@testmap`-tagged tests by scanning source, no pytest required.
|
|
2
|
+
|
|
3
|
+
The pytest plugin collects the same `{feature, kind}` records at collection time.
|
|
4
|
+
This module reads them statically instead: it parses each Python file and pulls
|
|
5
|
+
the `feature`/`kind` arguments off every `@testmap(...)` (or
|
|
6
|
+
`@pytest.mark.testmap(...)`) decorator, so `testmap report` can build the matrix
|
|
7
|
+
straight from the codebase.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _string_arg(call: ast.Call, name: str) -> str | None:
|
|
17
|
+
for kw in call.keywords:
|
|
18
|
+
if kw.arg == name and isinstance(kw.value, ast.Constant):
|
|
19
|
+
value = kw.value.value
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
return value
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_testmap_decorator(node: ast.expr) -> ast.Call | None:
|
|
26
|
+
"""Return the decorator Call if it targets a `testmap` marker, else None."""
|
|
27
|
+
if not isinstance(node, ast.Call):
|
|
28
|
+
return None
|
|
29
|
+
func = node.func
|
|
30
|
+
# `@testmap(...)` (bare import) or `@pytest.mark.testmap(...)` / `@x.testmap(...)`.
|
|
31
|
+
if isinstance(func, ast.Name) and func.id == "testmap":
|
|
32
|
+
return node
|
|
33
|
+
if isinstance(func, ast.Attribute) and func.attr == "testmap":
|
|
34
|
+
return node
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _records_from_file(path: Path, root: Path) -> list[dict[str, str]]:
|
|
39
|
+
try:
|
|
40
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
41
|
+
except SyntaxError, UnicodeDecodeError:
|
|
42
|
+
return [] # not parseable Python; nothing to collect
|
|
43
|
+
|
|
44
|
+
records: list[dict[str, str]] = []
|
|
45
|
+
rel = os.path.relpath(path, root)
|
|
46
|
+
for node in ast.walk(tree):
|
|
47
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
48
|
+
continue
|
|
49
|
+
for decorator in node.decorator_list:
|
|
50
|
+
call = _is_testmap_decorator(decorator)
|
|
51
|
+
if call is None:
|
|
52
|
+
continue
|
|
53
|
+
feature = _string_arg(call, "feature")
|
|
54
|
+
kind = _string_arg(call, "kind")
|
|
55
|
+
if feature is None or kind is None:
|
|
56
|
+
raise ValueError(f"{rel}::{node.name}: @testmap requires string feature and kind")
|
|
57
|
+
records.append({"nodeid": f"{rel}::{node.name}", "feature": feature, "kind": kind})
|
|
58
|
+
return records
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def discover(paths: Iterable[Path], root: Path | None = None) -> list[dict[str, str]]:
|
|
62
|
+
"""Scan `paths` (files or directories) for `@testmap`-tagged tests.
|
|
63
|
+
|
|
64
|
+
`root` anchors the reported nodeids (defaults to the current directory).
|
|
65
|
+
Records are sorted by nodeid so the output is stable across runs.
|
|
66
|
+
"""
|
|
67
|
+
root = (root or Path.cwd()).resolve()
|
|
68
|
+
records: list[dict[str, str]] = []
|
|
69
|
+
for path in paths:
|
|
70
|
+
files = sorted(path.rglob("*.py")) if path.is_dir() else [path]
|
|
71
|
+
for file in files:
|
|
72
|
+
records.extend(_records_from_file(file, root))
|
|
73
|
+
return sorted(records, key=lambda r: r["nodeid"])
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Core of testmap: load config, aggregate test records, render the report.
|
|
2
|
+
|
|
3
|
+
The same renderer is used by the standalone CLI and by the pytest plugin, so the
|
|
4
|
+
terminal output is identical no matter how the metadata was produced.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# The Status column symbols, used when [tool.testmap.statuses] is absent. At
|
|
12
|
+
# least one of each state so the table renders without any config.
|
|
13
|
+
DEFAULT_STATUSES = {"complete": "✓", "incomplete": "✗"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Config:
|
|
18
|
+
"""The kind taxonomy and which kinds each feature must have."""
|
|
19
|
+
|
|
20
|
+
kinds: list[str]
|
|
21
|
+
required: list[str] # global default required kinds
|
|
22
|
+
features: dict[str, list[str]] # per-feature required-kind overrides
|
|
23
|
+
excludes: dict[str, list[str]] # per-feature kinds dropped from requirements
|
|
24
|
+
statuses: dict[str, str] # Status-column symbol per state (complete/incomplete)
|
|
25
|
+
|
|
26
|
+
def excluded_for(self, feature: str) -> list[str]:
|
|
27
|
+
return self.excludes.get(feature, [])
|
|
28
|
+
|
|
29
|
+
def required_for(self, feature: str) -> list[str]:
|
|
30
|
+
# Start from the feature's override (or the global default), then drop
|
|
31
|
+
# any kinds this feature opts out of, e.g. a feature that needs no perf.
|
|
32
|
+
base = self.features.get(feature, self.required)
|
|
33
|
+
excluded = self.excludes.get(feature, ())
|
|
34
|
+
return [k for k in base if k not in excluded]
|
|
35
|
+
|
|
36
|
+
def status_symbol(self, complete: bool) -> str:
|
|
37
|
+
return self.statuses["complete" if complete else "incomplete"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(pyproject: Path) -> Config:
|
|
41
|
+
"""Load `[tool.testmap]` from a pyproject.toml.
|
|
42
|
+
|
|
43
|
+
A `[tool.testmap.features]` entry is either a list (the required kinds for
|
|
44
|
+
that feature) or a table taking `required` and/or `exclude`; excluded kinds
|
|
45
|
+
are dropped from that feature's requirements.
|
|
46
|
+
|
|
47
|
+
Invariant: every referenced kind (global, per-feature, excluded) is one of
|
|
48
|
+
`kinds`; a stray kind is a config bug, so we raise rather than silently drop it.
|
|
49
|
+
"""
|
|
50
|
+
tool = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {})
|
|
51
|
+
if "testmap" not in tool:
|
|
52
|
+
raise ValueError(f"no [tool.testmap] configuration in {pyproject}")
|
|
53
|
+
data = tool["testmap"]
|
|
54
|
+
kinds: list[str] = data["kinds"]
|
|
55
|
+
required: list[str] = data.get("required", kinds)
|
|
56
|
+
|
|
57
|
+
features: dict[str, list[str]] = {}
|
|
58
|
+
excludes: dict[str, list[str]] = {}
|
|
59
|
+
for name, entry in data.get("features", {}).items():
|
|
60
|
+
if isinstance(entry, dict):
|
|
61
|
+
if "required" in entry:
|
|
62
|
+
features[name] = entry["required"]
|
|
63
|
+
if entry.get("exclude"):
|
|
64
|
+
excludes[name] = entry["exclude"]
|
|
65
|
+
else:
|
|
66
|
+
features[name] = entry
|
|
67
|
+
|
|
68
|
+
referenced = {"required": required, **features, **excludes}
|
|
69
|
+
for name, kind_list in referenced.items():
|
|
70
|
+
unknown = sorted(set(kind_list) - set(kinds))
|
|
71
|
+
if unknown:
|
|
72
|
+
raise ValueError(f"[tool.testmap] {name} references unknown kinds {unknown}")
|
|
73
|
+
# Merge configured status symbols over the defaults so a partial table (e.g.
|
|
74
|
+
# only overriding "complete") keeps a symbol for the other state.
|
|
75
|
+
statuses = {**DEFAULT_STATUSES, **data.get("statuses", {})}
|
|
76
|
+
unknown_states = sorted(set(statuses) - set(DEFAULT_STATUSES))
|
|
77
|
+
if unknown_states:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"[tool.testmap.statuses] unknown states {unknown_states} "
|
|
80
|
+
f"(valid: {sorted(DEFAULT_STATUSES)})"
|
|
81
|
+
)
|
|
82
|
+
return Config(
|
|
83
|
+
kinds=kinds, required=required, features=features, excludes=excludes, statuses=statuses
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_report(tests: list[dict[str, str]], config: Config) -> dict:
|
|
88
|
+
"""Aggregate `{feature, kind}` records into the feature x kind matrix.
|
|
89
|
+
|
|
90
|
+
Raises on a kind not declared in `config.kinds` (no silent fallback). Only
|
|
91
|
+
features with at least one test appear; a feature is complete when none of
|
|
92
|
+
its required kinds are missing.
|
|
93
|
+
"""
|
|
94
|
+
matrix: dict[str, dict[str, int]] = {}
|
|
95
|
+
for test in tests:
|
|
96
|
+
feature, kind = test["feature"], test["kind"]
|
|
97
|
+
if kind not in config.kinds:
|
|
98
|
+
raise ValueError(f"unknown test kind {kind!r} (declared kinds: {config.kinds})")
|
|
99
|
+
matrix.setdefault(feature, {k: 0 for k in config.kinds})[kind] += 1
|
|
100
|
+
|
|
101
|
+
features = {}
|
|
102
|
+
for feature in sorted(matrix):
|
|
103
|
+
counts = matrix[feature]
|
|
104
|
+
missing = [k for k in config.required_for(feature) if counts[k] == 0]
|
|
105
|
+
excluded = [k for k in config.kinds if k in config.excluded_for(feature)]
|
|
106
|
+
features[feature] = {
|
|
107
|
+
"counts": counts,
|
|
108
|
+
"missing": missing,
|
|
109
|
+
"excluded": excluded,
|
|
110
|
+
"complete": not missing,
|
|
111
|
+
}
|
|
112
|
+
return {"features": features}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def render(report: dict, config: Config) -> str:
|
|
116
|
+
"""Render the report as the feature x kind table plus the Missing section."""
|
|
117
|
+
headers = ["Feature", *(k.capitalize() for k in config.kinds), "Status"]
|
|
118
|
+
rows = [headers]
|
|
119
|
+
for feature, data in report["features"].items():
|
|
120
|
+
status = config.status_symbol(data["complete"])
|
|
121
|
+
# Excluded kinds render as n/a so a blank isn't mistaken for a gap.
|
|
122
|
+
cells = ["n/a" if k in data["excluded"] else str(data["counts"][k]) for k in config.kinds]
|
|
123
|
+
rows.append([feature, *cells, status])
|
|
124
|
+
|
|
125
|
+
widths = [max(len(row[i]) for row in rows) for i in range(len(headers))]
|
|
126
|
+
|
|
127
|
+
def fmt(cells: list[str]) -> str:
|
|
128
|
+
# Feature left-justified; counts and status right-justified for a clean grid.
|
|
129
|
+
return " ".join(
|
|
130
|
+
[cells[0].ljust(widths[0])] + [c.rjust(widths[i]) for i, c in enumerate(cells[1:], 1)]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
lines = [fmt(headers), "-" * len(fmt(headers)), *(fmt(row) for row in rows[1:])]
|
|
134
|
+
|
|
135
|
+
missing = [
|
|
136
|
+
f" • {feature}: {kind}"
|
|
137
|
+
for feature, data in report["features"].items()
|
|
138
|
+
for kind in data["missing"]
|
|
139
|
+
]
|
|
140
|
+
if missing:
|
|
141
|
+
lines += ["", "Missing:", *missing]
|
|
142
|
+
return "\n".join(lines)
|