lltp 0.1.0a1__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.
- lltp/__init__.py +15 -0
- lltp/client.py +85 -0
- lltp/lifecycle.py +80 -0
- lltp/schemas/__init__.py +1 -0
- lltp/schemas/envelope.schema.json +1302 -0
- lltp/schemas/provider.schema.json +383 -0
- lltp/types.py +61 -0
- lltp/validate.py +106 -0
- lltp/webhook.py +91 -0
- lltp-0.1.0a1.dist-info/METADATA +131 -0
- lltp-0.1.0a1.dist-info/RECORD +14 -0
- lltp-0.1.0a1.dist-info/WHEEL +5 -0
- lltp-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- lltp-0.1.0a1.dist-info/top_level.txt +1 -0
lltp/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""LLTP v0.1 — Python reference SDK."""
|
|
2
|
+
|
|
3
|
+
from lltp.lifecycle import derive_order_status, merge_requirements
|
|
4
|
+
from lltp.validate import ValidationError, validate_catalog, validate_envelope
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0a1"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"__version__",
|
|
10
|
+
"ValidationError",
|
|
11
|
+
"derive_order_status",
|
|
12
|
+
"merge_requirements",
|
|
13
|
+
"validate_catalog",
|
|
14
|
+
"validate_envelope",
|
|
15
|
+
]
|
lltp/client.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""HTTP client for LLTP v0.1 lab providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from lltp.validate import validate_catalog, validate_envelope
|
|
11
|
+
|
|
12
|
+
JsonDict = Dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLTPClient:
|
|
16
|
+
"""Thin httpx wrapper for LLTP v0.1 provider endpoints."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
timeout: float = 30.0,
|
|
22
|
+
validate_outgoing: bool = True,
|
|
23
|
+
client: Optional[httpx.Client] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._validate_outgoing = validate_outgoing
|
|
26
|
+
self._owns_client = client is None
|
|
27
|
+
self._client = client or httpx.Client(timeout=timeout)
|
|
28
|
+
|
|
29
|
+
def close(self) -> None:
|
|
30
|
+
if self._owns_client:
|
|
31
|
+
self._client.close()
|
|
32
|
+
|
|
33
|
+
def __enter__(self) -> "LLTPClient":
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def __exit__(self, *args: object) -> None:
|
|
37
|
+
self.close()
|
|
38
|
+
|
|
39
|
+
def fetch_catalog(self, base_url: str) -> JsonDict:
|
|
40
|
+
url = self._url(base_url, "/.well-known/lltp-catalog.json")
|
|
41
|
+
response = self._client.get(url)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
data = response.json()
|
|
44
|
+
validate_catalog(data)
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
def submit_order(self, base_url: str, envelope: JsonDict) -> JsonDict:
|
|
48
|
+
if self._validate_outgoing:
|
|
49
|
+
validate_envelope(envelope)
|
|
50
|
+
url = self._url(base_url, "/.lltp/orders")
|
|
51
|
+
response = self._client.post(url, json=envelope)
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
return response.json()
|
|
54
|
+
|
|
55
|
+
def get_order(self, base_url: str, order_id: str) -> JsonDict:
|
|
56
|
+
url = self._url(base_url, f"/.lltp/orders/{order_id}")
|
|
57
|
+
response = self._client.get(url)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
return response.json()
|
|
60
|
+
|
|
61
|
+
def get_order_status(self, base_url: str, order_id: str) -> JsonDict:
|
|
62
|
+
url = self._url(base_url, f"/.lltp/orders/{order_id}/status")
|
|
63
|
+
response = self._client.get(url)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
return response.json()
|
|
66
|
+
|
|
67
|
+
def fulfill_requirement(
|
|
68
|
+
self,
|
|
69
|
+
base_url: str,
|
|
70
|
+
order_id: str,
|
|
71
|
+
requirement_id: str,
|
|
72
|
+
envelope: JsonDict,
|
|
73
|
+
) -> JsonDict:
|
|
74
|
+
if self._validate_outgoing:
|
|
75
|
+
validate_envelope(envelope)
|
|
76
|
+
path = f"/.lltp/orders/{order_id}/requirements/{requirement_id}"
|
|
77
|
+
url = self._url(base_url, path)
|
|
78
|
+
response = self._client.post(url, json=envelope)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
return response.json()
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _url(base_url: str, path: str) -> str:
|
|
84
|
+
base = base_url if base_url.endswith("/") else base_url + "/"
|
|
85
|
+
return urljoin(base, path.lstrip("/"))
|
lltp/lifecycle.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Order lifecycle derivation and requirement merge semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from lltp.types import MUTABLE_REQUIREMENT_FIELDS, RequirementDict
|
|
9
|
+
|
|
10
|
+
_SATISFIED = frozenset({"FULFILLED", "SKIPPED"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def derive_order_status(
|
|
14
|
+
requirements: List[RequirementDict],
|
|
15
|
+
*,
|
|
16
|
+
cancelled: bool = False,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Derive order status from the requirement set.
|
|
20
|
+
|
|
21
|
+
Implements rules in spec/0.1/lifecycle.md. SUBMITTED is not produced here —
|
|
22
|
+
it is an optional HTTP 202 courtesy only.
|
|
23
|
+
"""
|
|
24
|
+
if cancelled:
|
|
25
|
+
return "CANCELLED"
|
|
26
|
+
if any(r.get("status") == "REJECTED" for r in requirements):
|
|
27
|
+
return "FAILED"
|
|
28
|
+
if requirements and all(r.get("status") in _SATISFIED for r in requirements):
|
|
29
|
+
return "COMPLETED"
|
|
30
|
+
if any(
|
|
31
|
+
r.get("status") == "AWAITING" and r.get("assigned_to") == "REQUESTER"
|
|
32
|
+
for r in requirements
|
|
33
|
+
):
|
|
34
|
+
return "BLOCKED"
|
|
35
|
+
requester_done = all(
|
|
36
|
+
r.get("status") in _SATISFIED
|
|
37
|
+
for r in requirements
|
|
38
|
+
if r.get("assigned_to") == "REQUESTER"
|
|
39
|
+
)
|
|
40
|
+
if requester_done and any(
|
|
41
|
+
r.get("status") == "AWAITING"
|
|
42
|
+
and r.get("assigned_to") in ("PROVIDER", "EXTERNAL")
|
|
43
|
+
for r in requirements
|
|
44
|
+
):
|
|
45
|
+
return "IN_PROGRESS"
|
|
46
|
+
if any(r.get("status") == "AWAITING" for r in requirements):
|
|
47
|
+
return "ACTIVE"
|
|
48
|
+
return "ACTIVE"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def merge_requirements(
|
|
52
|
+
current: List[RequirementDict],
|
|
53
|
+
delta: List[RequirementDict],
|
|
54
|
+
) -> List[RequirementDict]:
|
|
55
|
+
"""
|
|
56
|
+
Merge STATUS delta entries into the current requirement list.
|
|
57
|
+
|
|
58
|
+
Upserts by requirement_id. Only mutable fields from the delta are applied
|
|
59
|
+
to existing entries; immutable template fields are preserved.
|
|
60
|
+
"""
|
|
61
|
+
by_id = {r["requirement_id"]: deepcopy(r) for r in current}
|
|
62
|
+
for patch in delta:
|
|
63
|
+
req_id = patch["requirement_id"]
|
|
64
|
+
if req_id not in by_id:
|
|
65
|
+
by_id[req_id] = deepcopy(patch)
|
|
66
|
+
continue
|
|
67
|
+
existing = by_id[req_id]
|
|
68
|
+
for key, value in patch.items():
|
|
69
|
+
if key in MUTABLE_REQUIREMENT_FIELDS or key == "requirement_id":
|
|
70
|
+
if key == "requirement_id":
|
|
71
|
+
continue
|
|
72
|
+
existing[key] = value
|
|
73
|
+
elif key not in existing:
|
|
74
|
+
existing[key] = value
|
|
75
|
+
return list(by_id.values())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def requirement_index(requirements: List[RequirementDict]) -> dict:
|
|
79
|
+
"""Map requirement_id -> requirement dict."""
|
|
80
|
+
return {r["requirement_id"]: r for r in requirements}
|
lltp/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vendored LLTP JSON Schema files."""
|