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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.