youeduc-sdk-messaging 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. sdk_messaging/__init__.py +104 -0
  2. sdk_messaging/_schema_semantics.py +254 -0
  3. sdk_messaging/admin.py +161 -0
  4. sdk_messaging/config.py +86 -0
  5. sdk_messaging/consumer.py +1361 -0
  6. sdk_messaging/contracts/__init__.py +50 -0
  7. sdk_messaging/contracts/schemas/usuarios/usuario-atualizado/v1.json +343 -0
  8. sdk_messaging/contracts/schemas/usuarios/usuario-criado/v1.json +308 -0
  9. sdk_messaging/contracts/schemas/usuarios/usuario-desativado/v1.json +42 -0
  10. sdk_messaging/contracts/schemas/usuarios/usuario-login-alterado/v1.json +46 -0
  11. sdk_messaging/contracts/schemas/usuarios/usuario-mesclado/v1.json +46 -0
  12. sdk_messaging/contracts/schemas/usuarios/usuario-reativado/v1.json +42 -0
  13. sdk_messaging/contracts/schemas/usuarios/usuario-removido/v1.json +42 -0
  14. sdk_messaging/contracts/usuarios.py +308 -0
  15. sdk_messaging/dedup.py +65 -0
  16. sdk_messaging/dlq.py +257 -0
  17. sdk_messaging/exceptions.py +57 -0
  18. sdk_messaging/headers.py +152 -0
  19. sdk_messaging/integrations/__init__.py +1 -0
  20. sdk_messaging/integrations/django.py +269 -0
  21. sdk_messaging/integrations/flask.py +188 -0
  22. sdk_messaging/integrations/redis.py +268 -0
  23. sdk_messaging/integrations/runtime.py +1086 -0
  24. sdk_messaging/messaging.py +249 -0
  25. sdk_messaging/naming.py +169 -0
  26. sdk_messaging/publisher.py +190 -0
  27. sdk_messaging/py.typed +0 -0
  28. sdk_messaging/retry.py +39 -0
  29. sdk_messaging/schema.py +338 -0
  30. sdk_messaging/telemetry.py +310 -0
  31. sdk_messaging/transport.py +190 -0
  32. sdk_messaging/types.py +172 -0
  33. youeduc_sdk_messaging-0.4.0.dist-info/METADATA +244 -0
  34. youeduc_sdk_messaging-0.4.0.dist-info/RECORD +36 -0
  35. youeduc_sdk_messaging-0.4.0.dist-info/WHEEL +4 -0
  36. youeduc_sdk_messaging-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,104 @@
1
+ """SDK de mensageria assíncrona sobre Apache Kafka."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _version
5
+
6
+ from sdk_messaging import naming
7
+ from sdk_messaging.admin import ensure_topics
8
+ from sdk_messaging.config import KafkaConfig
9
+ from sdk_messaging.consumer import (
10
+ Consumer,
11
+ HandlerCancelledError,
12
+ InvalidHandlerError,
13
+ InvalidPayloadError,
14
+ )
15
+ from sdk_messaging.dedup import InMemoryDuplicateChecker
16
+ from sdk_messaging.dlq import ReplayReport, replay_dlq
17
+ from sdk_messaging.exceptions import (
18
+ MessagingError,
19
+ NotStartedError,
20
+ PermanentProcessingError,
21
+ PublishError,
22
+ SchemaNotFoundError,
23
+ SchemaValidationError,
24
+ )
25
+ from sdk_messaging.messaging import Messaging
26
+ from sdk_messaging.publisher import Publisher
27
+ from sdk_messaging.retry import RetryPolicy
28
+ from sdk_messaging.schema import (
29
+ ChainSchemaLoader,
30
+ FileSystemSchemaLoader,
31
+ InMemorySchemaLoader,
32
+ JsonSchemaValidator,
33
+ SchemaRegistry,
34
+ )
35
+ from sdk_messaging.telemetry import NoopTelemetry, OtelTelemetry
36
+ from sdk_messaging.transport import KafkaProducerSender
37
+ from sdk_messaging.types import (
38
+ ConsumedMessage,
39
+ ConsumeOutcome,
40
+ ConsumeSpan,
41
+ DlqReason,
42
+ DuplicateChecker,
43
+ MessageHandler,
44
+ MessageInfo,
45
+ MessageSender,
46
+ PayloadValidator,
47
+ PublishResult,
48
+ PublishStatus,
49
+ SchemaLoader,
50
+ SchemaValidator,
51
+ SentRecord,
52
+ Telemetry,
53
+ )
54
+
55
+ try:
56
+ __version__ = _version("youeduc-sdk-messaging")
57
+ except PackageNotFoundError: # rodando do código-fonte, sem instalar
58
+ __version__ = "0.0.0"
59
+
60
+ __all__ = [
61
+ "ChainSchemaLoader",
62
+ "ConsumeOutcome",
63
+ "ConsumeSpan",
64
+ "ConsumedMessage",
65
+ "Consumer",
66
+ "DlqReason",
67
+ "DuplicateChecker",
68
+ "FileSystemSchemaLoader",
69
+ "HandlerCancelledError",
70
+ "InMemoryDuplicateChecker",
71
+ "InMemorySchemaLoader",
72
+ "InvalidHandlerError",
73
+ "InvalidPayloadError",
74
+ "JsonSchemaValidator",
75
+ "KafkaConfig",
76
+ "KafkaProducerSender",
77
+ "MessageHandler",
78
+ "MessageInfo",
79
+ "MessageSender",
80
+ "Messaging",
81
+ "MessagingError",
82
+ "NoopTelemetry",
83
+ "NotStartedError",
84
+ "OtelTelemetry",
85
+ "PayloadValidator",
86
+ "PermanentProcessingError",
87
+ "PublishError",
88
+ "PublishResult",
89
+ "PublishStatus",
90
+ "Publisher",
91
+ "ReplayReport",
92
+ "RetryPolicy",
93
+ "SchemaLoader",
94
+ "SchemaNotFoundError",
95
+ "SchemaRegistry",
96
+ "SchemaValidationError",
97
+ "SchemaValidator",
98
+ "SentRecord",
99
+ "Telemetry",
100
+ "__version__",
101
+ "ensure_topics",
102
+ "naming",
103
+ "replay_dlq",
104
+ ]
@@ -0,0 +1,254 @@
1
+ """Semântica de JSON Schema idêntica nas SDKs Python e Node (spec §10).
2
+
3
+ - ``pattern`` / ``patternProperties``: regex ECMA-262 (flag ``u``) traduzida para o ``re``
4
+ do Python. ``\\d``/``\\w``/``\\b`` são ASCII, ``\\s`` é o conjunto de espaços do ECMA,
5
+ ``.`` não casa terminadores de linha e ``$`` só casa no fim absoluto (no Python casaria
6
+ antes de um ``\\n`` final). Grupos nomeados ``(?<n>...)`` / ``\\k<n>`` viram a sintaxe
7
+ do Python. Outras construções são copiadas como estão: o que o ``re`` não entende é
8
+ recusado na construção do validator.
9
+ - ``format``: só os formatos da spec são verificados, com regras exatas (sem depender de
10
+ pacotes opcionais do ``jsonschema`` instalados no ambiente).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import calendar
16
+ import functools
17
+ import re
18
+ from typing import Any
19
+
20
+ from jsonschema import Draft202012Validator, FormatChecker
21
+
22
+ # --------------------------------------------------------------------------- regex ECMA
23
+
24
+ _WORD = "A-Za-z0-9_"
25
+ # WhiteSpace + LineTerminator do ECMA-262 (\s).
26
+ _SPACE = "\\t\\n\\x0b\\x0c\\r \\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff"
27
+ _LINE_TERMINATORS = "\\n\\r\\u2028\\u2029"
28
+ _ANY_CHAR = "[\\x00-\\U0010ffff]"
29
+ _NOTHING = "(?!)"
30
+
31
+ # Escapes de classe (conteúdo de [...]); as versões maiúsculas são complementos.
32
+ _CLASS_SETS = {"d": "0-9", "w": _WORD, "s": _SPACE}
33
+ _OUTSIDE_ESCAPES = {
34
+ "d": "[0-9]",
35
+ "D": "[^0-9]",
36
+ "w": f"[{_WORD}]",
37
+ "W": f"[^{_WORD}]",
38
+ "s": f"[{_SPACE}]",
39
+ "S": f"[^{_SPACE}]",
40
+ "b": f"(?:(?<=[{_WORD}])(?![{_WORD}])|(?<![{_WORD}])(?=[{_WORD}]))",
41
+ "B": f"(?:(?<=[{_WORD}])(?=[{_WORD}])|(?<![{_WORD}])(?![{_WORD}]))",
42
+ }
43
+ # Dentro de [...] estes caracteres formam operações de conjunto futuras no ``re``.
44
+ _CLASS_LITERALS_TO_ESCAPE = frozenset("[&~|")
45
+
46
+
47
+ @functools.lru_cache(maxsize=1024)
48
+ def ecma_pattern(pattern: str) -> str:
49
+ """Traduz uma regex ECMA-262 para uma regex ``re`` com o mesmo comportamento."""
50
+ out: list[str] = []
51
+ i, size = 0, len(pattern)
52
+ while i < size:
53
+ char = pattern[i]
54
+ if char == "\\" and i + 1 < size:
55
+ escaped = pattern[i + 1]
56
+ if escaped in _OUTSIDE_ESCAPES:
57
+ out.append(_OUTSIDE_ESCAPES[escaped])
58
+ elif escaped == "k" and pattern.startswith("<", i + 2):
59
+ end = pattern.find(">", i + 3)
60
+ if end != -1:
61
+ out.append(f"(?P={pattern[i + 3 : end]})")
62
+ i = end + 1
63
+ continue
64
+ out.append("\\k")
65
+ else:
66
+ out.append(char + escaped)
67
+ i += 2
68
+ elif char == "[":
69
+ i = _translate_class(pattern, i, out)
70
+ elif (
71
+ pattern.startswith("(?<", i) and i + 3 < size and pattern[i + 3] not in "=!"
72
+ ):
73
+ out.append("(?P<")
74
+ i += 3
75
+ elif char == ".":
76
+ out.append(f"[^{_LINE_TERMINATORS}]")
77
+ i += 1
78
+ elif char == "$":
79
+ out.append("\\Z")
80
+ i += 1
81
+ else:
82
+ out.append(char)
83
+ i += 1
84
+ return "".join(out)
85
+
86
+
87
+ def _translate_class(pattern: str, start: int, out: list[str]) -> int:
88
+ """Traduz ``[...]`` a partir de ``start``; retorna o índice após o ``]``."""
89
+ i = start + 1
90
+ negated = i < len(pattern) and pattern[i] == "^"
91
+ if negated:
92
+ i += 1
93
+ items: list[str] = []
94
+ complements: list[str] = []
95
+ while i < len(pattern):
96
+ char = pattern[i]
97
+ if char == "]":
98
+ break
99
+ if char == "\\" and i + 1 < len(pattern):
100
+ escaped = pattern[i + 1]
101
+ if escaped in _CLASS_SETS:
102
+ items.append(_CLASS_SETS[escaped])
103
+ elif escaped in "DWS":
104
+ complements.append(_CLASS_SETS[escaped.lower()])
105
+ else:
106
+ items.append(char + escaped)
107
+ i += 2
108
+ continue
109
+ items.append("\\" + char if char in _CLASS_LITERALS_TO_ESCAPE else char)
110
+ i += 1
111
+ else:
112
+ # Sem ']' de fechamento: o re do Python recusa o padrão na construção.
113
+ out.append(pattern[start:])
114
+ return len(pattern)
115
+ out.append(_class_expression("".join(items), complements, negated=negated))
116
+ return i + 1
117
+
118
+
119
+ def _class_expression(body: str, complements: list[str], *, negated: bool) -> str:
120
+ if not complements:
121
+ if not body: # ECMA: [] não casa nada, [^] casa qualquer caractere
122
+ return _ANY_CHAR if negated else _NOTHING
123
+ return f"[{'^' if negated else ''}{body}]"
124
+ if not negated: # união: body ou complemento de cada conjunto
125
+ parts = ([f"[{body}]"] if body else []) + [f"[^{c}]" for c in complements]
126
+ return "(?:" + "|".join(parts) + ")"
127
+ # [^...\D...]: fora de body e dentro de cada conjunto complementado.
128
+ prefix = f"(?![{body}])" if body else ""
129
+ lookaheads = "".join(f"(?=[{c}])" for c in complements[:-1])
130
+ return f"(?:{prefix}{lookaheads}[{complements[-1]}])"
131
+
132
+
133
+ # Palavras-chave cujo valor é subschema, mapa de subschemas ou lista de subschemas.
134
+ _SUBSCHEMA_MAPS = (
135
+ "properties",
136
+ "patternProperties",
137
+ "$defs",
138
+ "definitions",
139
+ "dependentSchemas",
140
+ )
141
+ _SUBSCHEMA_VALUES = (
142
+ "additionalProperties",
143
+ "unevaluatedProperties",
144
+ "items",
145
+ "additionalItems",
146
+ "unevaluatedItems",
147
+ "contains",
148
+ "propertyNames",
149
+ "not",
150
+ "if",
151
+ "then",
152
+ "else",
153
+ )
154
+ _SUBSCHEMA_LISTS = ("allOf", "anyOf", "oneOf", "prefixItems")
155
+
156
+
157
+ def ecma_schema(schema: Any) -> Any: # noqa: ANN401 - schema JSON arbitrário
158
+ """Cópia do schema com ``pattern`` e chaves de ``patternProperties`` traduzidos.
159
+
160
+ Só percorre posições de subschema (nunca ``const``/``enum``/``default``/``examples``
161
+ nem nomes de propriedades), então dados e nomes ficam intactos.
162
+ """
163
+ if not isinstance(schema, dict):
164
+ return schema
165
+ result = dict(schema)
166
+ pattern = result.get("pattern")
167
+ if isinstance(pattern, str):
168
+ result["pattern"] = ecma_pattern(pattern)
169
+ for keyword in _SUBSCHEMA_MAPS:
170
+ value = result.get(keyword)
171
+ if isinstance(value, dict):
172
+ translate_key = keyword == "patternProperties"
173
+ result[keyword] = {
174
+ (ecma_pattern(key) if translate_key else key): ecma_schema(sub)
175
+ for key, sub in value.items()
176
+ }
177
+ for keyword in _SUBSCHEMA_VALUES:
178
+ if keyword in result:
179
+ value = result[keyword]
180
+ result[keyword] = (
181
+ [ecma_schema(sub) for sub in value]
182
+ if isinstance(value, list)
183
+ else ecma_schema(value)
184
+ )
185
+ for keyword in _SUBSCHEMA_LISTS:
186
+ value = result.get(keyword)
187
+ if isinstance(value, list):
188
+ result[keyword] = [ecma_schema(sub) for sub in value]
189
+ return result
190
+
191
+
192
+ # --------------------------------------------------------------------------- formatos
193
+
194
+ _DATE_TIME_RE = re.compile(
195
+ r"([0-9]{4})-(0[1-9]|1[0-2])-([0-9]{2})[Tt]"
196
+ r"(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?"
197
+ r"(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
198
+ re.ASCII,
199
+ )
200
+ _UUID_RE = re.compile(
201
+ r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
202
+ re.ASCII,
203
+ )
204
+
205
+
206
+ def is_date_time(instance: object) -> bool:
207
+ """RFC 3339 ``date-time`` exato (fullmatch: nem ``\\n`` final), sem segundo bissexto."""
208
+ if not isinstance(instance, str):
209
+ return True
210
+ match = _DATE_TIME_RE.fullmatch(instance)
211
+ if match is None:
212
+ return False
213
+ year, month, day = (int(group) for group in match.groups())
214
+ return year >= 1 and 1 <= day <= calendar.monthrange(year, month)[1]
215
+
216
+
217
+ def is_time(instance: object) -> bool:
218
+ if not isinstance(instance, str):
219
+ return True
220
+ return is_date_time("1970-01-01T" + instance)
221
+
222
+
223
+ def is_uuid(instance: object) -> bool:
224
+ """``8-4-4-4-12`` hexadecimal exato (sem ``urn:uuid:``, chaves ou hífens extras)."""
225
+ if not isinstance(instance, str):
226
+ return True
227
+ return _UUID_RE.fullmatch(instance) is not None
228
+
229
+
230
+ def is_ecma_regex(instance: object) -> bool:
231
+ if not isinstance(instance, str):
232
+ return True
233
+ re.compile(ecma_pattern(instance))
234
+ return True
235
+
236
+
237
+ def _payload_format_checker() -> FormatChecker:
238
+ checker = FormatChecker(formats=())
239
+ reference = Draft202012Validator.FORMAT_CHECKER.checkers
240
+ # Regras do jsonschema que já coincidem com a spec.
241
+ for name in ("date", "email", "idn-email", "ipv4", "ipv6"):
242
+ checker.checkers[name] = reference[name]
243
+ checker.checks("date-time")(is_date_time)
244
+ checker.checks("time")(is_time)
245
+ checker.checks("uuid")(is_uuid)
246
+ return checker
247
+
248
+
249
+ # Payloads: só os formatos da spec; qualquer outro é anotação.
250
+ FORMAT_CHECKER = _payload_format_checker()
251
+
252
+ # Metaschema (check_schema): ``pattern``/``patternProperties`` são regex ECMA.
253
+ META_FORMAT_CHECKER = FormatChecker(formats=())
254
+ META_FORMAT_CHECKER.checks("regex", raises=re.error)(is_ecma_regex)
sdk_messaging/admin.py ADDED
@@ -0,0 +1,161 @@
1
+ """Provisionamento de tópicos (uso em bootstrap/dev; em produção prefira IaC)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Protocol
7
+
8
+ import structlog
9
+ from aiokafka.admin import AIOKafkaAdminClient, NewTopic
10
+ from aiokafka.errors import TopicAlreadyExistsError, for_code
11
+
12
+ from sdk_messaging.exceptions import MessagingError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable, Iterable, Mapping, Sequence
16
+
17
+ from sdk_messaging.config import KafkaConfig
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ class _CreateTopicsResponse(Protocol):
23
+ @property
24
+ def topic_errors(self) -> Sequence[Sequence[Any]]: ...
25
+
26
+
27
+ class _Closable(Protocol):
28
+ async def close(self) -> None: ...
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TopicSpec:
33
+ """Um tópico a criar: nome, partições e configs (usado por ``Messaging.ensure_topics``
34
+ para variar partições/configs por tipo de tópico, spec §2.6)."""
35
+
36
+ name: str
37
+ num_partitions: int
38
+ topic_configs: Mapping[str, str] = field(default_factory=dict)
39
+
40
+
41
+ async def ensure_topics(
42
+ config: KafkaConfig,
43
+ topics: Iterable[str],
44
+ *,
45
+ num_partitions: int = 3,
46
+ replication_factor: int = 1,
47
+ topic_configs: Mapping[str, str] | None = None,
48
+ admin_factory: Callable[..., Any] | None = None,
49
+ ) -> list[str]:
50
+ """Cria os tópicos que ainda não existem e devolve os nomes criados.
51
+
52
+ Tópico já existente não é erro. Qualquer outro erro por tópico levanta
53
+ ``MessagingError`` listando cada tópico e o erro; erros da requisição como um
54
+ todo (conexão, timeout) propagam como vierem do aiokafka.
55
+ """
56
+ if isinstance(topics, str):
57
+ raise TypeError("'topics' must be an iterable of topic names, not a str")
58
+ names = list(dict.fromkeys(topics))
59
+ if not names:
60
+ return []
61
+ for name in names:
62
+ if not isinstance(name, str) or not name:
63
+ raise ValueError(f"topic names must be non-empty strings; got {name!r}")
64
+ _require_count("num_partitions", num_partitions)
65
+ configs = dict(topic_configs or {})
66
+ return await create_topics(
67
+ config,
68
+ [TopicSpec(name, num_partitions, configs) for name in names],
69
+ replication_factor=replication_factor,
70
+ admin_factory=admin_factory,
71
+ )
72
+
73
+
74
+ def _require_count(name: str, value: object) -> None:
75
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
76
+ raise ValueError(f"{name!r} must be >= 1; got {value!r}")
77
+
78
+
79
+ async def create_topics(
80
+ config: KafkaConfig,
81
+ specs: Sequence[TopicSpec],
82
+ *,
83
+ replication_factor: int = 1,
84
+ admin_factory: Callable[..., Any] | None = None,
85
+ ) -> list[str]:
86
+ """Como :func:`ensure_topics`, com partições e configs por tópico. Nomes repetidos:
87
+ vale a primeira definição."""
88
+ unique: dict[str, TopicSpec] = {}
89
+ for spec in specs:
90
+ if not isinstance(spec.name, str) or not spec.name:
91
+ raise ValueError(
92
+ f"topic names must be non-empty strings; got {spec.name!r}"
93
+ )
94
+ _require_count("num_partitions", spec.num_partitions)
95
+ unique.setdefault(spec.name, spec)
96
+ if not unique:
97
+ return []
98
+ _require_count("replication_factor", replication_factor)
99
+
100
+ names = list(unique)
101
+ new_topics = [
102
+ NewTopic(
103
+ name=spec.name,
104
+ num_partitions=spec.num_partitions,
105
+ replication_factor=replication_factor,
106
+ topic_configs=dict(spec.topic_configs),
107
+ )
108
+ for spec in unique.values()
109
+ ]
110
+ factory: Callable[..., Any] = (
111
+ admin_factory if admin_factory is not None else AIOKafkaAdminClient
112
+ )
113
+ admin = factory(**config.client_kwargs())
114
+ try:
115
+ await admin.start()
116
+ response = await admin.create_topics(new_topics)
117
+ finally:
118
+ await _close_quietly(admin)
119
+
120
+ created, failures = _check_response(names, response)
121
+ if created:
122
+ logger.info("kafka_topics_created", messaging={"topics": created})
123
+ if failures:
124
+ raise MessagingError("failed to create topics: " + "; ".join(failures))
125
+ return created
126
+
127
+
128
+ def _check_response(
129
+ names: list[str], response: _CreateTopicsResponse
130
+ ) -> tuple[list[str], list[str]]:
131
+ """Interpreta ``topic_errors``: tuplas ``(topic, error_code[, error_message])``."""
132
+ succeeded: set[str] = set()
133
+ failures: list[str] = []
134
+ seen: set[str] = set()
135
+ for entry in response.topic_errors:
136
+ topic, code = entry[0], entry[1]
137
+ message = entry[2] if len(entry) > 2 else None
138
+ seen.add(topic)
139
+ if code == 0:
140
+ succeeded.add(topic)
141
+ elif code == TopicAlreadyExistsError.errno:
142
+ continue
143
+ else:
144
+ detail = f"{for_code(code).__name__} (error code {code})"
145
+ if message:
146
+ detail += f": {message}"
147
+ failures.append(f"{topic!r}: {detail}")
148
+ failures.extend(
149
+ f"{name!r}: missing from broker response" for name in names if name not in seen
150
+ )
151
+ return [name for name in names if name in succeeded], failures
152
+
153
+
154
+ async def _close_quietly(admin: _Closable) -> None:
155
+ try:
156
+ await admin.close()
157
+ except Exception as exc:
158
+ logger.warning(
159
+ "kafka_admin_close_failed",
160
+ error={"type": type(exc).__name__, "message": str(exc)},
161
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ import ssl
9
+ from collections.abc import Mapping
10
+
11
+ _SECURITY_PROTOCOLS = ("PLAINTEXT", "SSL", "SASL_PLAINTEXT", "SASL_SSL")
12
+ _TLS_PROTOCOLS = ("SSL", "SASL_SSL")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class KafkaConfig:
17
+ """Parâmetros de conexão compartilhados por producer, consumer e admin."""
18
+
19
+ bootstrap_servers: str
20
+ client_id: str | None = None
21
+ security_protocol: str = "PLAINTEXT"
22
+ sasl_mechanism: str | None = None
23
+ sasl_username: str | None = None
24
+ sasl_password: str | None = field(default=None, repr=False)
25
+ ssl_context: ssl.SSLContext | None = field(default=None, repr=False)
26
+
27
+ def __post_init__(self) -> None:
28
+ if not self.bootstrap_servers.strip():
29
+ raise ValueError("'bootstrap_servers' must not be empty")
30
+ if self.security_protocol not in _SECURITY_PROTOCOLS:
31
+ raise ValueError(
32
+ f"'security_protocol' must be one of {_SECURITY_PROTOCOLS}; "
33
+ f"got {self.security_protocol!r}"
34
+ )
35
+ if self.security_protocol.startswith("SASL") and self.sasl_mechanism is None:
36
+ raise ValueError("'sasl_mechanism' is required for SASL security protocols")
37
+
38
+ def client_kwargs(self) -> dict[str, Any]:
39
+ """kwargs comuns aceitos por AIOKafkaProducer, AIOKafkaConsumer e AIOKafkaAdminClient.
40
+
41
+ Com TLS (``SSL``/``SASL_SSL``) e sem ``ssl_context`` explícito, usa o
42
+ contexto padrão do aiokafka (CAs do sistema, verificação de hostname).
43
+ """
44
+ kwargs: dict[str, Any] = {
45
+ "bootstrap_servers": self.bootstrap_servers,
46
+ "security_protocol": self.security_protocol,
47
+ }
48
+ if self.client_id is not None:
49
+ kwargs["client_id"] = self.client_id
50
+ if self.sasl_mechanism is not None:
51
+ kwargs["sasl_mechanism"] = self.sasl_mechanism
52
+ kwargs["sasl_plain_username"] = self.sasl_username
53
+ kwargs["sasl_plain_password"] = self.sasl_password
54
+ if self.ssl_context is not None:
55
+ kwargs["ssl_context"] = self.ssl_context
56
+ elif self.security_protocol in _TLS_PROTOCOLS:
57
+ from aiokafka.helpers import create_ssl_context
58
+
59
+ kwargs["ssl_context"] = create_ssl_context()
60
+ return kwargs
61
+
62
+ @classmethod
63
+ def from_env(
64
+ cls, prefix: str = "KAFKA_", environ: Mapping[str, str] | None = None
65
+ ) -> KafkaConfig:
66
+ """Lê ``{prefix}BOOTSTRAP_SERVERS``, ``CLIENT_ID``, ``SECURITY_PROTOCOL``,
67
+ ``SASL_MECHANISM``, ``SASL_USERNAME`` e ``SASL_PASSWORD``.
68
+
69
+ TLS com CA própria exige montar o ``ssl_context`` no código; sem ele,
70
+ :meth:`client_kwargs` usa o contexto padrão.
71
+ """
72
+ env = os.environ if environ is None else environ
73
+ try:
74
+ servers = env[f"{prefix}BOOTSTRAP_SERVERS"]
75
+ except KeyError:
76
+ raise ValueError(
77
+ f"environment variable {prefix}BOOTSTRAP_SERVERS is required"
78
+ ) from None
79
+ return cls(
80
+ bootstrap_servers=servers,
81
+ client_id=env.get(f"{prefix}CLIENT_ID"),
82
+ security_protocol=env.get(f"{prefix}SECURITY_PROTOCOL", "PLAINTEXT"),
83
+ sasl_mechanism=env.get(f"{prefix}SASL_MECHANISM"),
84
+ sasl_username=env.get(f"{prefix}SASL_USERNAME"),
85
+ sasl_password=env.get(f"{prefix}SASL_PASSWORD"),
86
+ )