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.
- stapel_core/__init__.py +95 -0
- stapel_core/bus/__init__.py +35 -0
- stapel_core/bus/_config.py +81 -0
- stapel_core/bus/backends/__init__.py +0 -0
- stapel_core/bus/backends/kafka.py +194 -0
- stapel_core/bus/backends/memory.py +84 -0
- stapel_core/bus/backends/nats.py +282 -0
- stapel_core/bus/backends/routing.py +140 -0
- stapel_core/bus/base.py +36 -0
- stapel_core/bus/consumer.py +44 -0
- stapel_core/bus/event.py +57 -0
- stapel_core/bus/router.py +68 -0
- stapel_core/captcha/__init__.py +17 -0
- stapel_core/captcha/backends.py +150 -0
- stapel_core/comm/__init__.py +76 -0
- stapel_core/comm/actions.py +135 -0
- stapel_core/comm/config.py +72 -0
- stapel_core/comm/exceptions.py +34 -0
- stapel_core/comm/functions.py +186 -0
- stapel_core/comm/http.py +62 -0
- stapel_core/comm/nats.py +144 -0
- stapel_core/comm/registry.py +120 -0
- stapel_core/comm/schemas.py +66 -0
- stapel_core/comm/tasks.py +333 -0
- stapel_core/conf.py +90 -0
- stapel_core/core/__init__.py +18 -0
- stapel_core/core/config.py +174 -0
- stapel_core/core/jwt_handler.py +448 -0
- stapel_core/core/language.py +125 -0
- stapel_core/core/token_blacklist.py +63 -0
- stapel_core/core/token_manager.py +221 -0
- stapel_core/django/__init__.py +58 -0
- stapel_core/django/admin/__init__.py +1 -0
- stapel_core/django/admin/context.py +53 -0
- stapel_core/django/admin/mixins.py +149 -0
- stapel_core/django/admin/redirect.py +63 -0
- stapel_core/django/api/__init__.py +1 -0
- stapel_core/django/api/errors.py +458 -0
- stapel_core/django/api/pagination.py +336 -0
- stapel_core/django/api/permissions.py +162 -0
- stapel_core/django/api/revision.py +452 -0
- stapel_core/django/api/routers.py +10 -0
- stapel_core/django/api/serializers.py +262 -0
- stapel_core/django/apps.py +54 -0
- stapel_core/django/authentication.py +3 -0
- stapel_core/django/captcha.py +82 -0
- stapel_core/django/cdn/__init__.py +1 -0
- stapel_core/django/cdn/fields.py +295 -0
- stapel_core/django/cdn/ref_sync.py +119 -0
- stapel_core/django/errors.py +59 -0
- stapel_core/django/groups.py +212 -0
- stapel_core/django/jwt/__init__.py +1 -0
- stapel_core/django/jwt/authentication.py +181 -0
- stapel_core/django/jwt/backends.py +73 -0
- stapel_core/django/jwt/login_views.py +92 -0
- stapel_core/django/jwt/middleware.py +333 -0
- stapel_core/django/jwt/provider.py +191 -0
- stapel_core/django/jwt/session.py +59 -0
- stapel_core/django/jwt/utils.py +672 -0
- stapel_core/django/jwt/views.py +181 -0
- stapel_core/django/jwt_provider.py +2 -0
- stapel_core/django/management/__init__.py +0 -0
- stapel_core/django/management/commands/__init__.py +0 -0
- stapel_core/django/management/commands/check_flows.py +34 -0
- stapel_core/django/management/commands/consume_actions.py +57 -0
- stapel_core/django/management/commands/generate_flow_docs.py +39 -0
- stapel_core/django/management/commands/reset_sequences.py +124 -0
- stapel_core/django/management/commands/serve_functions.py +89 -0
- stapel_core/django/management/commands/staff_group.py +174 -0
- stapel_core/django/models.py +213 -0
- stapel_core/django/monitoring/__init__.py +1 -0
- stapel_core/django/monitoring/health.py +177 -0
- stapel_core/django/monitoring/telegram.py +114 -0
- stapel_core/django/openapi/__init__.py +7 -0
- stapel_core/django/openapi/extensions.py +106 -0
- stapel_core/django/openapi/mcp.py +164 -0
- stapel_core/django/openapi/openid.py +91 -0
- stapel_core/django/openapi/schemas.py +399 -0
- stapel_core/django/openapi/swagger.py +494 -0
- stapel_core/django/outbox/__init__.py +0 -0
- stapel_core/django/outbox/apps.py +8 -0
- stapel_core/django/outbox/management/__init__.py +0 -0
- stapel_core/django/outbox/management/commands/__init__.py +0 -0
- stapel_core/django/outbox/management/commands/dispatch_outbox.py +31 -0
- stapel_core/django/outbox/migrations/0001_initial.py +31 -0
- stapel_core/django/outbox/migrations/__init__.py +0 -0
- stapel_core/django/outbox/models.py +31 -0
- stapel_core/django/outbox/relay.py +72 -0
- stapel_core/django/settings.py +510 -0
- stapel_core/django/taskstore/__init__.py +0 -0
- stapel_core/django/taskstore/apps.py +22 -0
- stapel_core/django/taskstore/management/__init__.py +0 -0
- stapel_core/django/taskstore/management/commands/__init__.py +0 -0
- stapel_core/django/taskstore/management/commands/sweep_tasks.py +39 -0
- stapel_core/django/taskstore/migrations/0001_initial.py +37 -0
- stapel_core/django/taskstore/migrations/__init__.py +0 -0
- stapel_core/django/taskstore/models.py +42 -0
- stapel_core/django/templates/admin/base_site.html +229 -0
- stapel_core/django/users/__init__.py +1 -0
- stapel_core/django/users/admin.py +6 -0
- stapel_core/django/users/apps.py +8 -0
- stapel_core/django/users/migrations/0001_initial.py +62 -0
- stapel_core/django/users/migrations/0002_alter_user_managers_alter_user_groups_and_more.py +37 -0
- stapel_core/django/users/migrations/0003_remove_unique_constraints.py +23 -0
- stapel_core/django/users/migrations/0004_alter_user_phone.py +36 -0
- stapel_core/django/users/migrations/0005_alter_user_groups_alter_user_user_permissions.py +24 -0
- stapel_core/django/users/migrations/__init__.py +2 -0
- stapel_core/django/users/models.py +152 -0
- stapel_core/django/utils.py +8 -0
- stapel_core/django/workspaces.py +132 -0
- stapel_core/flows/__init__.py +38 -0
- stapel_core/flows/checks.py +116 -0
- stapel_core/flows/docs.py +192 -0
- stapel_core/flows/registry.py +169 -0
- stapel_core/gdpr.py +197 -0
- stapel_core/kafka/__init__.py +34 -0
- stapel_core/kafka/config.py +63 -0
- stapel_core/kafka/consumer.py +234 -0
- stapel_core/kafka/events.py +63 -0
- stapel_core/kafka/health.py +36 -0
- stapel_core/kafka/producer.py +105 -0
- stapel_core/kafka/topics.py +39 -0
- stapel_core/notifications/__init__.py +17 -0
- stapel_core/notifications/publish.py +90 -0
- stapel_core/notifications/schemas/emits/notification.requested.json +18 -0
- stapel_core/notifications/tokens.py +59 -0
- stapel_core/oauth.py +112 -0
- stapel_core/py.typed +0 -0
- stapel_core/signals.py +48 -0
- stapel_core/static/admin/js/cdn_image_widget.js +955 -0
- stapel_core/static/admin/js/jwt_session.js +150 -0
- stapel_core/testing.py +78 -0
- stapel_core/verification/__init__.py +64 -0
- stapel_core/verification/conf.py +27 -0
- stapel_core/verification/decorators.py +154 -0
- stapel_core/verification/errors.py +24 -0
- stapel_core/verification/factors.py +102 -0
- stapel_core/verification/grants.py +166 -0
- stapel_core/verification/policy.py +102 -0
- stapel_core-0.3.1.dist-info/METADATA +239 -0
- stapel_core-0.3.1.dist-info/RECORD +144 -0
- stapel_core-0.3.1.dist-info/WHEEL +5 -0
- stapel_core-0.3.1.dist-info/licenses/LICENSE +21 -0
- stapel_core-0.3.1.dist-info/top_level.txt +1 -0
stapel_core/__init__.py
ADDED
|
@@ -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()
|