quality-graph-core 0.1.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.
- quality_graph_core-0.1.0/.gitignore +20 -0
- quality_graph_core-0.1.0/PKG-INFO +23 -0
- quality_graph_core-0.1.0/README.md +13 -0
- quality_graph_core-0.1.0/pyproject.toml +15 -0
- quality_graph_core-0.1.0/src/quality_graph_core/__init__.py +11 -0
- quality_graph_core-0.1.0/src/quality_graph_core/adapters.py +372 -0
- quality_graph_core-0.1.0/src/quality_graph_core/graph.py +669 -0
- quality_graph_core-0.1.0/src/quality_graph_core/policy.py +124 -0
- quality_graph_core-0.1.0/src/quality_graph_core/provider.py +38 -0
- quality_graph_core-0.1.0/src/quality_graph_core/py.typed +1 -0
- quality_graph_core-0.1.0/src/quality_graph_core/result.py +580 -0
- quality_graph_core-0.1.0/src/quality_graph_core/schema.py +360 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.coverage
|
|
8
|
+
coverage.json
|
|
9
|
+
coverage.xml
|
|
10
|
+
htmlcov/
|
|
11
|
+
coverage-report/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
*.egg-info/
|
|
15
|
+
.mutmut-cache/
|
|
16
|
+
mutants/
|
|
17
|
+
reports/
|
|
18
|
+
.tools/
|
|
19
|
+
node_modules/
|
|
20
|
+
.DS_Store
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quality-graph-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Platform-independent Quality Graph contracts and decision logic
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: defusedxml>=0.7.1
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Quality Graph Core
|
|
12
|
+
|
|
13
|
+
Platform-independent graph, result protocol, policy, and provider contracts for Quality Graph.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from quality_graph_core import Graph, Result
|
|
17
|
+
|
|
18
|
+
graph = Graph.from_yaml(source)
|
|
19
|
+
result = Result.from_json(payload)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Providers implement the runtime-checkable `Provider` interface and return a deterministic
|
|
23
|
+
`GeneratedProject`. Core never imports a provider or the CLI.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Quality Graph Core
|
|
2
|
+
|
|
3
|
+
Platform-independent graph, result protocol, policy, and provider contracts for Quality Graph.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from quality_graph_core import Graph, Result
|
|
7
|
+
|
|
8
|
+
graph = Graph.from_yaml(source)
|
|
9
|
+
result = Result.from_json(payload)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Providers implement the runtime-checkable `Provider` interface and return a deterministic
|
|
13
|
+
`GeneratedProject`. Core never imports a provider or the CLI.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quality-graph-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Platform-independent Quality Graph contracts and decision logic"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["defusedxml>=0.7.1", "pyyaml>=6.0.2"]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/quality_graph_core"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public package metadata for Quality Graph Core."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from quality_graph_core.graph import Graph
|
|
6
|
+
from quality_graph_core.provider import GeneratedFile, GeneratedProject, Provider
|
|
7
|
+
from quality_graph_core.result import Result
|
|
8
|
+
|
|
9
|
+
__version__ = version("quality-graph-core")
|
|
10
|
+
|
|
11
|
+
__all__ = ["GeneratedFile", "GeneratedProject", "Graph", "Provider", "Result", "__version__"]
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Translate command and report formats into the shared result protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
from defusedxml import ElementTree
|
|
11
|
+
|
|
12
|
+
from quality_graph_core.result import (
|
|
13
|
+
Annotation,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
DiagnosticKind,
|
|
16
|
+
FailureKind,
|
|
17
|
+
Finding,
|
|
18
|
+
JsonValue,
|
|
19
|
+
Metric,
|
|
20
|
+
Provenance,
|
|
21
|
+
Result,
|
|
22
|
+
ResultStatus,
|
|
23
|
+
Severity,
|
|
24
|
+
SourceLocation,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Protocol
|
|
31
|
+
|
|
32
|
+
class XmlElement(Protocol):
|
|
33
|
+
"""Describe the defused XML element operations used by the adapter."""
|
|
34
|
+
|
|
35
|
+
tag: str
|
|
36
|
+
text: str | None
|
|
37
|
+
|
|
38
|
+
def iter(self, tag: str) -> Iterator[XmlElement]:
|
|
39
|
+
"""Iterate descendants with the requested tag."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def find(self, path: str) -> XmlElement | None:
|
|
43
|
+
"""Find the first matching descendant."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def get(self, key: str, _default: str = "") -> str:
|
|
47
|
+
"""Return one XML attribute or its default."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
MAX_REPORT_BYTES = 10 * 1024 * 1024
|
|
52
|
+
MAX_SUMMARY_CHARACTERS = 60_000
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AdapterError(ValueError):
|
|
56
|
+
"""Represent deterministic failure to read or translate a report."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class AdapterContext:
|
|
61
|
+
"""Provide trusted node and workflow metadata to an adapter."""
|
|
62
|
+
|
|
63
|
+
node_id: str
|
|
64
|
+
title: str
|
|
65
|
+
command_succeeded: bool
|
|
66
|
+
provenance: Provenance
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def adapt_exit(context: AdapterContext, output: str = "") -> Result:
|
|
70
|
+
"""Map one command exit outcome to a portable result."""
|
|
71
|
+
status = ResultStatus.PASSED if context.command_succeeded else ResultStatus.FAILED
|
|
72
|
+
failure = None if context.command_succeeded else FailureKind.COMMAND
|
|
73
|
+
summary = _bounded_summary(output.strip())
|
|
74
|
+
diagnostics = (
|
|
75
|
+
()
|
|
76
|
+
if context.command_succeeded
|
|
77
|
+
else (
|
|
78
|
+
Diagnostic(
|
|
79
|
+
DiagnosticKind.COMMAND,
|
|
80
|
+
"The declared command failed.",
|
|
81
|
+
summary[:20_000],
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return Result(
|
|
86
|
+
context.node_id,
|
|
87
|
+
context.title,
|
|
88
|
+
status,
|
|
89
|
+
context.provenance,
|
|
90
|
+
failure,
|
|
91
|
+
summary,
|
|
92
|
+
diagnostics=diagnostics,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def adapt_native(context: AdapterContext, report: bytes) -> Result:
|
|
97
|
+
"""Validate a native result and bind it to trusted execution metadata."""
|
|
98
|
+
try:
|
|
99
|
+
result = Result.from_json(report)
|
|
100
|
+
except (TypeError, ValueError, json.JSONDecodeError) as error:
|
|
101
|
+
message = f"Native result is invalid: {error}"
|
|
102
|
+
raise AdapterError(message) from error
|
|
103
|
+
if result.node_id != context.node_id or result.title != context.title:
|
|
104
|
+
message = "Native result identity does not match the declared node"
|
|
105
|
+
raise AdapterError(message)
|
|
106
|
+
if result.provenance != context.provenance:
|
|
107
|
+
message = "Native result provenance does not match the current workflow attempt"
|
|
108
|
+
raise AdapterError(message)
|
|
109
|
+
return _reconcile_command(context, result)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def adapt_sarif(context: AdapterContext, report: bytes) -> Result:
|
|
113
|
+
"""Translate SARIF findings and locations into the shared protocol."""
|
|
114
|
+
data = _decode_json(report, "SARIF")
|
|
115
|
+
root = _object(data, "SARIF report")
|
|
116
|
+
runs = _array(root.get("runs"), "SARIF runs")
|
|
117
|
+
findings: list[Finding] = []
|
|
118
|
+
annotations: list[Annotation] = []
|
|
119
|
+
for run_value in runs:
|
|
120
|
+
run = _object(run_value, "SARIF run")
|
|
121
|
+
for result_value in _array(run.get("results", []), "SARIF results"):
|
|
122
|
+
finding, annotation = _sarif_finding(_object(result_value, "SARIF result"))
|
|
123
|
+
findings.append(finding)
|
|
124
|
+
if annotation is not None:
|
|
125
|
+
annotations.append(annotation)
|
|
126
|
+
errors = sum(finding.severity is Severity.ERROR for finding in findings)
|
|
127
|
+
status = ResultStatus.FAILED if errors or not context.command_succeeded else ResultStatus.PASSED
|
|
128
|
+
result = Result(
|
|
129
|
+
context.node_id,
|
|
130
|
+
context.title,
|
|
131
|
+
status,
|
|
132
|
+
context.provenance,
|
|
133
|
+
FailureKind.QUALITY if status is ResultStatus.FAILED else None,
|
|
134
|
+
f"Found {len(findings)} SARIF findings ({errors} errors).",
|
|
135
|
+
(Metric("Findings", str(len(findings))), Metric("Errors", str(errors))),
|
|
136
|
+
tuple(findings),
|
|
137
|
+
tuple(annotations),
|
|
138
|
+
)
|
|
139
|
+
return _reconcile_command(context, result)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def adapt_junit(context: AdapterContext, report: bytes) -> Result:
|
|
143
|
+
"""Translate JUnit XML test failures into stable findings."""
|
|
144
|
+
try:
|
|
145
|
+
root = ElementTree.fromstring(report)
|
|
146
|
+
except ElementTree.ParseError as error:
|
|
147
|
+
message = f"JUnit report is invalid XML: {error}"
|
|
148
|
+
raise AdapterError(message) from error
|
|
149
|
+
if root.tag not in {"testsuite", "testsuites"}:
|
|
150
|
+
message = "JUnit report root must be testsuite or testsuites"
|
|
151
|
+
raise AdapterError(message)
|
|
152
|
+
cases = tuple(root.iter("testcase"))
|
|
153
|
+
findings = tuple(
|
|
154
|
+
finding
|
|
155
|
+
for case in cases
|
|
156
|
+
for finding in (_junit_finding(cast("XmlElement", case)),)
|
|
157
|
+
if finding is not None
|
|
158
|
+
)
|
|
159
|
+
skipped = sum(case.find("skipped") is not None for case in cases)
|
|
160
|
+
status = (
|
|
161
|
+
ResultStatus.FAILED if findings or not context.command_succeeded else ResultStatus.PASSED
|
|
162
|
+
)
|
|
163
|
+
result = Result(
|
|
164
|
+
context.node_id,
|
|
165
|
+
context.title,
|
|
166
|
+
status,
|
|
167
|
+
context.provenance,
|
|
168
|
+
FailureKind.QUALITY if status is ResultStatus.FAILED else None,
|
|
169
|
+
f"Ran {len(cases)} tests: {len(findings)} failed, {skipped} skipped.",
|
|
170
|
+
(
|
|
171
|
+
Metric("Tests", str(len(cases))),
|
|
172
|
+
Metric("Failures", str(len(findings))),
|
|
173
|
+
Metric("Skipped", str(skipped)),
|
|
174
|
+
),
|
|
175
|
+
findings,
|
|
176
|
+
)
|
|
177
|
+
return _reconcile_command(context, result)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def read_report(workspace: Path, relative_path: str) -> bytes:
|
|
181
|
+
"""Read one bounded report without escaping the repository workspace."""
|
|
182
|
+
root = workspace.resolve()
|
|
183
|
+
path = (root / relative_path).resolve()
|
|
184
|
+
if path != root and root not in path.parents:
|
|
185
|
+
message = f"Report path escapes the workspace: {relative_path}"
|
|
186
|
+
raise AdapterError(message)
|
|
187
|
+
if not path.is_file():
|
|
188
|
+
message = f"Report file does not exist: {relative_path}"
|
|
189
|
+
raise AdapterError(message)
|
|
190
|
+
size = path.stat().st_size
|
|
191
|
+
if size > MAX_REPORT_BYTES:
|
|
192
|
+
message = f"Report exceeds the {MAX_REPORT_BYTES}-byte limit: {relative_path}"
|
|
193
|
+
raise AdapterError(message)
|
|
194
|
+
return path.read_bytes()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def adapter_failure(context: AdapterContext, error: AdapterError) -> Result:
|
|
198
|
+
"""Represent adapter failure distinctly from a check failure."""
|
|
199
|
+
return Result(
|
|
200
|
+
context.node_id,
|
|
201
|
+
context.title,
|
|
202
|
+
ResultStatus.FAILED,
|
|
203
|
+
context.provenance,
|
|
204
|
+
FailureKind.ADAPTER,
|
|
205
|
+
str(error),
|
|
206
|
+
diagnostics=(Diagnostic(DiagnosticKind.ADAPTER, "Result adapter failed.", str(error)),),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _reconcile_command(context: AdapterContext, result: Result) -> Result:
|
|
211
|
+
if context.command_succeeded or result.status in {ResultStatus.FAILED, ResultStatus.CANCELLED}:
|
|
212
|
+
return result
|
|
213
|
+
diagnostic = Diagnostic(
|
|
214
|
+
DiagnosticKind.COMMAND,
|
|
215
|
+
"The declared command failed despite a passing report.",
|
|
216
|
+
)
|
|
217
|
+
return replace(
|
|
218
|
+
result,
|
|
219
|
+
status=ResultStatus.FAILED,
|
|
220
|
+
failure_kind=FailureKind.COMMAND,
|
|
221
|
+
diagnostics=(*result.diagnostics, diagnostic),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _bounded_summary(value: str) -> str:
|
|
226
|
+
if len(value) <= MAX_SUMMARY_CHARACTERS:
|
|
227
|
+
return value
|
|
228
|
+
omitted = len(value) - MAX_SUMMARY_CHARACTERS
|
|
229
|
+
while True:
|
|
230
|
+
notice = f"\n\n_Output truncated; {omitted} characters omitted._"
|
|
231
|
+
prefix_length = MAX_SUMMARY_CHARACTERS - len(notice)
|
|
232
|
+
updated = len(value) - prefix_length
|
|
233
|
+
if updated == omitted:
|
|
234
|
+
return value[:prefix_length] + notice
|
|
235
|
+
omitted = updated
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _decode_json(value: bytes, context: str) -> JsonValue:
|
|
239
|
+
try:
|
|
240
|
+
return cast("JsonValue", json.loads(value))
|
|
241
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
242
|
+
message = f"{context} is invalid JSON: {error}"
|
|
243
|
+
raise AdapterError(message) from error
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _sarif_finding(data: dict[str, JsonValue]) -> tuple[Finding, Annotation | None]:
|
|
247
|
+
rule_id = _optional_string(data.get("ruleId"), "SARIF rule id")
|
|
248
|
+
message = _sarif_message(_object(data.get("message"), "SARIF message"))
|
|
249
|
+
severity = _sarif_severity(_optional_string(data.get("level"), "SARIF level"))
|
|
250
|
+
location = _sarif_location(data.get("locations"))
|
|
251
|
+
partial = _optional_object(data.get("partialFingerprints"), "SARIF partial fingerprints")
|
|
252
|
+
fingerprint = _sarif_fingerprint(rule_id, message, location, partial)
|
|
253
|
+
finding_id = f"sarif-{fingerprint[:24]}"
|
|
254
|
+
finding = Finding(
|
|
255
|
+
finding_id,
|
|
256
|
+
severity,
|
|
257
|
+
message,
|
|
258
|
+
rule_id,
|
|
259
|
+
fingerprint=fingerprint,
|
|
260
|
+
location=location,
|
|
261
|
+
)
|
|
262
|
+
annotation = Annotation(severity, message, location, rule_id) if location is not None else None
|
|
263
|
+
return finding, annotation
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _sarif_message(data: dict[str, JsonValue]) -> str:
|
|
267
|
+
text = data.get("text", data.get("markdown"))
|
|
268
|
+
return _string(text, "SARIF message text")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _sarif_severity(value: str | None) -> Severity:
|
|
272
|
+
if value == "error":
|
|
273
|
+
return Severity.ERROR
|
|
274
|
+
if value == "warning":
|
|
275
|
+
return Severity.WARNING
|
|
276
|
+
return Severity.NOTICE
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _sarif_location(value: JsonValue) -> SourceLocation | None:
|
|
280
|
+
locations = _array(value if value is not None else [], "SARIF locations")
|
|
281
|
+
if not locations:
|
|
282
|
+
return None
|
|
283
|
+
location = _object(locations[0], "SARIF location")
|
|
284
|
+
physical = _object(location.get("physicalLocation"), "SARIF physical location")
|
|
285
|
+
artifact = _object(physical.get("artifactLocation"), "SARIF artifact location")
|
|
286
|
+
region = _object(physical.get("region"), "SARIF region")
|
|
287
|
+
start_line = _integer(region.get("startLine"), "SARIF start line")
|
|
288
|
+
end_line = _optional_integer(region.get("endLine"), "SARIF end line") or start_line
|
|
289
|
+
return SourceLocation(
|
|
290
|
+
_string(artifact.get("uri"), "SARIF artifact URI"),
|
|
291
|
+
start_line,
|
|
292
|
+
end_line,
|
|
293
|
+
_optional_integer(region.get("startColumn"), "SARIF start column"),
|
|
294
|
+
_optional_integer(region.get("endColumn"), "SARIF end column"),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _sarif_fingerprint(
|
|
299
|
+
rule_id: str | None,
|
|
300
|
+
message: str,
|
|
301
|
+
location: SourceLocation | None,
|
|
302
|
+
partial: dict[str, JsonValue] | None,
|
|
303
|
+
) -> str:
|
|
304
|
+
if partial:
|
|
305
|
+
semantic = "\n".join(
|
|
306
|
+
f"{key}={_string(value, 'SARIF fingerprint')}" for key, value in sorted(partial.items())
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
semantic = "\n".join((rule_id or "", message, location.path if location else ""))
|
|
310
|
+
return hashlib.sha256(semantic.encode()).hexdigest()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _junit_finding(case: XmlElement) -> Finding | None:
|
|
314
|
+
failure = case.find("failure")
|
|
315
|
+
if failure is None:
|
|
316
|
+
failure = case.find("error")
|
|
317
|
+
if failure is None:
|
|
318
|
+
return None
|
|
319
|
+
class_name = case.get("classname", "")
|
|
320
|
+
test_name = case.get("name", "unnamed test")
|
|
321
|
+
failure_type = failure.get("type", "failure")
|
|
322
|
+
message = failure.get("message") or (failure.text or "Test failed").strip()
|
|
323
|
+
semantic = f"{class_name}\n{test_name}\n{failure_type}\n{message}"
|
|
324
|
+
fingerprint = hashlib.sha256(semantic.encode()).hexdigest()
|
|
325
|
+
return Finding(
|
|
326
|
+
f"junit-{fingerprint[:24]}",
|
|
327
|
+
Severity.ERROR,
|
|
328
|
+
message,
|
|
329
|
+
failure_type,
|
|
330
|
+
fingerprint=fingerprint,
|
|
331
|
+
group=class_name or None,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _object(value: JsonValue, context: str) -> dict[str, JsonValue]:
|
|
336
|
+
if not isinstance(value, dict):
|
|
337
|
+
message = f"{context} must be an object"
|
|
338
|
+
raise AdapterError(message)
|
|
339
|
+
return value
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _optional_object(value: JsonValue, context: str) -> dict[str, JsonValue] | None:
|
|
343
|
+
return None if value is None else _object(value, context)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _array(value: JsonValue, context: str) -> list[JsonValue]:
|
|
347
|
+
if not isinstance(value, list):
|
|
348
|
+
message = f"{context} must be an array"
|
|
349
|
+
raise AdapterError(message)
|
|
350
|
+
return value
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _string(value: JsonValue, context: str) -> str:
|
|
354
|
+
if not isinstance(value, str):
|
|
355
|
+
message = f"{context} must be a string"
|
|
356
|
+
raise AdapterError(message)
|
|
357
|
+
return value
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _optional_string(value: JsonValue, context: str) -> str | None:
|
|
361
|
+
return None if value is None else _string(value, context)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _integer(value: JsonValue, context: str) -> int:
|
|
365
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
366
|
+
message = f"{context} must be an integer"
|
|
367
|
+
raise AdapterError(message)
|
|
368
|
+
return value
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _optional_integer(value: JsonValue, context: str) -> int | None:
|
|
372
|
+
return None if value is None else _integer(value, context)
|