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.
- sdk_messaging/__init__.py +104 -0
- sdk_messaging/_schema_semantics.py +254 -0
- sdk_messaging/admin.py +161 -0
- sdk_messaging/config.py +86 -0
- sdk_messaging/consumer.py +1361 -0
- sdk_messaging/contracts/__init__.py +50 -0
- sdk_messaging/contracts/schemas/usuarios/usuario-atualizado/v1.json +343 -0
- sdk_messaging/contracts/schemas/usuarios/usuario-criado/v1.json +308 -0
- sdk_messaging/contracts/schemas/usuarios/usuario-desativado/v1.json +42 -0
- sdk_messaging/contracts/schemas/usuarios/usuario-login-alterado/v1.json +46 -0
- sdk_messaging/contracts/schemas/usuarios/usuario-mesclado/v1.json +46 -0
- sdk_messaging/contracts/schemas/usuarios/usuario-reativado/v1.json +42 -0
- sdk_messaging/contracts/schemas/usuarios/usuario-removido/v1.json +42 -0
- sdk_messaging/contracts/usuarios.py +308 -0
- sdk_messaging/dedup.py +65 -0
- sdk_messaging/dlq.py +257 -0
- sdk_messaging/exceptions.py +57 -0
- sdk_messaging/headers.py +152 -0
- sdk_messaging/integrations/__init__.py +1 -0
- sdk_messaging/integrations/django.py +269 -0
- sdk_messaging/integrations/flask.py +188 -0
- sdk_messaging/integrations/redis.py +268 -0
- sdk_messaging/integrations/runtime.py +1086 -0
- sdk_messaging/messaging.py +249 -0
- sdk_messaging/naming.py +169 -0
- sdk_messaging/publisher.py +190 -0
- sdk_messaging/py.typed +0 -0
- sdk_messaging/retry.py +39 -0
- sdk_messaging/schema.py +338 -0
- sdk_messaging/telemetry.py +310 -0
- sdk_messaging/transport.py +190 -0
- sdk_messaging/types.py +172 -0
- youeduc_sdk_messaging-0.4.0.dist-info/METADATA +244 -0
- youeduc_sdk_messaging-0.4.0.dist-info/RECORD +36 -0
- youeduc_sdk_messaging-0.4.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|
sdk_messaging/config.py
ADDED
|
@@ -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
|
+
)
|