youeduc-sdk-messaging 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. youeduc_sdk_messaging-0.4.0/.gitignore +17 -0
  2. youeduc_sdk_messaging-0.4.0/LICENSE +21 -0
  3. youeduc_sdk_messaging-0.4.0/PKG-INFO +244 -0
  4. youeduc_sdk_messaging-0.4.0/README.md +205 -0
  5. youeduc_sdk_messaging-0.4.0/pyproject.toml +96 -0
  6. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/__init__.py +104 -0
  7. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/_schema_semantics.py +254 -0
  8. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/admin.py +161 -0
  9. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/config.py +86 -0
  10. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/consumer.py +1361 -0
  11. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/__init__.py +50 -0
  12. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/schemas/usuarios/usuario-atualizado/v1.json +343 -0
  13. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/schemas/usuarios/usuario-criado/v1.json +308 -0
  14. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/schemas/usuarios/usuario-desativado/v1.json +42 -0
  15. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/schemas/usuarios/usuario-login-alterado/v1.json +46 -0
  16. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/schemas/usuarios/usuario-mesclado/v1.json +46 -0
  17. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/schemas/usuarios/usuario-reativado/v1.json +42 -0
  18. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/schemas/usuarios/usuario-removido/v1.json +42 -0
  19. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/contracts/usuarios.py +308 -0
  20. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/dedup.py +65 -0
  21. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/dlq.py +257 -0
  22. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/exceptions.py +57 -0
  23. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/headers.py +152 -0
  24. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/integrations/__init__.py +1 -0
  25. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/integrations/django.py +269 -0
  26. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/integrations/flask.py +188 -0
  27. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/integrations/redis.py +268 -0
  28. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/integrations/runtime.py +1086 -0
  29. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/messaging.py +249 -0
  30. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/naming.py +169 -0
  31. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/publisher.py +190 -0
  32. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/py.typed +0 -0
  33. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/retry.py +39 -0
  34. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/schema.py +338 -0
  35. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/telemetry.py +310 -0
  36. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/transport.py +190 -0
  37. youeduc_sdk_messaging-0.4.0/src/sdk_messaging/types.py +172 -0
  38. youeduc_sdk_messaging-0.4.0/tests/__init__.py +0 -0
  39. youeduc_sdk_messaging-0.4.0/tests/integration/__init__.py +0 -0
  40. youeduc_sdk_messaging-0.4.0/tests/integration/conftest.py +448 -0
  41. youeduc_sdk_messaging-0.4.0/tests/integration/test_publish_consume.py +234 -0
  42. youeduc_sdk_messaging-0.4.0/tests/integration/test_resource_prefix.py +149 -0
  43. youeduc_sdk_messaging-0.4.0/tests/integration/test_retry_dlq.py +376 -0
  44. youeduc_sdk_messaging-0.4.0/tests/unit/__init__.py +0 -0
  45. youeduc_sdk_messaging-0.4.0/tests/unit/fakes_consumer.py +624 -0
  46. youeduc_sdk_messaging-0.4.0/tests/unit/stubs_django.py +102 -0
  47. youeduc_sdk_messaging-0.4.0/tests/unit/stubs_flask.py +73 -0
  48. youeduc_sdk_messaging-0.4.0/tests/unit/stubs_integrations.py +159 -0
  49. youeduc_sdk_messaging-0.4.0/tests/unit/test_admin.py +257 -0
  50. youeduc_sdk_messaging-0.4.0/tests/unit/test_config.py +162 -0
  51. youeduc_sdk_messaging-0.4.0/tests/unit/test_consumer_fixes.py +466 -0
  52. youeduc_sdk_messaging-0.4.0/tests/unit/test_consumer_lifecycle.py +274 -0
  53. youeduc_sdk_messaging-0.4.0/tests/unit/test_consumer_processing.py +504 -0
  54. youeduc_sdk_messaging-0.4.0/tests/unit/test_consumer_regressions.py +523 -0
  55. youeduc_sdk_messaging-0.4.0/tests/unit/test_contracts_usuarios.py +178 -0
  56. youeduc_sdk_messaging-0.4.0/tests/unit/test_dedup.py +127 -0
  57. youeduc_sdk_messaging-0.4.0/tests/unit/test_dlq.py +547 -0
  58. youeduc_sdk_messaging-0.4.0/tests/unit/test_exceptions.py +55 -0
  59. youeduc_sdk_messaging-0.4.0/tests/unit/test_generated_contracts.py +206 -0
  60. youeduc_sdk_messaging-0.4.0/tests/unit/test_headers.py +277 -0
  61. youeduc_sdk_messaging-0.4.0/tests/unit/test_integrations_django.py +340 -0
  62. youeduc_sdk_messaging-0.4.0/tests/unit/test_integrations_flask.py +275 -0
  63. youeduc_sdk_messaging-0.4.0/tests/unit/test_integrations_redis.py +448 -0
  64. youeduc_sdk_messaging-0.4.0/tests/unit/test_integrations_runtime.py +1308 -0
  65. youeduc_sdk_messaging-0.4.0/tests/unit/test_messaging.py +237 -0
  66. youeduc_sdk_messaging-0.4.0/tests/unit/test_naming.py +144 -0
  67. youeduc_sdk_messaging-0.4.0/tests/unit/test_packaging.py +26 -0
  68. youeduc_sdk_messaging-0.4.0/tests/unit/test_protocol_conformance.py +70 -0
  69. youeduc_sdk_messaging-0.4.0/tests/unit/test_publisher.py +484 -0
  70. youeduc_sdk_messaging-0.4.0/tests/unit/test_resource_prefix.py +435 -0
  71. youeduc_sdk_messaging-0.4.0/tests/unit/test_retry.py +69 -0
  72. youeduc_sdk_messaging-0.4.0/tests/unit/test_schema.py +679 -0
  73. youeduc_sdk_messaging-0.4.0/tests/unit/test_spec_vectors.py +1253 -0
  74. youeduc_sdk_messaging-0.4.0/tests/unit/test_telemetry.py +509 -0
  75. youeduc_sdk_messaging-0.4.0/tests/unit/test_transport.py +343 -0
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .venv/
10
+ .env
11
+ *.env
12
+ .memsearch-mini/
13
+ .idea/
14
+ .vscode/
15
+
16
+ # Referência local de comandos rpk (não versionar)
17
+ CONFIG.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 YouEduc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,244 @@
1
+ Metadata-Version: 2.5
2
+ Name: youeduc-sdk-messaging
3
+ Version: 0.4.0
4
+ Summary: SDK de mensageria assíncrona orientada a eventos sobre Apache Kafka (aiokafka)
5
+ Author: YouEduc
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: aiokafka,asyncio,dlq,events,json-schema,kafka,messaging,retry
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Distributed Computing
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: aiokafka<0.15,>=0.14.0
24
+ Requires-Dist: jsonschema>=4.23.0
25
+ Requires-Dist: opentelemetry-api>=1.27.0
26
+ Requires-Dist: referencing>=0.28.4
27
+ Requires-Dist: structlog>=24.4.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy==2.3.1; extra == 'dev'
30
+ Requires-Dist: opentelemetry-sdk==1.44.0; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
32
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
33
+ Requires-Dist: redis==8.1.0; extra == 'dev'
34
+ Requires-Dist: ruff==0.16.8; extra == 'dev'
35
+ Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
36
+ Provides-Extra: redis
37
+ Requires-Dist: redis>=5.0.1; extra == 'redis'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # youeduc-sdk-messaging
41
+
42
+ SDK Python de mensageria assíncrona orientada a eventos sobre **Apache Kafka**, construída sobre `aiokafka` e
43
+ `asyncio`. Publica eventos validados por JSON Schema e os consome com retry por tópicos, DLQ, deduplicação, commit
44
+ manual de offsets e observabilidade (OpenTelemetry + logs estruturados com `structlog`).
45
+
46
+ - Distribuição: `youeduc-sdk-messaging`; import: `sdk_messaging`.
47
+ - Tipada (`py.typed`), Python >= 3.10, Kafka >= 3.x.
48
+ - Existe uma SDK Node equivalente que segue o mesmo protocolo (tópicos, headers, retry/DLQ e telemetria): um serviço
49
+ Python publica e um serviço Node consome, e o contrário.
50
+
51
+ ## Instalação
52
+
53
+ Fixe sempre uma versão exata:
54
+
55
+ ```bash
56
+ pip install "youeduc-sdk-messaging==X.Y.Z"
57
+ ```
58
+
59
+ Com a deduplicação compartilhada em Redis (`sdk_messaging.integrations.redis`):
60
+
61
+ ```bash
62
+ pip install "youeduc-sdk-messaging[redis]==X.Y.Z"
63
+ ```
64
+
65
+ Conferir a versão instalada:
66
+
67
+ ```bash
68
+ python -c "import sdk_messaging; print(sdk_messaging.__version__)"
69
+ ```
70
+
71
+ Dependências de runtime: `aiokafka`, `jsonschema`, `referencing`, `structlog` e `opentelemetry-api`. O
72
+ `opentelemetry-sdk` e os exporters ficam a cargo da aplicação. Django, Flask, FastAPI e Celery **não** são
73
+ dependências: os adaptadores `sdk_messaging.integrations.django` e `sdk_messaging.integrations.flask` só importam o
74
+ framework quando o próprio serviço os importa.
75
+
76
+ ## Publicar
77
+
78
+ ```python
79
+ import asyncio
80
+ from pathlib import Path
81
+
82
+ from sdk_messaging import FileSystemSchemaLoader, KafkaConfig, Messaging
83
+
84
+
85
+ async def main() -> None:
86
+ async with Messaging(
87
+ KafkaConfig.from_env(), # KAFKA_BOOTSTRAP_SERVERS etc.
88
+ service="enrollment-api", # kebab-case; vai no header `producer`
89
+ environment="production",
90
+ schema_loader=FileSystemSchemaLoader(Path("schemas")),
91
+ ) as messaging:
92
+ result = await messaging.publish(
93
+ "enrollment", "student", "enrolled", "v1", # domain, aggregate, event, version
94
+ {"student_id": "s-123", "course_id": "c-9"},
95
+ key="s-123", # define a partição (ordem por chave)
96
+ idempotency_key="enrollment-987", # opcional; chave de dedup preferida
97
+ )
98
+ print(result.message_id, result.topic, result.partition, result.offset)
99
+
100
+
101
+ asyncio.run(main())
102
+ ```
103
+
104
+ O evento vai para o tópico `enrollment.events.v1` com `event_type = "student.enrolled"` e é validado contra
105
+ `schemas/enrollment/student-enrolled/v1.json` (JSON Schema Draft 2020-12) antes de sair. `publish` aguarda o ack do
106
+ broker; payload inválido levanta `SchemaValidationError` sem enviar nada, e erro do Kafka levanta `PublishError`.
107
+
108
+ ## Consumir
109
+
110
+ ```python
111
+ import asyncio
112
+ import signal
113
+ from pathlib import Path
114
+
115
+ from sdk_messaging import (
116
+ ConsumedMessage,
117
+ FileSystemSchemaLoader,
118
+ KafkaConfig,
119
+ Messaging,
120
+ PermanentProcessingError,
121
+ )
122
+
123
+
124
+ async def on_enrolled(message: ConsumedMessage) -> None:
125
+ if "course_id" not in message.payload:
126
+ raise PermanentProcessingError("course_id ausente") # vai direto para a DLQ
127
+ ... # retornar = sucesso; qualquer outra exceção = retry
128
+
129
+
130
+ async def main() -> None:
131
+ stop = asyncio.Event()
132
+ loop = asyncio.get_running_loop()
133
+ for sig in (signal.SIGINT, signal.SIGTERM):
134
+ loop.add_signal_handler(sig, stop.set)
135
+
136
+ async with Messaging(
137
+ KafkaConfig.from_env(),
138
+ service="learning-worker", # também é o consumer group padrão
139
+ environment="production",
140
+ schema_loader=FileSystemSchemaLoader(Path("schemas")),
141
+ ) as messaging:
142
+ consumer = messaging.consumer(
143
+ domain="enrollment",
144
+ version="v1",
145
+ handlers={"student.enrolled": on_enrolled}, # event_type -> handler async
146
+ processing_timeout_s=60,
147
+ )
148
+ await consumer.start()
149
+ consumer_done = asyncio.ensure_future(consumer.wait())
150
+ signal_received = asyncio.ensure_future(stop.wait())
151
+ await asyncio.wait({consumer_done, signal_received}, return_when=asyncio.FIRST_COMPLETED)
152
+ signal_received.cancel()
153
+ # Ao sair do bloco, os consumers terminam o registro em voo, commitam e o producer fecha.
154
+ await consumer_done # relança o erro fatal que derrubou o consumer, se houve
155
+
156
+
157
+ asyncio.run(main())
158
+ ```
159
+
160
+ Eventos do domínio sem handler são ignorados e o offset avança. Os tópicos (principal, retry e DLQ do grupo) precisam
161
+ existir antes do consumer iniciar: crie-os por IaC ou, em desenvolvimento, com `messaging.ensure_topics(...)`.
162
+
163
+ ## Garantias
164
+
165
+ | Garantia | Como |
166
+ |---|---|
167
+ | **At-least-once** no consumo | O offset só é commitado depois do desfecho final (sucesso, DLQ, ignorada) ou do envio **confirmado** para retry/DLQ. Um handler pode rodar mais de uma vez para a mesma mensagem: **handlers devem ser idempotentes** ou usar um `DuplicateChecker`. |
168
+ | **Ordem por chave** | Mensagens com a mesma `key` vão para a mesma partição e são processadas em sequência. Uma mensagem que vai para retry sai da ordem. |
169
+ | **Producer idempotente** | Um producer por processo, `acks=all` e idempotência ligada, compartilhado por publish, retry e DLQ. |
170
+ | **Schema obrigatório** | Todo payload é validado no publish e no consumo. |
171
+ | **Sem PII em logs** | Logs, spans, métricas e erros de schema nunca incluem valores do payload nem a `key`. |
172
+
173
+ Não há exactly-once nem transações Kafka.
174
+
175
+ ## Retry e DLQ
176
+
177
+ A política padrão (`RetryPolicy()`) reprocessa uma falha transitória após **30 s, 5 min, 1 h e 12 h**; a quinta falha
178
+ vai para a DLQ. Cada atraso tem um tópico de retry por consumer group (`{tópico}.{grupo}.retry.30s` ... `.retry.12h`)
179
+ e a DLQ é `{tópico}.{grupo}.dlq`. A mensagem de retry é relida só pelo próprio grupo e nunca volta ao tópico
180
+ principal; enquanto espera, a partição de retry fica pausada sem bloquear as demais.
181
+
182
+ - `PermanentProcessingError` (ou subclasse), payload inválido e falha de schema vão direto para a DLQ.
183
+ - Qualquer outra exceção, inclusive timeout de processamento, segue a política de retry.
184
+ - Política própria: `RetryPolicy(delays_s=(10, 60, 300))`; política vazia desativa o retry.
185
+ - A DLQ nunca marca a deduplicação, então `replay_dlq(...)` pode reprocessar as mensagens.
186
+
187
+ ## Prefixo de ambiente
188
+
189
+ Para homologação e produção no mesmo cluster, `resource_prefix` (kebab-case, como `hmg` ou `prd`) vira o primeiro
190
+ segmento do tópico principal e do consumer group, e por consequência de retry, DLQ e replay:
191
+
192
+ ```python
193
+ Messaging(kafka, service="catalogo-api", environment="homologacao", schema_loader=loader, resource_prefix="hmg")
194
+ # tópico: hmg.usuarios.events.v1 grupo: hmg.catalogo-api
195
+ # retry: hmg.usuarios.events.v1.catalogo-api.retry.30s DLQ: hmg.usuarios.events.v1.catalogo-api.dlq
196
+ ```
197
+
198
+ Prefixar também o grupo impede que réplicas dos dois ambientes dividam as partições do mesmo `group.id`. Sem prefixo,
199
+ os nomes não mudam.
200
+
201
+ ## Deduplicação
202
+
203
+ `InMemoryDuplicateChecker` serve a um processo só. Para várias réplicas, use o store Redis (extra `[redis]`), com um
204
+ `namespace` por sistema e ambiente:
205
+
206
+ ```python
207
+ from sdk_messaging.integrations.redis import RedisDedupStore
208
+
209
+ store = RedisDedupStore.from_url("redis://redis:6379/0", namespace="hmgcatalogo_api")
210
+ consumer = messaging.consumer(
211
+ domain="enrollment",
212
+ version="v1",
213
+ handlers={"student.enrolled": on_enrolled},
214
+ duplicate_checker=store.checker("learning-worker"),
215
+ )
216
+ ```
217
+
218
+ A chave é marcada só depois do sucesso do handler; retry e DLQ não marcam.
219
+
220
+ ## Contratos embutidos
221
+
222
+ Os schemas e tipos (`TypedDict`) dos eventos corporativos vêm no pacote, na mesma versão da SDK:
223
+
224
+ ```python
225
+ from sdk_messaging.contracts import schema_loader
226
+ from sdk_messaging.contracts.usuarios import UsuarioDesativado
227
+
228
+ loader = schema_loader() # InMemorySchemaLoader com todos os schemas embutidos
229
+ ```
230
+
231
+ Para combinar com schemas próprios do serviço:
232
+ `ChainSchemaLoader(FileSystemSchemaLoader(Path("schemas")), schema_loader())`.
233
+
234
+ ## Configuração
235
+
236
+ `KafkaConfig.from_env()` lê `KAFKA_BOOTSTRAP_SERVERS` (obrigatória), `KAFKA_CLIENT_ID`, `KAFKA_SECURITY_PROTOCOL`
237
+ (`PLAINTEXT`, `SSL`, `SASL_PLAINTEXT`, `SASL_SSL`), `KAFKA_SASL_MECHANISM`, `KAFKA_SASL_USERNAME` e
238
+ `KAFKA_SASL_PASSWORD`. Django, Flask e serviços sem framework também podem montar tudo a partir de um mapa
239
+ `SDK_MESSAGING` (`SERVICE`, `ENVIRONMENT`, `KAFKA`, `REDIS`, `RESOURCE_PREFIX`...) com
240
+ `sdk_messaging.integrations.runtime.MessagingSettings.from_mapping`.
241
+
242
+ ## Licença
243
+
244
+ MIT.
@@ -0,0 +1,205 @@
1
+ # youeduc-sdk-messaging
2
+
3
+ SDK Python de mensageria assíncrona orientada a eventos sobre **Apache Kafka**, construída sobre `aiokafka` e
4
+ `asyncio`. Publica eventos validados por JSON Schema e os consome com retry por tópicos, DLQ, deduplicação, commit
5
+ manual de offsets e observabilidade (OpenTelemetry + logs estruturados com `structlog`).
6
+
7
+ - Distribuição: `youeduc-sdk-messaging`; import: `sdk_messaging`.
8
+ - Tipada (`py.typed`), Python >= 3.10, Kafka >= 3.x.
9
+ - Existe uma SDK Node equivalente que segue o mesmo protocolo (tópicos, headers, retry/DLQ e telemetria): um serviço
10
+ Python publica e um serviço Node consome, e o contrário.
11
+
12
+ ## Instalação
13
+
14
+ Fixe sempre uma versão exata:
15
+
16
+ ```bash
17
+ pip install "youeduc-sdk-messaging==X.Y.Z"
18
+ ```
19
+
20
+ Com a deduplicação compartilhada em Redis (`sdk_messaging.integrations.redis`):
21
+
22
+ ```bash
23
+ pip install "youeduc-sdk-messaging[redis]==X.Y.Z"
24
+ ```
25
+
26
+ Conferir a versão instalada:
27
+
28
+ ```bash
29
+ python -c "import sdk_messaging; print(sdk_messaging.__version__)"
30
+ ```
31
+
32
+ Dependências de runtime: `aiokafka`, `jsonschema`, `referencing`, `structlog` e `opentelemetry-api`. O
33
+ `opentelemetry-sdk` e os exporters ficam a cargo da aplicação. Django, Flask, FastAPI e Celery **não** são
34
+ dependências: os adaptadores `sdk_messaging.integrations.django` e `sdk_messaging.integrations.flask` só importam o
35
+ framework quando o próprio serviço os importa.
36
+
37
+ ## Publicar
38
+
39
+ ```python
40
+ import asyncio
41
+ from pathlib import Path
42
+
43
+ from sdk_messaging import FileSystemSchemaLoader, KafkaConfig, Messaging
44
+
45
+
46
+ async def main() -> None:
47
+ async with Messaging(
48
+ KafkaConfig.from_env(), # KAFKA_BOOTSTRAP_SERVERS etc.
49
+ service="enrollment-api", # kebab-case; vai no header `producer`
50
+ environment="production",
51
+ schema_loader=FileSystemSchemaLoader(Path("schemas")),
52
+ ) as messaging:
53
+ result = await messaging.publish(
54
+ "enrollment", "student", "enrolled", "v1", # domain, aggregate, event, version
55
+ {"student_id": "s-123", "course_id": "c-9"},
56
+ key="s-123", # define a partição (ordem por chave)
57
+ idempotency_key="enrollment-987", # opcional; chave de dedup preferida
58
+ )
59
+ print(result.message_id, result.topic, result.partition, result.offset)
60
+
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ O evento vai para o tópico `enrollment.events.v1` com `event_type = "student.enrolled"` e é validado contra
66
+ `schemas/enrollment/student-enrolled/v1.json` (JSON Schema Draft 2020-12) antes de sair. `publish` aguarda o ack do
67
+ broker; payload inválido levanta `SchemaValidationError` sem enviar nada, e erro do Kafka levanta `PublishError`.
68
+
69
+ ## Consumir
70
+
71
+ ```python
72
+ import asyncio
73
+ import signal
74
+ from pathlib import Path
75
+
76
+ from sdk_messaging import (
77
+ ConsumedMessage,
78
+ FileSystemSchemaLoader,
79
+ KafkaConfig,
80
+ Messaging,
81
+ PermanentProcessingError,
82
+ )
83
+
84
+
85
+ async def on_enrolled(message: ConsumedMessage) -> None:
86
+ if "course_id" not in message.payload:
87
+ raise PermanentProcessingError("course_id ausente") # vai direto para a DLQ
88
+ ... # retornar = sucesso; qualquer outra exceção = retry
89
+
90
+
91
+ async def main() -> None:
92
+ stop = asyncio.Event()
93
+ loop = asyncio.get_running_loop()
94
+ for sig in (signal.SIGINT, signal.SIGTERM):
95
+ loop.add_signal_handler(sig, stop.set)
96
+
97
+ async with Messaging(
98
+ KafkaConfig.from_env(),
99
+ service="learning-worker", # também é o consumer group padrão
100
+ environment="production",
101
+ schema_loader=FileSystemSchemaLoader(Path("schemas")),
102
+ ) as messaging:
103
+ consumer = messaging.consumer(
104
+ domain="enrollment",
105
+ version="v1",
106
+ handlers={"student.enrolled": on_enrolled}, # event_type -> handler async
107
+ processing_timeout_s=60,
108
+ )
109
+ await consumer.start()
110
+ consumer_done = asyncio.ensure_future(consumer.wait())
111
+ signal_received = asyncio.ensure_future(stop.wait())
112
+ await asyncio.wait({consumer_done, signal_received}, return_when=asyncio.FIRST_COMPLETED)
113
+ signal_received.cancel()
114
+ # Ao sair do bloco, os consumers terminam o registro em voo, commitam e o producer fecha.
115
+ await consumer_done # relança o erro fatal que derrubou o consumer, se houve
116
+
117
+
118
+ asyncio.run(main())
119
+ ```
120
+
121
+ Eventos do domínio sem handler são ignorados e o offset avança. Os tópicos (principal, retry e DLQ do grupo) precisam
122
+ existir antes do consumer iniciar: crie-os por IaC ou, em desenvolvimento, com `messaging.ensure_topics(...)`.
123
+
124
+ ## Garantias
125
+
126
+ | Garantia | Como |
127
+ |---|---|
128
+ | **At-least-once** no consumo | O offset só é commitado depois do desfecho final (sucesso, DLQ, ignorada) ou do envio **confirmado** para retry/DLQ. Um handler pode rodar mais de uma vez para a mesma mensagem: **handlers devem ser idempotentes** ou usar um `DuplicateChecker`. |
129
+ | **Ordem por chave** | Mensagens com a mesma `key` vão para a mesma partição e são processadas em sequência. Uma mensagem que vai para retry sai da ordem. |
130
+ | **Producer idempotente** | Um producer por processo, `acks=all` e idempotência ligada, compartilhado por publish, retry e DLQ. |
131
+ | **Schema obrigatório** | Todo payload é validado no publish e no consumo. |
132
+ | **Sem PII em logs** | Logs, spans, métricas e erros de schema nunca incluem valores do payload nem a `key`. |
133
+
134
+ Não há exactly-once nem transações Kafka.
135
+
136
+ ## Retry e DLQ
137
+
138
+ A política padrão (`RetryPolicy()`) reprocessa uma falha transitória após **30 s, 5 min, 1 h e 12 h**; a quinta falha
139
+ vai para a DLQ. Cada atraso tem um tópico de retry por consumer group (`{tópico}.{grupo}.retry.30s` ... `.retry.12h`)
140
+ e a DLQ é `{tópico}.{grupo}.dlq`. A mensagem de retry é relida só pelo próprio grupo e nunca volta ao tópico
141
+ principal; enquanto espera, a partição de retry fica pausada sem bloquear as demais.
142
+
143
+ - `PermanentProcessingError` (ou subclasse), payload inválido e falha de schema vão direto para a DLQ.
144
+ - Qualquer outra exceção, inclusive timeout de processamento, segue a política de retry.
145
+ - Política própria: `RetryPolicy(delays_s=(10, 60, 300))`; política vazia desativa o retry.
146
+ - A DLQ nunca marca a deduplicação, então `replay_dlq(...)` pode reprocessar as mensagens.
147
+
148
+ ## Prefixo de ambiente
149
+
150
+ Para homologação e produção no mesmo cluster, `resource_prefix` (kebab-case, como `hmg` ou `prd`) vira o primeiro
151
+ segmento do tópico principal e do consumer group, e por consequência de retry, DLQ e replay:
152
+
153
+ ```python
154
+ Messaging(kafka, service="catalogo-api", environment="homologacao", schema_loader=loader, resource_prefix="hmg")
155
+ # tópico: hmg.usuarios.events.v1 grupo: hmg.catalogo-api
156
+ # retry: hmg.usuarios.events.v1.catalogo-api.retry.30s DLQ: hmg.usuarios.events.v1.catalogo-api.dlq
157
+ ```
158
+
159
+ Prefixar também o grupo impede que réplicas dos dois ambientes dividam as partições do mesmo `group.id`. Sem prefixo,
160
+ os nomes não mudam.
161
+
162
+ ## Deduplicação
163
+
164
+ `InMemoryDuplicateChecker` serve a um processo só. Para várias réplicas, use o store Redis (extra `[redis]`), com um
165
+ `namespace` por sistema e ambiente:
166
+
167
+ ```python
168
+ from sdk_messaging.integrations.redis import RedisDedupStore
169
+
170
+ store = RedisDedupStore.from_url("redis://redis:6379/0", namespace="hmgcatalogo_api")
171
+ consumer = messaging.consumer(
172
+ domain="enrollment",
173
+ version="v1",
174
+ handlers={"student.enrolled": on_enrolled},
175
+ duplicate_checker=store.checker("learning-worker"),
176
+ )
177
+ ```
178
+
179
+ A chave é marcada só depois do sucesso do handler; retry e DLQ não marcam.
180
+
181
+ ## Contratos embutidos
182
+
183
+ Os schemas e tipos (`TypedDict`) dos eventos corporativos vêm no pacote, na mesma versão da SDK:
184
+
185
+ ```python
186
+ from sdk_messaging.contracts import schema_loader
187
+ from sdk_messaging.contracts.usuarios import UsuarioDesativado
188
+
189
+ loader = schema_loader() # InMemorySchemaLoader com todos os schemas embutidos
190
+ ```
191
+
192
+ Para combinar com schemas próprios do serviço:
193
+ `ChainSchemaLoader(FileSystemSchemaLoader(Path("schemas")), schema_loader())`.
194
+
195
+ ## Configuração
196
+
197
+ `KafkaConfig.from_env()` lê `KAFKA_BOOTSTRAP_SERVERS` (obrigatória), `KAFKA_CLIENT_ID`, `KAFKA_SECURITY_PROTOCOL`
198
+ (`PLAINTEXT`, `SSL`, `SASL_PLAINTEXT`, `SASL_SSL`), `KAFKA_SASL_MECHANISM`, `KAFKA_SASL_USERNAME` e
199
+ `KAFKA_SASL_PASSWORD`. Django, Flask e serviços sem framework também podem montar tudo a partir de um mapa
200
+ `SDK_MESSAGING` (`SERVICE`, `ENVIRONMENT`, `KAFKA`, `REDIS`, `RESOURCE_PREFIX`...) com
201
+ `sdk_messaging.integrations.runtime.MessagingSettings.from_mapping`.
202
+
203
+ ## Licença
204
+
205
+ MIT.
@@ -0,0 +1,96 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27", "hatch-vcs>=0.5.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "youeduc-sdk-messaging"
7
+ dynamic = ["version"]
8
+ description = "SDK de mensageria assíncrona orientada a eventos sobre Apache Kafka (aiokafka)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ # PEP 639: expressão SPDX + arquivo; sem classifier "License ::" (redundante e rejeitado junto da expressão).
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ authors = [{ name = "YouEduc" }]
15
+ keywords = ["kafka", "aiokafka", "messaging", "events", "asyncio", "json-schema", "retry", "dlq"]
16
+ classifiers = [
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Framework :: AsyncIO",
27
+ "Topic :: System :: Distributed Computing",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = [
32
+ "aiokafka>=0.14.0,<0.15",
33
+ "jsonschema>=4.23.0",
34
+ "referencing>=0.28.4",
35
+ "structlog>=24.4.0",
36
+ "opentelemetry-api>=1.27.0",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ redis = ["redis>=5.0.1"]
41
+ dev = [
42
+ "redis==8.1.0",
43
+ "mypy==2.3.1",
44
+ "ruff==0.16.8",
45
+ "pytest==9.1.1",
46
+ "pytest-asyncio==1.4.0",
47
+ "opentelemetry-sdk==1.44.0",
48
+ "types-jsonschema==4.26.0.20260518",
49
+ ]
50
+
51
+ # Versão = tag git (vX.Y.Z, criada pelo auto-tag); sem git/tag, fallback 0.0.0.
52
+ [tool.hatch.version]
53
+ source = "vcs"
54
+ # O repositório git (e as tags vX.Y.Z, únicas para Python e Node) fica um nível acima.
55
+ raw-options = { root = "..", fallback_version = "0.0.0" }
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["src/sdk_messaging"]
59
+
60
+ # O sdist vai para o PyPI público: sem as instruções de agentes de IA do repositório.
61
+ [tool.hatch.build.targets.sdist]
62
+ exclude = ["CLAUDE.md"]
63
+
64
+ [tool.mypy]
65
+ strict = true
66
+ python_version = "3.10"
67
+ mypy_path = "src"
68
+ files = ["src", "tests"]
69
+
70
+ [[tool.mypy.overrides]]
71
+ module = ["aiokafka", "aiokafka.*"]
72
+ ignore_missing_imports = true
73
+
74
+ [[tool.mypy.overrides]]
75
+ module = ["tests.*"]
76
+ disallow_untyped_defs = false
77
+ disallow_incomplete_defs = false
78
+
79
+ [tool.ruff]
80
+ target-version = "py310"
81
+ src = ["src", "tests"]
82
+ extend-exclude = ["*.md"]
83
+
84
+ [tool.ruff.lint]
85
+ select = ["E", "F", "I", "UP", "B", "SIM", "TC", "ANN", "ASYNC", "RUF"]
86
+ ignore = ["E501"]
87
+
88
+ [tool.ruff.lint.per-file-ignores]
89
+ "tests/**" = ["ANN"]
90
+
91
+ [tool.pytest.ini_options]
92
+ asyncio_mode = "auto"
93
+ testpaths = ["tests"]
94
+ pythonpath = ["src"]
95
+ markers = ["integration: requer um broker Kafka real (KAFKA_BOOTSTRAP_SERVERS)"]
96
+ addopts = "-m 'not integration'"
@@ -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
+ ]