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 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.
@@ -0,0 +1,5 @@
1
+ # testmap
2
+
3
+ Standalone core + CLI for testmap. Ingests test metadata (emitted by
4
+ `pytest-testmap`) and renders the feature x test-kind matrix and the report of
5
+ required-but-missing test kinds.
@@ -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,6 @@
1
+ """Enable `python -m testmap` (and `uvx testmap`) to run the CLI."""
2
+
3
+ from testmap.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)