stapel-core 0.3.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.
Files changed (144) hide show
  1. stapel_core/__init__.py +95 -0
  2. stapel_core/bus/__init__.py +35 -0
  3. stapel_core/bus/_config.py +81 -0
  4. stapel_core/bus/backends/__init__.py +0 -0
  5. stapel_core/bus/backends/kafka.py +194 -0
  6. stapel_core/bus/backends/memory.py +84 -0
  7. stapel_core/bus/backends/nats.py +282 -0
  8. stapel_core/bus/backends/routing.py +140 -0
  9. stapel_core/bus/base.py +36 -0
  10. stapel_core/bus/consumer.py +44 -0
  11. stapel_core/bus/event.py +57 -0
  12. stapel_core/bus/router.py +68 -0
  13. stapel_core/captcha/__init__.py +17 -0
  14. stapel_core/captcha/backends.py +150 -0
  15. stapel_core/comm/__init__.py +76 -0
  16. stapel_core/comm/actions.py +135 -0
  17. stapel_core/comm/config.py +72 -0
  18. stapel_core/comm/exceptions.py +34 -0
  19. stapel_core/comm/functions.py +186 -0
  20. stapel_core/comm/http.py +62 -0
  21. stapel_core/comm/nats.py +144 -0
  22. stapel_core/comm/registry.py +120 -0
  23. stapel_core/comm/schemas.py +66 -0
  24. stapel_core/comm/tasks.py +333 -0
  25. stapel_core/conf.py +90 -0
  26. stapel_core/core/__init__.py +18 -0
  27. stapel_core/core/config.py +174 -0
  28. stapel_core/core/jwt_handler.py +448 -0
  29. stapel_core/core/language.py +125 -0
  30. stapel_core/core/token_blacklist.py +63 -0
  31. stapel_core/core/token_manager.py +221 -0
  32. stapel_core/django/__init__.py +58 -0
  33. stapel_core/django/admin/__init__.py +1 -0
  34. stapel_core/django/admin/context.py +53 -0
  35. stapel_core/django/admin/mixins.py +149 -0
  36. stapel_core/django/admin/redirect.py +63 -0
  37. stapel_core/django/api/__init__.py +1 -0
  38. stapel_core/django/api/errors.py +458 -0
  39. stapel_core/django/api/pagination.py +336 -0
  40. stapel_core/django/api/permissions.py +162 -0
  41. stapel_core/django/api/revision.py +452 -0
  42. stapel_core/django/api/routers.py +10 -0
  43. stapel_core/django/api/serializers.py +262 -0
  44. stapel_core/django/apps.py +54 -0
  45. stapel_core/django/authentication.py +3 -0
  46. stapel_core/django/captcha.py +82 -0
  47. stapel_core/django/cdn/__init__.py +1 -0
  48. stapel_core/django/cdn/fields.py +295 -0
  49. stapel_core/django/cdn/ref_sync.py +119 -0
  50. stapel_core/django/errors.py +59 -0
  51. stapel_core/django/groups.py +212 -0
  52. stapel_core/django/jwt/__init__.py +1 -0
  53. stapel_core/django/jwt/authentication.py +181 -0
  54. stapel_core/django/jwt/backends.py +73 -0
  55. stapel_core/django/jwt/login_views.py +92 -0
  56. stapel_core/django/jwt/middleware.py +333 -0
  57. stapel_core/django/jwt/provider.py +191 -0
  58. stapel_core/django/jwt/session.py +59 -0
  59. stapel_core/django/jwt/utils.py +672 -0
  60. stapel_core/django/jwt/views.py +181 -0
  61. stapel_core/django/jwt_provider.py +2 -0
  62. stapel_core/django/management/__init__.py +0 -0
  63. stapel_core/django/management/commands/__init__.py +0 -0
  64. stapel_core/django/management/commands/check_flows.py +34 -0
  65. stapel_core/django/management/commands/consume_actions.py +57 -0
  66. stapel_core/django/management/commands/generate_flow_docs.py +39 -0
  67. stapel_core/django/management/commands/reset_sequences.py +124 -0
  68. stapel_core/django/management/commands/serve_functions.py +89 -0
  69. stapel_core/django/management/commands/staff_group.py +174 -0
  70. stapel_core/django/models.py +213 -0
  71. stapel_core/django/monitoring/__init__.py +1 -0
  72. stapel_core/django/monitoring/health.py +177 -0
  73. stapel_core/django/monitoring/telegram.py +114 -0
  74. stapel_core/django/openapi/__init__.py +7 -0
  75. stapel_core/django/openapi/extensions.py +106 -0
  76. stapel_core/django/openapi/mcp.py +164 -0
  77. stapel_core/django/openapi/openid.py +91 -0
  78. stapel_core/django/openapi/schemas.py +399 -0
  79. stapel_core/django/openapi/swagger.py +494 -0
  80. stapel_core/django/outbox/__init__.py +0 -0
  81. stapel_core/django/outbox/apps.py +8 -0
  82. stapel_core/django/outbox/management/__init__.py +0 -0
  83. stapel_core/django/outbox/management/commands/__init__.py +0 -0
  84. stapel_core/django/outbox/management/commands/dispatch_outbox.py +31 -0
  85. stapel_core/django/outbox/migrations/0001_initial.py +31 -0
  86. stapel_core/django/outbox/migrations/__init__.py +0 -0
  87. stapel_core/django/outbox/models.py +31 -0
  88. stapel_core/django/outbox/relay.py +72 -0
  89. stapel_core/django/settings.py +510 -0
  90. stapel_core/django/taskstore/__init__.py +0 -0
  91. stapel_core/django/taskstore/apps.py +22 -0
  92. stapel_core/django/taskstore/management/__init__.py +0 -0
  93. stapel_core/django/taskstore/management/commands/__init__.py +0 -0
  94. stapel_core/django/taskstore/management/commands/sweep_tasks.py +39 -0
  95. stapel_core/django/taskstore/migrations/0001_initial.py +37 -0
  96. stapel_core/django/taskstore/migrations/__init__.py +0 -0
  97. stapel_core/django/taskstore/models.py +42 -0
  98. stapel_core/django/templates/admin/base_site.html +229 -0
  99. stapel_core/django/users/__init__.py +1 -0
  100. stapel_core/django/users/admin.py +6 -0
  101. stapel_core/django/users/apps.py +8 -0
  102. stapel_core/django/users/migrations/0001_initial.py +62 -0
  103. stapel_core/django/users/migrations/0002_alter_user_managers_alter_user_groups_and_more.py +37 -0
  104. stapel_core/django/users/migrations/0003_remove_unique_constraints.py +23 -0
  105. stapel_core/django/users/migrations/0004_alter_user_phone.py +36 -0
  106. stapel_core/django/users/migrations/0005_alter_user_groups_alter_user_user_permissions.py +24 -0
  107. stapel_core/django/users/migrations/__init__.py +2 -0
  108. stapel_core/django/users/models.py +152 -0
  109. stapel_core/django/utils.py +8 -0
  110. stapel_core/django/workspaces.py +132 -0
  111. stapel_core/flows/__init__.py +38 -0
  112. stapel_core/flows/checks.py +116 -0
  113. stapel_core/flows/docs.py +192 -0
  114. stapel_core/flows/registry.py +169 -0
  115. stapel_core/gdpr.py +197 -0
  116. stapel_core/kafka/__init__.py +34 -0
  117. stapel_core/kafka/config.py +63 -0
  118. stapel_core/kafka/consumer.py +234 -0
  119. stapel_core/kafka/events.py +63 -0
  120. stapel_core/kafka/health.py +36 -0
  121. stapel_core/kafka/producer.py +105 -0
  122. stapel_core/kafka/topics.py +39 -0
  123. stapel_core/notifications/__init__.py +17 -0
  124. stapel_core/notifications/publish.py +90 -0
  125. stapel_core/notifications/schemas/emits/notification.requested.json +18 -0
  126. stapel_core/notifications/tokens.py +59 -0
  127. stapel_core/oauth.py +112 -0
  128. stapel_core/py.typed +0 -0
  129. stapel_core/signals.py +48 -0
  130. stapel_core/static/admin/js/cdn_image_widget.js +955 -0
  131. stapel_core/static/admin/js/jwt_session.js +150 -0
  132. stapel_core/testing.py +78 -0
  133. stapel_core/verification/__init__.py +64 -0
  134. stapel_core/verification/conf.py +27 -0
  135. stapel_core/verification/decorators.py +154 -0
  136. stapel_core/verification/errors.py +24 -0
  137. stapel_core/verification/factors.py +102 -0
  138. stapel_core/verification/grants.py +166 -0
  139. stapel_core/verification/policy.py +102 -0
  140. stapel_core-0.3.1.dist-info/METADATA +239 -0
  141. stapel_core-0.3.1.dist-info/RECORD +144 -0
  142. stapel_core-0.3.1.dist-info/WHEEL +5 -0
  143. stapel_core-0.3.1.dist-info/licenses/LICENSE +21 -0
  144. stapel_core-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,95 @@
1
+ """Stapel Core — shared Django utilities for the Stapel framework.
2
+
3
+ The building blocks every Stapel package sits on:
4
+
5
+ - ``comm`` — Action/Function inter-module communication (``emit``,
6
+ ``on_action``, ``call``, ``function``) plus long-running tasks
7
+ (``start``, ``status``, ``task_handler``). Transports are deployment
8
+ configuration, not code.
9
+ - ``bus`` — transport-agnostic message bus (``publish``, ``get_bus``,
10
+ ``Event``) with Kafka/NATS/in-memory backends.
11
+ - ``conf.AppSettings`` — per-app settings namespaces (the DRF
12
+ ``api_settings`` pattern, generalized).
13
+ - ``signals`` — in-process Django signals for business milestones.
14
+ - ``django.api`` — API conventions: ``StapelResponse``,
15
+ ``StapelErrorResponse`` and ``StapelDataclassSerializer``.
16
+ - ``gdpr`` — GDPR provider protocol and in-process registry.
17
+ - ``django.users.AbstractStapelUser`` — base user model.
18
+ - ``core`` — framework-agnostic JWT primitives (``JWTHandler``,
19
+ ``TokenManager``, ``TokenBlacklist``, ``JWTConfig``).
20
+
21
+ All attributes are exported lazily (PEP 562), so importing
22
+ ``stapel_core`` stays cheap and never touches Django until a
23
+ Django-dependent attribute is actually used.
24
+ """
25
+
26
+ from importlib import import_module
27
+
28
+ try: # single source of truth: pyproject's version via package metadata
29
+ from importlib.metadata import version as _pkg_version
30
+
31
+ __version__ = _pkg_version("stapel-core")
32
+ except Exception: # editable/vendored checkout without dist-info
33
+ __version__ = "0.3.0"
34
+
35
+ # Attribute name -> (relative module, attribute in that module).
36
+ # An attribute of None means the module itself is the export.
37
+ _LAZY_EXPORTS = {
38
+ # comm — Actions, Functions, long-running tasks
39
+ "emit": (".comm", "emit"),
40
+ "on_action": (".comm", "on_action"),
41
+ "call": (".comm", "call"),
42
+ "function": (".comm", "function"),
43
+ "start": (".comm", "start"),
44
+ "status": (".comm", "status"),
45
+ "task_handler": (".comm", "task_handler"),
46
+ # flows — self-documenting business scenarios
47
+ "Flow": (".flows", "Flow"),
48
+ "flow_step": (".flows", "flow_step"),
49
+ "flow_registry": (".flows", "flow_registry"),
50
+ # verification — step-up (OTP/TOTP/passkey) on any endpoint
51
+ "requires_verification": (".verification", "requires_verification"),
52
+ "register_factor": (".verification", "register_factor"),
53
+ "VerificationFactor": (".verification", "VerificationFactor"),
54
+ "get_user_policy": (".verification", "get_user_policy"),
55
+ "invalidate_policy_cache": (".verification", "invalidate_policy_cache"),
56
+ # bus — transport-agnostic message bus
57
+ "publish": (".bus", "publish"),
58
+ "get_bus": (".bus", "get_bus"),
59
+ "Event": (".bus", "Event"),
60
+ # conf — per-app settings namespaces
61
+ "AppSettings": (".conf", "AppSettings"),
62
+ # signals — in-process business milestones (module export)
63
+ "signals": (".signals", None),
64
+ # API conventions — responses, errors, serializers
65
+ "StapelResponse": (".django.api.errors", "StapelResponse"),
66
+ "StapelErrorResponse": (".django.api.errors", "StapelErrorResponse"),
67
+ "StapelDataclassSerializer": (".django.api.serializers", "StapelDataclassSerializer"),
68
+ # GDPR — provider protocol + in-process registry
69
+ "GDPRProvider": (".gdpr", "GDPRProvider"),
70
+ "gdpr_registry": (".gdpr", "gdpr_registry"),
71
+ # Users — base user model
72
+ "AbstractStapelUser": (".django.users.models", "AbstractStapelUser"),
73
+ # Framework-agnostic JWT primitives (0.1.x root exports, kept stable)
74
+ "JWTHandler": (".core.jwt_handler", "JWTHandler"),
75
+ "TokenManager": (".core.token_manager", "TokenManager"),
76
+ "TokenBlacklist": (".core.token_blacklist", "TokenBlacklist"),
77
+ "JWTConfig": (".core.config", "JWTConfig"),
78
+ }
79
+
80
+ __all__ = sorted([*_LAZY_EXPORTS, "__version__"])
81
+
82
+
83
+ def __getattr__(name):
84
+ try:
85
+ module_path, attr = _LAZY_EXPORTS[name]
86
+ except KeyError:
87
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
88
+ module = import_module(module_path, __name__)
89
+ value = module if attr is None else getattr(module, attr)
90
+ globals()[name] = value # cache so __getattr__ runs once per name
91
+ return value
92
+
93
+
94
+ def __dir__():
95
+ return sorted(set(globals()) | set(_LAZY_EXPORTS))
@@ -0,0 +1,35 @@
1
+ """
2
+ stapel_core.bus — transport-agnostic message bus.
3
+
4
+ Public API:
5
+ publish(topic, event) — send an event
6
+ get_bus() — get the configured backend instance
7
+ reset_bus() — force re-init (tests)
8
+ Event — message envelope dataclass
9
+ BusBackend — ABC for custom backends
10
+ BaseBusConsumerCommand — base Django management command for consumers
11
+
12
+ Backend is set via Django setting:
13
+ STAPEL_BUS_BACKEND = "stapel_core.bus.backends.kafka.KafkaBus" # default/prod
14
+ STAPEL_BUS_BACKEND = "stapel_core.bus.backends.memory.MemoryBus" # tests/dev
15
+ """
16
+
17
+ from .base import BusBackend
18
+ from .consumer import BaseBusConsumerCommand
19
+ from .event import Event
20
+ from .router import get_bus, reset_bus
21
+
22
+
23
+ def publish(topic: str, event: Event) -> None:
24
+ """Publish *event* to *topic* via the configured backend."""
25
+ get_bus().publish(topic, event)
26
+
27
+
28
+ __all__ = [
29
+ "publish",
30
+ "get_bus",
31
+ "reset_bus",
32
+ "Event",
33
+ "BusBackend",
34
+ "BaseBusConsumerCommand",
35
+ ]
@@ -0,0 +1,81 @@
1
+ """
2
+ Connection settings for bus backends.
3
+
4
+ Resolution order for every value: environment variable first, then the
5
+ Django setting of the same name, then the default — a deployment switches
6
+ transports and endpoints purely through the environment (12-factor), while
7
+ tests keep configuring Django settings.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+
14
+ def _get(name: str, default: str = "") -> str:
15
+ value = os.environ.get(name)
16
+ if value:
17
+ return value
18
+ try:
19
+ from django.conf import settings
20
+
21
+ return getattr(settings, name, default) or default
22
+ except Exception: # settings not configured (plain scripts)
23
+ return default
24
+
25
+
26
+ class KafkaBusConfig:
27
+ @staticmethod
28
+ def _base() -> dict:
29
+ cfg: dict = {
30
+ "bootstrap.servers": _get("KAFKA_BOOTSTRAP_SERVERS", "kafka:9092"),
31
+ }
32
+ protocol = _get("KAFKA_SECURITY_PROTOCOL", "PLAINTEXT")
33
+ if protocol != "PLAINTEXT":
34
+ cfg["security.protocol"] = protocol
35
+ mechanism = _get("KAFKA_SASL_MECHANISM", "")
36
+ if mechanism:
37
+ cfg["sasl.mechanism"] = mechanism
38
+ cfg["sasl.username"] = _get("KAFKA_SASL_USERNAME", "")
39
+ cfg["sasl.password"] = _get("KAFKA_SASL_PASSWORD", "")
40
+ return cfg
41
+
42
+ @classmethod
43
+ def producer_config(cls) -> dict:
44
+ return {**cls._base(), "acks": "all"}
45
+
46
+ @classmethod
47
+ def consumer_config(cls, group: str) -> dict:
48
+ return {
49
+ **cls._base(),
50
+ "group.id": group,
51
+ "auto.offset.reset": "earliest",
52
+ "enable.auto.commit": False,
53
+ }
54
+
55
+
56
+ class NatsBusConfig:
57
+ """JetStream backend configuration.
58
+
59
+ NATS_URL broker address (nats://nats:4222)
60
+ STAPEL_NATS_STREAM JetStream stream name (stapel-events)
61
+ STAPEL_NATS_EVENT_PREFIX subject prefix (stapel.evt)
62
+
63
+ Every bus topic maps to the subject ``<prefix>.<topic>``; the stream
64
+ captures ``<prefix>.>`` so new topics need no broker-side changes.
65
+ """
66
+
67
+ @staticmethod
68
+ def url() -> str:
69
+ return _get("NATS_URL", "nats://nats:4222")
70
+
71
+ @staticmethod
72
+ def stream() -> str:
73
+ return _get("STAPEL_NATS_STREAM", "stapel-events")
74
+
75
+ @staticmethod
76
+ def subject_prefix() -> str:
77
+ return _get("STAPEL_NATS_EVENT_PREFIX", "stapel.evt")
78
+
79
+ @classmethod
80
+ def subject_for(cls, topic: str) -> str:
81
+ return f"{cls.subject_prefix()}.{topic}"
File without changes
@@ -0,0 +1,194 @@
1
+ """
2
+ Kafka bus backend — production transport via confluent-kafka.
3
+
4
+ Set in Django settings:
5
+ STAPEL_BUS_BACKEND = "stapel_core.bus.backends.kafka.KafkaBus"
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import signal
12
+ import threading
13
+ import time
14
+ from typing import Callable
15
+
16
+ from ..base import BusBackend
17
+ from ..event import Event
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ HEARTBEAT_PATH = os.environ.get("KAFKA_CONSUMER_HEARTBEAT", "/tmp/kafka_consumer_alive")
22
+ HEARTBEAT_STALENESS_S = int(os.environ.get("KAFKA_CONSUMER_HEARTBEAT_STALENESS_S", "120"))
23
+ WATCHDOG_INTERVAL_S = int(os.environ.get("KAFKA_CONSUMER_WATCHDOG_INTERVAL_S", "30"))
24
+
25
+ DLQ_SUFFIX = ".dlq"
26
+
27
+
28
+ def _dlq_topic(topic: str) -> str:
29
+ return topic + DLQ_SUFFIX
30
+
31
+
32
+ class KafkaBus(BusBackend):
33
+ """Thin wrapper around confluent-kafka Producer/Consumer."""
34
+
35
+ def __init__(self) -> None:
36
+ self._producer = None
37
+ self._producer_lock = threading.Lock()
38
+
39
+ # ------------------------------------------------------------------
40
+ # Publish
41
+ # ------------------------------------------------------------------
42
+
43
+ def _get_producer(self):
44
+ if self._producer is not None:
45
+ return self._producer
46
+ with self._producer_lock:
47
+ if self._producer is not None:
48
+ return self._producer
49
+ from confluent_kafka import Producer
50
+ from stapel_core.bus._config import KafkaBusConfig
51
+ self._producer = Producer(KafkaBusConfig.producer_config())
52
+ return self._producer
53
+
54
+ def publish(self, topic: str, event: Event) -> None:
55
+ producer = self._get_producer()
56
+ key_bytes = (event.key or event.event_id).encode("utf-8")
57
+ producer.produce(
58
+ topic,
59
+ key=key_bytes,
60
+ value=event.to_bytes(),
61
+ callback=self._delivery_callback,
62
+ )
63
+ producer.poll(0)
64
+
65
+ @staticmethod
66
+ def _delivery_callback(err, msg):
67
+ if err:
68
+ logger.error("KafkaBus delivery failed: %s topic=%s", err, msg.topic())
69
+ else:
70
+ logger.debug("KafkaBus delivered topic=%s offset=%s", msg.topic(), msg.offset())
71
+
72
+ # ------------------------------------------------------------------
73
+ # Consume
74
+ # ------------------------------------------------------------------
75
+
76
+ def consume(
77
+ self,
78
+ topics: list[str],
79
+ group: str,
80
+ handler: Callable[[Event], None],
81
+ *,
82
+ poll_timeout: float = 0.1,
83
+ ) -> None:
84
+ from confluent_kafka import Consumer, KafkaError
85
+ from stapel_core.bus._config import KafkaBusConfig
86
+
87
+ config = KafkaBusConfig.consumer_config(group)
88
+ consumer = Consumer(config)
89
+ consumer.subscribe(topics)
90
+
91
+ running = threading.Event()
92
+ running.set()
93
+
94
+ def _shutdown(signum, frame):
95
+ logger.info("KafkaBus shutdown signal received")
96
+ running.clear()
97
+
98
+ signal.signal(signal.SIGINT, _shutdown)
99
+ signal.signal(signal.SIGTERM, _shutdown)
100
+
101
+ self._start_watchdog(running)
102
+
103
+ try:
104
+ while running.is_set():
105
+ msg = consumer.poll(timeout=poll_timeout)
106
+ self._touch_heartbeat()
107
+ if msg is None:
108
+ continue
109
+ if msg.error():
110
+ if msg.error().code() == KafkaError._PARTITION_EOF:
111
+ continue
112
+ logger.error("KafkaBus consumer error: %s", msg.error())
113
+ continue
114
+
115
+ try:
116
+ event = Event.from_bytes(msg.value())
117
+ except Exception:
118
+ # Poison message: deserialization failure outside the
119
+ # retry loop would crash consume() and, with the offset
120
+ # uncommitted, wedge the partition on restart.
121
+ logger.exception(
122
+ "KafkaBus undecodable message on %s, sending raw to DLQ",
123
+ msg.topic(),
124
+ )
125
+ if self._send_raw_to_dlq(msg.topic(), msg.value()):
126
+ consumer.commit(msg)
127
+ continue
128
+
129
+ retries = 0
130
+ dlq_ok = True
131
+ while retries <= 3:
132
+ try:
133
+ handler(event)
134
+ break
135
+ except Exception:
136
+ retries += 1
137
+ if retries > 3:
138
+ logger.exception("KafkaBus DLQ event_id=%s", event.event_id)
139
+ dlq_ok = self._send_to_dlq(msg.topic(), event)
140
+ else:
141
+ time.sleep(2 ** retries)
142
+ # Commit only when the message was handled or confirmed in
143
+ # the DLQ — otherwise the offset would advance past a
144
+ # message that exists nowhere else (silent loss).
145
+ if dlq_ok:
146
+ consumer.commit(msg)
147
+ finally:
148
+ consumer.close()
149
+
150
+ def _send_to_dlq(self, original_topic: str, event: Event) -> bool:
151
+ try:
152
+ self.publish(_dlq_topic(original_topic), event)
153
+ return True
154
+ except Exception:
155
+ logger.exception("KafkaBus failed to send to DLQ")
156
+ return False
157
+
158
+ def _send_raw_to_dlq(self, original_topic: str, raw: bytes) -> bool:
159
+ """DLQ a message that could not even be deserialized."""
160
+ try:
161
+ event = Event(
162
+ event_type="__undecodable__",
163
+ service="bus",
164
+ payload={"raw": raw.decode("utf-8", errors="replace"), "topic": original_topic},
165
+ )
166
+ self.publish(_dlq_topic(original_topic), event)
167
+ return True
168
+ except Exception:
169
+ logger.exception("KafkaBus failed to DLQ undecodable message")
170
+ return False
171
+
172
+ @staticmethod
173
+ def _touch_heartbeat() -> None:
174
+ try:
175
+ open(HEARTBEAT_PATH, "w").close()
176
+ except OSError:
177
+ pass
178
+
179
+ def _start_watchdog(self, running: threading.Event) -> None:
180
+ def _watch():
181
+ while running.is_set():
182
+ time.sleep(WATCHDOG_INTERVAL_S)
183
+ try:
184
+ mtime = os.path.getmtime(HEARTBEAT_PATH)
185
+ age = time.time() - mtime
186
+ if age > HEARTBEAT_STALENESS_S:
187
+ logger.critical("KafkaBus heartbeat stale (%.0fs), exiting", age)
188
+ running.clear()
189
+ os.kill(os.getpid(), signal.SIGTERM)
190
+ except FileNotFoundError:
191
+ pass
192
+
193
+ t = threading.Thread(target=_watch, daemon=True)
194
+ t.start()
@@ -0,0 +1,84 @@
1
+ """
2
+ In-memory bus backend — for tests and local dev without a broker.
3
+
4
+ Published events are stored in ``MemoryBus.events`` so tests can assert on them:
5
+
6
+ from stapel_core.bus import get_bus
7
+ bus = get_bus()
8
+ assert bus.events[-1].event_type == "profile.changed"
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import queue
14
+ import threading
15
+ from collections import defaultdict
16
+ from typing import Callable
17
+
18
+ from ..base import BusBackend
19
+ from ..event import Event
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class MemoryBus(BusBackend):
25
+ """
26
+ Thread-safe in-memory bus. Subscribers are registered per topic.
27
+ ``publish()`` delivers synchronously to all registered handlers,
28
+ then appends to ``self.events`` for test introspection.
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self.events: list[Event] = []
33
+ self._subscribers: dict[str, list[Callable[[Event], None]]] = defaultdict(list)
34
+ self._queue: queue.Queue[Event] = queue.Queue()
35
+ self._lock = threading.Lock()
36
+
37
+ # ------------------------------------------------------------------
38
+ # BusBackend interface
39
+ # ------------------------------------------------------------------
40
+
41
+ def publish(self, topic: str, event: Event) -> None:
42
+ logger.debug("MemoryBus.publish topic=%s event_id=%s", topic, event.event_id)
43
+ with self._lock:
44
+ self.events.append(event)
45
+ for handler in self._subscribers.get(topic, []):
46
+ try:
47
+ handler(event)
48
+ except Exception:
49
+ logger.exception("MemoryBus handler error topic=%s", topic)
50
+ self._queue.put(event)
51
+
52
+ def consume(
53
+ self,
54
+ topics: list[str],
55
+ group: str,
56
+ handler: Callable[[Event], None],
57
+ *,
58
+ poll_timeout: float = 0.1,
59
+ ) -> None:
60
+ """Drain the queue, calling *handler* for each event whose type matches *topics*."""
61
+ logger.debug("MemoryBus.consume topics=%s group=%s", topics, group)
62
+ try:
63
+ while True:
64
+ event = self._queue.get(timeout=poll_timeout)
65
+ if event.event_type in topics:
66
+ handler(event)
67
+ except queue.Empty:
68
+ pass
69
+
70
+ # ------------------------------------------------------------------
71
+ # Test helpers
72
+ # ------------------------------------------------------------------
73
+
74
+ def subscribe(self, topic: str, handler: Callable[[Event], None]) -> None:
75
+ """Register a synchronous handler called on every publish to *topic*."""
76
+ self._subscribers[topic].append(handler)
77
+
78
+ def clear(self) -> None:
79
+ """Reset state between tests."""
80
+ with self._lock:
81
+ self.events.clear()
82
+ self._subscribers.clear()
83
+ while not self._queue.empty():
84
+ self._queue.get_nowait()