passageway-sdk 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- passageway_sdk/__init__.py +191 -0
- passageway_sdk/markdown.py +78 -0
- passageway_sdk/settings.py +44 -0
- passageway_sdk/web.py +142 -0
- passageway_sdk-0.3.0.dist-info/METADATA +27 -0
- passageway_sdk-0.3.0.dist-info/RECORD +8 -0
- passageway_sdk-0.3.0.dist-info/WHEEL +4 -0
- passageway_sdk-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
passageway_sdk/web.py
ADDED
|
@@ -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,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,8 @@
|
|
|
1
|
+
passageway_sdk/__init__.py,sha256=2i9FveaYIg9RsFWCQ8PjV7--jZ98F5UHBc_BPSc_O2A,8247
|
|
2
|
+
passageway_sdk/markdown.py,sha256=jkcCFLNoZEeR2ahXBDvZWNT5XMjYKfrWFUGLJxrV7b8,3481
|
|
3
|
+
passageway_sdk/settings.py,sha256=YsTnQ1ptbVi4X7N6go_IlBVdhePNkwKb9ApdE9V8TnU,1633
|
|
4
|
+
passageway_sdk/web.py,sha256=ff8rybmOkRTLeGB0hEi75Xrv_WCuZOQWZL5qKkBOtgk,7492
|
|
5
|
+
passageway_sdk-0.3.0.dist-info/METADATA,sha256=ppWfacQFKh_m32Bb0waV7AXBwG--k1IQLM_ioIZPH3s,1072
|
|
6
|
+
passageway_sdk-0.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
passageway_sdk-0.3.0.dist-info/licenses/LICENSE,sha256=AhfRnZW5Yfi9VuESZGyPTziynbdP9kNMOtCykeDpAyw,1072
|
|
8
|
+
passageway_sdk-0.3.0.dist-info/RECORD,,
|
|
@@ -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.
|