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.
- github_task_protocol-1.0.1.dist-info/METADATA +88 -0
- github_task_protocol-1.0.1.dist-info/RECORD +16 -0
- github_task_protocol-1.0.1.dist-info/WHEEL +5 -0
- github_task_protocol-1.0.1.dist-info/entry_points.txt +2 -0
- github_task_protocol-1.0.1.dist-info/licenses/LICENSE +21 -0
- gtp/__init__.py +3 -0
- gtp/__main__.py +4 -0
- gtp/carrier.py +107 -0
- gtp/cli.py +52 -0
- gtp/github.py +214 -0
- gtp/model.py +93 -0
- gtp/presentation.py +419 -0
- gtp/reducer.py +184 -0
- gtp/schema.py +211 -0
- gtp/status.py +552 -0
- gtp/urls.py +100 -0
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
|