github-task-protocol 1.0.1__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.
gtp/schema.py ADDED
@@ -0,0 +1,211 @@
1
+ """Strict JSON parsing and closed GTP v1 Record validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import unicodedata
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ from .urls import parse_github_url
12
+
13
+
14
+ UUID_V4 = re.compile(
15
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
16
+ )
17
+ FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
18
+ CONDITION_ID = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
19
+
20
+
21
+ class DuplicateKeyError(ValueError):
22
+ def __init__(self, key: str):
23
+ super().__init__(f"duplicate key: {key}")
24
+ self.key = key
25
+
26
+
27
+ def _pairs_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
28
+ result: dict[str, Any] = {}
29
+ for key, value in pairs:
30
+ if key in result:
31
+ raise DuplicateKeyError(key)
32
+ result[key] = value
33
+ return result
34
+
35
+
36
+ def strict_json_loads(source: str) -> tuple[Any | None, list[dict[str, str]]]:
37
+ try:
38
+ value = json.loads(
39
+ source,
40
+ object_pairs_hook=_pairs_object,
41
+ parse_constant=lambda token: (_ for _ in ()).throw(ValueError(token)),
42
+ )
43
+ except DuplicateKeyError as error:
44
+ return None, [{"code": "duplicate_key", "path": f"$.{error.key}"}]
45
+ except (json.JSONDecodeError, ValueError) as error:
46
+ return None, [{"code": "invalid_json", "path": "$", "message": str(error)}]
47
+ return value, []
48
+
49
+
50
+ def _error(errors: list[dict[str, str]], code: str, path: str, message: str | None = None) -> None:
51
+ item = {"code": code, "path": path}
52
+ if message:
53
+ item["message"] = message
54
+ errors.append(item)
55
+
56
+
57
+ def _closed_object(
58
+ value: object,
59
+ path: str,
60
+ allowed: set[str],
61
+ required: set[str],
62
+ errors: list[dict[str, str]],
63
+ ) -> dict[str, Any] | None:
64
+ if not isinstance(value, dict):
65
+ _error(errors, "invalid_type", path, "expected object")
66
+ return None
67
+ for field in sorted(set(value) - allowed):
68
+ _error(errors, "unknown_field", f"{path}.{field}")
69
+ for field in sorted(required - set(value)):
70
+ _error(errors, "missing_field", f"{path}.{field}")
71
+ return value
72
+
73
+
74
+ def _clean_text(value: object) -> bool:
75
+ return (
76
+ isinstance(value, str)
77
+ and bool(value)
78
+ and value == value.strip()
79
+ and not any(unicodedata.category(char) == "Cc" for char in value)
80
+ )
81
+
82
+
83
+ def _scope_path_valid(value: object) -> bool:
84
+ if (
85
+ not isinstance(value, str)
86
+ or not value
87
+ or value.startswith("/")
88
+ or any(character in value for character in "*?[]")
89
+ ):
90
+ return False
91
+ if value == ".":
92
+ return True
93
+ directory = value.endswith("/")
94
+ core = value[:-1] if directory else value
95
+ segments = core.split("/")
96
+ return bool(core) and all(segment and segment not in {".", ".."} for segment in segments)
97
+
98
+
99
+ def _validate_envelope(record: dict[str, Any], errors: list[dict[str, str]]) -> None:
100
+ if record.get("gtp") != "1.0":
101
+ _error(errors, "invalid_value", "$.gtp")
102
+ if record.get("type") not in {"contract", "start", "done", "stop"}:
103
+ _error(errors, "invalid_value", "$.type")
104
+ if not isinstance(record.get("id"), str) or not UUID_V4.fullmatch(record["id"]):
105
+ _error(errors, "invalid_value", "$.id")
106
+
107
+
108
+ def _validate_contract(record: dict[str, Any], errors: list[dict[str, str]]) -> None:
109
+ if not _clean_text(record.get("goal")):
110
+ _error(errors, "invalid_value", "$.goal")
111
+ scope = record.get("scope")
112
+ if not isinstance(scope, list) or not scope:
113
+ _error(errors, "invalid_type", "$.scope", "expected non-empty array")
114
+ else:
115
+ seen: set[str] = set()
116
+ for index, item in enumerate(scope):
117
+ path = f"$.scope[{index}]"
118
+ if not _scope_path_valid(item):
119
+ _error(errors, "invalid_value", path)
120
+ elif item in seen:
121
+ _error(errors, "duplicate_value", path)
122
+ else:
123
+ seen.add(item)
124
+ conditions = record.get("done_conditions")
125
+ if not isinstance(conditions, dict) or not conditions:
126
+ _error(errors, "invalid_type", "$.done_conditions", "expected non-empty object")
127
+ return
128
+ for condition_id, condition in conditions.items():
129
+ base = f"$.done_conditions.{condition_id}"
130
+ if not CONDITION_ID.fullmatch(condition_id):
131
+ _error(errors, "invalid_condition_id", base)
132
+ item = _closed_object(condition, base, {"text", "evidence_kind"}, {"text", "evidence_kind"}, errors)
133
+ if item is None:
134
+ continue
135
+ if not _clean_text(item.get("text")):
136
+ _error(errors, "invalid_value", f"{base}.text")
137
+ if item.get("evidence_kind") not in {"check", "artifact"}:
138
+ _error(errors, "invalid_value", f"{base}.evidence_kind")
139
+
140
+
141
+ def _validate_start(record: dict[str, Any], errors: list[dict[str, str]]) -> None:
142
+ if parse_github_url(record.get("contract_ref"), "comment") is None:
143
+ _error(errors, "invalid_url", "$.contract_ref")
144
+ branch = record.get("branch")
145
+ parsed = urlsplit(branch) if isinstance(branch, str) else None
146
+ if (
147
+ not _clean_text(branch)
148
+ or branch.startswith("refs/heads/")
149
+ or (parsed is not None and (parsed.scheme or parsed.netloc))
150
+ ):
151
+ _error(errors, "invalid_value", "$.branch")
152
+
153
+
154
+ def _validate_done(record: dict[str, Any], errors: list[dict[str, str]]) -> None:
155
+ if parse_github_url(record.get("pr_ref"), "pr") is None:
156
+ _error(errors, "invalid_url", "$.pr_ref")
157
+ head_sha = record.get("head_sha")
158
+ if not isinstance(head_sha, str) or not FULL_SHA.fullmatch(head_sha):
159
+ _error(errors, "invalid_value", "$.head_sha")
160
+ evidence = record.get("evidence")
161
+ if not isinstance(evidence, dict) or not evidence:
162
+ _error(errors, "invalid_type", "$.evidence", "expected non-empty object")
163
+ return
164
+ for condition_id, url in evidence.items():
165
+ path = f"$.evidence.{condition_id}"
166
+ if not CONDITION_ID.fullmatch(condition_id):
167
+ _error(errors, "invalid_condition_id", path)
168
+ if parse_github_url(url, "check") is None and parse_github_url(url, "artifact") is None:
169
+ _error(errors, "invalid_url", path)
170
+
171
+
172
+ def _validate_stop(record: dict[str, Any], errors: list[dict[str, str]]) -> None:
173
+ reason = record.get("reason")
174
+ successor = record.get("successor_ref")
175
+ if reason == "abandoned":
176
+ if successor is not None:
177
+ _error(errors, "invalid_value", "$.successor_ref")
178
+ elif reason == "superseded":
179
+ if parse_github_url(successor, "issue") is None:
180
+ _error(errors, "invalid_url", "$.successor_ref")
181
+ else:
182
+ _error(errors, "invalid_value", "$.reason")
183
+
184
+
185
+ RECORD_FIELDS = {
186
+ "contract": {"gtp", "type", "id", "goal", "scope", "done_conditions"},
187
+ "start": {"gtp", "type", "id", "contract_ref", "branch"},
188
+ "done": {"gtp", "type", "id", "pr_ref", "head_sha", "evidence"},
189
+ "stop": {"gtp", "type", "id", "reason", "successor_ref"},
190
+ }
191
+
192
+
193
+ def validate_record(value: object) -> list[dict[str, str]]:
194
+ errors: list[dict[str, str]] = []
195
+ if not isinstance(value, dict):
196
+ return [{"code": "invalid_type", "path": "$", "message": "expected object"}]
197
+ record_type = value.get("type")
198
+ allowed = RECORD_FIELDS.get(record_type, {"gtp", "type", "id"})
199
+ record = _closed_object(value, "$", allowed, allowed, errors)
200
+ if record is None:
201
+ return errors
202
+ _validate_envelope(record, errors)
203
+ if record_type == "contract":
204
+ _validate_contract(record, errors)
205
+ elif record_type == "start":
206
+ _validate_start(record, errors)
207
+ elif record_type == "done":
208
+ _validate_done(record, errors)
209
+ elif record_type == "stop":
210
+ _validate_stop(record, errors)
211
+ return errors