graphcheck 0.1.0__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.
- graphcheck/__init__.py +9 -0
- graphcheck/application/__init__.py +0 -0
- graphcheck/application/artifacts.py +145 -0
- graphcheck/application/paths.py +6 -0
- graphcheck/application/run.py +233 -0
- graphcheck/application/suites.py +44 -0
- graphcheck/baselines.py +228 -0
- graphcheck/bootstrap.py +19 -0
- graphcheck/cli.py +2339 -0
- graphcheck/connection_profiles.py +119 -0
- graphcheck/contracts/__init__.py +0 -0
- graphcheck/contracts/check.py +258 -0
- graphcheck/contracts/profile.py +290 -0
- graphcheck/contracts/results.py +409 -0
- graphcheck/contracts/scalars.py +23 -0
- graphcheck/contracts/schemas.py +140 -0
- graphcheck/debug_diagnostics.py +155 -0
- graphcheck/diff.py +420 -0
- graphcheck/engine/__init__.py +64 -0
- graphcheck/engine/baseline.py +262 -0
- graphcheck/engine/compiler.py +577 -0
- graphcheck/engine/core_pack.py +681 -0
- graphcheck/engine/evaluator.py +982 -0
- graphcheck/engine/executor.py +192 -0
- graphcheck/engine/identifiers.py +25 -0
- graphcheck/engine/parameters.py +99 -0
- graphcheck/engine/pii_pack.py +318 -0
- graphcheck/engine/runner.py +1798 -0
- graphcheck/engine/sampling.py +316 -0
- graphcheck/errors.py +69 -0
- graphcheck/generation/__init__.py +1 -0
- graphcheck/generation/client.py +383 -0
- graphcheck/generation/config.py +100 -0
- graphcheck/generation/disclosure.py +115 -0
- graphcheck/generation/prompts.py +112 -0
- graphcheck/generation/proposals.py +271 -0
- graphcheck/generation/service.py +379 -0
- graphcheck/generation/transmission.py +311 -0
- graphcheck/generation/writer.py +116 -0
- graphcheck/mcp/adapter.py +133 -0
- graphcheck/mcp/server.py +62 -0
- graphcheck/neo4j_adapter.py +1431 -0
- graphcheck/observability/__init__.py +13 -0
- graphcheck/observability/collector.py +31 -0
- graphcheck/observability/health.py +69 -0
- graphcheck/observability/metrics.py +34 -0
- graphcheck/observability/runner.py +28 -0
- graphcheck/observability/server.py +18 -0
- graphcheck/packs/__init__.py +213 -0
- graphcheck/packs/catalog.py +106 -0
- graphcheck/packs/core.yml +113 -0
- graphcheck/packs/metadata.py +329 -0
- graphcheck/packs/pii.yml +77 -0
- graphcheck/profiler.py +822 -0
- graphcheck/project.py +115 -0
- graphcheck/reporting/__init__.py +41 -0
- graphcheck/reporting/explorer.py +372 -0
- graphcheck/reporting/history.py +527 -0
- graphcheck/reporting/html.py +2239 -0
- graphcheck/reporting/presentation.py +156 -0
- graphcheck/reporting/redaction.py +321 -0
- graphcheck/reporting/writer.py +97 -0
- graphcheck/scoring.py +156 -0
- graphcheck/telemetry/__init__.py +28 -0
- graphcheck/telemetry/collector.py +296 -0
- graphcheck/telemetry/consent.py +208 -0
- graphcheck/telemetry/events.py +417 -0
- graphcheck/telemetry/inactive.py +130 -0
- graphcheck/telemetry/policy.py +769 -0
- graphcheck/telemetry/posthog.py +253 -0
- graphcheck/telemetry/release.py +10 -0
- graphcheck/telemetry/runtime.py +375 -0
- graphcheck/telemetry/types.py +222 -0
- graphcheck/yaml_loader.py +43 -0
- graphcheck-0.1.0.dist-info/METADATA +187 -0
- graphcheck-0.1.0.dist-info/RECORD +79 -0
- graphcheck-0.1.0.dist-info/WHEEL +4 -0
- graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
- graphcheck-0.1.0.dist-info/licenses/LICENSE +202 -0
graphcheck/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""GraphCheck — semantic observability for property graphs."""
|
|
2
|
+
|
|
3
|
+
# Single source of truth for the version. `pyproject.toml` declares the version as dynamic and
|
|
4
|
+
# reads it from here at build time (see `[tool.hatch.version]`), so the built distribution and
|
|
5
|
+
# `--version` always agree without a second literal or a runtime metadata lookup (the latter is
|
|
6
|
+
# slow enough on Windows to breach the cold-start budget for the `--version` fast path).
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__"]
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from filelock import FileLock
|
|
12
|
+
|
|
13
|
+
from graphcheck.contracts.results import Results
|
|
14
|
+
|
|
15
|
+
RenderObserver = Callable[[int, bool], None]
|
|
16
|
+
RenderedArtifacts = tuple[bytes, bytes, bytes]
|
|
17
|
+
|
|
18
|
+
# The `latest` alias is the only artifact target multiple runs contend for (historical run
|
|
19
|
+
# directories are unique per run id). MCP 2.0 dispatches synchronous tools through worker
|
|
20
|
+
# threads, so two run_suite calls can publish concurrently within one process; separate CLI
|
|
21
|
+
# processes can also publish at once. This in-process thread lock is shared by every code
|
|
22
|
+
# path that publishes `latest`.
|
|
23
|
+
_LATEST_PUBLISH_LOCK = threading.Lock()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def latest_publication_lock(runs_dir: Path) -> Iterator[None]:
|
|
28
|
+
"""Serialize publication of the shared `latest` alias across threads and processes.
|
|
29
|
+
|
|
30
|
+
Every writer that swaps `<runs_dir>/latest` must hold this lock so the exists/move/swap
|
|
31
|
+
sequence in publish_run_directory can never interleave with another publisher.
|
|
32
|
+
"""
|
|
33
|
+
file_lock = FileLock(str(runs_dir / ".latest.lock"))
|
|
34
|
+
with _LATEST_PUBLISH_LOCK, file_lock:
|
|
35
|
+
yield
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_run_artifacts(
|
|
39
|
+
results: Results,
|
|
40
|
+
*,
|
|
41
|
+
render_observer: RenderObserver | None = None,
|
|
42
|
+
) -> RenderedArtifacts:
|
|
43
|
+
"""Render the results.json, report.html, and summary.json bytes exactly once.
|
|
44
|
+
|
|
45
|
+
Rendering once and publishing the bytes to both the history directory and `latest`
|
|
46
|
+
keeps the two directories byte-identical and avoids re-rendering the HTML report twice.
|
|
47
|
+
"""
|
|
48
|
+
from graphcheck.reporting.history import report_summary_json
|
|
49
|
+
from graphcheck.reporting.html import render_validated_html_report
|
|
50
|
+
from graphcheck.reporting.writer import validated_results_json
|
|
51
|
+
|
|
52
|
+
model, rendered_json = validated_results_json(results)
|
|
53
|
+
|
|
54
|
+
render_started = time.monotonic()
|
|
55
|
+
try:
|
|
56
|
+
rendered_html = render_validated_html_report(model)
|
|
57
|
+
except Exception:
|
|
58
|
+
if render_observer is not None:
|
|
59
|
+
render_observer(max(0, round((time.monotonic() - render_started) * 1000)), False)
|
|
60
|
+
raise
|
|
61
|
+
if render_observer is not None:
|
|
62
|
+
render_observer(max(0, round((time.monotonic() - render_started) * 1000)), True)
|
|
63
|
+
|
|
64
|
+
rendered_summary = report_summary_json(model)
|
|
65
|
+
return (
|
|
66
|
+
rendered_json.encode("utf-8"),
|
|
67
|
+
rendered_html.encode("utf-8"),
|
|
68
|
+
rendered_summary.encode("utf-8"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_run_artifacts(
|
|
73
|
+
results: Results,
|
|
74
|
+
runs_dir: Path,
|
|
75
|
+
*,
|
|
76
|
+
render_observer: RenderObserver | None = None,
|
|
77
|
+
) -> tuple[Path, Path]:
|
|
78
|
+
"""Publish a run's history directory and refresh the shared `latest` alias.
|
|
79
|
+
|
|
80
|
+
This is the single artifact writer used by both `graphcheck run` and the MCP server
|
|
81
|
+
(through execute_run), so every surface produces identical artifacts: a report_name-based
|
|
82
|
+
history id, an atomically swapped results/report/summary triple, and a serialized `latest`
|
|
83
|
+
refresh.
|
|
84
|
+
"""
|
|
85
|
+
from graphcheck.reporting.history import report_name
|
|
86
|
+
|
|
87
|
+
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
resolved_runs = runs_dir.resolve()
|
|
89
|
+
results.run.id = report_name(results)
|
|
90
|
+
historical_dir = runs_dir / results.run.id
|
|
91
|
+
if (
|
|
92
|
+
historical_dir.name.casefold() == "latest"
|
|
93
|
+
or historical_dir.resolve().parent != resolved_runs
|
|
94
|
+
):
|
|
95
|
+
raise ValueError(f"run id cannot be used as an artifact directory: {results.run.id!r}")
|
|
96
|
+
|
|
97
|
+
artifacts = render_run_artifacts(results, render_observer=render_observer)
|
|
98
|
+
publish_run_directory(artifacts, historical_dir)
|
|
99
|
+
|
|
100
|
+
latest_dir = runs_dir / "latest"
|
|
101
|
+
with latest_publication_lock(runs_dir):
|
|
102
|
+
publish_run_directory(artifacts, latest_dir)
|
|
103
|
+
return latest_dir / "results.json", latest_dir / "report.html"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def publish_run_directory(artifacts: RenderedArtifacts, directory: Path) -> None:
|
|
107
|
+
"""Stage and swap a complete results/report/summary triple without exposing a mixed set."""
|
|
108
|
+
|
|
109
|
+
parent = directory.parent
|
|
110
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
token = uuid.uuid4().hex
|
|
112
|
+
staging = parent / f".{directory.name}.staging-{token}"
|
|
113
|
+
backup = parent / f".{directory.name}.backup-{token}"
|
|
114
|
+
staging.mkdir()
|
|
115
|
+
previous_moved = False
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
for name, content in zip(
|
|
119
|
+
("results.json", "report.html", "summary.json"), artifacts, strict=True
|
|
120
|
+
):
|
|
121
|
+
(staging / name).write_bytes(content)
|
|
122
|
+
|
|
123
|
+
if directory.exists():
|
|
124
|
+
is_junction = getattr(directory, "is_junction", lambda: False)
|
|
125
|
+
if not directory.is_dir() or directory.is_symlink() or is_junction():
|
|
126
|
+
raise OSError(f"refusing to replace linked or non-directory artifact: {directory}")
|
|
127
|
+
directory.replace(backup)
|
|
128
|
+
previous_moved = True
|
|
129
|
+
|
|
130
|
+
staging.replace(directory)
|
|
131
|
+
|
|
132
|
+
except Exception:
|
|
133
|
+
if previous_moved and backup.exists():
|
|
134
|
+
if directory.exists():
|
|
135
|
+
shutil.rmtree(directory)
|
|
136
|
+
backup.replace(directory)
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
else:
|
|
140
|
+
if backup.exists():
|
|
141
|
+
shutil.rmtree(backup)
|
|
142
|
+
|
|
143
|
+
finally:
|
|
144
|
+
if staging.exists():
|
|
145
|
+
shutil.rmtree(staging)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from graphcheck.application.artifacts import (
|
|
11
|
+
RenderObserver,
|
|
12
|
+
write_run_artifacts,
|
|
13
|
+
)
|
|
14
|
+
from graphcheck.application.paths import project_path
|
|
15
|
+
from graphcheck.application.suites import load_suite_inputs
|
|
16
|
+
from graphcheck.connection_profiles import (
|
|
17
|
+
load_profiles,
|
|
18
|
+
select_profile,
|
|
19
|
+
)
|
|
20
|
+
from graphcheck.contracts.results import CheckError, Results
|
|
21
|
+
from graphcheck.engine import (
|
|
22
|
+
DirectoryBaselineProvider,
|
|
23
|
+
Engine,
|
|
24
|
+
EngineConfig,
|
|
25
|
+
failed_results,
|
|
26
|
+
)
|
|
27
|
+
from graphcheck.errors import GraphCheckError
|
|
28
|
+
from graphcheck.neo4j_adapter import Neo4jClient
|
|
29
|
+
from graphcheck.project import (
|
|
30
|
+
find_project_root,
|
|
31
|
+
load_project_config,
|
|
32
|
+
)
|
|
33
|
+
from graphcheck.telemetry.events import EngineEventSink
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class RunRequest:
|
|
38
|
+
profile: str | None
|
|
39
|
+
suite_ids: list[str]
|
|
40
|
+
tags: list[str]
|
|
41
|
+
fail_fast: bool
|
|
42
|
+
concurrency: int | None = None
|
|
43
|
+
verify_read_only_credential: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class RunOutcome:
|
|
48
|
+
results: Results
|
|
49
|
+
results_path: Path | None
|
|
50
|
+
report_path: Path | None
|
|
51
|
+
artifact_error: Exception | None = None
|
|
52
|
+
# `time.monotonic()` boundaries so a caller can attribute setup versus
|
|
53
|
+
# artifact-write time correctly. `setup_done_perf` is stamped once profile,
|
|
54
|
+
# client, credential, and suite setup finish (before the engine runs);
|
|
55
|
+
# `artifact_started_perf` is stamped immediately before artifacts are written.
|
|
56
|
+
setup_done_perf: float | None = None
|
|
57
|
+
artifact_started_perf: float | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def execute_run(
|
|
61
|
+
request: RunRequest,
|
|
62
|
+
*,
|
|
63
|
+
progress_callback: Callable[[int, int, str], None] | None = None,
|
|
64
|
+
event_sink: EngineEventSink | None = None,
|
|
65
|
+
render_observer: RenderObserver | None = None,
|
|
66
|
+
client_factory: Callable[[object, int], Neo4jClient] | None = None,
|
|
67
|
+
artifact_writer: Callable[..., tuple[Path, Path]] = write_run_artifacts,
|
|
68
|
+
target_observer: Callable[[object], None] | None = None,
|
|
69
|
+
) -> RunOutcome:
|
|
70
|
+
"""
|
|
71
|
+
Execute a GraphCheck run independently of the CLI or MCP.
|
|
72
|
+
"""
|
|
73
|
+
root = find_project_root()
|
|
74
|
+
config = load_project_config(root)
|
|
75
|
+
artifacts = project_path(root, config.artifacts)
|
|
76
|
+
runs_dir = artifacts / "runs"
|
|
77
|
+
|
|
78
|
+
checks_dir = project_path(root, config.checks)
|
|
79
|
+
profiles = load_profiles(root)
|
|
80
|
+
_, selected_profile = select_profile(
|
|
81
|
+
profiles,
|
|
82
|
+
request.profile,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
client: Neo4jClient | None = None
|
|
86
|
+
setup_done_perf: float | None = None
|
|
87
|
+
engine_started = False
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
max_concurrency = request.concurrency or int(config.concurrency)
|
|
91
|
+
|
|
92
|
+
factory = client_factory or _new_neo4j_client
|
|
93
|
+
client = factory(
|
|
94
|
+
selected_profile,
|
|
95
|
+
max_concurrency,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if request.verify_read_only_credential:
|
|
99
|
+
target = _verify_cli_audit_credential(client)
|
|
100
|
+
if target_observer is not None:
|
|
101
|
+
target_observer(target)
|
|
102
|
+
|
|
103
|
+
suite_inputs = load_suite_inputs(
|
|
104
|
+
checks_dir,
|
|
105
|
+
request.suite_ids,
|
|
106
|
+
)
|
|
107
|
+
setup_done_perf = time.monotonic()
|
|
108
|
+
engine = Engine(
|
|
109
|
+
client,
|
|
110
|
+
baselines=DirectoryBaselineProvider(
|
|
111
|
+
artifacts / "baselines",
|
|
112
|
+
),
|
|
113
|
+
config=EngineConfig(max_concurrency=max_concurrency),
|
|
114
|
+
progress_callback=progress_callback,
|
|
115
|
+
event_sink=event_sink,
|
|
116
|
+
)
|
|
117
|
+
engine_started = True
|
|
118
|
+
results = engine.run(
|
|
119
|
+
suite_inputs,
|
|
120
|
+
tags=request.tags,
|
|
121
|
+
fail_fast=request.fail_fast,
|
|
122
|
+
selection_suites=request.suite_ids or None,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
except GraphCheckError as exc:
|
|
126
|
+
if setup_done_perf is None:
|
|
127
|
+
setup_done_perf = time.monotonic()
|
|
128
|
+
results = failed_results(
|
|
129
|
+
exc.error,
|
|
130
|
+
suite_ids=request.suite_ids,
|
|
131
|
+
tags=request.tags,
|
|
132
|
+
fail_fast=request.fail_fast,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
if setup_done_perf is None:
|
|
137
|
+
setup_done_perf = time.monotonic()
|
|
138
|
+
if engine_started:
|
|
139
|
+
# An unexpected fault raised by Engine.run() is an engine error, not a
|
|
140
|
+
# configuration problem. Preserve `engine.unexpected` so the CLI reports it as
|
|
141
|
+
# ENGINE / ENGINE_ERROR rather than a user configuration failure.
|
|
142
|
+
error = CheckError(
|
|
143
|
+
code="engine.unexpected",
|
|
144
|
+
message=f"The GraphCheck engine failed unexpectedly: {type(exc).__name__}: {exc}",
|
|
145
|
+
fix="Re-run the check suite; if it recurs, file a bug with the run details.",
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
error = CheckError(
|
|
149
|
+
code="run.configuration",
|
|
150
|
+
message=f"GraphCheck could not prepare the run: {type(exc).__name__}: {exc}",
|
|
151
|
+
fix="Fix the project configuration, then run `graphcheck debug` and try again.",
|
|
152
|
+
)
|
|
153
|
+
results = failed_results(
|
|
154
|
+
error,
|
|
155
|
+
suite_ids=request.suite_ids,
|
|
156
|
+
tags=request.tags,
|
|
157
|
+
fail_fast=request.fail_fast,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
finally:
|
|
161
|
+
if client is not None:
|
|
162
|
+
with suppress(Exception):
|
|
163
|
+
client.close()
|
|
164
|
+
|
|
165
|
+
# Publish exactly once, outside the setup/engine exception translation above. A write
|
|
166
|
+
# failure must preserve the completed (or already-failed) result and surface as
|
|
167
|
+
# artifact_error with the real artifact-write timing boundary — never a retried write
|
|
168
|
+
# nor a re-labelled run.configuration result.
|
|
169
|
+
artifact_started_perf = time.monotonic()
|
|
170
|
+
try:
|
|
171
|
+
results_path, report_path = artifact_writer(
|
|
172
|
+
results,
|
|
173
|
+
runs_dir,
|
|
174
|
+
render_observer=render_observer,
|
|
175
|
+
)
|
|
176
|
+
except Exception as artifact_exc:
|
|
177
|
+
return RunOutcome(
|
|
178
|
+
results=results,
|
|
179
|
+
results_path=None,
|
|
180
|
+
report_path=None,
|
|
181
|
+
artifact_error=artifact_exc,
|
|
182
|
+
setup_done_perf=setup_done_perf,
|
|
183
|
+
artifact_started_perf=artifact_started_perf,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return RunOutcome(
|
|
187
|
+
results=results,
|
|
188
|
+
results_path=results_path,
|
|
189
|
+
report_path=report_path,
|
|
190
|
+
setup_done_perf=setup_done_perf,
|
|
191
|
+
artifact_started_perf=artifact_started_perf,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _new_neo4j_client(profile, max_concurrency: int):
|
|
196
|
+
"""Construct the workload-aware Neo4j client while retaining simple test doubles."""
|
|
197
|
+
parameters = inspect.signature(Neo4jClient).parameters.values()
|
|
198
|
+
accepts_setting = any(
|
|
199
|
+
parameter.name == "max_concurrency" or parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
200
|
+
for parameter in parameters
|
|
201
|
+
)
|
|
202
|
+
return (
|
|
203
|
+
Neo4jClient(profile, max_concurrency=max_concurrency)
|
|
204
|
+
if accepts_setting
|
|
205
|
+
else Neo4jClient(profile)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _verify_cli_audit_credential(client: object) -> object | None:
|
|
210
|
+
"""Probe the target, verify the read-only credential, and return the probed target.
|
|
211
|
+
|
|
212
|
+
The returned target carries the live node/relationship counts so a caller can render a
|
|
213
|
+
run header without probing the database a second time.
|
|
214
|
+
"""
|
|
215
|
+
verify = getattr(client, "verify_read_only_credential", None)
|
|
216
|
+
probe = getattr(client, "probe", None)
|
|
217
|
+
result = probe() if callable(probe) else None
|
|
218
|
+
if callable(verify):
|
|
219
|
+
verify()
|
|
220
|
+
target = result[0] if isinstance(result, tuple) else result
|
|
221
|
+
if isinstance(result, tuple) and len(result) > 2 and target is not None:
|
|
222
|
+
counts = result[2]
|
|
223
|
+
copy = getattr(target, "model_copy", None)
|
|
224
|
+
if callable(copy):
|
|
225
|
+
target = copy(
|
|
226
|
+
update={
|
|
227
|
+
"nodes": getattr(counts, "nodes", getattr(target, "nodes", None)),
|
|
228
|
+
"relationships": getattr(
|
|
229
|
+
counts, "relationships", getattr(target, "relationships", None)
|
|
230
|
+
),
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
return target
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from graphcheck.engine.runner import SuiteInput
|
|
6
|
+
from graphcheck.errors import GraphCheckError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_suite_inputs(checks_dir: Path, requested_suites: list[str]) -> list[SuiteInput]:
|
|
10
|
+
if not checks_dir.is_dir():
|
|
11
|
+
raise GraphCheckError(
|
|
12
|
+
"run.checks_missing",
|
|
13
|
+
f"Configured checks directory was not found: {checks_dir}",
|
|
14
|
+
"Create the directory or fix `checks` in graphcheck.yml.",
|
|
15
|
+
)
|
|
16
|
+
try:
|
|
17
|
+
paths = sorted(
|
|
18
|
+
path
|
|
19
|
+
for path in checks_dir.rglob("*")
|
|
20
|
+
if path.is_file() and path.suffix.lower() in {".yml", ".yaml"}
|
|
21
|
+
)
|
|
22
|
+
except OSError as exc:
|
|
23
|
+
raise GraphCheckError(
|
|
24
|
+
"run.checks_unreadable",
|
|
25
|
+
f"Could not enumerate check suites in {checks_dir}: {exc}",
|
|
26
|
+
"Check the configured checks path and its filesystem permissions.",
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
loaded: list[SuiteInput] = []
|
|
30
|
+
for path in paths:
|
|
31
|
+
try:
|
|
32
|
+
text = path.read_text(encoding="utf-8")
|
|
33
|
+
loaded.append(SuiteInput.from_yaml(text, source=str(path)))
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise GraphCheckError(
|
|
36
|
+
"run.suite_invalid",
|
|
37
|
+
f"Suite {path} is invalid: {type(exc).__name__}: {exc}",
|
|
38
|
+
"Fix the suite YAML and remove unknown keys, then run it again.",
|
|
39
|
+
) from exc
|
|
40
|
+
|
|
41
|
+
if not requested_suites:
|
|
42
|
+
return loaded
|
|
43
|
+
requested = set(requested_suites)
|
|
44
|
+
return [item for item in loaded if item.suite.suite in requested]
|
graphcheck/baselines.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from graphcheck.contracts.profile import BaselineProfile
|
|
9
|
+
from graphcheck.errors import GraphCheckError
|
|
10
|
+
from graphcheck.project import find_project_root
|
|
11
|
+
|
|
12
|
+
_BASELINE_NAME = re.compile(r"\d{8}T\d{6}(?:\.\d{6})?\.json")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def baseline_directory(
|
|
16
|
+
project_root: Path | None = None,
|
|
17
|
+
artifacts: str | Path = ".graphcheck",
|
|
18
|
+
) -> Path:
|
|
19
|
+
"""Return the configured timestamped-baseline directory."""
|
|
20
|
+
|
|
21
|
+
root = find_project_root() if project_root is None else project_root
|
|
22
|
+
if project_root is None and Path(artifacts) == Path(".graphcheck"):
|
|
23
|
+
artifacts = _discovered_artifacts(root)
|
|
24
|
+
configured = Path(artifacts)
|
|
25
|
+
artifacts_dir = configured if configured.is_absolute() else root / configured
|
|
26
|
+
return artifacts_dir / "baselines"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def current_baseline_file(
|
|
30
|
+
project_root: Path | None = None,
|
|
31
|
+
artifacts: str | Path = ".graphcheck",
|
|
32
|
+
) -> Path:
|
|
33
|
+
root = find_project_root() if project_root is None else project_root
|
|
34
|
+
if project_root is None and Path(artifacts) == Path(".graphcheck"):
|
|
35
|
+
artifacts = _discovered_artifacts(root)
|
|
36
|
+
configured = Path(artifacts)
|
|
37
|
+
artifacts_dir = configured if configured.is_absolute() else root / configured
|
|
38
|
+
return artifacts_dir / "current-baseline.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _discovered_artifacts(root: Path) -> str | Path:
|
|
42
|
+
"""Use configured artifacts when a real project file exists; aid legacy callers/tests."""
|
|
43
|
+
|
|
44
|
+
from graphcheck.project import PROJECT_FILE, load_project_config
|
|
45
|
+
|
|
46
|
+
if not (root / PROJECT_FILE).is_file():
|
|
47
|
+
return ".graphcheck"
|
|
48
|
+
return load_project_config(root).artifacts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_baseline(
|
|
52
|
+
profile: BaselineProfile,
|
|
53
|
+
project_root: Path | None = None,
|
|
54
|
+
artifacts: str | Path = ".graphcheck",
|
|
55
|
+
) -> Path:
|
|
56
|
+
baselines_dir = baseline_directory(project_root, artifacts)
|
|
57
|
+
baselines_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
timestamp = datetime.now(UTC)
|
|
59
|
+
content = profile.model_dump_json(
|
|
60
|
+
by_alias=True,
|
|
61
|
+
indent=2,
|
|
62
|
+
)
|
|
63
|
+
while True:
|
|
64
|
+
path = baselines_dir / f"{timestamp:%Y%m%dT%H%M%S.%f}.json"
|
|
65
|
+
try:
|
|
66
|
+
with path.open("x", encoding="utf-8") as snapshot:
|
|
67
|
+
snapshot.write(content)
|
|
68
|
+
return path
|
|
69
|
+
except FileExistsError:
|
|
70
|
+
timestamp += timedelta(microseconds=1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_baselines(
|
|
74
|
+
project_root: Path | None = None,
|
|
75
|
+
artifacts: str | Path = ".graphcheck",
|
|
76
|
+
) -> list[Path]:
|
|
77
|
+
baselines_dir = baseline_directory(project_root, artifacts)
|
|
78
|
+
if not baselines_dir.is_dir():
|
|
79
|
+
return []
|
|
80
|
+
return sorted(
|
|
81
|
+
path
|
|
82
|
+
for path in baselines_dir.iterdir()
|
|
83
|
+
if path.is_file() and _BASELINE_NAME.fullmatch(path.name)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def latest_baseline(
|
|
88
|
+
project_root: Path | None = None,
|
|
89
|
+
artifacts: str | Path = ".graphcheck",
|
|
90
|
+
) -> Path | None:
|
|
91
|
+
baselines = list_baselines(project_root, artifacts)
|
|
92
|
+
return baselines[-1] if baselines else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def set_current_baseline(
|
|
96
|
+
filename: str | None = None,
|
|
97
|
+
project_root: Path | None = None,
|
|
98
|
+
artifacts: str | Path = ".graphcheck",
|
|
99
|
+
) -> Path:
|
|
100
|
+
baselines_dir = baseline_directory(project_root, artifacts)
|
|
101
|
+
baselines = list_baselines(project_root, artifacts)
|
|
102
|
+
|
|
103
|
+
if filename is None:
|
|
104
|
+
if not baselines:
|
|
105
|
+
raise GraphCheckError(
|
|
106
|
+
"baseline.missing",
|
|
107
|
+
"No timestamped baseline snapshots were found.",
|
|
108
|
+
"Run `graphcheck profile` to create a baseline snapshot first.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Default to the previous run.
|
|
112
|
+
# If only one baseline exists, use that.
|
|
113
|
+
selected = baselines[0] if len(baselines) == 1 else baselines[-2]
|
|
114
|
+
|
|
115
|
+
else:
|
|
116
|
+
selected = {path.name: path for path in baselines}.get(filename)
|
|
117
|
+
if selected is None:
|
|
118
|
+
raise GraphCheckError(
|
|
119
|
+
"baseline.not_found",
|
|
120
|
+
f"Baseline snapshot {filename!r} was not found in {baselines_dir}.",
|
|
121
|
+
"Choose an existing timestamped snapshot, or run `graphcheck profile`.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
selected_file = current_baseline_file(project_root, artifacts)
|
|
125
|
+
selected_file.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
selected_file.write_text(
|
|
127
|
+
json.dumps({"baseline": selected.name}, indent=2) + "\n",
|
|
128
|
+
encoding="utf-8",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return selected
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_current_baseline(
|
|
135
|
+
project_root: Path | None = None,
|
|
136
|
+
artifacts: str | Path = ".graphcheck",
|
|
137
|
+
) -> Path | None:
|
|
138
|
+
selected_file = current_baseline_file(project_root, artifacts)
|
|
139
|
+
if not selected_file.exists():
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
payload = json.loads(selected_file.read_text(encoding="utf-8"))
|
|
143
|
+
filename = payload["baseline"]
|
|
144
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
145
|
+
raise GraphCheckError(
|
|
146
|
+
"baseline.current_invalid",
|
|
147
|
+
f"{selected_file} is not valid baseline metadata.",
|
|
148
|
+
"Run `graphcheck baseline set` to select an active baseline again.",
|
|
149
|
+
) from exc
|
|
150
|
+
if not isinstance(filename, str):
|
|
151
|
+
raise GraphCheckError(
|
|
152
|
+
"baseline.current_invalid",
|
|
153
|
+
f"{selected_file} is not valid baseline metadata.",
|
|
154
|
+
"Run `graphcheck baseline set` to select an active baseline again.",
|
|
155
|
+
)
|
|
156
|
+
baselines = list_baselines(project_root, artifacts)
|
|
157
|
+
selected = {path.name: path for path in baselines}.get(filename)
|
|
158
|
+
if selected is None:
|
|
159
|
+
raise GraphCheckError(
|
|
160
|
+
"baseline.current_missing",
|
|
161
|
+
f"The active baseline snapshot {filename!r} does not exist.",
|
|
162
|
+
"Run `graphcheck baseline set` to select an existing baseline.",
|
|
163
|
+
)
|
|
164
|
+
return selected
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_diff_baselines(
|
|
168
|
+
current_baseline_name: str | None = None,
|
|
169
|
+
latest_baseline_name: str | None = None,
|
|
170
|
+
*,
|
|
171
|
+
project_root: Path | None = None,
|
|
172
|
+
artifacts: str | Path = ".graphcheck",
|
|
173
|
+
) -> tuple[Path, Path]:
|
|
174
|
+
"""Resolve the Current and Latest Baseline snapshots for a diff."""
|
|
175
|
+
if (current_baseline_name is None) != (latest_baseline_name is None):
|
|
176
|
+
raise GraphCheckError(
|
|
177
|
+
"baseline.not_found",
|
|
178
|
+
"Both Current Baseline and Latest Baseline must be specified together.",
|
|
179
|
+
"Provide two baseline snapshots, or omit both to use the defaults.",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if current_baseline_name is not None and latest_baseline_name is not None:
|
|
183
|
+
return (
|
|
184
|
+
_resolve_baseline_path(
|
|
185
|
+
current_baseline_name,
|
|
186
|
+
project_root=project_root,
|
|
187
|
+
artifacts=artifacts,
|
|
188
|
+
),
|
|
189
|
+
_resolve_baseline_path(
|
|
190
|
+
latest_baseline_name,
|
|
191
|
+
project_root=project_root,
|
|
192
|
+
artifacts=artifacts,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
baselines = list_baselines(project_root, artifacts)
|
|
197
|
+
if len(baselines) < 2:
|
|
198
|
+
raise GraphCheckError(
|
|
199
|
+
"baseline.missing",
|
|
200
|
+
"At least two timestamped baseline snapshots are required for a diff.",
|
|
201
|
+
"Run `graphcheck profile` at least twice to create baseline snapshots.",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
current_baseline = get_current_baseline(project_root, artifacts)
|
|
205
|
+
if current_baseline is None:
|
|
206
|
+
current_baseline = baselines[-2]
|
|
207
|
+
return current_baseline, baselines[-1]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _resolve_baseline_path(
|
|
211
|
+
name: str,
|
|
212
|
+
*,
|
|
213
|
+
project_root: Path | None = None,
|
|
214
|
+
artifacts: str | Path = ".graphcheck",
|
|
215
|
+
) -> Path:
|
|
216
|
+
requested = Path(name)
|
|
217
|
+
if requested.is_file():
|
|
218
|
+
return requested
|
|
219
|
+
|
|
220
|
+
selected = {path.name: path for path in list_baselines(project_root, artifacts)}.get(name)
|
|
221
|
+
if selected is not None:
|
|
222
|
+
return selected
|
|
223
|
+
|
|
224
|
+
raise GraphCheckError(
|
|
225
|
+
"baseline.not_found",
|
|
226
|
+
f"Baseline snapshot {name!r} was not found.",
|
|
227
|
+
"Choose an existing timestamped snapshot or valid baseline file path.",
|
|
228
|
+
)
|
graphcheck/bootstrap.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Lightweight console entry point with a standard-library-only version fast path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from graphcheck import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cli() -> None:
|
|
11
|
+
if sys.argv[1:] == ["--version"]:
|
|
12
|
+
print(f"graphcheck {__version__}")
|
|
13
|
+
return
|
|
14
|
+
from graphcheck.telemetry.consent import resolve_consent
|
|
15
|
+
|
|
16
|
+
consent = resolve_consent()
|
|
17
|
+
from graphcheck.cli import cli as typer_cli
|
|
18
|
+
|
|
19
|
+
typer_cli(consent=consent)
|