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.
- passageway_sdk-0.3.0/.gitignore +11 -0
- passageway_sdk-0.3.0/LICENSE +21 -0
- passageway_sdk-0.3.0/PKG-INFO +27 -0
- passageway_sdk-0.3.0/README.md +15 -0
- passageway_sdk-0.3.0/passageway_sdk/__init__.py +191 -0
- passageway_sdk-0.3.0/passageway_sdk/markdown.py +78 -0
- passageway_sdk-0.3.0/passageway_sdk/settings.py +44 -0
- passageway_sdk-0.3.0/passageway_sdk/web.py +142 -0
- passageway_sdk-0.3.0/pyproject.toml +24 -0
- passageway_sdk-0.3.0/tests/test_markdown.py +77 -0
- passageway_sdk-0.3.0/tests/test_sdk.py +296 -0
- passageway_sdk-0.3.0/tests/test_settings.py +31 -0
- passageway_sdk-0.3.0/tests/test_web.py +204 -0
|
@@ -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")
|