firetail-finchly 0.0.1__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,37 @@
1
+ """Python SDK for the Finchly platform."""
2
+
3
+ from ._version import __version__
4
+ from .config import DEFAULT_MANIFEST_PATH, PlatformConfig, load_config
5
+ from .errors import IDENTITY_REJECTIONS, FinchlyError, IdentityError
6
+ from .identity import IDENTITY_HEADER, Identity, IdentityVerifier
7
+ from .manifest import check_manifest, load_manifest
8
+ from .platform_api import (
9
+ MANIFEST_VERSION,
10
+ PLATFORM_API_VERSION,
11
+ SDK_NAME,
12
+ SDK_VERSION,
13
+ SUPPORTED_PLATFORM_API_VERSIONS,
14
+ )
15
+ from .service import ServiceClient, ServiceResponse
16
+
17
+ __all__ = [
18
+ "DEFAULT_MANIFEST_PATH",
19
+ "FinchlyError",
20
+ "IDENTITY_HEADER",
21
+ "IDENTITY_REJECTIONS",
22
+ "Identity",
23
+ "IdentityError",
24
+ "IdentityVerifier",
25
+ "MANIFEST_VERSION",
26
+ "PLATFORM_API_VERSION",
27
+ "PlatformConfig",
28
+ "SDK_NAME",
29
+ "SDK_VERSION",
30
+ "SUPPORTED_PLATFORM_API_VERSIONS",
31
+ "ServiceClient",
32
+ "ServiceResponse",
33
+ "__version__",
34
+ "check_manifest",
35
+ "load_config",
36
+ "load_manifest",
37
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,90 @@
1
+ """Settings a service is deployed with. See spec/registration.md, section 1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Mapping
8
+ from urllib.parse import urlsplit
9
+
10
+ from .errors import FinchlyError
11
+
12
+ #: Where the manifest lives in an image unless PLATFORM_MANIFEST_PATH says otherwise.
13
+ DEFAULT_MANIFEST_PATH = "/finchly/manifest.json"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PlatformConfig:
18
+ #: The gateway's internal address, without a trailing slash.
19
+ gateway_url: str
20
+ #: Path of the manifest inside the container.
21
+ manifest_path: str = DEFAULT_MANIFEST_PATH
22
+ #: Address the gateway should use to reach this service, if set.
23
+ app_address: str | None = None
24
+ #: Enrolment token for self-registration, if set. Left out of the repr,
25
+ #: so it does not appear when the config is logged.
26
+ enrolment_token: str | None = field(default=None, repr=False)
27
+ #: The credential a built-in service registers its instance key with,
28
+ #: if set. Left out of the repr, like the enrolment token.
29
+ service_credential: str | None = field(default=None, repr=False)
30
+
31
+
32
+ def _http_url(name: str, value: str, problems: list[str]) -> str | None:
33
+ try:
34
+ parts = urlsplit(value)
35
+ # Reading these can raise for a malformed port or host.
36
+ _ = parts.port, parts.hostname
37
+ except ValueError:
38
+ problems.append(f"{name} is not a URL")
39
+ return None
40
+ if not parts.scheme or " " in value:
41
+ problems.append(f"{name} is not a URL")
42
+ return None
43
+ if parts.scheme not in ("http", "https") or not parts.hostname:
44
+ problems.append(f"{name} must be an http or https URL")
45
+ return None
46
+ if parts.username or parts.password:
47
+ problems.append(f"{name} must not contain credentials")
48
+ return None
49
+ if parts.query or parts.fragment:
50
+ problems.append(f"{name} must not have a query string or fragment")
51
+ return None
52
+ return value.rstrip("/")
53
+
54
+
55
+ def load_config(env: Mapping[str, str] | None = None) -> PlatformConfig:
56
+ """Read the platform settings from the environment.
57
+
58
+ Raises a FinchlyError listing every problem, so a misconfigured
59
+ container fails once with the full picture.
60
+ """
61
+ env = os.environ if env is None else env
62
+ problems: list[str] = []
63
+
64
+ gateway_url: str | None = None
65
+ raw_gateway = (env.get("PLATFORM_GATEWAY_URL") or "").strip()
66
+ if not raw_gateway:
67
+ problems.append("PLATFORM_GATEWAY_URL is not set")
68
+ else:
69
+ gateway_url = _http_url("PLATFORM_GATEWAY_URL", raw_gateway, problems)
70
+
71
+ raw_address = (env.get("PLATFORM_APP_ADDRESS") or "").strip()
72
+ app_address = _http_url("PLATFORM_APP_ADDRESS", raw_address, problems) if raw_address else None
73
+
74
+ manifest_path = (env.get("PLATFORM_MANIFEST_PATH") or "").strip() or DEFAULT_MANIFEST_PATH
75
+ if not manifest_path.startswith("/"):
76
+ problems.append("PLATFORM_MANIFEST_PATH must be an absolute path")
77
+
78
+ enrolment_token = (env.get("PLATFORM_ENROLMENT_TOKEN") or "").strip() or None
79
+ service_credential = (env.get("PLATFORM_SERVICE_CREDENTIAL") or "").strip() or None
80
+
81
+ if problems or gateway_url is None:
82
+ raise FinchlyError("config_invalid", "Platform settings are invalid", problems)
83
+
84
+ return PlatformConfig(
85
+ gateway_url=gateway_url,
86
+ manifest_path=manifest_path,
87
+ app_address=app_address,
88
+ enrolment_token=enrolment_token,
89
+ service_credential=service_credential,
90
+ )
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+
5
+
6
+ class FinchlyError(Exception):
7
+ """Every error this SDK raises on purpose.
8
+
9
+ ``code`` is stable and safe to branch on. ``problems`` lists each thing
10
+ that was wrong, so a caller can report all of them at once instead of
11
+ one per restart.
12
+
13
+ Codes: ``config_invalid``, ``manifest_invalid``, ``manifest_unreadable``,
14
+ ``identity_invalid``, ``gateway_unreachable``, ``platform_api_unsupported``,
15
+ ``service_call_invalid``, ``service_key_rejected``.
16
+ """
17
+
18
+ def __init__(self, code: str, message: str, problems: Sequence[str] = ()) -> None:
19
+ self.code = code
20
+ self.problems: tuple[str, ...] = tuple(problems)
21
+ text = f"{message}: {'; '.join(self.problems)}" if self.problems else message
22
+ super().__init__(text)
23
+
24
+
25
+ #: Why an identity token can be rejected. Defined in spec/identity-token.md.
26
+ IDENTITY_REJECTIONS = (
27
+ "missing",
28
+ "malformed",
29
+ "algorithm",
30
+ "unknown_key",
31
+ "signature",
32
+ "issuer",
33
+ "audience",
34
+ "expired",
35
+ "claims",
36
+ )
37
+
38
+
39
+ class IdentityError(FinchlyError):
40
+ """The request carries no acceptable identity.
41
+
42
+ Treat it as unauthenticated (HTTP 401). ``reason`` is one of
43
+ ``IDENTITY_REJECTIONS`` and is for logs; do not show it to the caller.
44
+ """
45
+
46
+ def __init__(self, reason: str) -> None:
47
+ super().__init__("identity_invalid", f"Identity token rejected: {reason}")
48
+ self.reason = reason
@@ -0,0 +1,322 @@
1
+ """Verifies the x-platform-identity token.
2
+
3
+ Every check here is required by spec/identity-token.md in
4
+ firetail-io/finchly, and the conformance vectors in tests/conformance pin
5
+ the verdict for each kind of bad token.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from dataclasses import dataclass
17
+ from types import MappingProxyType
18
+ from typing import Any, Callable, Mapping
19
+ from urllib.parse import urlsplit
20
+
21
+ import jwt
22
+
23
+ from .errors import FinchlyError, IdentityError
24
+ from .platform_api import SUPPORTED_PLATFORM_API_VERSIONS
25
+
26
+ #: Header the gateway sets on every request it proxies.
27
+ IDENTITY_HEADER = "x-platform-identity"
28
+
29
+ _REQUIRED_CLAIMS = ("iss", "sub", "aud", "iat", "exp")
30
+ _DEFAULT_CLOCK_SKEW_SECONDS = 30
31
+ _KEY_CACHE_SECONDS = 60 * 60
32
+ _KEY_REFETCH_COOLDOWN_SECONDS = 30
33
+ _GATEWAY_TIMEOUT_SECONDS = 5.0
34
+ _SLUG = re.compile(r"[a-z][a-z0-9]*(-[a-z0-9]+)*")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Identity:
39
+ """Who is calling, as asserted by the gateway."""
40
+
41
+ #: The user's platform id, or ``service:<slug>`` for a service calling as itself.
42
+ subject: str
43
+ #: True when the caller is a service calling as itself, with no user.
44
+ service: bool
45
+ email: str | None
46
+ name: str | None
47
+ #: Platform group names.
48
+ groups: tuple[str, ...]
49
+ #: The user's roles for this service.
50
+ roles: tuple[str, ...]
51
+ #: How the caller was authenticated.
52
+ amr: tuple[str, ...]
53
+ #: ``service:<slug>`` when a service is acting on the user's behalf.
54
+ actor: str | None
55
+ session_id: str | None
56
+ #: Every claim in the token. Read-only.
57
+ claims: Mapping[str, Any]
58
+
59
+
60
+ def _is_number(value: Any) -> bool:
61
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
62
+
63
+
64
+ def _optional_string(claims: Mapping[str, Any], name: str) -> str | None:
65
+ value = claims.get(name)
66
+ if value is None and name not in claims:
67
+ return None
68
+ if not isinstance(value, str):
69
+ raise IdentityError("claims")
70
+ return value
71
+
72
+
73
+ def _string_tuple(claims: Mapping[str, Any], name: str) -> tuple[str, ...]:
74
+ if name not in claims:
75
+ return ()
76
+ value = claims[name]
77
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
78
+ raise IdentityError("claims")
79
+ return tuple(value)
80
+
81
+
82
+ def _keys_by_id(jwks: Any) -> dict[str, Any]:
83
+ """Index a JWKS by kid, keeping only keys usable for ES256."""
84
+ keys: dict[str, Any] = {}
85
+ entries = jwks.get("keys") if isinstance(jwks, dict) else None
86
+ if not isinstance(entries, list):
87
+ raise ValueError("key set has no keys list")
88
+ for entry in entries:
89
+ if not isinstance(entry, dict) or not isinstance(entry.get("kid"), str):
90
+ continue
91
+ if entry.get("kty") != "EC" or entry.get("crv") != "P-256":
92
+ continue
93
+ try:
94
+ keys[entry["kid"]] = jwt.PyJWK(entry, algorithm="ES256").key
95
+ except jwt.PyJWTError:
96
+ continue
97
+ return keys
98
+
99
+
100
+ class IdentityVerifier:
101
+ """Verifies identity tokens for one service.
102
+
103
+ Create one at startup and reuse it: it caches the gateway's issuer and
104
+ signing keys. It is safe to share between threads.
105
+
106
+ Give ``gateway_url`` (``PLATFORM_GATEWAY_URL``); the issuer and keys are
107
+ fetched from it. ``issuer`` and ``jwks`` together fix them instead, for
108
+ tests.
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ *,
114
+ slug: str,
115
+ gateway_url: str | None = None,
116
+ issuer: str | None = None,
117
+ jwks: Mapping[str, Any] | None = None,
118
+ clock_skew_seconds: float = _DEFAULT_CLOCK_SKEW_SECONDS,
119
+ now: Callable[[], float] | None = None,
120
+ ) -> None:
121
+ problems: list[str] = []
122
+ if not isinstance(slug, str) or not _SLUG.fullmatch(slug):
123
+ problems.append("slug must be this service's slug")
124
+ fixed = issuer is not None or jwks is not None
125
+ if fixed and gateway_url is not None:
126
+ problems.append("give either gateway_url, or issuer and jwks, not both")
127
+ elif fixed and (issuer is None or jwks is None):
128
+ problems.append("issuer and jwks must be given together")
129
+ elif not fixed and not gateway_url:
130
+ problems.append("gateway_url is required")
131
+ elif not fixed and urlsplit(gateway_url or "").scheme not in ("http", "https"):
132
+ # urllib would otherwise follow file: and other schemes.
133
+ problems.append("gateway_url must be an http or https URL")
134
+ if not _is_number(clock_skew_seconds) or clock_skew_seconds < 0:
135
+ problems.append("clock_skew_seconds must be zero or more")
136
+ if problems:
137
+ raise FinchlyError("config_invalid", "Identity verifier options are invalid", problems)
138
+
139
+ self._slug = slug
140
+ self._skew = clock_skew_seconds
141
+ #: Seconds since the epoch.
142
+ self._now = now or time.time
143
+ self._gateway_url = gateway_url.rstrip("/") if gateway_url else None
144
+ self._lock = threading.Lock()
145
+
146
+ self._issuer: str | None = issuer
147
+ self._keys: dict[str, Any] | None = None
148
+ self._keys_fetched_at = 0.0
149
+ if jwks is not None:
150
+ try:
151
+ self._keys = _keys_by_id(dict(jwks))
152
+ except ValueError as exc:
153
+ raise FinchlyError(
154
+ "config_invalid", "Identity verifier options are invalid", [f"jwks: {exc}"]
155
+ ) from exc
156
+
157
+ # ── Gateway ────────────────────────────────────────────────────────────
158
+
159
+ def _get_json(self, path: str) -> Any:
160
+ url = f"{self._gateway_url}{path}"
161
+ try:
162
+ with urllib.request.urlopen(url, timeout=_GATEWAY_TIMEOUT_SECONDS) as response:
163
+ return json.loads(response.read().decode("utf-8"))
164
+ except (urllib.error.URLError, OSError, ValueError) as exc:
165
+ raise FinchlyError("gateway_unreachable", f"Cannot read {url}", [str(exc)]) from exc
166
+
167
+ def _discovered_issuer(self) -> str:
168
+ """The gateway's issuer. A failed discovery is not cached."""
169
+ if self._issuer is not None:
170
+ return self._issuer
171
+ with self._lock:
172
+ if self._issuer is None:
173
+ path = "/_gateway/.well-known/platform.json"
174
+ doc = self._get_json(path)
175
+ issuer = doc.get("issuer") if isinstance(doc, dict) else None
176
+ if not isinstance(issuer, str) or not issuer:
177
+ raise FinchlyError(
178
+ "gateway_unreachable", f"{self._gateway_url}{path} does not name an issuer"
179
+ )
180
+ served = doc.get("platformApi")
181
+ served = served if isinstance(served, list) else []
182
+ if not any(v in SUPPORTED_PLATFORM_API_VERSIONS for v in served):
183
+ raise FinchlyError(
184
+ "platform_api_unsupported",
185
+ f"The gateway serves platform API {json.dumps(served)}; "
186
+ f"this SDK speaks {', '.join(SUPPORTED_PLATFORM_API_VERSIONS)}",
187
+ )
188
+ self._issuer = issuer
189
+ return self._issuer
190
+
191
+ def _key_for(self, kid: Any) -> Any:
192
+ """The published key with this id, refetching when it is not known."""
193
+ if not isinstance(kid, str):
194
+ raise IdentityError("unknown_key")
195
+ if self._gateway_url is None:
196
+ key = (self._keys or {}).get(kid)
197
+ if key is None:
198
+ raise IdentityError("unknown_key")
199
+ return key
200
+
201
+ with self._lock:
202
+ age = time.monotonic() - self._keys_fetched_at
203
+ known = self._keys is not None and kid in self._keys
204
+ stale = self._keys is None or age >= _KEY_CACHE_SECONDS
205
+ # An unknown kid may mean the gateway has rotated its key, so
206
+ # look again, but not more often than the cooldown allows.
207
+ may_refetch = self._keys is None or age >= _KEY_REFETCH_COOLDOWN_SECONDS
208
+ if stale or (not known and may_refetch):
209
+ try:
210
+ fetched = _keys_by_id(self._get_json("/_gateway/.well-known/jwks.json"))
211
+ except ValueError as exc:
212
+ raise FinchlyError(
213
+ "gateway_unreachable", "The gateway's key set is not usable", [str(exc)]
214
+ ) from exc
215
+ self._keys = fetched
216
+ self._keys_fetched_at = time.monotonic()
217
+ key = (self._keys or {}).get(kid)
218
+ if key is None:
219
+ raise IdentityError("unknown_key")
220
+ return key
221
+
222
+ # ── Verification ───────────────────────────────────────────────────────
223
+
224
+ def verify(self, token: str | None) -> Identity:
225
+ """Verify a token. Raises IdentityError if it is not acceptable."""
226
+ if not isinstance(token, str) or not token:
227
+ raise IdentityError("missing")
228
+
229
+ try:
230
+ header = jwt.get_unverified_header(token)
231
+ except jwt.PyJWTError as exc:
232
+ raise IdentityError("malformed") from exc
233
+ # Checked first and by hand, so that no other property of the token
234
+ # can influence what happens to one with the wrong algorithm.
235
+ if header.get("alg") != "ES256":
236
+ raise IdentityError("algorithm")
237
+
238
+ issuer = self._discovered_issuer()
239
+ key = self._key_for(header.get("kid"))
240
+
241
+ try:
242
+ # Only the signature is checked here. The claims are checked
243
+ # below, against this verifier's clock, so that the rules are the
244
+ # ones in the specification and not the library's defaults.
245
+ claims = jwt.decode(
246
+ token,
247
+ key=key,
248
+ algorithms=["ES256"],
249
+ options={
250
+ "verify_signature": True,
251
+ "verify_exp": False,
252
+ "verify_nbf": False,
253
+ "verify_iat": False,
254
+ "verify_aud": False,
255
+ "verify_iss": False,
256
+ "verify_sub": False,
257
+ "verify_jti": False,
258
+ "require": [],
259
+ },
260
+ )
261
+ except jwt.InvalidSignatureError as exc:
262
+ raise IdentityError("signature") from exc
263
+ except jwt.InvalidAlgorithmError as exc:
264
+ raise IdentityError("algorithm") from exc
265
+ except jwt.DecodeError as exc:
266
+ raise IdentityError("malformed") from exc
267
+ except jwt.PyJWTError as exc:
268
+ raise IdentityError("claims") from exc
269
+
270
+ return self._identity(claims, issuer)
271
+
272
+ def _identity(self, claims: dict[str, Any], issuer: str) -> Identity:
273
+ for name in _REQUIRED_CLAIMS:
274
+ if name not in claims:
275
+ raise IdentityError("claims")
276
+ if claims["iss"] != issuer:
277
+ raise IdentityError("issuer")
278
+ # A token is for exactly one service. A list of audiences that
279
+ # happens to include this one is not accepted.
280
+ if claims["aud"] != self._slug or not isinstance(claims["aud"], str):
281
+ raise IdentityError("audience")
282
+ if not isinstance(claims["sub"], str) or not claims["sub"]:
283
+ raise IdentityError("claims")
284
+ if not _is_number(claims["iat"]) or not _is_number(claims["exp"]):
285
+ raise IdentityError("claims")
286
+ # Rejected once the full skew has passed since exp.
287
+ if claims["exp"] <= self._now() - self._skew:
288
+ raise IdentityError("expired")
289
+
290
+ actor: str | None = None
291
+ if "act" in claims:
292
+ act = claims["act"]
293
+ if not isinstance(act, dict) or not isinstance(act.get("sub"), str):
294
+ raise IdentityError("claims")
295
+ actor = act["sub"]
296
+
297
+ subject: str = claims["sub"]
298
+ return Identity(
299
+ subject=subject,
300
+ service=subject.startswith("service:"),
301
+ email=_optional_string(claims, "email"),
302
+ name=_optional_string(claims, "name"),
303
+ groups=_string_tuple(claims, "groups"),
304
+ roles=_string_tuple(claims, "roles"),
305
+ amr=_string_tuple(claims, "amr"),
306
+ actor=actor,
307
+ session_id=_optional_string(claims, "sid"),
308
+ claims=MappingProxyType(dict(claims)),
309
+ )
310
+
311
+ def from_headers(self, headers: Mapping[str, Any]) -> Identity:
312
+ """Verify the token in a request's headers.
313
+
314
+ Header names are matched without regard to case. The gateway sets the
315
+ header exactly once, so more than one value is rejected.
316
+ """
317
+ values = [v for k, v in headers.items() if isinstance(k, str) and k.lower() == IDENTITY_HEADER]
318
+ if not values:
319
+ raise IdentityError("missing")
320
+ if len(values) > 1 or isinstance(values[0], (list, tuple)):
321
+ raise IdentityError("malformed")
322
+ return self.verify(values[0])
@@ -0,0 +1,97 @@
1
+ """Load a Finchly manifest and check what this SDK depends on.
2
+
3
+ The schema is in firetail-io/finchly under spec/manifest.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from typing import Any
11
+
12
+ from .config import DEFAULT_MANIFEST_PATH
13
+ from .errors import FinchlyError
14
+ from .platform_api import MANIFEST_VERSION, SUPPORTED_PLATFORM_API_VERSIONS
15
+
16
+ # Used with fullmatch: in Python "$" also matches before a trailing newline,
17
+ # which would let "slug\n" through.
18
+ _SLUG = re.compile(r"[a-z][a-z0-9]*(-[a-z0-9]+)*")
19
+
20
+
21
+ def _is_int(value: Any) -> bool:
22
+ # bool is a subclass of int in Python; True is not a port.
23
+ return isinstance(value, int) and not isinstance(value, bool)
24
+
25
+
26
+ def check_manifest(value: Any) -> dict[str, Any]:
27
+ """Check the parts of a manifest this SDK itself depends on.
28
+
29
+ This is deliberately not the full schema. The platform runs the full
30
+ check when the service registers, and is the authority. The point here
31
+ is to stop a container at startup, with a clear message, when the
32
+ manifest is missing something the SDK cannot work without.
33
+ """
34
+ if not isinstance(value, dict):
35
+ raise FinchlyError("manifest_invalid", "Manifest is not a JSON object")
36
+
37
+ problems: list[str] = []
38
+
39
+ version = value.get("manifestVersion")
40
+ if not _is_int(version) or version != MANIFEST_VERSION:
41
+ problems.append(f"manifestVersion must be {MANIFEST_VERSION}")
42
+
43
+ platform_api = value.get("platformApi")
44
+ if not isinstance(platform_api, str):
45
+ problems.append("platformApi must be a string")
46
+ elif platform_api not in SUPPORTED_PLATFORM_API_VERSIONS:
47
+ problems.append(
48
+ f'platformApi "{platform_api}" is not one this SDK speaks '
49
+ f"({', '.join(SUPPORTED_PLATFORM_API_VERSIONS)})"
50
+ )
51
+
52
+ slug = value.get("slug")
53
+ slug_ok = isinstance(slug, str) and 2 <= len(slug) <= 40 and bool(_SLUG.fullmatch(slug))
54
+ if not slug_ok:
55
+ problems.append(
56
+ "slug must be 2 to 40 lowercase letters, digits and single hyphens, starting with a letter"
57
+ )
58
+
59
+ name = value.get("name")
60
+ if not isinstance(name, str) or not name:
61
+ problems.append("name must be a non-empty string")
62
+
63
+ release = value.get("version")
64
+ if not isinstance(release, str) or not release:
65
+ problems.append("version must be a non-empty string")
66
+
67
+ port = value.get("port")
68
+ # isinstance is spelled out here so type checkers narrow `port` to int.
69
+ if not (isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535):
70
+ problems.append("port must be an integer from 1 to 65535")
71
+
72
+ health_path = value.get("healthPath")
73
+ if not isinstance(health_path, str):
74
+ problems.append("healthPath must be a string")
75
+ elif slug_ok and not health_path.startswith(f"/{slug}/"):
76
+ problems.append(f"healthPath must start with /{slug}/")
77
+
78
+ if problems:
79
+ raise FinchlyError("manifest_invalid", "Manifest is invalid", problems)
80
+ return value
81
+
82
+
83
+ def load_manifest(path: str = DEFAULT_MANIFEST_PATH) -> dict[str, Any]:
84
+ """Read and check the manifest from the container's filesystem."""
85
+ try:
86
+ with open(path, encoding="utf-8") as handle:
87
+ text = handle.read()
88
+ except OSError as exc:
89
+ raise FinchlyError(
90
+ "manifest_unreadable", f"Cannot read manifest at {path}", [str(exc)]
91
+ ) from exc
92
+
93
+ try:
94
+ parsed = json.loads(text)
95
+ except ValueError as exc:
96
+ raise FinchlyError("manifest_invalid", f"Manifest at {path} is not valid JSON") from exc
97
+ return check_manifest(parsed)
@@ -0,0 +1,24 @@
1
+ """The platform API versions this SDK release speaks.
2
+
3
+ The platform API is specified in firetail-io/finchly under spec/. This
4
+ package is one implementation of it and defines no protocol of its own.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._version import __version__
10
+
11
+ #: Platform API version this SDK release speaks by default.
12
+ PLATFORM_API_VERSION = "v1"
13
+
14
+ #: Every platform API version this SDK release can speak.
15
+ SUPPORTED_PLATFORM_API_VERSIONS: tuple[str, ...] = ("v1",)
16
+
17
+ #: Manifest schema version this SDK release reads.
18
+ MANIFEST_VERSION = 1
19
+
20
+ #: Package name, as reported to the platform at registration.
21
+ SDK_NAME = "firetail_finchly"
22
+
23
+ #: Package version, as reported to the platform at registration.
24
+ SDK_VERSION = __version__
File without changes
@@ -0,0 +1,351 @@
1
+ """Calls from this service to another, through the gateway.
2
+
3
+ Services never address each other directly. A call goes to the gateway's
4
+ internal address with a token this instance signs with its own key; the
5
+ gateway checks it, decides whether the call is allowed, and forwards it.
6
+ The token format is in spec/service-token.md and key registration in
7
+ spec/registration.md, both in firetail-io/finchly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import re
16
+ import threading
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ import uuid
21
+ from dataclasses import dataclass
22
+ from datetime import datetime
23
+ from typing import Any, Callable, Mapping
24
+
25
+ import jwt
26
+ from cryptography.hazmat.primitives.asymmetric import ec
27
+
28
+ from .errors import FinchlyError
29
+
30
+ _SLUG = re.compile(r"[a-z][a-z0-9]*(-[a-z0-9]+)*")
31
+ _USER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
32
+ _DOT_SEGMENT = re.compile(r"(^|/)\.\.?(/|$)")
33
+ #: The audience of a token meant for the gateway itself.
34
+ _GATEWAY_AUDIENCE = "gateway"
35
+ _KEYS_PATH = "/_gateway/keys"
36
+ #: How long a token is valid. The specification allows up to 300 seconds.
37
+ _TOKEN_TTL_SECONDS = 60
38
+ _REGISTRATION_TIMEOUT_SECONDS = 10.0
39
+ #: After a call is refused as unauthenticated, wait this long before registering again.
40
+ _REREGISTER_COOLDOWN_SECONDS = 60.0
41
+
42
+
43
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
44
+ """Hand a redirect back instead of following it.
45
+
46
+ A credential or a token is for the gateway and goes nowhere a response
47
+ points.
48
+ """
49
+
50
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
51
+ return None
52
+
53
+
54
+ _OPENER = urllib.request.build_opener(_NoRedirect)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ServiceResponse:
59
+ """What the target answered."""
60
+
61
+ status: int
62
+ #: Header names in lower case.
63
+ headers: Mapping[str, str]
64
+ body: bytes
65
+
66
+ def json(self) -> Any:
67
+ return json.loads(self.body.decode("utf-8"))
68
+
69
+
70
+ def _b64url(data: bytes) -> str:
71
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
72
+
73
+
74
+ def _invalid(message: str) -> FinchlyError:
75
+ return FinchlyError("service_call_invalid", message)
76
+
77
+
78
+ def _send(request: urllib.request.Request, timeout: float) -> ServiceResponse:
79
+ """Send a request and return the response, whatever its status."""
80
+ try:
81
+ with _OPENER.open(request, timeout=timeout) as response:
82
+ return ServiceResponse(
83
+ response.status, {k.lower(): v for k, v in response.headers.items()}, response.read()
84
+ )
85
+ except urllib.error.HTTPError as err:
86
+ with err:
87
+ return ServiceResponse(err.code, {k.lower(): v for k, v in err.headers.items()}, err.read())
88
+
89
+
90
+ class ServiceClient:
91
+ """Calls other services, as this service or for a user.
92
+
93
+ Create one at startup and share it; it is safe to use from several
94
+ threads.
95
+
96
+ :param slug: this service's slug.
97
+ :param gateway_url: the gateway's internal address (PLATFORM_GATEWAY_URL).
98
+ :param credential: the credential this built-in service was deployed
99
+ with (PLATFORM_SERVICE_CREDENTIAL). It is what gets the instance
100
+ key accepted. An app has none; its key is accepted when it
101
+ registers.
102
+ :param now: clock, in seconds since the epoch. For tests.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ *,
108
+ slug: str,
109
+ gateway_url: str,
110
+ credential: str | None = None,
111
+ now: Callable[[], float] | None = None,
112
+ ) -> None:
113
+ if not isinstance(slug, str) or not _SLUG.fullmatch(slug):
114
+ raise _invalid(f"slug {slug!r} is not a slug")
115
+ if not isinstance(gateway_url, str) or not re.match(r"https?://", gateway_url):
116
+ raise _invalid("gateway_url must be an http or https URL")
117
+ self._slug = slug
118
+ self._issuer = f"service:{slug}"
119
+ self._gateway_url = gateway_url.rstrip("/")
120
+ self._credential = credential or None
121
+ self._now = now or time.time
122
+ self._lock = threading.Lock()
123
+
124
+ self._private_key: ec.EllipticCurvePrivateKey | None = None
125
+ self._public_jwk: dict[str, str] = {}
126
+ self._kid: str | None = None
127
+ #: When the gateway stops accepting the key, and when to extend it.
128
+ self._expires_at = 0.0
129
+ self._renew_at = 0.0
130
+ #: Set when a call came back 401: the gateway may no longer know the key.
131
+ self._doubted = False
132
+ self._last_forced_at = float("-inf")
133
+
134
+ def __repr__(self) -> str:
135
+ # Never the credential.
136
+ return f"ServiceClient(slug={self._slug!r}, gateway_url={self._gateway_url!r})"
137
+
138
+ # The instance key -----------------------------------------------------
139
+
140
+ @property
141
+ def key_id(self) -> str | None:
142
+ """The id of this instance's key, once the gateway has accepted one."""
143
+ return self._kid if self._expires_at else None
144
+
145
+ def start(self) -> None:
146
+ """Make the instance key and have the gateway accept it.
147
+
148
+ Optional: the first call does this. Call it at startup to fail
149
+ early instead.
150
+ """
151
+ self._instance_key()
152
+
153
+ def _generate(self) -> None:
154
+ private_key = ec.generate_private_key(ec.SECP256R1())
155
+ numbers = private_key.public_key().public_numbers()
156
+ jwk = {
157
+ "kty": "EC",
158
+ "crv": "P-256",
159
+ "x": _b64url(numbers.x.to_bytes(32, "big")),
160
+ "y": _b64url(numbers.y.to_bytes(32, "big")),
161
+ }
162
+ # RFC 7638: the required members, in order, with no spaces.
163
+ canonical = json.dumps(
164
+ {name: jwk[name] for name in ("crv", "kty", "x", "y")}, separators=(",", ":")
165
+ )
166
+ self._private_key = private_key
167
+ self._public_jwk = jwk
168
+ self._kid = _b64url(hashlib.sha256(canonical.encode("ascii")).digest())
169
+
170
+ def _sign(self, audience: str, subject: str) -> str:
171
+ issued_at = int(self._now())
172
+ return jwt.encode(
173
+ {
174
+ "iss": self._issuer,
175
+ "sub": subject,
176
+ "aud": audience,
177
+ "iat": issued_at,
178
+ "exp": issued_at + _TOKEN_TTL_SECONDS,
179
+ "jti": str(uuid.uuid4()),
180
+ },
181
+ self._private_key, # type: ignore[arg-type]
182
+ algorithm="ES256",
183
+ headers={"kid": self._kid, "typ": "JWT"},
184
+ )
185
+
186
+ def _register(self, authorization: str, with_slug: bool) -> ServiceResponse:
187
+ """Ask the gateway to accept the key, or to extend it."""
188
+ body: dict[str, Any] = {"key": {"kid": self._kid, "jwk": self._public_jwk}}
189
+ if with_slug:
190
+ body["slug"] = self._slug
191
+ request = urllib.request.Request(
192
+ f"{self._gateway_url}{_KEYS_PATH}",
193
+ data=json.dumps(body).encode("utf-8"),
194
+ method="POST",
195
+ headers={
196
+ "Authorization": authorization,
197
+ "Content-Type": "application/json",
198
+ "Accept": "application/json",
199
+ },
200
+ )
201
+ try:
202
+ return _send(request, _REGISTRATION_TIMEOUT_SECONDS)
203
+ except (urllib.error.URLError, OSError) as err:
204
+ reason = getattr(err, "reason", err)
205
+ raise FinchlyError(
206
+ "gateway_unreachable",
207
+ "Cannot register this instance's key with the gateway",
208
+ [str(reason)],
209
+ ) from None
210
+
211
+ def _accept(self, response: ServiceResponse) -> None:
212
+ body: Any = None
213
+ try:
214
+ body = response.json()
215
+ except ValueError:
216
+ pass
217
+ expires_at: float | None = None
218
+ if response.status == 200 and isinstance(body, dict) and isinstance(body.get("keyExpiresAt"), str):
219
+ try:
220
+ expires_at = datetime.fromisoformat(body["keyExpiresAt"].replace("Z", "+00:00")).timestamp()
221
+ except ValueError:
222
+ expires_at = None
223
+ if expires_at is None:
224
+ reason = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
225
+ raise FinchlyError(
226
+ "service_key_rejected",
227
+ "The gateway did not accept this instance's key",
228
+ [reason or f"status {response.status}"],
229
+ )
230
+ now = self._now()
231
+ self._expires_at = expires_at
232
+ # Extend it when two thirds of its life has gone.
233
+ self._renew_at = now + (expires_at - now) * 2 / 3
234
+
235
+ def _with_credential(self) -> None:
236
+ if not self._credential:
237
+ raise FinchlyError(
238
+ "service_key_rejected",
239
+ "This instance has no key the gateway accepts, and no credential to register one",
240
+ [
241
+ "A built-in service is deployed with PLATFORM_SERVICE_CREDENTIAL.",
242
+ "An app's key is accepted when it registers, which this SDK release cannot do yet.",
243
+ ],
244
+ )
245
+ self._accept(self._register(f"Service-Credential {self._credential}", True))
246
+
247
+ def _instance_key(self) -> None:
248
+ """Make sure the instance key exists, is registered and is current."""
249
+ with self._lock:
250
+ now = self._now()
251
+ # Only a credential can re-register, so only then is it worth trying.
252
+ force = (
253
+ self._doubted
254
+ and self._credential is not None
255
+ and now - self._last_forced_at >= _REREGISTER_COOLDOWN_SECONDS
256
+ )
257
+ if self._expires_at and not force and now < self._renew_at:
258
+ return
259
+ if self._private_key is None:
260
+ self._generate()
261
+ if force:
262
+ self._last_forced_at = now
263
+ self._doubted = False
264
+ elif self._expires_at and now < self._expires_at:
265
+ # Still accepted: extend it with a token it signs itself.
266
+ response = self._register(f"Bearer {self._sign(_GATEWAY_AUDIENCE, self._issuer)}", False)
267
+ if response.status != 401:
268
+ self._accept(response)
269
+ return
270
+ # New, expired, or no longer known to the gateway: use the credential.
271
+ self._with_credential()
272
+
273
+ # Calls ----------------------------------------------------------------
274
+
275
+ def _subject(self, as_user: str | None) -> str:
276
+ if as_user is None:
277
+ return self._issuer
278
+ if not isinstance(as_user, str) or not _USER_ID.fullmatch(as_user):
279
+ raise _invalid("as_user must be a user's platform id")
280
+ return as_user
281
+
282
+ @staticmethod
283
+ def _check_target(target: str) -> None:
284
+ if not isinstance(target, str) or not _SLUG.fullmatch(target):
285
+ raise _invalid(f"target {target!r} is not a slug")
286
+
287
+ def token(self, target: str, *, as_user: str | None = None) -> str:
288
+ """A signed token for a call to ``target``. Most code wants ``call``."""
289
+ self._check_target(target)
290
+ subject = self._subject(as_user)
291
+ self._instance_key()
292
+ return self._sign(target, subject)
293
+
294
+ def url(self, target: str, path: str) -> str:
295
+ """The gateway URL for a path under ``target``. ``path`` starts with "/"."""
296
+ self._check_target(target)
297
+ # The path is joined as text. It must not be able to climb out of the target.
298
+ if (
299
+ not isinstance(path, str)
300
+ or not path.startswith("/")
301
+ or "\\" in path
302
+ or _DOT_SEGMENT.search(path.split("?", 1)[0])
303
+ ):
304
+ raise _invalid('path must start with "/" and contain no "." or ".." segments')
305
+ return f"{self._gateway_url}/{target}{path}"
306
+
307
+ def headers(self, target: str, *, as_user: str | None = None) -> dict[str, str]:
308
+ """The header to add to a request to ``target``, for use with another HTTP client.
309
+
310
+ Build the address with ``url``. A token is for one target and is
311
+ valid for a minute: get fresh headers for every request.
312
+ """
313
+ return {"Authorization": f"Bearer {self.token(target, as_user=as_user)}"}
314
+
315
+ def call(
316
+ self,
317
+ target: str,
318
+ path: str,
319
+ *,
320
+ method: str = "GET",
321
+ headers: Mapping[str, str] | None = None,
322
+ body: bytes | None = None,
323
+ as_user: str | None = None,
324
+ timeout: float = 30.0,
325
+ ) -> ServiceResponse:
326
+ """Call another service and return its response, whatever the status.
327
+
328
+ A 403 means this service's manifest does not declare the target in
329
+ ``calls``, or the user has no access to it. A redirect is handed
330
+ back, not followed. Nothing is retried.
331
+
332
+ :param as_user: the id of the user this call is made for. The
333
+ target then sees that user, and this service as the one acting
334
+ for them. Leave it out to call as the service itself.
335
+ """
336
+ url = self.url(target, path)
337
+ token = self.token(target, as_user=as_user)
338
+ sent = {name: value for name, value in (headers or {}).items() if name.lower() != "authorization"}
339
+ # Whatever the caller set, the gateway reads this one.
340
+ sent["Authorization"] = f"Bearer {token}"
341
+ request = urllib.request.Request(url, data=body, method=method.upper(), headers=sent)
342
+ try:
343
+ response = _send(request, timeout)
344
+ except (urllib.error.URLError, OSError) as err:
345
+ reason = getattr(err, "reason", err)
346
+ raise FinchlyError(
347
+ "gateway_unreachable", f"Cannot reach {target} through the gateway", [str(reason)]
348
+ ) from None
349
+ if response.status == 401:
350
+ self._doubted = True
351
+ return response
@@ -0,0 +1,242 @@
1
+ Metadata-Version: 2.5
2
+ Name: firetail_finchly
3
+ Version: 0.0.1
4
+ Summary: Python SDK for the Finchly platform
5
+ Project-URL: Repository, https://github.com/firetail-io/finchly-sdk-py
6
+ Project-URL: Changelog, https://github.com/firetail-io/finchly-sdk-py/blob/main/CHANGELOG.md
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: pyjwt[crypto]<3,>=2.8
16
+ Provides-Extra: dev
17
+ Requires-Dist: build>=1.2; extra == 'dev'
18
+ Requires-Dist: pytest>=8; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # firetail_finchly
22
+
23
+ Python SDK for the [Finchly](https://github.com/firetail-io/finchly)
24
+ platform. A service or app uses it to register with a Finchly deployment,
25
+ verify who is calling it, and reach the platform's database, object store,
26
+ queue and other services.
27
+
28
+ Status: early. The package is tested, but only the first four modules
29
+ exist.
30
+
31
+ The SDK implements the platform API, which is specified in
32
+ `firetail-io/finchly` under `spec/`. This release speaks platform API `v1`
33
+ (draft).
34
+
35
+ ## Install
36
+
37
+ ```
38
+ pip install firetail_finchly
39
+ ```
40
+
41
+ Requires Python 3.10 or later. Depends on PyJWT with its `crypto` extra.
42
+
43
+ ## Use
44
+
45
+ ```python
46
+ from firetail_finchly import load_config, load_manifest
47
+
48
+ config = load_config() # reads PLATFORM_* settings
49
+ manifest = load_manifest(config.manifest_path)
50
+ ```
51
+
52
+ Both raise a `FinchlyError` with a stable `code` and a `problems` tuple that
53
+ names everything wrong, so a misconfigured container fails once with the
54
+ full picture.
55
+
56
+ ### Who is calling
57
+
58
+ The gateway puts a short-lived signed token in the `x-platform-identity`
59
+ header of every request it proxies. It is the only identity a service
60
+ should accept.
61
+
62
+ ```python
63
+ from firetail_finchly import IdentityError, IdentityVerifier
64
+
65
+ # Once, at startup. It caches the gateway's issuer and signing keys, and
66
+ # is safe to share between threads.
67
+ identity = IdentityVerifier(slug=manifest["slug"], gateway_url=config.gateway_url)
68
+
69
+ # Per request.
70
+ try:
71
+ caller = identity.from_headers(request.headers)
72
+ caller.subject # user id, or "service:<slug>"
73
+ caller.email # None for a service calling as itself
74
+ caller.groups # platform groups
75
+ caller.roles # roles for this service: "user", "operator"
76
+ caller.actor # "service:<slug>" when a service acts for the user
77
+ except IdentityError as err:
78
+ ... # Unauthenticated: respond 401. err.reason is for logs only.
79
+ ```
80
+
81
+ Any other `FinchlyError` from the verifier means the gateway could not be
82
+ reached; respond 503.
83
+
84
+ A token is rejected unless it is signed with ES256 by a key the gateway
85
+ publishes, names that gateway as issuer and this service as its only
86
+ audience, and has not expired. The rules are in `spec/identity-token.md` in
87
+ `firetail-io/finchly`.
88
+
89
+ No other header is ever consulted. On a path the manifest declares in
90
+ `publicPaths` there is no token, and the service authenticates the request
91
+ itself.
92
+
93
+ ### Calling another service
94
+
95
+ Services never address each other directly. A call goes through the
96
+ gateway, signed by this instance's own key.
97
+
98
+ ```python
99
+ from firetail_finchly import ServiceClient
100
+
101
+ # Once, at startup. Safe to share between threads.
102
+ service = ServiceClient(
103
+ slug=manifest["slug"],
104
+ gateway_url=config.gateway_url,
105
+ credential=config.service_credential,
106
+ )
107
+ service.start() # optional: fail now, not on the first call
108
+
109
+ # As the service itself.
110
+ response = service.call(
111
+ "notification-service",
112
+ "/api/send",
113
+ method="POST",
114
+ headers={"Content-Type": "application/json"},
115
+ body=b'{"text": "hello"}',
116
+ )
117
+ response.status, response.headers, response.body, response.json()
118
+
119
+ # For a user: the target sees that user, and this service acting for them.
120
+ service.call("notification-service", "/api/inbox", as_user=caller.subject)
121
+ ```
122
+
123
+ `call` returns the response, whatever its status.
124
+
125
+ - **403** means this service's manifest does not list the target in
126
+ `calls`, or the user has no access to the target.
127
+ - **A redirect is handed back, not followed.**
128
+ - **Nothing is retried.** After a 401 the next call registers the key
129
+ again, in case the gateway no longer knows it.
130
+
131
+ To use another HTTP client, such as `requests` or `httpx`, take the address
132
+ and the header from the client and send the request yourself. A token is
133
+ for one target and is valid for a minute, so get fresh headers each time:
134
+
135
+ ```python
136
+ requests.post(
137
+ service.url("notification-service", "/api/send"),
138
+ headers=service.headers("notification-service"),
139
+ json={"text": "hello"},
140
+ allow_redirects=False,
141
+ )
142
+ ```
143
+
144
+ The instance makes an ES256 key pair in memory the first time it is needed.
145
+ The private half never leaves the process. The public half is registered
146
+ with the gateway using the credential the service was deployed with, and
147
+ extended by itself for as long as the instance runs. The rules are in
148
+ `spec/service-token.md` and `spec/registration.md` in `firetail-io/finchly`.
149
+
150
+ Only a built-in service has a credential. An app's key is accepted when it
151
+ registers, which the `app` module will do; until then an app cannot use
152
+ `service`.
153
+
154
+ ### Settings
155
+
156
+ | Variable | Required | Meaning |
157
+ |---|---|---|
158
+ | `PLATFORM_GATEWAY_URL` | yes | The gateway's internal address |
159
+ | `PLATFORM_MANIFEST_PATH` | no | Manifest path in the container. Default `/finchly/manifest.json` |
160
+ | `PLATFORM_APP_ADDRESS` | no | Address the gateway should use to reach this service |
161
+ | `PLATFORM_ENROLMENT_TOKEN` | no | Token for self-registration |
162
+ | `PLATFORM_SERVICE_CREDENTIAL` | no | What a built-in service registers its instance key with |
163
+
164
+ The enrolment token and the service credential are left out of the
165
+ config's `repr`, so logging the config does not leak them.
166
+
167
+ ## Modules
168
+
169
+ The Python SDK may trail the Node SDK for modules that only Node services
170
+ use.
171
+
172
+ | Module | What it does | State |
173
+ |---|---|---|
174
+ | `config` | Read and validate the platform settings | Done |
175
+ | `manifest` | Load the manifest and check what the SDK depends on | Done |
176
+ | `identity` | Verify `x-platform-identity` | Done. Directory lookups (`list_users`, `members_of`) and `require_*` helpers are planned |
177
+ | `app` | Register with the platform, serve `/_platform/*`, renew keys, fetch settings | Next |
178
+ | `service` | Call other services through the gateway, as the service or for a user | Done for built-in services. An app's first key waits on `app` |
179
+ | `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Planned |
180
+ | `blobs` | Object storage with conditional writes | Planned |
181
+ | `queue` | Publish, lease, ack, nack | Planned |
182
+ | `secrets` | Secrets and envelope encryption | Planned |
183
+ | `llm` | Model calls through the platform | Planned |
184
+ | `audit`, `usage`, `notify`, `schedule`, `connectors` | Clients for platform services | Planned |
185
+ | `ui` | Shared header, footer and theme | Not planned; Node only |
186
+
187
+ The manifest check here is not the full schema. The platform runs the full
188
+ check at registration and is the authority.
189
+
190
+ ## Develop
191
+
192
+ ```
193
+ python -m venv .venv && . .venv/bin/activate
194
+ pip install -e ".[dev]"
195
+ python -m pytest
196
+ ```
197
+
198
+ Without a local Python environment, run them in a container:
199
+
200
+ ```
201
+ docker run --rm -v "$PWD":/src:ro python:3.12-slim sh -c \
202
+ "cp -r /src /w && cd /w && pip install -q -e '.[dev]' && python -m pytest -q"
203
+ ```
204
+
205
+ ### Conformance vectors
206
+
207
+ `tests/conformance/` holds test vectors copied from `firetail-io/finchly`
208
+ (`conformance/vectors/`). Both SDKs must reach the verdict each vector
209
+ states. Do not edit them here: change them in `finchly`, regenerate, and
210
+ copy the file to both SDK repositories. Until `finchly` publishes its
211
+ conformance suite as an image, the copy is made by hand.
212
+
213
+ ## Release
214
+
215
+ Each package version follows semver, independent of the Node SDK and of the
216
+ platform.
217
+
218
+ 1. Changes land on `main` by pull request. Each one adds a line to
219
+ `CHANGELOG.md` under "Unreleased".
220
+ 2. To release, set the version in `src/firetail_finchly/_version.py`, move
221
+ the changelog entries under that version, merge, and tag the merge
222
+ commit `v` plus the version, for example `v1.2.0`.
223
+ 3. The `release` workflow checks the tag matches the version, runs the
224
+ tests, builds the sdist and wheel, and publishes.
225
+ 4. A release candidate uses the PEP 440 form, `1.2.0rc1`, tagged
226
+ `v1.2.0rc1`. pip does not install pre-releases unless asked.
227
+ 5. A bad release is yanked on PyPI and followed by a patch release.
228
+
229
+ Publishing uses PyPI trusted publishing: the workflow proves its identity to
230
+ PyPI with its GitHub Actions token, so no PyPI token is stored in this
231
+ repository.
232
+
233
+ One-time setup, not yet done:
234
+
235
+ - On PyPI, add a pending trusted publisher for the project
236
+ `firetail_finchly`: owner `firetail-io`, repository `finchly-sdk-py`,
237
+ workflow `release.yml`, environment `pypi`.
238
+ - Create a GitHub environment called `pypi` in this repository, restricted
239
+ to tags.
240
+
241
+ Still to add: the conformance suite from `firetail-io/finchly`, run in CI on
242
+ every change, once that suite exists.
@@ -0,0 +1,12 @@
1
+ firetail_finchly/__init__.py,sha256=IheK8HWBcojxM7Bk_zIZIwaRdO2aFphjdJSotlg0FJo,982
2
+ firetail_finchly/_version.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
3
+ firetail_finchly/config.py,sha256=WgaVUdXrGzGIJ-s9z5jdrzAYzSG4gFGtAz3EiKod5Ms,3506
4
+ firetail_finchly/errors.py,sha256=v6r_Ca0qkHmjmHAjq2Bj8BR_0__amEfh3jaivNEEfsI,1471
5
+ firetail_finchly/identity.py,sha256=ojLOhYKCU8b3tUR9W5U2Y3Rl-oxc04qxxQnrwt9NdVw,13135
6
+ firetail_finchly/manifest.py,sha256=JDGUjVVDiwKi9GCgA0FYKKYoF5-5FDEAk2i0bgufoiM,3601
7
+ firetail_finchly/platform_api.py,sha256=4PkNaiMohm2TAh4VAAyZ4GVULht0O40AoewKapo49qE,738
8
+ firetail_finchly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ firetail_finchly/service.py,sha256=EVqrTTQr8cL_iPSdcNC9jb8lRxesVZaa3PLZcHADtH4,13682
10
+ firetail_finchly-0.0.1.dist-info/METADATA,sha256=hTWqTCeewX06a-gPZUAXSTXbqlp2vh1d9n0hkR6YaWU,9177
11
+ firetail_finchly-0.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ firetail_finchly-0.0.1.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