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 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
+ ]
@@ -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,4 @@
1
+ from surfdude.dialects.agenttrader import AgentTraderDialect
2
+ from surfdude.dialects.simple import SimpleDialect
3
+
4
+ __all__ = ["AgentTraderDialect", "SimpleDialect"]
@@ -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