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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
9
|
+
|
|
10
|
+
from graphcheck.errors import (
|
|
11
|
+
profile_invalid,
|
|
12
|
+
profile_missing,
|
|
13
|
+
profile_not_found,
|
|
14
|
+
profile_password_missing,
|
|
15
|
+
profile_uri_invalid,
|
|
16
|
+
)
|
|
17
|
+
from graphcheck.project import PROFILES_FILE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectionProfile(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
uri: str
|
|
24
|
+
user: str
|
|
25
|
+
password: str | None = None
|
|
26
|
+
password_env: str | None = None
|
|
27
|
+
database: str
|
|
28
|
+
|
|
29
|
+
def resolved(self, name: str) -> ConnectionProfile:
|
|
30
|
+
if self.password_env:
|
|
31
|
+
env_password = os.environ.get(self.password_env)
|
|
32
|
+
if env_password:
|
|
33
|
+
return self.model_copy(update={"password": env_password, "password_env": None})
|
|
34
|
+
if not self.password:
|
|
35
|
+
raise profile_password_missing(name, self.password_env)
|
|
36
|
+
if not self.password:
|
|
37
|
+
raise profile_password_missing(name)
|
|
38
|
+
return self.model_copy(update={"password_env": None})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProfilesFile(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
default: str
|
|
45
|
+
profiles: dict[str, ConnectionProfile]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_profiles() -> ProfilesFile:
|
|
49
|
+
return ProfilesFile(
|
|
50
|
+
default="local",
|
|
51
|
+
profiles={
|
|
52
|
+
"local": ConnectionProfile(
|
|
53
|
+
uri="bolt://localhost:7687",
|
|
54
|
+
user="neo4j",
|
|
55
|
+
password="graphora",
|
|
56
|
+
database="neo4j",
|
|
57
|
+
)
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_default_profiles(root: Path) -> None:
|
|
63
|
+
(root / PROFILES_FILE).write_text(
|
|
64
|
+
"""# Edit the inline password below for the fastest local setup.
|
|
65
|
+
# For CI/production, remove `password` and use `password_env: NEO4J_PASSWORD` instead.
|
|
66
|
+
# Enterprise: assign only the built-in reader role. Community: EXPLAIN guards each query.
|
|
67
|
+
# Use bolt:// for direct local Bolt, or neo4j+s:// for a CA-signed TLS/routing endpoint.
|
|
68
|
+
default: local
|
|
69
|
+
profiles:
|
|
70
|
+
local:
|
|
71
|
+
uri: bolt://localhost:7687
|
|
72
|
+
user: neo4j
|
|
73
|
+
password: graphora
|
|
74
|
+
database: neo4j
|
|
75
|
+
""",
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_profiles(root: Path) -> ProfilesFile:
|
|
81
|
+
path = root / PROFILES_FILE
|
|
82
|
+
if not path.exists():
|
|
83
|
+
raise profile_missing()
|
|
84
|
+
try:
|
|
85
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
86
|
+
return ProfilesFile.model_validate(raw)
|
|
87
|
+
except (OSError, yaml.YAMLError, ValidationError) as exc:
|
|
88
|
+
raise profile_invalid(f"Invalid profiles.yml: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def select_profile(
|
|
92
|
+
profiles: ProfilesFile, name: str | None = None
|
|
93
|
+
) -> tuple[str, ConnectionProfile]:
|
|
94
|
+
selected = name or profiles.default
|
|
95
|
+
try:
|
|
96
|
+
profile = profiles.profiles[selected]
|
|
97
|
+
except KeyError as exc:
|
|
98
|
+
raise profile_not_found(selected) from exc
|
|
99
|
+
validate_profile_uri(profile.uri)
|
|
100
|
+
return selected, profile.resolved(selected)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_profile_uri(uri: str) -> None:
|
|
104
|
+
try:
|
|
105
|
+
parsed = urlsplit(uri)
|
|
106
|
+
valid = parsed.scheme in {
|
|
107
|
+
"bolt",
|
|
108
|
+
"bolt+s",
|
|
109
|
+
"bolt+ssc",
|
|
110
|
+
"neo4j",
|
|
111
|
+
"neo4j+s",
|
|
112
|
+
"neo4j+ssc",
|
|
113
|
+
} and bool(parsed.hostname)
|
|
114
|
+
if parsed.port is not None and not 1 <= parsed.port <= 65535:
|
|
115
|
+
valid = False
|
|
116
|
+
except ValueError:
|
|
117
|
+
valid = False
|
|
118
|
+
if not valid:
|
|
119
|
+
raise profile_uri_invalid(uri)
|
|
File without changes
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
from graphcheck.contracts.results import Pattern, Severity
|
|
9
|
+
from graphcheck.contracts.scalars import JsonSchemaInteger
|
|
10
|
+
from graphcheck.packs import REGISTRY
|
|
11
|
+
from graphcheck.yaml_loader import DuplicateKeyError as DuplicateKeyError
|
|
12
|
+
from graphcheck.yaml_loader import load_yaml_mapping
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnknownCheckError(ValueError):
|
|
16
|
+
"""A conformance check references a `check` name not in the pack registry."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SuiteValidationError(ValueError):
|
|
20
|
+
"""A suite file is syntactically valid YAML but invalid as a GraphCheck suite."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_suite_yaml(text: str) -> dict:
|
|
24
|
+
try:
|
|
25
|
+
return load_yaml_mapping(text, description="a suite file")
|
|
26
|
+
except DuplicateKeyError:
|
|
27
|
+
raise
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
raise SuiteValidationError(str(exc)) from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_severity(value: object) -> object:
|
|
33
|
+
if type(value) is str:
|
|
34
|
+
return Severity(value)
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
type SuiteSeverity = Annotated[Severity | None, BeforeValidator(_parse_severity)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Strict(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Defaults(_Strict):
|
|
46
|
+
severity: SuiteSeverity = None
|
|
47
|
+
tags: list[str] = []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Envelope(_Strict):
|
|
51
|
+
id: str
|
|
52
|
+
severity: SuiteSeverity = None
|
|
53
|
+
tags: list[str] = []
|
|
54
|
+
provenance: str | None = None
|
|
55
|
+
generated: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConformanceCheck(_Envelope):
|
|
59
|
+
# No populate_by_name: the external key is the frozen `with` alias only, never `with_`.
|
|
60
|
+
check: str
|
|
61
|
+
with_: dict = Field(alias="with") # required — SPEC-02 freezes `check` + `with` for conformance
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RowBounds(_Strict):
|
|
65
|
+
min: JsonSchemaInteger | None = None
|
|
66
|
+
max: JsonSchemaInteger | None = None
|
|
67
|
+
exactly: JsonSchemaInteger | None = None
|
|
68
|
+
|
|
69
|
+
@model_validator(mode="after")
|
|
70
|
+
def _bounds_are_meaningful_and_consistent(self) -> RowBounds:
|
|
71
|
+
values = {"min": self.min, "max": self.max, "exactly": self.exactly}
|
|
72
|
+
if all(value is None for value in values.values()):
|
|
73
|
+
raise ValueError("rows must declare at least one of min, max, or exactly")
|
|
74
|
+
for name, value in values.items():
|
|
75
|
+
if value is not None and value < 0:
|
|
76
|
+
raise ValueError(f"rows.{name} must be non-negative")
|
|
77
|
+
if self.min is not None and self.max is not None and self.min > self.max:
|
|
78
|
+
raise ValueError("rows.min must not exceed rows.max")
|
|
79
|
+
if self.exactly is not None:
|
|
80
|
+
if self.min is not None and self.exactly < self.min:
|
|
81
|
+
raise ValueError("rows.exactly must not be less than rows.min")
|
|
82
|
+
if self.max is not None and self.exactly > self.max:
|
|
83
|
+
raise ValueError("rows.exactly must not exceed rows.max")
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def permits(self, count: int) -> bool:
|
|
87
|
+
if self.exactly is not None and count != self.exactly:
|
|
88
|
+
return False
|
|
89
|
+
if self.min is not None and count < self.min:
|
|
90
|
+
return False
|
|
91
|
+
return self.max is None or count <= self.max
|
|
92
|
+
|
|
93
|
+
def permits_nonempty(self) -> bool:
|
|
94
|
+
if self.exactly is not None:
|
|
95
|
+
return self.exactly > 0
|
|
96
|
+
return self.max is None or self.max > 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Expect(_Strict):
|
|
100
|
+
rows: RowBounds | None = None
|
|
101
|
+
columns: list[str] | None = None
|
|
102
|
+
unique: bool | None = None
|
|
103
|
+
contains: list | None = None
|
|
104
|
+
equals: list | None = None
|
|
105
|
+
empty: bool | None = None
|
|
106
|
+
|
|
107
|
+
@model_validator(mode="after")
|
|
108
|
+
def _assertions_are_meaningful_and_consistent(self) -> Expect:
|
|
109
|
+
assertions = (
|
|
110
|
+
self.rows,
|
|
111
|
+
self.columns,
|
|
112
|
+
self.unique,
|
|
113
|
+
self.contains,
|
|
114
|
+
self.equals,
|
|
115
|
+
self.empty,
|
|
116
|
+
)
|
|
117
|
+
if all(assertion is None for assertion in assertions):
|
|
118
|
+
raise ValueError("expect must declare at least one assertion")
|
|
119
|
+
if self.contains == []:
|
|
120
|
+
raise ValueError("expect.contains must not be empty")
|
|
121
|
+
|
|
122
|
+
if self.rows is not None:
|
|
123
|
+
if self.empty is True and not self.rows.permits(0):
|
|
124
|
+
raise ValueError("expect.empty=true conflicts with expect.rows")
|
|
125
|
+
if self.empty is False and not self.rows.permits_nonempty():
|
|
126
|
+
raise ValueError("expect.empty=false conflicts with expect.rows")
|
|
127
|
+
if self.equals is not None and not self.rows.permits(len(self.equals)):
|
|
128
|
+
raise ValueError("expect.equals length conflicts with expect.rows")
|
|
129
|
+
if self.contains and not self.rows.permits_nonempty():
|
|
130
|
+
raise ValueError("expect.contains conflicts with expect.rows")
|
|
131
|
+
|
|
132
|
+
if self.empty is True:
|
|
133
|
+
if self.contains:
|
|
134
|
+
raise ValueError("expect.empty=true conflicts with expect.contains")
|
|
135
|
+
if self.equals is not None and self.equals:
|
|
136
|
+
raise ValueError("expect.empty=true conflicts with expect.equals")
|
|
137
|
+
elif self.empty is False and self.equals == []:
|
|
138
|
+
raise ValueError("expect.empty=false conflicts with empty expect.equals")
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
self.contains is not None
|
|
142
|
+
and self.equals is not None
|
|
143
|
+
and any(value not in self.equals for value in self.contains)
|
|
144
|
+
):
|
|
145
|
+
raise ValueError("expect.contains must be a subset of expect.equals")
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CompetencyCheck(_Envelope):
|
|
150
|
+
question: str
|
|
151
|
+
query: str
|
|
152
|
+
params: dict = {}
|
|
153
|
+
expect: Expect
|
|
154
|
+
|
|
155
|
+
@model_validator(mode="after")
|
|
156
|
+
def _content_is_valid(self) -> CompetencyCheck:
|
|
157
|
+
if not self.question.strip():
|
|
158
|
+
raise ValueError("competency question must not be blank")
|
|
159
|
+
if not self.query.strip():
|
|
160
|
+
raise ValueError("competency query must not be blank")
|
|
161
|
+
if any(not isinstance(key, str) for key in self.params):
|
|
162
|
+
raise ValueError("competency params keys must be strings")
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DriftCheck(_Envelope):
|
|
167
|
+
metric: str
|
|
168
|
+
target: dict
|
|
169
|
+
baseline: str = "latest"
|
|
170
|
+
tolerance: dict
|
|
171
|
+
|
|
172
|
+
@model_validator(mode="after")
|
|
173
|
+
def _content_is_valid(self) -> DriftCheck:
|
|
174
|
+
if not self.metric.strip():
|
|
175
|
+
raise ValueError("drift metric must not be blank")
|
|
176
|
+
if not self.baseline.strip():
|
|
177
|
+
raise ValueError("drift baseline must not be blank")
|
|
178
|
+
if not self.tolerance:
|
|
179
|
+
raise ValueError("drift tolerance must not be empty")
|
|
180
|
+
if any(not isinstance(key, str) for key in self.target):
|
|
181
|
+
raise ValueError("drift target keys must be strings")
|
|
182
|
+
if any(not isinstance(key, str) for key in self.tolerance):
|
|
183
|
+
raise ValueError("drift tolerance keys must be strings")
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class LoadedCheck(_Strict):
|
|
188
|
+
id: str
|
|
189
|
+
pattern: Pattern
|
|
190
|
+
severity: Severity # resolved (check -> defaults -> error)
|
|
191
|
+
tags: list[str] # resolved (defaults + check)
|
|
192
|
+
provenance: str | None = None
|
|
193
|
+
generated: bool # effective (file OR check)
|
|
194
|
+
spec: ConformanceCheck | CompetencyCheck | DriftCheck # validated payload the engine executes
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Suite(_Strict):
|
|
198
|
+
suite: str
|
|
199
|
+
checks: list[LoadedCheck]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _SuiteFile(_Strict):
|
|
203
|
+
suite: str | None = None # optional in the file; falls back to the source filename stem
|
|
204
|
+
generated: bool = False
|
|
205
|
+
defaults: Defaults = Defaults()
|
|
206
|
+
conformance: list[ConformanceCheck] = []
|
|
207
|
+
competency: list[CompetencyCheck] = []
|
|
208
|
+
drift: list[DriftCheck] = []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _competency_pattern(expect: Expect) -> Pattern:
|
|
212
|
+
if expect.contains is not None or expect.equals is not None:
|
|
213
|
+
return Pattern.COMPETENCY_REGRESSION
|
|
214
|
+
return Pattern.COMPETENCY_SHAPE
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_suite(text: str, *, source: str | None = None) -> Suite:
|
|
218
|
+
raw = load_suite_yaml(text)
|
|
219
|
+
parsed = _SuiteFile.model_validate(raw)
|
|
220
|
+
suite_id = parsed.suite or (Path(source).stem if source else None)
|
|
221
|
+
if not suite_id:
|
|
222
|
+
raise SuiteValidationError("suite name required: no `suite:` key and no source filename")
|
|
223
|
+
defaults = parsed.defaults
|
|
224
|
+
checks: list[LoadedCheck] = []
|
|
225
|
+
|
|
226
|
+
def resolve(env: _Envelope, pattern: Pattern) -> LoadedCheck:
|
|
227
|
+
severity = env.severity or defaults.severity or Severity.ERROR
|
|
228
|
+
tags = list(dict.fromkeys([*defaults.tags, *env.tags]))
|
|
229
|
+
generated = parsed.generated or env.generated
|
|
230
|
+
return LoadedCheck(
|
|
231
|
+
id=env.id,
|
|
232
|
+
pattern=pattern,
|
|
233
|
+
severity=severity,
|
|
234
|
+
tags=tags,
|
|
235
|
+
provenance=env.provenance,
|
|
236
|
+
generated=generated,
|
|
237
|
+
spec=env,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
for c in parsed.conformance:
|
|
241
|
+
if c.check not in REGISTRY:
|
|
242
|
+
raise UnknownCheckError(f"unknown check type: {c.check!r}")
|
|
243
|
+
# Validate `with` against the pack model AND keep the normalized result, so pack
|
|
244
|
+
# defaults (e.g. completeness threshold=1.0) survive onto spec.with_ for the engine.
|
|
245
|
+
c.with_ = REGISTRY[c.check].model_validate(c.with_).model_dump()
|
|
246
|
+
checks.append(resolve(c, Pattern.CONFORMANCE))
|
|
247
|
+
for c in parsed.competency:
|
|
248
|
+
checks.append(resolve(c, _competency_pattern(c.expect)))
|
|
249
|
+
for c in parsed.drift:
|
|
250
|
+
checks.append(resolve(c, Pattern.DRIFT))
|
|
251
|
+
|
|
252
|
+
seen: set[str] = set()
|
|
253
|
+
for lc in checks:
|
|
254
|
+
if lc.id in seen:
|
|
255
|
+
raise SuiteValidationError(f"duplicate check id {lc.id!r} in suite {suite_id!r}")
|
|
256
|
+
seen.add(lc.id)
|
|
257
|
+
|
|
258
|
+
return Suite(suite=suite_id, checks=checks)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
10
|
+
|
|
11
|
+
from graphcheck.contracts.results import RunTarget
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = "1.0"
|
|
14
|
+
FINGERPRINT_ALGORITHM = "sha256"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProfileStatus(StrEnum):
|
|
18
|
+
COMPLETE = "complete"
|
|
19
|
+
PARTIAL = "partial"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Strict(BaseModel):
|
|
23
|
+
model_config = ConfigDict(
|
|
24
|
+
extra="forbid",
|
|
25
|
+
strict=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProfileMetadata(_Strict):
|
|
30
|
+
generated_at: str
|
|
31
|
+
graphcheck_version: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProfileProperty(_Strict):
|
|
35
|
+
name: str
|
|
36
|
+
type: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LabelProfile(_Strict):
|
|
40
|
+
name: str
|
|
41
|
+
count: int = Field(ge=0)
|
|
42
|
+
properties: list[ProfileProperty]
|
|
43
|
+
degree_distribution: DegreeDistribution | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RelationshipTypeProfile(_Strict):
|
|
47
|
+
name: str
|
|
48
|
+
count: int = Field(ge=0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConstraintProfile(_Strict):
|
|
52
|
+
name: str
|
|
53
|
+
type: str
|
|
54
|
+
labels_or_types: list[str]
|
|
55
|
+
properties: list[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IndexProfile(_Strict):
|
|
59
|
+
name: str
|
|
60
|
+
type: str
|
|
61
|
+
labels_or_types: list[str]
|
|
62
|
+
properties: list[str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GraphSchema(_Strict):
|
|
66
|
+
labels: list[LabelProfile]
|
|
67
|
+
relationship_types: list[RelationshipTypeProfile]
|
|
68
|
+
constraints: list[ConstraintProfile]
|
|
69
|
+
indexes: list[IndexProfile]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DegreeDistribution(_Strict):
|
|
73
|
+
median: float = Field(ge=0)
|
|
74
|
+
p95: float = Field(ge=0)
|
|
75
|
+
p99: float = Field(ge=0)
|
|
76
|
+
maximum: int = Field(ge=0)
|
|
77
|
+
|
|
78
|
+
@model_validator(mode="after")
|
|
79
|
+
def _percentiles_are_ordered(self) -> DegreeDistribution:
|
|
80
|
+
if not self.median <= self.p95 <= self.p99 <= self.maximum:
|
|
81
|
+
raise ValueError("degree distribution must satisfy median <= p95 <= p99 <= maximum")
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PropertyCoverage(_Strict):
|
|
86
|
+
owner: Literal["node", "relationship"]
|
|
87
|
+
owner_name: str
|
|
88
|
+
property: str
|
|
89
|
+
coverage: float = Field(ge=0, le=100)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProfileStatistics(_Strict):
|
|
93
|
+
node_count: int = Field(ge=0)
|
|
94
|
+
relationship_count: int = Field(ge=0)
|
|
95
|
+
property_coverage: list[PropertyCoverage]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BaselineProfile(_Strict):
|
|
99
|
+
schema_version: Literal["1.0"]
|
|
100
|
+
status: ProfileStatus
|
|
101
|
+
partial_reason: str | None
|
|
102
|
+
target: RunTarget
|
|
103
|
+
metadata: ProfileMetadata
|
|
104
|
+
graph_schema: GraphSchema = Field(alias="schema")
|
|
105
|
+
statistics: ProfileStatistics
|
|
106
|
+
fingerprint: str
|
|
107
|
+
|
|
108
|
+
@model_validator(mode="after")
|
|
109
|
+
def _semantic_invariants(self) -> BaselineProfile:
|
|
110
|
+
if self.status is ProfileStatus.COMPLETE:
|
|
111
|
+
if self.partial_reason is not None:
|
|
112
|
+
raise ValueError("complete baseline must have partial_reason=null")
|
|
113
|
+
|
|
114
|
+
for label_profile in self.graph_schema.labels:
|
|
115
|
+
if label_profile.degree_distribution is None:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"label {label_profile.name!r} must carry "
|
|
118
|
+
"degree_distribution in a complete profile"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for relationship_type in self.graph_schema.relationship_types:
|
|
122
|
+
if relationship_type.count > self.statistics.relationship_count:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"schema.relationship_types[{relationship_type.name!r}].count "
|
|
125
|
+
f"({relationship_type.count}) exceeds "
|
|
126
|
+
f"statistics.relationship_count "
|
|
127
|
+
f"({self.statistics.relationship_count})"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
elif not self.partial_reason:
|
|
131
|
+
raise ValueError("partial baseline must carry a non-empty partial_reason")
|
|
132
|
+
|
|
133
|
+
for label_profile in self.graph_schema.labels:
|
|
134
|
+
if label_profile.count > self.statistics.node_count:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"schema.labels[{label_profile.name!r}].count "
|
|
137
|
+
f"({label_profile.count}) exceeds "
|
|
138
|
+
f"statistics.node_count "
|
|
139
|
+
f"({self.statistics.node_count})"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expected_fingerprint = profile_fingerprint(self.graph_schema, self.statistics)
|
|
143
|
+
if self.fingerprint != expected_fingerprint:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"fingerprint must be {expected_fingerprint!r} for the v0 profile fingerprint input"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
label_names = [label_profile.name for label_profile in self.graph_schema.labels]
|
|
149
|
+
|
|
150
|
+
_require_sorted("schema.labels", label_names)
|
|
151
|
+
_require_unique("schema.labels", label_names)
|
|
152
|
+
|
|
153
|
+
for label_profile in self.graph_schema.labels:
|
|
154
|
+
property_names = [prop.name for prop in label_profile.properties]
|
|
155
|
+
|
|
156
|
+
_require_sorted(
|
|
157
|
+
f"schema.labels[{label_profile.name!r}].properties",
|
|
158
|
+
property_names,
|
|
159
|
+
)
|
|
160
|
+
_require_unique(
|
|
161
|
+
f"schema.labels[{label_profile.name!r}].properties",
|
|
162
|
+
property_names,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
relationship_names = [rel.name for rel in self.graph_schema.relationship_types]
|
|
166
|
+
|
|
167
|
+
_require_sorted(
|
|
168
|
+
"schema.relationship_types",
|
|
169
|
+
relationship_names,
|
|
170
|
+
)
|
|
171
|
+
_require_unique(
|
|
172
|
+
"schema.relationship_types",
|
|
173
|
+
relationship_names,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
constraint_names = [constraint.name for constraint in self.graph_schema.constraints]
|
|
177
|
+
|
|
178
|
+
_require_sorted(
|
|
179
|
+
"schema.constraints",
|
|
180
|
+
constraint_names,
|
|
181
|
+
)
|
|
182
|
+
_require_unique(
|
|
183
|
+
"schema.constraints",
|
|
184
|
+
constraint_names,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for constraint in self.graph_schema.constraints:
|
|
188
|
+
_require_sorted(
|
|
189
|
+
f"schema.constraints[{constraint.name!r}].labels_or_types",
|
|
190
|
+
constraint.labels_or_types,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
_require_unique(
|
|
194
|
+
f"schema.constraints[{constraint.name!r}].labels_or_types",
|
|
195
|
+
constraint.labels_or_types,
|
|
196
|
+
)
|
|
197
|
+
_require_sorted(
|
|
198
|
+
f"schema.constraints[{constraint.name!r}].properties",
|
|
199
|
+
constraint.properties,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
_require_unique(
|
|
203
|
+
f"schema.constraints[{constraint.name!r}].properties",
|
|
204
|
+
constraint.properties,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
index_names = [index.name for index in self.graph_schema.indexes]
|
|
208
|
+
|
|
209
|
+
_require_sorted(
|
|
210
|
+
"schema.indexes",
|
|
211
|
+
index_names,
|
|
212
|
+
)
|
|
213
|
+
_require_unique(
|
|
214
|
+
"schema.indexes",
|
|
215
|
+
index_names,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
for index in self.graph_schema.indexes:
|
|
219
|
+
_require_sorted(
|
|
220
|
+
f"schema.indexes[{index.name!r}].labels_or_types",
|
|
221
|
+
index.labels_or_types,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
_require_unique(
|
|
225
|
+
f"schema.indexes[{index.name!r}].labels_or_types",
|
|
226
|
+
index.labels_or_types,
|
|
227
|
+
)
|
|
228
|
+
_require_sorted(
|
|
229
|
+
f"schema.indexes[{index.name!r}].properties",
|
|
230
|
+
index.properties,
|
|
231
|
+
)
|
|
232
|
+
_require_unique(
|
|
233
|
+
f"schema.indexes[{index.name!r}].properties",
|
|
234
|
+
index.properties,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
coverage_identities = [
|
|
238
|
+
(
|
|
239
|
+
coverage.owner,
|
|
240
|
+
coverage.owner_name,
|
|
241
|
+
coverage.property,
|
|
242
|
+
)
|
|
243
|
+
for coverage in self.statistics.property_coverage
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
_require_sorted(
|
|
247
|
+
"statistics.property_coverage",
|
|
248
|
+
coverage_identities,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
_require_unique(
|
|
252
|
+
"statistics.property_coverage",
|
|
253
|
+
coverage_identities,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _require_sorted(field: str, values: list[object]) -> None:
|
|
260
|
+
"""Ensure collections are canonically ordered."""
|
|
261
|
+
if values != sorted(values):
|
|
262
|
+
raise ValueError(f"{field} must be canonically sorted")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _require_unique(field: str, values: list[Hashable]) -> None:
|
|
266
|
+
"""Ensure collections do not contain duplicate identities."""
|
|
267
|
+
if len(values) != len(set(values)):
|
|
268
|
+
raise ValueError(f"{field} must contain unique values")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def fingerprint_input(
|
|
272
|
+
graph_schema: GraphSchema, statistics: ProfileStatistics
|
|
273
|
+
) -> dict[str, object]:
|
|
274
|
+
return {
|
|
275
|
+
"labels": [label.model_dump() for label in graph_schema.labels],
|
|
276
|
+
"relationship_types": [
|
|
277
|
+
relationship_type.model_dump() for relationship_type in graph_schema.relationship_types
|
|
278
|
+
],
|
|
279
|
+
"node_count": statistics.node_count,
|
|
280
|
+
"relationship_count": statistics.relationship_count,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def profile_fingerprint(graph_schema: GraphSchema, statistics: ProfileStatistics) -> str:
|
|
285
|
+
raw = json.dumps(
|
|
286
|
+
fingerprint_input(graph_schema, statistics),
|
|
287
|
+
sort_keys=True,
|
|
288
|
+
separators=(",", ":"),
|
|
289
|
+
).encode()
|
|
290
|
+
return f"{FINGERPRINT_ALGORITHM}:{hashlib.sha256(raw).hexdigest()}"
|