surfdude 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.
- surfdude/__init__.py +20 -0
- surfdude/conformance.py +114 -0
- surfdude/dialect/__init__.py +72 -0
- surfdude/dialects/__init__.py +4 -0
- surfdude/dialects/agenttrader.py +128 -0
- surfdude/dialects/simple.py +80 -0
- surfdude/hypertext/__init__.py +32 -0
- surfdude/hypertext/errors.py +122 -0
- surfdude/hypertext/model.py +122 -0
- surfdude/surf/__init__.py +0 -0
- surfdude/surf/preflight.py +40 -0
- surfdude/surf/request.py +59 -0
- surfdude/surf/retry.py +98 -0
- surfdude/surf/surfer.py +354 -0
- surfdude/surf/values.py +184 -0
- surfdude/surf/wave.py +184 -0
- surfdude-0.1.0.dist-info/METADATA +69 -0
- surfdude-0.1.0.dist-info/RECORD +19 -0
- surfdude-0.1.0.dist-info/WHEEL +4 -0
surfdude/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Public exports for the surfdude package."""
|
|
2
|
+
|
|
3
|
+
from surfdude.dialects import AgentTraderDialect, SimpleDialect
|
|
4
|
+
from surfdude.surf.retry import RetryAttempt, RetryPolicy, StandardRetry, never
|
|
5
|
+
from surfdude.surf.surfer import Page, Surfer
|
|
6
|
+
from surfdude.surf.wave import Report, Wave, WaveStoppedError
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AgentTraderDialect",
|
|
10
|
+
"Page",
|
|
11
|
+
"Report",
|
|
12
|
+
"RetryAttempt",
|
|
13
|
+
"RetryPolicy",
|
|
14
|
+
"SimpleDialect",
|
|
15
|
+
"StandardRetry",
|
|
16
|
+
"Surfer",
|
|
17
|
+
"Wave",
|
|
18
|
+
"WaveStoppedError",
|
|
19
|
+
"never",
|
|
20
|
+
]
|
surfdude/conformance.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from surfdude.dialect import Dialect, HTTPResponse
|
|
8
|
+
from surfdude.hypertext import Control, Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(dialect: Dialect, directory: Path) -> None:
|
|
12
|
+
"""Run every case in directory/cases.json against dialect (call from pytest)."""
|
|
13
|
+
raw = (directory / "cases.json").read_bytes()
|
|
14
|
+
spec = json.loads(raw)
|
|
15
|
+
if spec["dialect"] != dialect.name():
|
|
16
|
+
raise AssertionError(
|
|
17
|
+
f'cases.json is for dialect {spec["dialect"]!r} but this is {dialect.name()!r}'
|
|
18
|
+
)
|
|
19
|
+
for case in spec["cases"]:
|
|
20
|
+
_run_case(dialect, directory, case)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_case(dialect: Dialect, directory: Path, case: dict[str, Any]) -> None:
|
|
24
|
+
body = (directory / case["file"]).read_bytes()
|
|
25
|
+
headers: dict[str, list[str]] = {}
|
|
26
|
+
for k, v in (case.get("headers") or {}).items():
|
|
27
|
+
headers[k] = [v]
|
|
28
|
+
if case.get("contentType"):
|
|
29
|
+
headers["Content-Type"] = [case["contentType"]]
|
|
30
|
+
response = HTTPResponse(status_code=case.get("status", 200), headers=headers, body=body)
|
|
31
|
+
if not dialect.matches(response):
|
|
32
|
+
raise AssertionError(f"the {dialect.name()} dialect should recognise this response")
|
|
33
|
+
resource = dialect.read(response)
|
|
34
|
+
_check(resource, case["expect"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check(resource: Resource, want: dict[str, Any]) -> None:
|
|
38
|
+
if resource.status != want.get("status", resource.status):
|
|
39
|
+
raise AssertionError(f"status = {resource.status}, want {want.get('status')}")
|
|
40
|
+
if "notes" in want:
|
|
41
|
+
n = want["notes"]
|
|
42
|
+
if n is not None and len(resource.notes) != n:
|
|
43
|
+
raise AssertionError(f"notes = {len(resource.notes)}, want {n}")
|
|
44
|
+
if "paging" in want and want["paging"] is not None:
|
|
45
|
+
got = resource.paging
|
|
46
|
+
wp = want["paging"]
|
|
47
|
+
if (
|
|
48
|
+
got is None
|
|
49
|
+
or got.skip != wp.get("skip", 0)
|
|
50
|
+
or got.take != wp.get("take", 0)
|
|
51
|
+
or got.count != wp.get("count", 0)
|
|
52
|
+
or got.total != wp.get("total")
|
|
53
|
+
):
|
|
54
|
+
raise AssertionError(f"paging = {got}, want {wp}")
|
|
55
|
+
if "rels" in want and want["rels"] is not None and resource.rels() != want["rels"]:
|
|
56
|
+
raise AssertionError(f"rels = {resource.rels()}, want {want['rels']}")
|
|
57
|
+
for rel, w in (want.get("controls") or {}).items():
|
|
58
|
+
c = resource.control(rel)
|
|
59
|
+
if c is None:
|
|
60
|
+
raise AssertionError(f"missing control {rel!r}")
|
|
61
|
+
_check_control(rel, c, w)
|
|
62
|
+
if "items" in want and want["items"] is not None:
|
|
63
|
+
if len(resource.items) != len(want["items"]):
|
|
64
|
+
raise AssertionError(f"items = {len(resource.items)}, want {len(want['items'])}")
|
|
65
|
+
for i, w in enumerate(want["items"]):
|
|
66
|
+
it = resource.items[i]
|
|
67
|
+
if w.get("rels") is not None and it.rels() != w["rels"]:
|
|
68
|
+
raise AssertionError(f"item {i} rels = {it.rels()}, want {w['rels']}")
|
|
69
|
+
for rel, cw in (w.get("controls") or {}).items():
|
|
70
|
+
c = it.control(rel)
|
|
71
|
+
if c is None:
|
|
72
|
+
raise AssertionError(f"item {i} is missing control {rel!r}")
|
|
73
|
+
_check_control(rel, c, cw)
|
|
74
|
+
if "problems" in want and want["problems"] is not None:
|
|
75
|
+
if len(resource.problems) != len(want["problems"]):
|
|
76
|
+
raise AssertionError(
|
|
77
|
+
f"problems = {len(resource.problems)}, want {len(want['problems'])}"
|
|
78
|
+
)
|
|
79
|
+
for i, p in enumerate(want["problems"]):
|
|
80
|
+
got = resource.problems[i]
|
|
81
|
+
if got.field != p.get("field") or got.code != p.get("code"):
|
|
82
|
+
raise AssertionError(f"problem {i} = {got}, want {p}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_control(rel: str, c: Control, w: dict[str, Any]) -> None:
|
|
86
|
+
if c.form != w.get("form", c.form):
|
|
87
|
+
raise AssertionError(f"{rel}: form = {c.form}, want {w.get('form')}")
|
|
88
|
+
if c.http_method() != w.get("method", "GET"):
|
|
89
|
+
raise AssertionError(f"{rel}: method = {c.http_method()}, want {w.get('method')}")
|
|
90
|
+
if w.get("inputs") is not None:
|
|
91
|
+
names = [inp.name for inp in c.inputs]
|
|
92
|
+
if names != w["inputs"]:
|
|
93
|
+
raise AssertionError(f"{rel}: inputs = {names}, want {w['inputs']}")
|
|
94
|
+
if w.get("required") is not None:
|
|
95
|
+
names = [inp.name for inp in c.inputs if inp.required and inp.value is None]
|
|
96
|
+
if names != w["required"]:
|
|
97
|
+
raise AssertionError(f"{rel}: required = {names}, want {w['required']}")
|
|
98
|
+
if w.get("requires") is not None and c.requires != w["requires"]:
|
|
99
|
+
raise AssertionError(f"{rel}: requires = {c.requires}, want {w['requires']}")
|
|
100
|
+
if w.get("ifMatch") is not None and c.if_match != w["ifMatch"]:
|
|
101
|
+
raise AssertionError(f"{rel}: ifMatch = {c.if_match!r}, want {w['ifMatch']!r}")
|
|
102
|
+
if w.get("inputValues"):
|
|
103
|
+
for name, want in w["inputValues"].items():
|
|
104
|
+
inp = next((i for i in c.inputs if i.name == name), None)
|
|
105
|
+
if inp is None:
|
|
106
|
+
raise AssertionError(f"{rel}: input {name!r} not found for inputValues check")
|
|
107
|
+
if not _input_value_equal(inp.value, want):
|
|
108
|
+
raise AssertionError(f"{rel}: input {name!r} value = {inp.value!r}, want {want!r}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _input_value_equal(got: Any, want: Any) -> bool:
|
|
112
|
+
if got == want:
|
|
113
|
+
return True
|
|
114
|
+
return str(got) == str(want)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from surfdude.hypertext import Resource, UnsupportedMediaTypeError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class HTTPResponse:
|
|
11
|
+
"""Minimal response shape for dialect matching and reading."""
|
|
12
|
+
|
|
13
|
+
status_code: int
|
|
14
|
+
headers: dict[str, list[str]]
|
|
15
|
+
body: bytes
|
|
16
|
+
|
|
17
|
+
def header_get(self, name: str) -> str:
|
|
18
|
+
for k, values in self.headers.items():
|
|
19
|
+
if k.lower() == name.lower() and values:
|
|
20
|
+
return values[0]
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
def header_values(self, name: str) -> list[str]:
|
|
24
|
+
for k, values in self.headers.items():
|
|
25
|
+
if k.lower() == name.lower():
|
|
26
|
+
return values
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Dialect(Protocol):
|
|
31
|
+
def name(self) -> str: ...
|
|
32
|
+
|
|
33
|
+
def accept(self) -> str: ...
|
|
34
|
+
|
|
35
|
+
def body_type(self) -> str: ...
|
|
36
|
+
|
|
37
|
+
def matches(self, response: HTTPResponse) -> bool: ...
|
|
38
|
+
|
|
39
|
+
def read(self, response: HTTPResponse) -> Resource: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Registry:
|
|
44
|
+
dialects: list[Dialect] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def names(self) -> list[str]:
|
|
47
|
+
return [d.name() for d in self.dialects]
|
|
48
|
+
|
|
49
|
+
def accept(self) -> str:
|
|
50
|
+
seen: set[str] = set()
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
for d in self.dialects:
|
|
53
|
+
a = d.accept()
|
|
54
|
+
if a and a not in seen:
|
|
55
|
+
seen.add(a)
|
|
56
|
+
parts.append(a)
|
|
57
|
+
return ", ".join(parts)
|
|
58
|
+
|
|
59
|
+
def choose(self, response: HTTPResponse) -> Dialect | None:
|
|
60
|
+
for d in self.dialects:
|
|
61
|
+
if d.matches(response):
|
|
62
|
+
return d
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def first(self) -> Dialect | None:
|
|
66
|
+
return self.dialects[0] if self.dialects else None
|
|
67
|
+
|
|
68
|
+
def unsupported(self, response: HTTPResponse) -> UnsupportedMediaTypeError:
|
|
69
|
+
return UnsupportedMediaTypeError(
|
|
70
|
+
content_type=response.header_get("Content-Type"),
|
|
71
|
+
tried=self.names(),
|
|
72
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from email.message import Message
|
|
5
|
+
|
|
6
|
+
from surfdude.dialect import HTTPResponse
|
|
7
|
+
from surfdude.hypertext import Control, Input, Item, Paging, Price, Problem, Resource
|
|
8
|
+
|
|
9
|
+
PROFILE_URI = "https://agenttrader.net/profiles/envelope/v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentTraderDialect:
|
|
13
|
+
def name(self) -> str:
|
|
14
|
+
return "agenttrader"
|
|
15
|
+
|
|
16
|
+
def accept(self) -> str:
|
|
17
|
+
return "application/json"
|
|
18
|
+
|
|
19
|
+
def body_type(self) -> str:
|
|
20
|
+
return "application/json"
|
|
21
|
+
|
|
22
|
+
def matches(self, response: HTTPResponse) -> bool:
|
|
23
|
+
mt = _parse_media_type(response.header_get("Content-Type"))
|
|
24
|
+
if not (mt == "application/json" or mt.endswith("+json")):
|
|
25
|
+
return False
|
|
26
|
+
for link in response.header_values("Link"):
|
|
27
|
+
if f"<{PROFILE_URI}>" in link and 'rel="profile"' in link:
|
|
28
|
+
return True
|
|
29
|
+
try:
|
|
30
|
+
probe = json.loads(response.body)
|
|
31
|
+
except json.JSONDecodeError:
|
|
32
|
+
return False
|
|
33
|
+
return isinstance(probe.get("status"), int) and isinstance(probe.get("data"), list)
|
|
34
|
+
|
|
35
|
+
def read(self, response: HTTPResponse) -> Resource:
|
|
36
|
+
env = json.loads(response.body)
|
|
37
|
+
res = Resource(
|
|
38
|
+
status=env.get("status") or response.status_code,
|
|
39
|
+
message=env.get("message", ""),
|
|
40
|
+
notes=list(env.get("notes") or []),
|
|
41
|
+
controls=_to_controls(env.get("_links") or [], env.get("_actions") or []),
|
|
42
|
+
body=response.body,
|
|
43
|
+
)
|
|
44
|
+
paging = env.get("paging")
|
|
45
|
+
if paging:
|
|
46
|
+
res.paging = Paging(
|
|
47
|
+
skip=paging.get("skip", 0),
|
|
48
|
+
take=paging.get("take", 0),
|
|
49
|
+
count=paging.get("count", 0),
|
|
50
|
+
total=paging.get("total"),
|
|
51
|
+
)
|
|
52
|
+
for raw in env.get("data") or []:
|
|
53
|
+
if isinstance(raw, dict):
|
|
54
|
+
item_raw = json.dumps(raw).encode()
|
|
55
|
+
res.items.append(
|
|
56
|
+
Item(
|
|
57
|
+
controls=_to_controls(raw.get("_links") or [], raw.get("_actions") or []),
|
|
58
|
+
notes=list(raw.get("notes") or []),
|
|
59
|
+
data=item_raw,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
res.items.append(Item(data=json.dumps(raw).encode()))
|
|
64
|
+
if res.status >= 400:
|
|
65
|
+
for raw in env.get("data") or []:
|
|
66
|
+
if not isinstance(raw, dict):
|
|
67
|
+
continue
|
|
68
|
+
if raw.get("message"):
|
|
69
|
+
res.problems.append(
|
|
70
|
+
Problem(
|
|
71
|
+
field=raw.get("field", ""),
|
|
72
|
+
code=raw.get("code", ""),
|
|
73
|
+
message=raw.get("message", ""),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return res
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_media_type(value: str) -> str:
|
|
80
|
+
if not value:
|
|
81
|
+
return ""
|
|
82
|
+
msg = Message()
|
|
83
|
+
msg["content-type"] = value
|
|
84
|
+
return msg.get_content_type()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _to_controls(links: list, actions: list) -> list[Control]:
|
|
88
|
+
out: list[Control] = []
|
|
89
|
+
for link in links:
|
|
90
|
+
out.append(
|
|
91
|
+
Control(
|
|
92
|
+
rel=link.get("rel", ""),
|
|
93
|
+
title=link.get("title", ""),
|
|
94
|
+
href=link.get("href", ""),
|
|
95
|
+
method=link.get("method", ""),
|
|
96
|
+
type=link.get("type", ""),
|
|
97
|
+
requires=list(link.get("requires") or []),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
for action in actions:
|
|
101
|
+
c = Control(
|
|
102
|
+
rel=action.get("rel", ""),
|
|
103
|
+
title=action.get("title", ""),
|
|
104
|
+
href=action.get("action", ""),
|
|
105
|
+
method=action.get("method", ""),
|
|
106
|
+
enctype=action.get("enctype", ""),
|
|
107
|
+
form=True,
|
|
108
|
+
requires=list(action.get("requires") or []),
|
|
109
|
+
if_match=action.get("ifMatch", ""),
|
|
110
|
+
)
|
|
111
|
+
price = action.get("price")
|
|
112
|
+
if price:
|
|
113
|
+
c.price = Price(
|
|
114
|
+
amount=price.get("amount", ""),
|
|
115
|
+
currency=price.get("currency", ""),
|
|
116
|
+
note=price.get("note", ""),
|
|
117
|
+
)
|
|
118
|
+
for field in action.get("fields") or []:
|
|
119
|
+
c.inputs.append(
|
|
120
|
+
Input(
|
|
121
|
+
name=field.get("name", ""),
|
|
122
|
+
required=field.get("required", False),
|
|
123
|
+
value=field.get("value"),
|
|
124
|
+
schema=dict(field.get("schema") or {}),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
out.append(c)
|
|
128
|
+
return out
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from email.message import Message
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
|
|
7
|
+
from surfdude.dialect import HTTPResponse
|
|
8
|
+
from surfdude.hypertext import Control, Input, Item, Resource, Schema
|
|
9
|
+
|
|
10
|
+
MEDIA_TYPE = "application/simple-hypertext+json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SimpleDialect:
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "simple"
|
|
16
|
+
|
|
17
|
+
def accept(self) -> str:
|
|
18
|
+
return MEDIA_TYPE
|
|
19
|
+
|
|
20
|
+
def body_type(self) -> str:
|
|
21
|
+
return "application/json"
|
|
22
|
+
|
|
23
|
+
def matches(self, response: HTTPResponse) -> bool:
|
|
24
|
+
mt = _parse_media_type(response.header_get("Content-Type"))
|
|
25
|
+
return mt == MEDIA_TYPE
|
|
26
|
+
|
|
27
|
+
def read(self, response: HTTPResponse) -> Resource:
|
|
28
|
+
doc = json.loads(response.body)
|
|
29
|
+
links = doc.get("links") or []
|
|
30
|
+
res = Resource(
|
|
31
|
+
status=response.status_code,
|
|
32
|
+
message=HTTPStatus(response.status_code).phrase,
|
|
33
|
+
controls=_controls(links),
|
|
34
|
+
body=response.body,
|
|
35
|
+
)
|
|
36
|
+
for item in doc.get("items") or []:
|
|
37
|
+
raw = json.dumps(item).encode()
|
|
38
|
+
res.items.append(
|
|
39
|
+
Item(controls=_controls(item.get("links") or []), data=raw)
|
|
40
|
+
)
|
|
41
|
+
return res
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_media_type(value: str) -> str:
|
|
45
|
+
if not value:
|
|
46
|
+
return ""
|
|
47
|
+
msg = Message()
|
|
48
|
+
msg["content-type"] = value
|
|
49
|
+
return msg.get_content_type()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _controls(links: list) -> list[Control]:
|
|
53
|
+
out: list[Control] = []
|
|
54
|
+
for link in links:
|
|
55
|
+
form = link.get("form") or []
|
|
56
|
+
c = Control(
|
|
57
|
+
rel=link.get("rel", ""),
|
|
58
|
+
title=link.get("description", ""),
|
|
59
|
+
href=link.get("href", ""),
|
|
60
|
+
method=link.get("method", ""),
|
|
61
|
+
if_match=link.get("ifMatch", ""),
|
|
62
|
+
form=len(form) > 0,
|
|
63
|
+
)
|
|
64
|
+
for inp in form:
|
|
65
|
+
hin = Input(
|
|
66
|
+
name=inp.get("name", ""),
|
|
67
|
+
required=not inp.get("isOptional", False),
|
|
68
|
+
value=inp.get("value"),
|
|
69
|
+
)
|
|
70
|
+
schema: Schema = {}
|
|
71
|
+
if inp.get("label"):
|
|
72
|
+
schema["description"] = inp["label"]
|
|
73
|
+
opts = inp.get("options")
|
|
74
|
+
if opts:
|
|
75
|
+
schema["enum"] = list(opts)
|
|
76
|
+
if schema:
|
|
77
|
+
hin.schema = schema
|
|
78
|
+
c.inputs.append(hin)
|
|
79
|
+
out.append(c)
|
|
80
|
+
return out
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from surfdude.hypertext.errors import (
|
|
2
|
+
InvalidInputError,
|
|
3
|
+
MissingInputError,
|
|
4
|
+
MultiItemReadError,
|
|
5
|
+
NoDataError,
|
|
6
|
+
RelNotFoundError,
|
|
7
|
+
RequestFailedError,
|
|
8
|
+
UnexpectedInputError,
|
|
9
|
+
UnexpectedStatusError,
|
|
10
|
+
UnsupportedMediaTypeError,
|
|
11
|
+
)
|
|
12
|
+
from surfdude.hypertext.model import Control, Input, Item, Paging, Price, Problem, Resource, Schema
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Control",
|
|
16
|
+
"Input",
|
|
17
|
+
"InvalidInputError",
|
|
18
|
+
"Item",
|
|
19
|
+
"MissingInputError",
|
|
20
|
+
"MultiItemReadError",
|
|
21
|
+
"NoDataError",
|
|
22
|
+
"Paging",
|
|
23
|
+
"Price",
|
|
24
|
+
"Problem",
|
|
25
|
+
"RelNotFoundError",
|
|
26
|
+
"RequestFailedError",
|
|
27
|
+
"Resource",
|
|
28
|
+
"Schema",
|
|
29
|
+
"UnexpectedInputError",
|
|
30
|
+
"UnexpectedStatusError",
|
|
31
|
+
"UnsupportedMediaTypeError",
|
|
32
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from surfdude.hypertext.model import Input, Resource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RelNotFoundError(Exception):
|
|
10
|
+
rel: str
|
|
11
|
+
where: str
|
|
12
|
+
offered: list[str]
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
offered = ", ".join(self.offered) if self.offered else "nothing"
|
|
16
|
+
return (
|
|
17
|
+
f'Unable to follow "{self.rel}". {self.where} has no control with that rel. '
|
|
18
|
+
f"It offers: {offered}."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MissingInputError(Exception):
|
|
24
|
+
rel: str
|
|
25
|
+
missing: list[Input]
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
parts: list[str] = []
|
|
29
|
+
for inp in self.missing:
|
|
30
|
+
label = inp.name
|
|
31
|
+
desc = inp.schema.get("description") if inp.schema else None
|
|
32
|
+
if isinstance(desc, str) and desc:
|
|
33
|
+
label = f"{inp.name} ({desc})"
|
|
34
|
+
parts.append(label)
|
|
35
|
+
return f'Unable to submit "{self.rel}". It needs: {", ".join(parts)}.'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class InvalidInputError(Exception):
|
|
40
|
+
rel: str
|
|
41
|
+
name: str
|
|
42
|
+
reason: str
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
return (
|
|
46
|
+
f'Unable to submit "{self.rel}". The value for {self.name} is not allowed: {self.reason}.'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class UnexpectedInputError(Exception):
|
|
52
|
+
rel: str
|
|
53
|
+
name: str
|
|
54
|
+
allowed: list[str]
|
|
55
|
+
|
|
56
|
+
def __str__(self) -> str:
|
|
57
|
+
return (
|
|
58
|
+
f'Unable to submit "{self.rel}". It has no input called {self.name}. '
|
|
59
|
+
f"Its inputs are: {', '.join(self.allowed)}."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class UnsupportedMediaTypeError(Exception):
|
|
65
|
+
content_type: str
|
|
66
|
+
tried: list[str]
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
return (
|
|
70
|
+
f'Unable to read the response. No dialect understands "{self.content_type}". '
|
|
71
|
+
f"Dialects tried: {', '.join(self.tried)}."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class UnexpectedStatusError(Exception):
|
|
77
|
+
step: str
|
|
78
|
+
status: int
|
|
79
|
+
message: str = ""
|
|
80
|
+
resource: Resource | None = None
|
|
81
|
+
attempts: int = 1
|
|
82
|
+
"""How many times the request was sent. More than 1 only when a retry policy tried again."""
|
|
83
|
+
url: str = ""
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
text = f"Unable to {self.step}. The server answered {self.status}"
|
|
87
|
+
if self.message:
|
|
88
|
+
text += f": {self.message}"
|
|
89
|
+
if self.resource and self.resource.problems:
|
|
90
|
+
ps = [f"{p.field} {p.message}".strip() for p in self.resource.problems]
|
|
91
|
+
text += f" ({'; '.join(ps)})"
|
|
92
|
+
text += "."
|
|
93
|
+
if self.attempts > 1:
|
|
94
|
+
text += f" It was tried {self.attempts} times."
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class RequestFailedError(Exception):
|
|
100
|
+
"""The request never got an answer, for example the connection failed or timed out."""
|
|
101
|
+
|
|
102
|
+
step: str
|
|
103
|
+
cause: Exception
|
|
104
|
+
attempts: int = 1
|
|
105
|
+
|
|
106
|
+
def __str__(self) -> str:
|
|
107
|
+
tried = f" after {self.attempts} attempts" if self.attempts > 1 else ""
|
|
108
|
+
return f"Unable to {self.step}. The request failed{tried}: {self.cause}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class NoDataError(Exception):
|
|
113
|
+
def __str__(self) -> str:
|
|
114
|
+
return "Unable to read data. The response carried none."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class MultiItemReadError(Exception):
|
|
118
|
+
def __init__(self, count: int) -> None:
|
|
119
|
+
self.count = count
|
|
120
|
+
super().__init__(
|
|
121
|
+
f"Unable to read data. This page has {count} items; select one with Item(i)."
|
|
122
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
Schema = dict[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Input:
|
|
12
|
+
name: str
|
|
13
|
+
required: bool = False
|
|
14
|
+
value: Any = None
|
|
15
|
+
schema: Schema = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Price:
|
|
20
|
+
amount: str = ""
|
|
21
|
+
currency: str = ""
|
|
22
|
+
note: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Control:
|
|
27
|
+
rel: str
|
|
28
|
+
title: str = ""
|
|
29
|
+
href: str = ""
|
|
30
|
+
method: str = ""
|
|
31
|
+
enctype: str = ""
|
|
32
|
+
type: str = ""
|
|
33
|
+
form: bool = False
|
|
34
|
+
inputs: list[Input] = field(default_factory=list)
|
|
35
|
+
requires: list[str] = field(default_factory=list)
|
|
36
|
+
if_match: str = ""
|
|
37
|
+
price: Price | None = None
|
|
38
|
+
|
|
39
|
+
def http_method(self) -> str:
|
|
40
|
+
return (self.method or "GET").upper()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Paging:
|
|
45
|
+
skip: int = 0
|
|
46
|
+
take: int = 0
|
|
47
|
+
count: int = 0
|
|
48
|
+
total: int | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Problem:
|
|
53
|
+
field: str = ""
|
|
54
|
+
code: str = ""
|
|
55
|
+
message: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Item:
|
|
60
|
+
controls: list[Control] = field(default_factory=list)
|
|
61
|
+
notes: list[str] = field(default_factory=list)
|
|
62
|
+
data: bytes = b""
|
|
63
|
+
|
|
64
|
+
def control(self, rel: str) -> Control | None:
|
|
65
|
+
return _find(self.controls, rel)
|
|
66
|
+
|
|
67
|
+
def rels(self) -> list[str]:
|
|
68
|
+
return [c.rel for c in self.controls]
|
|
69
|
+
|
|
70
|
+
def decode(self, into: Any) -> None:
|
|
71
|
+
parsed = json.loads(self.data)
|
|
72
|
+
if isinstance(into, dict) and isinstance(parsed, dict):
|
|
73
|
+
into.clear()
|
|
74
|
+
into.update(parsed)
|
|
75
|
+
return
|
|
76
|
+
if isinstance(parsed, dict):
|
|
77
|
+
for k, v in parsed.items():
|
|
78
|
+
if hasattr(into, k):
|
|
79
|
+
setattr(into, k, v)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Resource:
|
|
84
|
+
status: int = 0
|
|
85
|
+
message: str = ""
|
|
86
|
+
notes: list[str] = field(default_factory=list)
|
|
87
|
+
paging: Paging | None = None
|
|
88
|
+
controls: list[Control] = field(default_factory=list)
|
|
89
|
+
items: list[Item] = field(default_factory=list)
|
|
90
|
+
problems: list[Problem] = field(default_factory=list)
|
|
91
|
+
body: bytes = b""
|
|
92
|
+
|
|
93
|
+
def control(self, rel: str) -> Control | None:
|
|
94
|
+
return _find(self.controls, rel)
|
|
95
|
+
|
|
96
|
+
def rels(self) -> list[str]:
|
|
97
|
+
return [c.rel for c in self.controls]
|
|
98
|
+
|
|
99
|
+
def duplicate_rels(self) -> list[str]:
|
|
100
|
+
counts: dict[str, int] = {}
|
|
101
|
+
for c in self.controls:
|
|
102
|
+
if c.rel:
|
|
103
|
+
counts[c.rel] = counts.get(c.rel, 0) + 1
|
|
104
|
+
return sorted(rel for rel, n in counts.items() if n > 1)
|
|
105
|
+
|
|
106
|
+
def decode(self, into: Any) -> None:
|
|
107
|
+
if self.problems:
|
|
108
|
+
from surfdude.hypertext.errors import NoDataError
|
|
109
|
+
|
|
110
|
+
raise NoDataError()
|
|
111
|
+
if not self.items:
|
|
112
|
+
from surfdude.hypertext.errors import NoDataError
|
|
113
|
+
|
|
114
|
+
raise NoDataError()
|
|
115
|
+
self.items[0].decode(into)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find(controls: list[Control], rel: str) -> Control | None:
|
|
119
|
+
for c in controls:
|
|
120
|
+
if c.rel == rel:
|
|
121
|
+
return c
|
|
122
|
+
return None
|