surfdude 0.1.0__tar.gz

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.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.3
2
+ Name: surfdude
3
+ Version: 0.1.0
4
+ Summary: Hypermedia API client: drive journeys by rel, not URLs
5
+ Author: Luke Puplett
6
+ Author-email: Luke Puplett <lpuplett@pacificam.co.uk>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Python: >=3.12
9
+ Project-URL: Homepage, https://github.com/lukepuplett/surfdude-csharp
10
+ Project-URL: Repository, https://github.com/lukepuplett/surfdude-csharp
11
+ Project-URL: Documentation, https://github.com/lukepuplett/surfdude-csharp/blob/master/README.md
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Surfdude for Python
15
+
16
+ Hypermedia client: drive an API by `rel` names, not URLs. Register a **dialect** per **media type** (`SimpleDialect`, `AgentTraderDialect`). Same [fixtures](../fixtures/) as Go and C#.
17
+
18
+ ## Toolchain (if you are new to Python here)
19
+
20
+ | Piece | What we use | Why |
21
+ |-------|-------------|-----|
22
+ | **Python version** | **3.12+** (`requires-python` in `pyproject.toml`) | Modern typing and long support window |
23
+ | **Local pin** | `.python-version` | Tells [uv](https://docs.astral.sh/uv/) which interpreter to install for this folder |
24
+ | **Package manager** | **uv** only | Creates `.venv`, installs deps, runs pytest — no system `pip` needed |
25
+ | **HTTP** | **httpx** | Sync client (async can come later) |
26
+ | **Tests** | **pytest** | Conformance runs every row in `../fixtures/*/cases.json` |
27
+ | **Lint** | **ruff** | Style and import hygiene |
28
+
29
+ You do not need a global Python install if uv is on your PATH: uv downloads the pinned version on first `sync`.
30
+
31
+ ## Commands
32
+
33
+ ```sh
34
+ cd python
35
+ uv sync --all-groups # create .venv and install surfdude + dev tools
36
+ uv run pytest # conformance + wave journey tests
37
+ uv run ruff check src tests # lint
38
+ ```
39
+
40
+ ## Layout
41
+
42
+ ```text
43
+ src/surfdude/
44
+ hypertext/ Resource, Control, errors
45
+ dialect/ registry and HTTPResponse for dialects
46
+ dialects/ simple, agenttrader
47
+ surf/ Surfer, Page, Wave
48
+ conformance.py shared fixture runner (same cases as Go)
49
+ tests/
50
+ ```
51
+
52
+ ## Sample ride
53
+
54
+ ```python
55
+ from surfdude import AgentTraderDialect, Surfer
56
+
57
+ with Surfer.new("http://localhost:8080", AgentTraderDialect()) as s:
58
+ s.wave().from_root().then_submit(
59
+ "search-listings", {"make": "MINI"}
60
+ ).then_item(0).then_submit(
61
+ "shortlist", {"note": "nice colour"}
62
+ ).go()
63
+ ```
64
+
65
+ See [../docs/model.md](../docs/model.md) and [../docs/dialects.md](../docs/dialects.md).
66
+
67
+ ## Publishing
68
+
69
+ PyPI package **`surfdude`**: tag **`vX.Y.Z`** on the repo triggers [Release workflow](../.github/workflows/release.yml) (trusted publisher OIDC). Install: `pip install surfdude==X.Y.Z`. Full process: [../docs/releasing.md](../docs/releasing.md).
@@ -0,0 +1,56 @@
1
+ # Surfdude for Python
2
+
3
+ Hypermedia client: drive an API by `rel` names, not URLs. Register a **dialect** per **media type** (`SimpleDialect`, `AgentTraderDialect`). Same [fixtures](../fixtures/) as Go and C#.
4
+
5
+ ## Toolchain (if you are new to Python here)
6
+
7
+ | Piece | What we use | Why |
8
+ |-------|-------------|-----|
9
+ | **Python version** | **3.12+** (`requires-python` in `pyproject.toml`) | Modern typing and long support window |
10
+ | **Local pin** | `.python-version` | Tells [uv](https://docs.astral.sh/uv/) which interpreter to install for this folder |
11
+ | **Package manager** | **uv** only | Creates `.venv`, installs deps, runs pytest — no system `pip` needed |
12
+ | **HTTP** | **httpx** | Sync client (async can come later) |
13
+ | **Tests** | **pytest** | Conformance runs every row in `../fixtures/*/cases.json` |
14
+ | **Lint** | **ruff** | Style and import hygiene |
15
+
16
+ You do not need a global Python install if uv is on your PATH: uv downloads the pinned version on first `sync`.
17
+
18
+ ## Commands
19
+
20
+ ```sh
21
+ cd python
22
+ uv sync --all-groups # create .venv and install surfdude + dev tools
23
+ uv run pytest # conformance + wave journey tests
24
+ uv run ruff check src tests # lint
25
+ ```
26
+
27
+ ## Layout
28
+
29
+ ```text
30
+ src/surfdude/
31
+ hypertext/ Resource, Control, errors
32
+ dialect/ registry and HTTPResponse for dialects
33
+ dialects/ simple, agenttrader
34
+ surf/ Surfer, Page, Wave
35
+ conformance.py shared fixture runner (same cases as Go)
36
+ tests/
37
+ ```
38
+
39
+ ## Sample ride
40
+
41
+ ```python
42
+ from surfdude import AgentTraderDialect, Surfer
43
+
44
+ with Surfer.new("http://localhost:8080", AgentTraderDialect()) as s:
45
+ s.wave().from_root().then_submit(
46
+ "search-listings", {"make": "MINI"}
47
+ ).then_item(0).then_submit(
48
+ "shortlist", {"note": "nice colour"}
49
+ ).go()
50
+ ```
51
+
52
+ See [../docs/model.md](../docs/model.md) and [../docs/dialects.md](../docs/dialects.md).
53
+
54
+ ## Publishing
55
+
56
+ PyPI package **`surfdude`**: tag **`vX.Y.Z`** on the repo triggers [Release workflow](../.github/workflows/release.yml) (trusted publisher OIDC). Install: `pip install surfdude==X.Y.Z`. Full process: [../docs/releasing.md](../docs/releasing.md).
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "surfdude"
3
+ version = "0.1.0"
4
+ description = "Hypermedia API client: drive journeys by rel, not URLs"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["httpx>=0.28.1"]
8
+
9
+ [[project.authors]]
10
+ name = "Luke Puplett"
11
+ email = "lpuplett@pacificam.co.uk"
12
+
13
+ [project.urls]
14
+ Homepage = "https://github.com/lukepuplett/surfdude-csharp"
15
+ Repository = "https://github.com/lukepuplett/surfdude-csharp"
16
+ Documentation = "https://github.com/lukepuplett/surfdude-csharp/blob/master/README.md"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=9.0",
21
+ "ruff>=0.17",
22
+ ]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
26
+
27
+ [tool.ruff]
28
+ line-length = 100
29
+ target-version = "py312"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.12.9,<0.13.0"]
33
+ build-backend = "uv_build"
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "surfdude"
3
+ version = "0.1.0"
4
+ description = "Hypermedia API client: drive journeys by rel, not URLs"
5
+ readme = "README.md"
6
+ authors = [{ name = "Luke Puplett", email = "lpuplett@pacificam.co.uk" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = ["httpx>=0.28.1"]
9
+
10
+ [project.urls]
11
+ Homepage = "https://github.com/lukepuplett/surfdude-csharp"
12
+ Repository = "https://github.com/lukepuplett/surfdude-csharp"
13
+ Documentation = "https://github.com/lukepuplett/surfdude-csharp/blob/master/README.md"
14
+
15
+ [dependency-groups]
16
+ dev = ["pytest>=9.0", "ruff>=0.17"]
17
+
18
+ [tool.pytest.ini_options]
19
+ testpaths = ["tests"]
20
+ [tool.ruff]
21
+ line-length = 100
22
+ target-version = "py312"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12.9,<0.13.0"]
26
+ build-backend = "uv_build"
@@ -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
+ ]