passageway-sdk 0.3.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,11 @@
1
+ docs/superpowers/
2
+ .superpowers/
3
+ .env
4
+ .DS_Store
5
+ .venv/
6
+ __pycache__/
7
+ .pytest_cache/
8
+ .models/
9
+ dist/
10
+ site/
11
+ .playwright-mcp/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcel Bruckner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.5
2
+ Name: passageway-sdk
3
+ Version: 0.3.0
4
+ Summary: Write a passageway connector as three small functions: the protocol plumbing is done for you
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Provides-Extra: web
9
+ Requires-Dist: httpx>=0.28.1; extra == 'web'
10
+ Requires-Dist: truststore>=0.10; extra == 'web'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # passageway-sdk
14
+
15
+ Write a [passageway](https://passageway.marceltov.de/) connector as three small functions. No dependencies; `passageway-sdk[web]` adds a polite web client for connectors that read a website.
16
+
17
+ ```python
18
+ from passageway_sdk import Connector
19
+
20
+ class Notes(Connector):
21
+ def versions(self): ... # {id: version} for every item
22
+ def fetch(self, ids): ... # an item record per id
23
+
24
+ Notes.main()
25
+ ```
26
+
27
+ The SDK dispatches the mode, reads the ids, buffers the output (all or nothing), maps errors to exit codes and validates each item. Reference: https://passageway.marceltov.de/reference/sdk/ and the [guide](https://passageway.marceltov.de/guides/write-a-connector/).
@@ -0,0 +1,15 @@
1
+ # passageway-sdk
2
+
3
+ Write a [passageway](https://passageway.marceltov.de/) connector as three small functions. No dependencies; `passageway-sdk[web]` adds a polite web client for connectors that read a website.
4
+
5
+ ```python
6
+ from passageway_sdk import Connector
7
+
8
+ class Notes(Connector):
9
+ def versions(self): ... # {id: version} for every item
10
+ def fetch(self, ids): ... # an item record per id
11
+
12
+ Notes.main()
13
+ ```
14
+
15
+ The SDK dispatches the mode, reads the ids, buffers the output (all or nothing), maps errors to exit codes and validates each item. Reference: https://passageway.marceltov.de/reference/sdk/ and the [guide](https://passageway.marceltov.de/guides/write-a-connector/).
@@ -0,0 +1,191 @@
1
+ """Write a passageway connector as three small functions; the protocol plumbing is done here.
2
+
3
+ Subclass Connector, implement versions() and fetch() (and changed() if the source can answer it cheaply), and call
4
+ YourConnector.main(). The reference is https://passageway.marceltov.de/reference/connector-protocol/.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ from collections.abc import Iterable, Mapping
11
+ from datetime import datetime
12
+ from typing import NoReturn, TextIO
13
+
14
+ from passageway_sdk.markdown import first_h1, split_markdown, strip_code
15
+
16
+ __all__ = ["Connector", "SettingsError", "SourceError", "first_h1", "split_markdown", "strip_code", "validate_item"]
17
+
18
+ PROTOCOL = "1"
19
+ MODES = "check|list|fetch|changed"
20
+
21
+
22
+ class SettingsError(Exception):
23
+ """The settings are wrong. The connector exits 2 with the message on stderr and prints nothing."""
24
+
25
+
26
+ class SourceError(Exception):
27
+ """The source failed (down, refused, unreadable). The connector exits 1 with the message and prints nothing."""
28
+
29
+
30
+ def _check(ok: bool, message: str) -> None:
31
+ if not ok:
32
+ raise ValueError(message)
33
+
34
+
35
+ def _strings(record: dict, key: str, *, non_empty: bool = False) -> None:
36
+ values = record.get(key, [])
37
+ _check(isinstance(values, list) and all(isinstance(v, str) and (v or not non_empty) for v in values),
38
+ f"'{key}' must be a list of {'non-empty ' if non_empty else ''}strings")
39
+
40
+
41
+ def validate_item(record: object) -> None:
42
+ """Raises ValueError if the record is not a valid item (the same rules the core applies)."""
43
+ _check(isinstance(record, dict), "a record must be a JSON object")
44
+ for key in ("title", "url", "version"):
45
+ _check(isinstance(record.get(key), str), f"'{key}' must be a string")
46
+ _check(isinstance(record.get("id"), str) and record["id"], "'id' must be a non-empty string")
47
+ sections = record.get("sections")
48
+ _check(isinstance(sections, list), "'sections' must be a list")
49
+ for s in sections:
50
+ _check(isinstance(s, dict) and isinstance(s.get("text"), str), "a section needs a string 'text'")
51
+ _check(s.get("heading") is None or isinstance(s["heading"], str), "'heading' must be a string")
52
+ weight = record.get("weight", 1.0)
53
+ _check(isinstance(weight, (int, float)) and not isinstance(weight, bool) and 0 <= weight <= 2,
54
+ "'weight' must be a number from 0 to 2")
55
+ _strings(record, "path")
56
+ _strings(record, "labels")
57
+ _strings(record, "links", non_empty=True)
58
+ _check(record.get("updated") is None or isinstance(record["updated"], str), "'updated' must be a string")
59
+
60
+
61
+ class Connector:
62
+ """One source. Subclass it; settings arrive in __init__ as the environment."""
63
+
64
+ name = "connector" # prefixes the messages on stderr
65
+ expected_errors: tuple[type[Exception], ...] = () # extra exceptions that mean "the source failed" (exit 1)
66
+
67
+ def __init__(self, env: Mapping[str, str]):
68
+ """Read and validate the settings; raise SettingsError if they are wrong."""
69
+
70
+ def versions(self) -> dict[str, str]:
71
+ """Every item that exists, as id -> version. The version changes when the content changes."""
72
+ raise NotImplementedError
73
+
74
+ def fetch(self, ids: list[str]) -> Iterable[dict]:
75
+ """The item record for each requested id that still exists."""
76
+ raise NotImplementedError
77
+
78
+ def changed(self, since: datetime) -> Iterable[dict]:
79
+ """Optional: items changed since `since` (timezone-aware). Print nothing if that is not cheap."""
80
+ return ()
81
+
82
+ def count(self) -> int:
83
+ return len(self.versions())
84
+
85
+ def warn(self, message: str) -> None:
86
+ """Report something odd (a skipped file, say) on stderr without failing the call."""
87
+ self.__dict__.setdefault("_warnings", []).append(message)
88
+
89
+ # -- the plumbing ------------------------------------------------------------------------
90
+
91
+ @classmethod
92
+ def run(cls, argv: list[str], env: Mapping[str, str], stdin: TextIO, stdout: TextIO, stderr: TextIO,
93
+ **options) -> int:
94
+ """Run one mode and return the exit code. Output is written only if everything succeeded.
95
+
96
+ A SettingsError gives 2, a SourceError or an expected error 1. Any other exception is a bug in the connector and
97
+ propagates (the process then exits 1 with a traceback); nothing has been written by then either.
98
+ """
99
+ mode = argv[1] if len(argv) > 1 else ""
100
+ if mode not in MODES.split("|"):
101
+ print(f"usage: {os.path.basename(argv[0]) if argv else 'connector'} {MODES} (changed takes --since <UTC time>)",
102
+ file=stderr)
103
+ return 2
104
+ since = None
105
+ if mode == "changed" and cls.changed is not Connector.changed: # a connector without `changed` has no use for the time
106
+ try:
107
+ since = _since(argv)
108
+ except ValueError as e:
109
+ print(f"{cls.name}: {e}", file=stderr)
110
+ return 2
111
+ connector = None
112
+ try:
113
+ connector = cls(env, **options)
114
+ lines = connector._lines(mode, stdin, since)
115
+ except SettingsError as e:
116
+ _show_warnings(connector, stderr)
117
+ print(f"{cls.name}: {e}", file=stderr)
118
+ return 2
119
+ except (SourceError, *cls.expected_errors) as e:
120
+ _show_warnings(connector, stderr)
121
+ print(f"{cls.name}: {e}", file=stderr)
122
+ return 1
123
+ _show_warnings(connector, stderr)
124
+ stdout.write("".join(lines))
125
+ return 0
126
+
127
+ @classmethod
128
+ def main(cls) -> NoReturn:
129
+ sys.exit(cls.run(sys.argv, os.environ, sys.stdin, sys.stdout, sys.stderr))
130
+
131
+ def _lines(self, mode: str, stdin: TextIO, since: datetime | None) -> list[str]:
132
+ if mode == "check":
133
+ count = self.count()
134
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
135
+ raise SourceError(f"count() must return a whole number from 0, got {count!r}")
136
+ return [_line({"ok": True, "count": count, "protocol": PROTOCOL})]
137
+ if mode == "list":
138
+ return [_line(_listed(i, v)) for i, v in self.versions().items()]
139
+ if mode == "changed":
140
+ return [_line(_valid(item)) for item in self.changed(since)]
141
+ ids = _ids(stdin)
142
+ lines = []
143
+ for item in self.fetch(ids):
144
+ if _valid(item)["id"] not in ids:
145
+ raise SourceError(f"fetch returned {item['id']!r}, which was not requested")
146
+ lines.append(_line(item))
147
+ return lines
148
+
149
+
150
+ def _show_warnings(connector: "Connector | None", stderr: TextIO) -> None:
151
+ for message in getattr(connector, "_warnings", ()):
152
+ print(f"{connector.name}: warning: {message}", file=stderr)
153
+
154
+
155
+ def _line(record: dict) -> str:
156
+ return json.dumps(record) + "\n" # ASCII-safe on purpose: a locale without UTF-8 must not crash the output
157
+
158
+
159
+ def _valid(item: object) -> dict:
160
+ try:
161
+ validate_item(item)
162
+ except ValueError as e:
163
+ label = item.get("id") if isinstance(item, dict) else None
164
+ raise SourceError(f"invalid item{f' {label!r}' if label else ''}: {e}") from None
165
+ return item
166
+
167
+
168
+ def _listed(item_id: object, version: object) -> dict:
169
+ if not (isinstance(item_id, str) and item_id and isinstance(version, str)):
170
+ raise SourceError(f"invalid list entry {item_id!r}: an id is a non-empty string and a version a string")
171
+ return {"id": item_id, "version": version}
172
+
173
+
174
+ def _ids(stdin: TextIO) -> list[str]:
175
+ try:
176
+ ids = [json.loads(line)["id"] for line in stdin if line.strip()]
177
+ if not all(isinstance(i, str) and i for i in ids):
178
+ raise ValueError
179
+ return ids
180
+ except (ValueError, KeyError, TypeError):
181
+ raise SourceError("fetch expects one JSON object per line on stdin, each with a non-empty string 'id'") from None
182
+
183
+
184
+ def _since(argv: list[str]) -> datetime:
185
+ try:
186
+ value = datetime.fromisoformat(argv[argv.index("--since") + 1].replace("Z", "+00:00"))
187
+ except (ValueError, IndexError):
188
+ raise ValueError("changed needs --since <UTC time>, e.g. 2026-01-01T00:00:00Z") from None
189
+ if value.tzinfo is None:
190
+ raise ValueError("--since must carry a timezone, e.g. 2026-01-01T00:00:00Z")
191
+ return value
@@ -0,0 +1,78 @@
1
+ """Markdown helpers for connectors: cut a document into sections at its headings, and see it without code."""
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+
6
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
7
+ _HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$")
8
+ _INLINE_CODE = re.compile(r"(?<!`)(`+)(?!`)[^\n]+?(?<!`)\1(?!`)") # a span closes with a run of exactly the same length
9
+ SEPARATOR = " › "
10
+
11
+
12
+ def _lines(text: str) -> Iterator[tuple[str, bool]]:
13
+ """Each line with whether it is inside a fenced code block (the fence lines themselves count as inside)."""
14
+ fence = "" # the opening fence while inside a block
15
+ for line in text.splitlines():
16
+ match = _FENCE.match(line)
17
+ if not fence:
18
+ # the info string of a backtick fence cannot contain a backtick: "```a`b" is not a fence
19
+ opens = bool(match) and not (match.group(1)[0] == "`" and "`" in match.group(2))
20
+ if opens:
21
+ fence = match.group(1)
22
+ yield line, opens
23
+ else:
24
+ # a closing fence: the same character, at least as long, and nothing after it
25
+ if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence) and not match.group(2).strip():
26
+ fence = ""
27
+ yield line, True
28
+
29
+
30
+ def split_markdown(text: str, *, skip_first_h1: bool = False) -> list[dict]:
31
+ """Sections for an item: {"heading": "Setup › Linux", "text": "…"}, one per heading level (# to ######).
32
+
33
+ The heading is the trail of the parent headings. Text before the first heading has heading None. A `#` inside a fenced
34
+ code block is not a heading. Sections without text are dropped (their heading stays in the trail of their children).
35
+ With skip_first_h1 the first level-1 heading is left out of the trail, because it is the title of the item.
36
+ """
37
+ sections: list[dict] = []
38
+ trail: list[tuple[int, str]] = []
39
+ lines: list[str] = []
40
+ heading: str | None = None
41
+ seen_h1 = False
42
+
43
+ def flush() -> None:
44
+ body = "\n".join(lines).strip()
45
+ if body:
46
+ sections.append({"heading": heading, "text": body})
47
+ lines.clear()
48
+
49
+ for line, in_code in _lines(text):
50
+ match = None if in_code else _HEADING.match(line)
51
+ if not match or not match.group(2).strip():
52
+ lines.append(line)
53
+ continue
54
+ flush()
55
+ level, title = len(match.group(1)), match.group(2).strip()
56
+ if skip_first_h1 and level == 1 and not seen_h1:
57
+ seen_h1 = True
58
+ trail = [] # the title is above everything: what follows starts again from the top
59
+ else:
60
+ trail = [t for t in trail if t[0] < level] + [(level, title)]
61
+ heading = SEPARATOR.join(t[1] for t in trail) or None
62
+ flush()
63
+ return sections
64
+
65
+
66
+ def strip_code(text: str) -> str:
67
+ """The text without fenced code blocks and inline code, e.g. to find the links a document really contains."""
68
+ outside = "\n".join(line for line, in_code in _lines(text) if not in_code)
69
+ return _INLINE_CODE.sub("", outside)
70
+
71
+
72
+ def first_h1(text: str) -> str | None:
73
+ """The text of the first level-1 heading outside code, or None: the title of a document that has no other."""
74
+ for line, in_code in _lines(text):
75
+ match = None if in_code else _HEADING.match(line)
76
+ if match and len(match.group(1)) == 1 and match.group(2).strip():
77
+ return match.group(2).strip()
78
+ return None
@@ -0,0 +1,44 @@
1
+ """Read connector settings from the environment: every value is a string, and a wrong one is a SettingsError."""
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from passageway_sdk import SettingsError
6
+
7
+
8
+ def names(env: Mapping[str, str], key: str) -> tuple[str, ...]:
9
+ """A list setting: values separated by `|`, blanks dropped; empty if the setting is missing."""
10
+ return tuple(p.strip() for p in env.get(key, "").split("|") if p.strip())
11
+
12
+
13
+ def whole(env: Mapping[str, str], key: str, default: int, low: int = 1) -> int:
14
+ """A whole number of at least `low`."""
15
+ raw = env.get(key, "").strip()
16
+ try:
17
+ value = int(raw) if raw else default
18
+ except ValueError:
19
+ raise SettingsError(f"{key} must be a whole number, got {raw!r}") from None
20
+ if value < low:
21
+ raise SettingsError(f"{key} must be at least {low}, got {value}")
22
+ return value
23
+
24
+
25
+ def flag(env: Mapping[str, str], key: str, default: bool) -> bool:
26
+ """`true` or `false`, in any case."""
27
+ raw = env.get(key, "").strip().lower()
28
+ if not raw:
29
+ return default
30
+ if raw not in ("true", "false"):
31
+ raise SettingsError(f"{key} must be true or false, got {raw!r}")
32
+ return raw == "true"
33
+
34
+
35
+ def seconds(env: Mapping[str, str], key: str, default: float) -> float:
36
+ """A number of seconds that is not negative."""
37
+ raw = env.get(key, "").strip()
38
+ try:
39
+ value = float(raw) if raw else default
40
+ except ValueError:
41
+ raise SettingsError(f"{key} must be a number of seconds, got {raw!r}") from None
42
+ if value < 0:
43
+ raise SettingsError(f"{key} must not be negative, got {value}")
44
+ return value
@@ -0,0 +1,142 @@
1
+ """A read-mostly web client for connectors and embedders. Needs the extra: `pip install passageway-sdk[web]`."""
2
+
3
+ import json as jsonlib
4
+ import ssl
5
+ import time
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from types import MappingProxyType
9
+ from typing import Any, NamedTuple
10
+
11
+ from passageway_sdk import SourceError
12
+
13
+ try:
14
+ import httpx
15
+ import truststore
16
+ except ImportError as e: # the SDK itself has no dependencies; this module is the one that has
17
+ raise ImportError("passageway_sdk.web needs httpx and truststore: install passageway-sdk[web]") from e
18
+
19
+
20
+ class Reply(NamedTuple):
21
+ status: int
22
+ text: str # decoded as UTF-8, undecodable bytes replaced
23
+ content_type: str
24
+ headers: Mapping[str, str] = MappingProxyType({}) # names in lower case
25
+
26
+ def json(self) -> Any:
27
+ return jsonlib.loads(self.text)
28
+
29
+
30
+ class TooLarge(SourceError):
31
+ """The reply was larger than the limit."""
32
+
33
+
34
+ class HttpError(SourceError):
35
+ """The server answered with a status the call cannot go on with: a 5xx or 429 that retries did not clear, or any
36
+ non-200 when the call was `required`."""
37
+
38
+ def __init__(self, url: str, status: int, message: str | None = None, attempts: int = 1):
39
+ super().__init__(message or f"{url}: HTTP {status}")
40
+ self.url, self.status, self.attempts = url, status, attempts # attempts > 1: a retry policy gave up
41
+
42
+
43
+ class NetworkError(SourceError):
44
+ """The server could not be reached or went silent (a connection or timeout error)."""
45
+
46
+ def __init__(self, url: str, detail: str):
47
+ super().__init__(f"{url}: {detail}")
48
+ self.url, self.detail = url, detail
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Retry:
53
+ """Wait and try again when the server asks to come back later (seconds)."""
54
+
55
+ statuses: tuple[int, ...] = (429, 502, 503, 504)
56
+ network: bool = False # also retry when the server cannot be reached or times out
57
+ budget: float = 900 # the most that is waited in total for one request before giving up
58
+ max_backoff: float = 60 # the longest wait the client picks itself: 1, 2, 4, … up to this
59
+ max_after: float = 300 # the longest wait a Retry-After header may ask for
60
+ say_from: float = 10 # a wait this long is reported to `log`, shorter ones stay quiet
61
+
62
+
63
+ class Web:
64
+ """HTTP with the operating system's trust store (never certifi's bundle), a pause between requests and a size cap.
65
+
66
+ A failure to reach the server, or a 5xx or 429 that a `retry` policy did not clear, raises SourceError (HttpError for a
67
+ status), so a source that is down fails the whole call and never looks empty. Any other status is returned for the
68
+ connector to judge; `required=True` raises on it too. Turning TLS checking off (`verify=False`) is an explicit switch.
69
+ """
70
+
71
+ def __init__(self, user_agent: str | None = None, *, base_url: str = "", headers: Mapping[str, str] | None = None,
72
+ verify: bool = True, follow_redirects: bool = True, pause: float = 0.2, timeout: float = 30.0,
73
+ max_bytes: int = 2_000_000, retry: Retry | None = None, log: Callable[[str], None] | None = None, transport: "httpx.BaseTransport | None" = None,
74
+ sleep: Callable[[float], None] = time.sleep):
75
+ all_headers = {**({"User-Agent": user_agent} if user_agent else {}), **(headers or {})}
76
+ self._http = httpx.Client(base_url=base_url, headers=all_headers, timeout=timeout, follow_redirects=follow_redirects,
77
+ transport=transport, verify=truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) if verify else False)
78
+ self._pause, self._max_bytes, self._retry, self._log, self._sleep, self._last = pause, max_bytes, retry, log, sleep, None
79
+
80
+ def get(self, url: str, *, params: Mapping[str, str] | None = None, required: bool = False, max_bytes: int | None = None,
81
+ retry_if: Callable[[Reply], str | None] | None = None) -> Reply:
82
+ return self.request("GET", url, params=params, required=required, max_bytes=max_bytes, retry_if=retry_if)
83
+
84
+ def request(self, method: str, url: str, *, params: Mapping[str, str] | None = None, json: Any = None, required: bool = False,
85
+ max_bytes: int | None = None, retry_if: Callable[[Reply], str | None] | None = None) -> Reply:
86
+ """`retry_if(reply)` may name a reason to try again although the status is fine (an API that answers 200 with
87
+ "I am lagging"); it counts against the same budget as a busy status."""
88
+ limit, waited, attempt = max_bytes or self._max_bytes, 0.0, 0
89
+ retry = self._retry
90
+ if retry_if and not retry:
91
+ raise ValueError("retry_if needs a retry policy: pass retry=Retry(...) to Web")
92
+ while True:
93
+ attempt += 1
94
+ retry_after = None
95
+ try:
96
+ reply, retry_after = self._once(method, url, params, json, limit)
97
+ reason = f"HTTP {reply.status}"
98
+ if again := retry_if(reply) if retry_if else None:
99
+ reason = again
100
+ elif not (retry and reply.status in retry.statuses):
101
+ if reply.status >= 500 or reply.status == 429 or (required and reply.status != 200):
102
+ raise HttpError(url, reply.status)
103
+ return reply
104
+ except NetworkError as e:
105
+ if not (retry and retry.network):
106
+ raise
107
+ reply, reason = None, e.detail
108
+ if waited >= retry.budget:
109
+ message = f"{url}: giving up after {attempt} attempts and {waited:.0f} s of waiting: {reason}"
110
+ raise HttpError(url, reply.status, message, attempt) if reply else NetworkError(url, message.removeprefix(f"{url}: "))
111
+ wait = min(self._wait(attempt, retry_after), retry.budget - waited)
112
+ if wait >= retry.say_from and self._log:
113
+ self._log(f"{reason} on {method} {url}: waiting {wait:.0f} s ({waited:.0f} s of {retry.budget:.0f} s waited)")
114
+ self._sleep(wait)
115
+ waited += wait
116
+
117
+ def _wait(self, attempt: int, retry_after: str | None) -> float:
118
+ backoff = min(float(2 ** (attempt - 1)), self._retry.max_backoff)
119
+ try:
120
+ return max(backoff, min(float(retry_after), self._retry.max_after)) if retry_after else backoff
121
+ except ValueError:
122
+ return backoff
123
+
124
+ def _once(self, method: str, url: str, params, json, limit: int) -> tuple[Reply, str | None]:
125
+ if self._last is not None and (wait := self._pause - (time.monotonic() - self._last)) > 0:
126
+ self._sleep(wait)
127
+ try:
128
+ with self._http.stream(method, url, params=params, json=json) as response:
129
+ body = bytearray()
130
+ for chunk in response.iter_bytes():
131
+ body += chunk
132
+ if len(body) > limit:
133
+ raise TooLarge(f"{url}: larger than {limit} bytes")
134
+ headers = {k.lower(): v for k, v in response.headers.items()}
135
+ reply = Reply(response.status_code, body.decode("utf-8", errors="replace"), headers.get("content-type", ""), headers)
136
+ return reply, headers.get("retry-after")
137
+ except httpx.TransportError as e:
138
+ raise NetworkError(url, f"{type(e).__name__}: {e}") from e
139
+ except httpx.HTTPError as e:
140
+ raise SourceError(f"{url}: {e}") from e
141
+ finally:
142
+ self._last = time.monotonic()
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "passageway-sdk"
7
+ version = "0.3.0"
8
+ description = "Write a passageway connector as three small functions: the protocol plumbing is done for you"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ web = ["httpx>=0.28.1", "truststore>=0.10"]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["passageway_sdk"]
19
+
20
+ [dependency-groups]
21
+ dev = ["pytest>=8.3", "passageway", "httpx>=0.28.1", "truststore>=0.10"]
22
+
23
+ [tool.uv.sources]
24
+ passageway = { workspace = true }
@@ -0,0 +1,77 @@
1
+ from passageway_sdk import split_markdown, strip_code
2
+
3
+
4
+ def sections(text, **kw):
5
+ return [(s["heading"], s["text"]) for s in split_markdown(text, **kw)]
6
+
7
+
8
+ def test_text_before_the_first_heading_is_a_section_without_heading():
9
+ assert sections("Intro text.\n\n## Setup\nInstall it.") == [(None, "Intro text."), ("Setup", "Install it.")]
10
+
11
+
12
+ def test_the_heading_is_the_trail_of_its_parents():
13
+ text = "# Guide\n\n## Setup\nA\n\n### Linux\nB\n\n### Mac\nC\n\n## Usage\nD"
14
+ assert sections(text) == [("Guide › Setup", "A"), ("Guide › Setup › Linux", "B"),
15
+ ("Guide › Setup › Mac", "C"), ("Guide › Usage", "D")]
16
+
17
+
18
+ def test_the_first_h1_can_be_left_out_because_it_is_the_title():
19
+ text = "# Guide\nIntro.\n\n## Setup\nA\n\n# Second\nB"
20
+ assert sections(text, skip_first_h1=True) == [(None, "Intro."), ("Setup", "A"), ("Second", "B")]
21
+
22
+
23
+ def test_a_level_can_be_skipped_and_a_shallower_heading_resets_the_trail():
24
+ assert sections("## A\nx\n\n##### Deep\ny\n\n## B\nz") == [("A", "x"), ("A › Deep", "y"), ("B", "z")]
25
+
26
+
27
+ def test_headings_without_text_are_dropped_but_stay_in_the_trail():
28
+ assert sections("## A\n### B\ntext") == [("A › B", "text")]
29
+
30
+
31
+ def test_a_hash_inside_a_fenced_code_block_is_not_a_heading():
32
+ text = "## Setup\n```bash\n# install\nmake\n```\nAfter.\n\n~~~\n## also not\n~~~\n"
33
+ assert sections(text) == [("Setup", "```bash\n# install\nmake\n```\nAfter.\n\n~~~\n## also not\n~~~")]
34
+
35
+
36
+ def test_a_longer_fence_is_not_closed_by_a_shorter_one():
37
+ text = "## A\n````\n```\n## inside\n```\n````\nend"
38
+ assert sections(text) == [("A", "````\n```\n## inside\n```\n````\nend")]
39
+
40
+
41
+ def test_heading_syntax_rules():
42
+ assert sections("#NoSpace\ntext") == [(None, "#NoSpace\ntext")] # needs a space
43
+ assert sections("####### seven\ntext") == [(None, "####### seven\ntext")]
44
+ assert sections("## Closed ##\ntext") == [("Closed", "text")]
45
+ assert sections(" ## Indented up to three\ntext") == [("Indented up to three", "text")]
46
+ assert sections(" ## four spaces is code\ntext") == [(None, "## four spaces is code\ntext")] # not a heading
47
+
48
+
49
+ def test_empty_input_has_no_sections():
50
+ assert split_markdown("") == [] and split_markdown("\n\n \n") == []
51
+
52
+
53
+ def test_strip_code_removes_fenced_blocks_and_inline_code():
54
+ text = "See [a](a.md).\n```\n[b](b.md)\n```\nand `[c](c.md)` but [d](d.md)."
55
+ out = strip_code(text)
56
+ assert "a.md" in out and "d.md" in out and "b.md" not in out and "c.md" not in out
57
+
58
+
59
+ def test_first_h1_is_the_first_level_one_heading_outside_code():
60
+ from passageway_sdk import first_h1
61
+ assert first_h1("intro\n\n# Real title\n\n# Second") == "Real title"
62
+ assert first_h1("```\n# not a title\n```\n## Sub\n# Title `with code` ##") == "Title `with code`"
63
+ assert first_h1("## Only a sub heading") is None and first_h1("") is None
64
+
65
+
66
+ def test_a_first_h1_that_comes_after_other_headings_resets_the_trail_when_skipped():
67
+ assert sections("## Setup\nx\n# Appendix\ny\n## A\nz", skip_first_h1=True) == [("Setup", "x"), (None, "y"), ("A", "z")]
68
+
69
+
70
+ def test_strip_code_handles_spans_that_contain_a_backtick():
71
+ assert "e.md" in strip_code("x ``a`b`` [`f()`](e.md)")
72
+ out = strip_code("a ``x ` [c](d.md)`` b")
73
+ assert "d.md" not in out and out.startswith("a") and out.endswith("b")
74
+
75
+
76
+ def test_a_backtick_fence_whose_info_string_has_a_backtick_is_not_a_fence():
77
+ assert sections("```foo`bar\n## B\ntext") == [(None, "```foo`bar"), ("B", "text")]
@@ -0,0 +1,296 @@
1
+ import io
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ import textwrap
6
+ from datetime import datetime, timezone
7
+
8
+ import pytest
9
+
10
+ from passageway.protocol import Connector as CoreConnector, parse_item
11
+ from passageway.testing.contract import check_connector
12
+ from passageway_sdk import Connector, SettingsError, SourceError
13
+
14
+ ITEM = {"id": "a", "title": "Doc a", "url": "http://x/a", "version": "1", "sections": [{"heading": None, "text": "body"}]}
15
+
16
+
17
+ class Demo(Connector):
18
+ name = "demo connector"
19
+
20
+ def __init__(self, env, **options):
21
+ self.items = {"a": ITEM, "b": {**ITEM, "id": "b", "version": "2"}}
22
+ if env.get("BAD"):
23
+ raise SettingsError("BAD must not be set")
24
+ self.fail = env.get("FAIL")
25
+ self.options = options
26
+
27
+ def versions(self):
28
+ if self.fail == "source":
29
+ raise SourceError("the source is down")
30
+ if self.fail == "crash":
31
+ raise RuntimeError("a bug")
32
+ return {i: r["version"] for i, r in self.items.items()}
33
+
34
+ def fetch(self, ids):
35
+ for i in ids:
36
+ if i in self.items:
37
+ yield self.items[i]
38
+ if self.fail == "late" and i == "a":
39
+ raise SourceError("died after the first item")
40
+
41
+
42
+ def call(cls, mode, *, stdin="", env=None, extra=()):
43
+ out, err = io.StringIO(), io.StringIO()
44
+ code = cls.run(["prog", mode, *extra], env or {}, io.StringIO(stdin), out, err)
45
+ return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
46
+
47
+
48
+ def test_check_counts_the_versions_and_names_the_protocol():
49
+ assert call(Demo, "check")[:2] == (0, [{"ok": True, "count": 2, "protocol": "1"}])
50
+
51
+
52
+ def test_list_prints_id_and_version_per_item():
53
+ assert call(Demo, "list")[:2] == (0, [{"id": "a", "version": "1"}, {"id": "b", "version": "2"}])
54
+
55
+
56
+ def test_fetch_reads_ids_from_stdin_and_skips_blank_lines():
57
+ code, records, _ = call(Demo, "fetch", stdin='{"id": "b"}\n\n{"id": "zzz"}\n')
58
+ assert code == 0 and [r["id"] for r in records] == ["b"]
59
+
60
+
61
+ def test_changed_is_optional_and_prints_nothing_by_default():
62
+ assert call(Demo, "changed", extra=["--since", "2026-01-01T00:00:00Z"])[:2] == (0, [])
63
+
64
+
65
+ def test_changed_receives_an_aware_datetime():
66
+ seen = []
67
+
68
+ class Changing(Demo):
69
+ def changed(self, since):
70
+ seen.append(since)
71
+ return [ITEM]
72
+
73
+ assert call(Changing, "changed", extra=["--since", "2026-01-01T00:00:00Z"])[1] == [ITEM]
74
+ assert seen == [datetime(2026, 1, 1, tzinfo=timezone.utc)]
75
+
76
+
77
+ @pytest.mark.parametrize("extra", [[], ["--since"], ["--since", "yesterday"], ["--since", "2026-01-01T00:00:00"]])
78
+ def test_a_connector_that_implements_changed_needs_a_valid_since(extra):
79
+ class Changing(Demo):
80
+ def changed(self, since):
81
+ return [ITEM]
82
+
83
+ code, records, err = call(Changing, "changed", extra=extra)
84
+ assert code == 2 and records == [] and "--since" in err
85
+
86
+
87
+ def test_a_connector_without_changed_does_not_care_about_since():
88
+ assert call(Demo, "changed")[:2] == (0, [])
89
+
90
+
91
+ def test_an_unknown_mode_is_a_usage_error():
92
+ code, records, err = call(Demo, "bogus")
93
+ assert code == 2 and records == [] and "usage" in err
94
+
95
+
96
+ def test_bad_settings_exit_2_with_the_message_and_no_output():
97
+ code, records, err = call(Demo, "list", env={"BAD": "1"})
98
+ assert code == 2 and records == [] and err.strip() == "demo connector: BAD must not be set"
99
+
100
+
101
+ def test_a_source_failure_exits_1_with_the_message_and_no_traceback():
102
+ code, records, err = call(Demo, "list", env={"FAIL": "source"})
103
+ assert code == 1 and records == [] and err.strip() == "demo connector: the source is down"
104
+
105
+
106
+ def test_all_or_nothing_a_failure_after_some_items_prints_nothing():
107
+ code, records, _ = call(Demo, "fetch", stdin='{"id": "a"}\n{"id": "b"}\n', env={"FAIL": "late"})
108
+ assert code == 1 and records == []
109
+
110
+
111
+ def test_an_unexpected_error_is_not_swallowed():
112
+ with pytest.raises(RuntimeError):
113
+ call(Demo, "list", env={"FAIL": "crash"})
114
+
115
+
116
+ def test_expected_errors_can_be_declared_by_the_connector():
117
+ class Declares(Demo):
118
+ expected_errors = (KeyError,)
119
+
120
+ def versions(self):
121
+ raise KeyError("missing")
122
+
123
+ assert call(Declares, "list")[0] == 1
124
+
125
+
126
+ def test_a_bad_stdin_line_is_a_source_error():
127
+ code, records, err = call(Demo, "fetch", stdin="not json\n")
128
+ assert code == 1 and records == [] and "fetch" in err
129
+
130
+
131
+ def test_options_reach_the_constructor():
132
+ seen = {}
133
+
134
+ class Optioned(Demo):
135
+ def __init__(self, env, **options):
136
+ super().__init__(env)
137
+ seen.update(options)
138
+
139
+ out = io.StringIO()
140
+ Optioned.run(["prog", "list"], {}, io.StringIO(), out, io.StringIO(), pause=0)
141
+ assert seen == {"pause": 0}
142
+
143
+
144
+ @pytest.mark.parametrize("bad", [
145
+ {**ITEM, "id": ""}, {**ITEM, "version": 1}, {**ITEM, "sections": "x"}, {**ITEM, "sections": [{"heading": "H"}]},
146
+ {**ITEM, "weight": 3}, {**ITEM, "weight": True}, {**ITEM, "path": "P"}, {**ITEM, "links": [""]},
147
+ {k: v for k, v in ITEM.items() if k != "url"}, [],
148
+ ])
149
+ def test_an_invalid_item_is_a_source_error_naming_the_problem(bad):
150
+ class Bad(Demo):
151
+ def fetch(self, ids):
152
+ yield bad
153
+
154
+ code, records, err = call(Bad, "fetch", stdin='{"id": "a"}\n')
155
+ assert code == 1 and records == [] and "invalid item" in err
156
+
157
+
158
+ @pytest.mark.parametrize("bad", [
159
+ {**ITEM, "id": ""}, {**ITEM, "version": 1}, {**ITEM, "weight": 3}, {**ITEM, "links": [""]},
160
+ {**ITEM, "sections": [{"heading": 3, "text": "x"}]}, {**ITEM, "updated": 5},
161
+ ])
162
+ def test_the_sdk_and_the_core_validate_alike(bad):
163
+ class Bad(Demo):
164
+ def fetch(self, ids):
165
+ yield bad
166
+
167
+ sdk_accepts = call(Bad, "fetch", stdin='{"id": "a"}\n')[0] == 0
168
+ try:
169
+ parse_item(bad)
170
+ core_accepts = True
171
+ except ValueError:
172
+ core_accepts = False
173
+ assert sdk_accepts is core_accepts is False
174
+
175
+
176
+ def test_output_is_ascii_safe_so_a_locale_without_utf8_cannot_crash_it():
177
+ class Umlaut(Demo):
178
+ def fetch(self, ids):
179
+ yield {**ITEM, "title": "Köln Süd"}
180
+
181
+ out = io.StringIO()
182
+ Umlaut.run(["prog", "fetch"], {}, io.StringIO('{"id": "a"}\n'), out, io.StringIO())
183
+ assert out.getvalue().isascii() and json.loads(out.getvalue())["title"] == "Köln Süd"
184
+
185
+
186
+ FOLDER = '''
187
+ import hashlib
188
+ from pathlib import Path
189
+
190
+ from passageway_sdk import Connector, SettingsError
191
+
192
+
193
+ class Folder(Connector):
194
+ name = "folder connector"
195
+
196
+ def __init__(self, env):
197
+ self.root = Path(env.get("FOLDER", "")).resolve()
198
+ if not self.root.is_dir():
199
+ raise SettingsError(f"FOLDER must be an existing folder, got {env.get('FOLDER')!r}")
200
+
201
+ def files(self):
202
+ return {p.relative_to(self.root).as_posix(): p for p in sorted(self.root.rglob("*.txt"))}
203
+
204
+ def versions(self):
205
+ return {i: hashlib.sha256(p.read_bytes()).hexdigest()[:12] for i, p in self.files().items()}
206
+
207
+ def fetch(self, ids):
208
+ for i, p in self.files().items():
209
+ if i in ids:
210
+ yield {"id": i, "title": p.stem, "url": p.as_uri(), "version": self.versions()[i],
211
+ "sections": [{"heading": None, "text": p.read_text(encoding="utf-8")}]}
212
+
213
+
214
+ Folder.main()
215
+ '''
216
+
217
+
218
+ def test_a_folder_connector_is_under_thirty_lines_and_passes_the_contract_check(tmp_path):
219
+ assert len(FOLDER.strip().splitlines()) < 30
220
+ script = tmp_path / "folder.py"
221
+ script.write_text(FOLDER)
222
+ notes = tmp_path / "notes"
223
+ (notes / "sub").mkdir(parents=True)
224
+ (notes / "a.txt").write_text("Alpha note.")
225
+ (notes / "sub" / "b.txt").write_text("Beta note.")
226
+ connector = CoreConnector("folder", [sys.executable, str(script)], env={"FOLDER": str(notes)})
227
+ check_connector(connector)
228
+ assert {i.id for i in connector.fetch(["a.txt", "sub/b.txt"])} == {"a.txt", "sub/b.txt"}
229
+
230
+
231
+ def test_main_exits_with_the_code_as_a_real_process(tmp_path):
232
+ script = tmp_path / "folder.py"
233
+ script.write_text(FOLDER)
234
+ run = subprocess.run([sys.executable, str(script), "list"], capture_output=True, text=True,
235
+ env={"PATH": "/usr/bin", "FOLDER": str(tmp_path / "missing")})
236
+ assert run.returncode == 2 and run.stdout == "" and "FOLDER must be an existing folder" in run.stderr
237
+
238
+
239
+ @pytest.mark.parametrize("line", ['{"id": 1}', '{"id": null}', '{"id": ""}', '[]', '{"name": "a"}'])
240
+ def test_fetch_ids_must_be_non_empty_strings(line):
241
+ code, records, err = call(Demo, "fetch", stdin=line + "\n")
242
+ assert code == 1 and records == [] and "fetch expects" in err
243
+
244
+
245
+ @pytest.mark.parametrize("versions", [{"": "1"}, {2: "1"}, {"a": 3}, {"a": None}])
246
+ def test_list_entries_are_validated_like_the_core_does(versions):
247
+ class Listing(Demo):
248
+ def versions(self):
249
+ return versions
250
+
251
+ code, records, err = call(Listing, "list")
252
+ assert code == 1 and records == [] and "invalid" in err
253
+
254
+
255
+ @pytest.mark.parametrize("count", [-1, 1.5, "3", True, None])
256
+ def test_a_custom_count_must_be_a_whole_number_from_zero(count):
257
+ class Counting(Demo):
258
+ def count(self):
259
+ return count
260
+
261
+ code, records, err = call(Counting, "check")
262
+ assert code == 1 and records == [] and "count" in err
263
+
264
+
265
+ def test_fetch_may_only_return_requested_ids():
266
+ class Greedy(Demo):
267
+ def fetch(self, ids):
268
+ yield {**ITEM, "id": "zzz"}
269
+
270
+ code, records, err = call(Greedy, "fetch", stdin='{"id": "a"}\n')
271
+ assert code == 1 and records == [] and "zzz" in err and "not requested" in err
272
+
273
+
274
+ def test_the_usage_message_names_the_program():
275
+ assert "prog" in call(Demo, "bogus")[2]
276
+
277
+
278
+ def test_warnings_go_to_stderr_and_do_not_fail_the_call():
279
+ class Warns(Demo):
280
+ def versions(self):
281
+ self.warn("skipped notes/big.md: larger than 1 MiB")
282
+ return super().versions()
283
+
284
+ code, records, err = call(Warns, "list")
285
+ assert code == 0 and len(records) == 2
286
+ assert err.strip() == "demo connector: warning: skipped notes/big.md: larger than 1 MiB"
287
+
288
+
289
+ def test_warnings_are_also_shown_when_the_call_fails():
290
+ class WarnsThenFails(Demo):
291
+ def versions(self):
292
+ self.warn("odd file")
293
+ raise SourceError("the source is down")
294
+
295
+ code, records, err = call(WarnsThenFails, "list")
296
+ assert code == 1 and records == [] and "warning: odd file" in err and "the source is down" in err
@@ -0,0 +1,31 @@
1
+ import pytest
2
+
3
+ from passageway_sdk import SettingsError
4
+ from passageway_sdk.settings import flag, names, seconds, whole
5
+
6
+
7
+ def test_names_splits_at_a_bar_and_drops_blanks():
8
+ assert names({"A": " x | y ||z "}, "A") == ("x", "y", "z")
9
+ assert names({}, "A") == ()
10
+
11
+
12
+ def test_whole_reads_a_whole_number_with_a_default_and_a_floor():
13
+ assert whole({}, "N", 5) == 5
14
+ assert whole({"N": " 7 "}, "N", 5) == 7
15
+ assert whole({"N": "0"}, "N", 5, low=0) == 0
16
+ for bad in ("x", "1.5", "0"):
17
+ with pytest.raises(SettingsError, match="N"):
18
+ whole({"N": bad}, "N", 5)
19
+
20
+
21
+ def test_flag_reads_true_or_false_only():
22
+ assert flag({}, "F", True) is True and flag({"F": "False"}, "F", True) is False and flag({"F": " true "}, "F", False) is True
23
+ with pytest.raises(SettingsError, match="F"):
24
+ flag({"F": "maybe"}, "F", True)
25
+
26
+
27
+ def test_seconds_reads_a_number_that_is_not_negative():
28
+ assert seconds({}, "S", 0.2) == 0.2 and seconds({"S": "0"}, "S", 0.2) == 0.0
29
+ for bad in ("x", "-1"):
30
+ with pytest.raises(SettingsError, match="S"):
31
+ seconds({"S": bad}, "S", 0.2)
@@ -0,0 +1,204 @@
1
+ import httpx
2
+ import pytest
3
+ import truststore
4
+
5
+ from passageway_sdk import SourceError
6
+ from passageway_sdk.web import TooLarge, Web
7
+
8
+
9
+ def web(handler, **options):
10
+ return Web("test-agent", transport=httpx.MockTransport(handler), sleep=lambda s: None, **options)
11
+
12
+
13
+ def ok(request):
14
+ return httpx.Response(200, headers={"Content-Type": "text/html"}, text="hello")
15
+
16
+
17
+ def test_a_reply_has_status_text_and_content_type():
18
+ reply = web(ok).get("https://x.example/")
19
+ assert (reply.status, reply.text, reply.content_type) == (200, "hello", "text/html")
20
+
21
+
22
+ def test_the_user_agent_is_sent():
23
+ seen = []
24
+ web(lambda r: seen.append(r.headers["user-agent"]) or httpx.Response(200)).get("https://x.example/")
25
+ assert seen == ["test-agent"]
26
+
27
+
28
+ def test_a_missing_page_is_a_reply_unless_it_is_required():
29
+ handler = lambda r: httpx.Response(404) # noqa: E731
30
+ assert web(handler).get("https://x.example/").status == 404
31
+ with pytest.raises(SourceError, match="404"):
32
+ web(handler).get("https://x.example/", required=True)
33
+
34
+
35
+ @pytest.mark.parametrize("status", [429, 500, 503])
36
+ def test_a_busy_or_failing_server_fails_the_call(status):
37
+ with pytest.raises(SourceError, match=str(status)):
38
+ web(lambda r: httpx.Response(status)).get("https://x.example/")
39
+
40
+
41
+ def test_a_network_error_fails_the_call():
42
+ def refuse(request):
43
+ raise httpx.ConnectError("refused")
44
+
45
+ with pytest.raises(SourceError, match="refused"):
46
+ web(refuse).get("https://x.example/")
47
+
48
+
49
+ def test_a_reply_over_the_limit_is_too_large_and_the_limit_can_be_set_per_call():
50
+ big = lambda r: httpx.Response(200, content=b"x" * 100) # noqa: E731
51
+ with pytest.raises(TooLarge):
52
+ web(big, max_bytes=10).get("https://x.example/")
53
+ assert len(web(big, max_bytes=10).get("https://x.example/", max_bytes=1000).text) == 100
54
+ assert issubclass(TooLarge, SourceError)
55
+
56
+
57
+ def test_requests_are_spaced_by_the_pause():
58
+ waits = []
59
+ client = Web("a", pause=5, transport=httpx.MockTransport(ok), sleep=waits.append)
60
+ client.get("https://x.example/")
61
+ client.get("https://x.example/")
62
+ assert len(waits) == 1 and 0 < waits[0] <= 5 # the first request does not wait
63
+
64
+
65
+ def test_https_uses_the_system_trust_store():
66
+ assert isinstance(Web("a")._http._transport._pool._ssl_context, truststore.SSLContext)
67
+
68
+
69
+ def test_text_that_is_not_utf8_is_replaced_not_fatal():
70
+ assert web(lambda r: httpx.Response(200, content=b"caf\xe9")).get("https://x.example/").text == "caf�"
71
+
72
+
73
+ # ---- a client for an API: base address, headers, TLS switch, redirects, retries, other methods ----------------------------
74
+
75
+ from passageway_sdk.web import HttpError, NetworkError, Retry # noqa: E402
76
+
77
+
78
+ def test_a_base_url_and_default_headers_apply_to_every_request():
79
+ seen = []
80
+ client = web(lambda r: seen.append((str(r.url), r.headers["authorization"])) or httpx.Response(200),
81
+ base_url="https://api.example/v1", headers={"Authorization": "Bearer t"}, pause=0)
82
+ client.get("/notes", params={"q": "a b"})
83
+ assert seen == [("https://api.example/v1/notes?q=a+b", "Bearer t")]
84
+
85
+
86
+ def test_a_server_error_carries_its_status():
87
+ with pytest.raises(HttpError) as raised:
88
+ web(lambda r: httpx.Response(503)).get("https://x.example/")
89
+ assert raised.value.status == 503 and isinstance(raised.value, SourceError)
90
+ with pytest.raises(HttpError) as raised:
91
+ web(lambda r: httpx.Response(401)).get("https://x.example/", required=True)
92
+ assert raised.value.status == 401
93
+
94
+
95
+ def test_turning_tls_checking_off_is_an_explicit_switch():
96
+ assert Web("a", verify=False)._http._transport._pool._ssl_context.verify_mode == ssl_none()
97
+
98
+
99
+ def ssl_none():
100
+ import ssl
101
+
102
+ return ssl.CERT_NONE
103
+
104
+
105
+ def test_redirects_are_followed_unless_refused():
106
+ def handler(request):
107
+ return httpx.Response(302, headers={"Location": "https://y.example/"}) if request.url.host == "x.example" else httpx.Response(200)
108
+
109
+ assert web(handler).get("https://x.example/").status == 200
110
+ assert web(handler, follow_redirects=False).get("https://x.example/").status == 302
111
+
112
+
113
+ def test_a_post_sends_json_and_a_reply_reads_as_json():
114
+ seen = []
115
+
116
+ def handler(request):
117
+ seen.append((request.method, request.content))
118
+ return httpx.Response(200, json={"ok": True})
119
+
120
+ reply = web(handler).request("POST", "https://x.example/", json={"a": 1})
121
+ assert seen == [("POST", b'{"a":1}')] and reply.json() == {"ok": True}
122
+
123
+
124
+ def test_a_retry_policy_waits_for_a_busy_server_and_then_succeeds():
125
+ replies = iter([httpx.Response(429, headers={"Retry-After": "7"}), httpx.Response(503), httpx.Response(200, text="ok")])
126
+ waits = []
127
+ client = Web("a", pause=0, retry=Retry(), transport=httpx.MockTransport(lambda r: next(replies)), sleep=waits.append)
128
+ assert client.get("https://x.example/").text == "ok"
129
+ assert waits[0] == 7 and 0 < waits[1] <= 60 # Retry-After is honoured, otherwise a backoff
130
+
131
+
132
+ def test_a_retry_policy_gives_up_after_its_budget():
133
+ waits = []
134
+ client = Web("a", pause=0, retry=Retry(budget=10, max_backoff=4), transport=httpx.MockTransport(lambda r: httpx.Response(503)),
135
+ sleep=waits.append)
136
+ with pytest.raises(HttpError, match="503"):
137
+ client.get("https://x.example/")
138
+ assert 0 < sum(waits) <= 10
139
+
140
+
141
+ def test_without_a_retry_policy_nothing_is_retried():
142
+ calls = []
143
+ with pytest.raises(HttpError):
144
+ web(lambda r: calls.append(1) or httpx.Response(503)).get("https://x.example/")
145
+ assert len(calls) == 1
146
+
147
+
148
+ def test_a_wait_is_clipped_to_the_budget_and_the_give_up_message_says_how_long_it_waited():
149
+ waits = []
150
+ client = Web("a", pause=0, retry=Retry(budget=10, max_backoff=4), transport=httpx.MockTransport(lambda r: httpx.Response(503)),
151
+ sleep=waits.append)
152
+ with pytest.raises(HttpError, match=r"giving up after 5 attempts and 10 s of waiting: HTTP 503") as raised:
153
+ client.get("https://x.example/")
154
+ assert waits == [1, 2, 4, 3] and raised.value.attempts == 5 and raised.value.status == 503
155
+
156
+
157
+ def test_a_long_wait_is_reported_to_the_log_as_it_starts():
158
+ replies = iter([httpx.Response(429, headers={"Retry-After": "40"}), httpx.Response(200)])
159
+ said = []
160
+ client = Web("a", pause=0, retry=Retry(), log=said.append, transport=httpx.MockTransport(lambda r: next(replies)), sleep=lambda s: None)
161
+ client.get("https://x.example/p")
162
+ assert said == ["HTTP 429 on GET https://x.example/p: waiting 40 s (0 s of 900 s waited)"]
163
+
164
+
165
+ def test_network_errors_are_retried_only_when_the_policy_says_so():
166
+ calls = []
167
+
168
+ def flaky(request):
169
+ calls.append(1)
170
+ if len(calls) == 1:
171
+ raise httpx.ConnectError("refused")
172
+ return httpx.Response(200, text="ok")
173
+
174
+ with pytest.raises(NetworkError):
175
+ web(flaky).get("https://x.example/")
176
+ calls.clear()
177
+ client = Web("a", pause=0, retry=Retry(network=True), transport=httpx.MockTransport(flaky), sleep=lambda s: None)
178
+ assert client.get("https://x.example/").text == "ok"
179
+
180
+
181
+ def test_a_reply_has_its_headers_in_lower_case():
182
+ client = web(lambda r: httpx.Response(302, headers={"Location": "https://y.example/"}), follow_redirects=False)
183
+ assert client.get("https://x.example/").headers["location"] == "https://y.example/"
184
+
185
+
186
+ def test_a_reply_the_caller_calls_busy_is_retried_like_a_busy_status():
187
+ replies = iter([httpx.Response(200, text="lag"), httpx.Response(200, text="lag"), httpx.Response(200, text="done")])
188
+ waits, said = [], []
189
+ client = Web("a", pause=0, retry=Retry(say_from=1), log=said.append, transport=httpx.MockTransport(lambda r: next(replies)),
190
+ sleep=waits.append)
191
+ reply = client.get("https://x.example/", retry_if=lambda r: "maxlag" if r.text == "lag" else None)
192
+ assert reply.text == "done" and waits == [1, 2] and said[0].startswith("maxlag on GET")
193
+
194
+
195
+ def test_a_reply_that_stays_busy_gives_up_with_the_reason():
196
+ client = Web("a", pause=0, retry=Retry(budget=3), transport=httpx.MockTransport(lambda r: httpx.Response(200, text="lag")),
197
+ sleep=lambda s: None)
198
+ with pytest.raises(HttpError, match="giving up after 3 attempts and 3 s of waiting: maxlag"):
199
+ client.get("https://x.example/", retry_if=lambda r: "maxlag")
200
+
201
+
202
+ def test_retry_if_needs_a_retry_policy():
203
+ with pytest.raises(ValueError, match="retry"):
204
+ web(ok).get("https://x.example/", retry_if=lambda r: "x")