lightweb-adapter-sdk 0.2.2__tar.gz → 0.2.3__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.
Files changed (45) hide show
  1. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/CHANGELOG.md +44 -0
  2. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/PKG-INFO +1 -1
  3. lightweb_adapter_sdk-0.2.3/src/lightweb_adapter_sdk/event_consumer/parser.py +119 -0
  4. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/testkit/fake_event_source.py +39 -24
  5. lightweb_adapter_sdk-0.2.2/src/lightweb_adapter_sdk/event_consumer/parser.py +0 -94
  6. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/.github/workflows/ci.yml +0 -0
  7. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/.github/workflows/release.yml +0 -0
  8. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/.gitignore +0 -0
  9. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/LICENSE +0 -0
  10. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/README.md +0 -0
  11. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/conformance-audit.md +0 -0
  12. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/conformance.md +0 -0
  13. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/contract.md +0 -0
  14. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/event-consumer.md +0 -0
  15. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/examples/round-0-prompt.md +0 -0
  16. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/round-prompt-template.md +0 -0
  17. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/supervisor-side-edits.md +0 -0
  18. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/testkit.md +0 -0
  19. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/docs/wrap-procedures.md +0 -0
  20. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/pyproject.toml +0 -0
  21. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/__init__.py +0 -0
  22. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/adapter/__init__.py +0 -0
  23. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/__init__.py +0 -0
  24. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/fault_control.py +0 -0
  25. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_apply.py +0 -0
  26. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_capabilities.py +0 -0
  27. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_disable.py +0 -0
  28. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_event_consumer.py +0 -0
  29. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_health.py +0 -0
  30. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_identity_mapping.py +0 -0
  31. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_list_accounts.py +0 -0
  32. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/conformance/test_reconcile.py +0 -0
  33. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/event_consumer/__init__.py +0 -0
  34. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/event_consumer/dedup.py +0 -0
  35. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/event_consumer/jetstream.py +0 -0
  36. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/event_consumer/protocol.py +0 -0
  37. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/py.typed +0 -0
  38. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/reference/__init__.py +0 -0
  39. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/reference/event_consumer.py +0 -0
  40. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/reference/fake_server.py +0 -0
  41. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/reference/fault_control.py +0 -0
  42. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/testkit/__init__.py +0 -0
  43. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/testkit/fake_entitlements.py +0 -0
  44. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/testkit/structural.py +0 -0
  45. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.3}/src/lightweb_adapter_sdk/types/__init__.py +0 -0
@@ -8,6 +8,50 @@ Breaking changes are flagged with `**BREAKING:**` at the start of the entry, eve
8
8
 
9
9
  The SDK is in 0.x until its contract has been validated by at least one production adapter. Per [`CONVENTIONS.md`](CONVENTIONS.md) §1.3, breaking changes may land in minor (0.X → 0.(X+1)) releases while in 0.x; pin against a specific minor version.
10
10
 
11
+ ## [0.2.3] - 2026-05-24
12
+
13
+ ### Fixed
14
+
15
+ - `event_consumer.parser.parse_envelope` now reads the platform's
16
+ native event-envelope field names — `event_id`, `event_type`,
17
+ `event_version`, `occurred_at`, `emitted_by` — per
18
+ `libs/events/schemas/entitlements/_envelope.json` and the entitlements
19
+ service's `outbox/envelope.py::build_envelope`. It previously read a
20
+ CloudEvents shape (`id`, `type`, `specversion`, `time`, `source`) that
21
+ the platform never emits, rejecting every real event at `Missing
22
+ required field: id`; an adapter consuming the live bus would have
23
+ nacked/dead-lettered all entitlements events. `member_id` is still
24
+ sourced from `data.member_id`; `subject` is tolerated and ignored (it
25
+ is schema-required and always present, but the consumer hook is
26
+ `data.member_id`, since `subject.id` varies by event type). The
27
+ `EventEnvelope` type is unchanged — this is a parser-internal fix,
28
+ non-breaking. The parser docstring, which had incorrectly described
29
+ the CloudEvents shape as matching the schema, is corrected to the
30
+ native shape. Surfaced by NextCloud D3 consumer-wiring integration
31
+ work (integration finding F8).
32
+ - `testkit.FakeEventSource.publish`/`publish_async` now serialize the
33
+ native envelope shape (matching `build_envelope`: `event_id`,
34
+ `event_type`, `event_version`, `occurred_at`, `emitted_by`, a member
35
+ `subject`, `data.member_id`, and `context` as an object). They
36
+ previously emitted the same CloudEvents shape the parser wrongly read
37
+ — so the fake producer and the parser were a matched pair that agreed
38
+ with each other and not with the platform, which is why no test caught
39
+ F8. Adapter authors testing consumers against `FakeEventSource` now
40
+ exercise the real on-wire shape. Behavior change to the testkit fake;
41
+ adapter consumer tests that inspected the published bytes for `id`/
42
+ `type`/etc. must read the native field names.
43
+
44
+ ### Added
45
+
46
+ - Raw-bytes parser coverage in `tests/unit/test_event_consumer_conformance.py`
47
+ (`TestParseEnvelope`): asserts `parse_envelope` reads the native
48
+ envelope shape and rejects the legacy CloudEvents shape (F8 regression
49
+ guard), plus a case confirming `member_id` is taken from
50
+ `data.member_id` rather than `subject.id`. The prior parser tests
51
+ asserted the CloudEvents shape — they validated the bug rather than
52
+ catching it. Test payloads are derived from the producer
53
+ (`build_envelope`) pending a captured real event.
54
+
11
55
  ## [0.2.2] - 2026-05-24
12
56
 
13
57
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightweb-adapter-sdk
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Published Python SDK for the Lightweb adapter contract
5
5
  Project-URL: Homepage, https://github.com/lightweb/adapter-sdk
6
6
  Project-URL: Repository, https://github.com/lightweb/adapter-sdk
@@ -0,0 +1,119 @@
1
+ """Envelope parser: raw JetStream payload → EventEnvelope.
2
+
3
+ The platform publishes JetStream messages as JSON in the entitlements
4
+ event-envelope shape (per ``libs/events/schemas/entitlements/_envelope.json``,
5
+ Conventions §4.3, emitted by the entitlements service's
6
+ ``outbox/envelope.py::build_envelope``):
7
+
8
+ .. code-block:: json
9
+
10
+ {
11
+ "event_id": "evt_<base58>",
12
+ "event_type": "entitlements.<entity>.<verb>",
13
+ "event_version": "1.0",
14
+ "occurred_at": "2026-05-17T12:00:00Z",
15
+ "emitted_by": "service:entitlements",
16
+ "subject": {"type": "member", "id": "mem_..."},
17
+ "data": {"member_id": "mem_...", ...},
18
+ "context": {...}
19
+ }
20
+
21
+ This module parses that shape into the SDK's ``EventEnvelope``,
22
+ flattening ``data.member_id`` to the top level and omitting ``subject``
23
+ and ``data`` (beyond ``member_id``). ``subject`` is tolerated and
24
+ ignored — the platform always emits it (the schema requires it), but
25
+ the consumer's hook is ``data.member_id``, not ``subject.id`` (subject's
26
+ ``id`` varies by event type: member_id for member events, subscription_id
27
+ for subscription events, etc.).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from datetime import datetime, timezone
34
+
35
+ from lightweb_adapter_sdk.types import EventEnvelope
36
+
37
+
38
+ class ParseError(ValueError):
39
+ """The raw payload could not be parsed into an EventEnvelope."""
40
+
41
+
42
+ def parse_envelope(raw_payload: bytes) -> EventEnvelope:
43
+ """Parse a raw JetStream message payload into an ``EventEnvelope``.
44
+
45
+ Args:
46
+ raw_payload: The raw bytes from JetStream, expected to be
47
+ UTF-8 encoded JSON matching the entitlements event-envelope
48
+ schema (``_envelope.json``).
49
+
50
+ Returns:
51
+ A fully-populated ``EventEnvelope``.
52
+
53
+ Raises:
54
+ ParseError: If the payload is not valid JSON, is missing
55
+ required fields, or has an unexpected shape.
56
+ """
57
+ try:
58
+ data = json.loads(raw_payload)
59
+ except (json.JSONDecodeError, TypeError) as exc:
60
+ raise ParseError(f"Payload is not valid JSON: {exc}") from exc
61
+
62
+ if not isinstance(data, dict):
63
+ raise ParseError(
64
+ f"Payload must be a JSON object, got {type(data).__name__}"
65
+ )
66
+
67
+ # Required top-level fields, per _envelope.json. ``subject`` and
68
+ # ``data`` are also schema-required but handled separately below
69
+ # (subject is tolerated-and-ignored; data is read for member_id).
70
+ for field in (
71
+ "event_id",
72
+ "event_type",
73
+ "event_version",
74
+ "occurred_at",
75
+ "emitted_by",
76
+ ):
77
+ if field not in data:
78
+ raise ParseError(f"Missing required field: {field}")
79
+
80
+ # data.member_id is the source for EventEnvelope.member_id.
81
+ if "data" not in data or not isinstance(data["data"], dict):
82
+ raise ParseError("Missing or invalid 'data' field")
83
+ if "member_id" not in data["data"]:
84
+ raise ParseError("Missing 'data.member_id' field")
85
+
86
+ member_id = data["data"]["member_id"]
87
+ if not isinstance(member_id, str):
88
+ raise ParseError(
89
+ f"'data.member_id' must be a string, got {type(member_id).__name__}"
90
+ )
91
+
92
+ # context is optional and preserved when present. The producer emits
93
+ # an empty object ``{}`` (not null) when there is no context; both an
94
+ # empty dict and an absent/None context are accepted here.
95
+ context = data.get("context")
96
+ if context is not None and not isinstance(context, dict):
97
+ raise ParseError("'context' must be an object when present")
98
+
99
+ # occurred_at — accept ISO-8601 strings (the producer emits with a
100
+ # trailing 'Z'; datetime.fromisoformat handles 'Z' on Python 3.11+).
101
+ occurred_at_str = data["occurred_at"]
102
+ try:
103
+ occurred_at = datetime.fromisoformat(occurred_at_str)
104
+ if occurred_at.tzinfo is None:
105
+ occurred_at = occurred_at.replace(tzinfo=timezone.utc)
106
+ except (ValueError, TypeError) as exc:
107
+ raise ParseError(
108
+ f"Invalid 'occurred_at' field: {occurred_at_str!r}: {exc}"
109
+ ) from exc
110
+
111
+ return EventEnvelope(
112
+ event_id=data["event_id"],
113
+ event_type=data["event_type"],
114
+ event_version=data["event_version"],
115
+ occurred_at=occurred_at,
116
+ emitted_by=data["emitted_by"],
117
+ member_id=member_id,
118
+ context=context,
119
+ )
@@ -4,6 +4,17 @@ Provides ``FakeEventSource``, an in-process fake that publishes
4
4
  ``EventEnvelope`` instances as raw JetStream-style JSON bytes,
5
5
  allowing tests to drive event consumer behavior without a real
6
6
  NATS/JetStream connection.
7
+
8
+ The published bytes match the platform's native event-envelope shape
9
+ (per ``libs/events/schemas/entitlements/_envelope.json`` and the
10
+ entitlements service's ``build_envelope``): ``event_id``, ``event_type``,
11
+ ``event_version``, ``occurred_at``, ``emitted_by``, ``subject``,
12
+ ``data`` (carrying ``member_id``), and ``context``. This is the shape
13
+ ``parse_envelope`` consumes, so a round-trip publish→handle_event
14
+ exercises the real parse path. (Prior to 0.2.3 this fake emitted a
15
+ CloudEvents shape — ``id``/``type``/``specversion``/``time``/``source``
16
+ — matching a parser that read the same wrong shape; neither matched the
17
+ platform. See CHANGELOG 0.2.3 / integration finding F8.)
7
18
  """
8
19
 
9
20
  from __future__ import annotations
@@ -23,6 +34,10 @@ class FakeEventSource:
23
34
  callbacks (consumers). Events are stored internally for inspection
24
35
  and can be published synchronously or asynchronously.
25
36
 
37
+ The serialized bytes use the platform's native envelope shape so
38
+ that consumers exercising ``parse_envelope`` see exactly what the
39
+ entitlements service emits.
40
+
26
41
  Args:
27
42
  subject: The subject name this fake source publishes to
28
43
  (e.g. ``"entitlements.member.>"``).
@@ -47,6 +62,28 @@ class FakeEventSource:
47
62
  self._sequence += 1
48
63
  return f"evt-fake-{self._sequence:04d}"
49
64
 
65
+ def _serialize(self, envelope: EventEnvelope) -> bytes:
66
+ """Serialize an ``EventEnvelope`` to the platform's native shape.
67
+
68
+ Mirrors the entitlements service's ``build_envelope`` output:
69
+ native field names, a member ``subject`` derived from
70
+ ``member_id`` (the consumer ignores ``subject`` but the real
71
+ envelope always carries it), ``member_id`` nested under
72
+ ``data``, and ``context`` as an object (``{}`` when empty,
73
+ matching the producer, which never emits null).
74
+ """
75
+ payload_dict: dict[str, Any] = {
76
+ "event_id": envelope.event_id,
77
+ "event_type": envelope.event_type,
78
+ "event_version": envelope.event_version,
79
+ "occurred_at": envelope.occurred_at.isoformat(),
80
+ "emitted_by": envelope.emitted_by,
81
+ "subject": {"type": "member", "id": envelope.member_id},
82
+ "data": {"member_id": envelope.member_id},
83
+ "context": envelope.context if envelope.context is not None else {},
84
+ }
85
+ return json.dumps(payload_dict).encode("utf-8")
86
+
50
87
  def publish(self, envelope: EventEnvelope) -> bytes:
51
88
  """Publish an event envelope as raw JSON bytes.
52
89
 
@@ -58,18 +95,7 @@ class FakeEventSource:
58
95
  Returns:
59
96
  The raw JSON bytes that were published.
60
97
  """
61
- payload_dict = {
62
- "id": envelope.event_id,
63
- "type": envelope.event_type,
64
- "specversion": envelope.event_version,
65
- "time": envelope.occurred_at.isoformat(),
66
- "source": envelope.emitted_by,
67
- "data": {"member_id": envelope.member_id},
68
- }
69
- if envelope.context is not None:
70
- payload_dict["context"] = envelope.context
71
-
72
- raw = json.dumps(payload_dict).encode("utf-8")
98
+ raw = self._serialize(envelope)
73
99
  self._published.append((envelope, raw))
74
100
 
75
101
  for consumer in self._consumers:
@@ -88,18 +114,7 @@ class FakeEventSource:
88
114
  Returns:
89
115
  The raw JSON bytes that were published.
90
116
  """
91
- payload_dict = {
92
- "id": envelope.event_id,
93
- "type": envelope.event_type,
94
- "specversion": envelope.event_version,
95
- "time": envelope.occurred_at.isoformat(),
96
- "source": envelope.emitted_by,
97
- "data": {"member_id": envelope.member_id},
98
- }
99
- if envelope.context is not None:
100
- payload_dict["context"] = envelope.context
101
-
102
- raw = json.dumps(payload_dict).encode("utf-8")
117
+ raw = self._serialize(envelope)
103
118
  self._published.append((envelope, raw))
104
119
 
105
120
  for consumer in self._consumers:
@@ -1,94 +0,0 @@
1
- """Envelope parser: raw JetStream payload → EventEnvelope.
2
-
3
- The platform publishes JetStream messages as JSON with the following
4
- shape (per ``libs/events/schemas/entitlements/_envelope.json``):
5
-
6
- .. code-block:: json
7
-
8
- {
9
- "id": "<event-id>",
10
- "type": "entitlements.<entity>.<verb>",
11
- "specversion": "1.0",
12
- "time": "2026-05-17T12:00:00Z",
13
- "source": "service:entitlements",
14
- "subject": {"type": "member", "id": "mem_..."},
15
- "data": {"member_id": "mem_...", ...},
16
- "context": {"trace_context": "..."}
17
- }
18
-
19
- This module parses that shape into the SDK's ``EventEnvelope``,
20
- flattening ``data.member_id`` to the top level and omitting ``subject``
21
- and ``data`` (beyond ``member_id``).
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import json
27
- from datetime import datetime, timezone
28
-
29
- from lightweb_adapter_sdk.types import EventEnvelope
30
-
31
-
32
- class ParseError(ValueError):
33
- """The raw payload could not be parsed into an EventEnvelope."""
34
-
35
-
36
- def parse_envelope(raw_payload: bytes) -> EventEnvelope:
37
- """Parse a raw JetStream message payload into an ``EventEnvelope``.
38
-
39
- Args:
40
- raw_payload: The raw bytes from JetStream, expected to be
41
- UTF-8 encoded JSON matching the platform envelope schema.
42
-
43
- Returns:
44
- A fully-populated ``EventEnvelope``.
45
-
46
- Raises:
47
- ParseError: If the payload is not valid JSON, is missing
48
- required fields, or has an unexpected shape.
49
- """
50
- try:
51
- data = json.loads(raw_payload)
52
- except (json.JSONDecodeError, TypeError) as exc:
53
- raise ParseError(f"Payload is not valid JSON: {exc}") from exc
54
-
55
- # Required top-level fields
56
- for field in ("id", "type", "specversion", "time", "source"):
57
- if field not in data:
58
- raise ParseError(f"Missing required field: {field}")
59
-
60
- # data.member_id is the source for EventEnvelope.member_id
61
- if "data" not in data or not isinstance(data["data"], dict):
62
- raise ParseError("Missing or invalid 'data' field")
63
- if "member_id" not in data["data"]:
64
- raise ParseError("Missing 'data.member_id' field")
65
-
66
- member_id = data["data"]["member_id"]
67
- if not isinstance(member_id, str):
68
- raise ParseError(
69
- f"'data.member_id' must be a string, got {type(member_id).__name__}"
70
- )
71
-
72
- # context is optional but preserved when present
73
- context = data.get("context")
74
- if context is not None and not isinstance(context, dict):
75
- raise ParseError("'context' must be an object when present")
76
-
77
- # Parse time — accept ISO-8601 strings
78
- time_str = data["time"]
79
- try:
80
- occurred_at = datetime.fromisoformat(time_str)
81
- if occurred_at.tzinfo is None:
82
- occurred_at = occurred_at.replace(tzinfo=timezone.utc)
83
- except (ValueError, TypeError) as exc:
84
- raise ParseError(f"Invalid 'time' field: {time_str}: {exc}") from exc
85
-
86
- return EventEnvelope(
87
- event_id=data["id"],
88
- event_type=data["type"],
89
- event_version=data["specversion"],
90
- occurred_at=occurred_at,
91
- emitted_by=data["source"],
92
- member_id=member_id,
93
- context=context,
94
- )