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.
- surfdude-0.1.0/PKG-INFO +69 -0
- surfdude-0.1.0/README.md +56 -0
- surfdude-0.1.0/pyproject.toml +33 -0
- surfdude-0.1.0/pyproject.toml.orig +26 -0
- surfdude-0.1.0/src/surfdude/__init__.py +20 -0
- surfdude-0.1.0/src/surfdude/conformance.py +114 -0
- surfdude-0.1.0/src/surfdude/dialect/__init__.py +72 -0
- surfdude-0.1.0/src/surfdude/dialects/__init__.py +4 -0
- surfdude-0.1.0/src/surfdude/dialects/agenttrader.py +128 -0
- surfdude-0.1.0/src/surfdude/dialects/simple.py +80 -0
- surfdude-0.1.0/src/surfdude/hypertext/__init__.py +32 -0
- surfdude-0.1.0/src/surfdude/hypertext/errors.py +122 -0
- surfdude-0.1.0/src/surfdude/hypertext/model.py +122 -0
- surfdude-0.1.0/src/surfdude/surf/__init__.py +0 -0
- surfdude-0.1.0/src/surfdude/surf/preflight.py +40 -0
- surfdude-0.1.0/src/surfdude/surf/request.py +59 -0
- surfdude-0.1.0/src/surfdude/surf/retry.py +98 -0
- surfdude-0.1.0/src/surfdude/surf/surfer.py +354 -0
- surfdude-0.1.0/src/surfdude/surf/values.py +184 -0
- surfdude-0.1.0/src/surfdude/surf/wave.py +184 -0
surfdude-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|
surfdude-0.1.0/README.md
ADDED
|
@@ -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,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
|
+
]
|