fastkafka2 0.1.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.
- fastkafka2-0.1.0/PKG-INFO +133 -0
- fastkafka2-0.1.0/README.md +121 -0
- fastkafka2-0.1.0/fastkafka/__init__.py +19 -0
- fastkafka2-0.1.0/fastkafka/admin.py +47 -0
- fastkafka2-0.1.0/fastkafka/app.py +79 -0
- fastkafka2-0.1.0/fastkafka/consumer.py +87 -0
- fastkafka2-0.1.0/fastkafka/dependencies.py +36 -0
- fastkafka2-0.1.0/fastkafka/di/__init__.py +1 -0
- fastkafka2-0.1.0/fastkafka/di/di_container.py +43 -0
- fastkafka2-0.1.0/fastkafka/handler.py +19 -0
- fastkafka2-0.1.0/fastkafka/logging_utils.py +19 -0
- fastkafka2-0.1.0/fastkafka/message.py +11 -0
- fastkafka2-0.1.0/fastkafka/producer.py +57 -0
- fastkafka2-0.1.0/fastkafka/py.typed +0 -0
- fastkafka2-0.1.0/fastkafka/registry.py +61 -0
- fastkafka2-0.1.0/fastkafka/services/__init__.py +1 -0
- fastkafka2-0.1.0/fastkafka/services/base.py +12 -0
- fastkafka2-0.1.0/fastkafka/services/retry.py +27 -0
- fastkafka2-0.1.0/fastkafka2.egg-info/PKG-INFO +133 -0
- fastkafka2-0.1.0/fastkafka2.egg-info/SOURCES.txt +23 -0
- fastkafka2-0.1.0/fastkafka2.egg-info/dependency_links.txt +1 -0
- fastkafka2-0.1.0/fastkafka2.egg-info/requires.txt +4 -0
- fastkafka2-0.1.0/fastkafka2.egg-info/top_level.txt +1 -0
- fastkafka2-0.1.0/pyproject.toml +23 -0
- fastkafka2-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastkafka2
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Next-generation FastAPI-like DX for Kafka (version 2)
|
|
5
|
+
Author: Ruslan Ramazanov
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pydantic>=2.6
|
|
9
|
+
Requires-Dist: anyio>=4
|
|
10
|
+
Requires-Dist: aiokafka>=0.10
|
|
11
|
+
Requires-Dist: typing-extensions>=4.8
|
|
12
|
+
|
|
13
|
+
# fastkafka2
|
|
14
|
+
|
|
15
|
+
Next-generation FastAPI-like DX for Kafka (version 2).
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
``` bash
|
|
20
|
+
pip install fastkafka2
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Use example
|
|
24
|
+
### Project arch
|
|
25
|
+
```shell
|
|
26
|
+
├── api/
|
|
27
|
+
│ ├── kafka/
|
|
28
|
+
│ │ ├── handlers/
|
|
29
|
+
│ │ │ ├── example/
|
|
30
|
+
│ │ │ │ ├─ schemas.py
|
|
31
|
+
│ │ │ │ └─ handler.py
|
|
32
|
+
│ │ │ └── base_handler.py
|
|
33
|
+
│ │ └── lifespan.py
|
|
34
|
+
│
|
|
35
|
+
├── main.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Schemas
|
|
39
|
+
``` python
|
|
40
|
+
# api\kafka\handlers\example\schemas.py
|
|
41
|
+
from pydantic import BaseModel
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ExampleSchema(BaseModel):
|
|
45
|
+
msg: str
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Handler
|
|
49
|
+
``` python
|
|
50
|
+
# api\kafka\handlers\example\handler.py
|
|
51
|
+
import logging
|
|
52
|
+
|
|
53
|
+
from fastkafka.handler import KafkaHandler
|
|
54
|
+
from fastkafka.message import KafkaMessage
|
|
55
|
+
from fastkafka.producer import KafkaProducer
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
handler = KafkaHandler()
|
|
59
|
+
|
|
60
|
+
kafka_producer = KafkaProducer(bootstrap_servers="127.0.0.1:9092")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@handler("example")
|
|
64
|
+
async def example_handler(message: KafkaMessage):
|
|
65
|
+
t = int(message.headers.get("try")) + 1
|
|
66
|
+
logging.info(f"Пришло: {message}")
|
|
67
|
+
await kafka_producer.send_message(
|
|
68
|
+
topic="example-2", data={"msg": "wddwd"}, headers={"try": f"{t}"}, key=None
|
|
69
|
+
)
|
|
70
|
+
logging.info(f"Отправил: {f'{t}'}")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
### Grouping of handlers
|
|
75
|
+
``` python
|
|
76
|
+
# api\kafka\handlers\base_handler.py
|
|
77
|
+
from api.kafka.handlers.example.handler import handler as example_handler
|
|
78
|
+
|
|
79
|
+
from fastkafka.handler import KafkaHandler
|
|
80
|
+
|
|
81
|
+
base_handler = KafkaHandler()
|
|
82
|
+
|
|
83
|
+
base_handler.include_handler(example_handler)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
### Lifespan fastkafka app
|
|
88
|
+
``` python
|
|
89
|
+
# api/kafka/lifespan.py
|
|
90
|
+
import logging
|
|
91
|
+
from contextlib import asynccontextmanager
|
|
92
|
+
from fastkafka.app import KafkaApp
|
|
93
|
+
from api.kafka.handlers.base_handler import base_handler
|
|
94
|
+
|
|
95
|
+
from api.kafka.handlers.example.handler import kafka_producer
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@asynccontextmanager
|
|
99
|
+
async def lifespan(app: KafkaApp):
|
|
100
|
+
logging.info("Lifespan: запуск")
|
|
101
|
+
try:
|
|
102
|
+
await kafka_producer.start()
|
|
103
|
+
yield
|
|
104
|
+
logging.info("Lifespan: выполнен")
|
|
105
|
+
finally:
|
|
106
|
+
await kafka_producer.stop()
|
|
107
|
+
logging.info("Lifespan: остановка")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
app = KafkaApp(
|
|
111
|
+
title="Kafka Gateway",
|
|
112
|
+
description="Kafka-based microservice",
|
|
113
|
+
bootstrap_servers="127.0.0.1:9092",
|
|
114
|
+
lifespan=lifespan,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
app.include_handler(base_handler)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
### Entry point main app
|
|
122
|
+
``` python
|
|
123
|
+
# main.py
|
|
124
|
+
import asyncio
|
|
125
|
+
from logging_config import setup_logging
|
|
126
|
+
from api.kafka.lifespan import app
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
setup_logging()
|
|
130
|
+
asyncio.run(app.run())
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# fastkafka2
|
|
2
|
+
|
|
3
|
+
Next-generation FastAPI-like DX for Kafka (version 2).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
``` bash
|
|
8
|
+
pip install fastkafka2
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use example
|
|
12
|
+
### Project arch
|
|
13
|
+
```shell
|
|
14
|
+
├── api/
|
|
15
|
+
│ ├── kafka/
|
|
16
|
+
│ │ ├── handlers/
|
|
17
|
+
│ │ │ ├── example/
|
|
18
|
+
│ │ │ │ ├─ schemas.py
|
|
19
|
+
│ │ │ │ └─ handler.py
|
|
20
|
+
│ │ │ └── base_handler.py
|
|
21
|
+
│ │ └── lifespan.py
|
|
22
|
+
│
|
|
23
|
+
├── main.py
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Schemas
|
|
27
|
+
``` python
|
|
28
|
+
# api\kafka\handlers\example\schemas.py
|
|
29
|
+
from pydantic import BaseModel
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExampleSchema(BaseModel):
|
|
33
|
+
msg: str
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Handler
|
|
37
|
+
``` python
|
|
38
|
+
# api\kafka\handlers\example\handler.py
|
|
39
|
+
import logging
|
|
40
|
+
|
|
41
|
+
from fastkafka.handler import KafkaHandler
|
|
42
|
+
from fastkafka.message import KafkaMessage
|
|
43
|
+
from fastkafka.producer import KafkaProducer
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
handler = KafkaHandler()
|
|
47
|
+
|
|
48
|
+
kafka_producer = KafkaProducer(bootstrap_servers="127.0.0.1:9092")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@handler("example")
|
|
52
|
+
async def example_handler(message: KafkaMessage):
|
|
53
|
+
t = int(message.headers.get("try")) + 1
|
|
54
|
+
logging.info(f"Пришло: {message}")
|
|
55
|
+
await kafka_producer.send_message(
|
|
56
|
+
topic="example-2", data={"msg": "wddwd"}, headers={"try": f"{t}"}, key=None
|
|
57
|
+
)
|
|
58
|
+
logging.info(f"Отправил: {f'{t}'}")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
### Grouping of handlers
|
|
63
|
+
``` python
|
|
64
|
+
# api\kafka\handlers\base_handler.py
|
|
65
|
+
from api.kafka.handlers.example.handler import handler as example_handler
|
|
66
|
+
|
|
67
|
+
from fastkafka.handler import KafkaHandler
|
|
68
|
+
|
|
69
|
+
base_handler = KafkaHandler()
|
|
70
|
+
|
|
71
|
+
base_handler.include_handler(example_handler)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
### Lifespan fastkafka app
|
|
76
|
+
``` python
|
|
77
|
+
# api/kafka/lifespan.py
|
|
78
|
+
import logging
|
|
79
|
+
from contextlib import asynccontextmanager
|
|
80
|
+
from fastkafka.app import KafkaApp
|
|
81
|
+
from api.kafka.handlers.base_handler import base_handler
|
|
82
|
+
|
|
83
|
+
from api.kafka.handlers.example.handler import kafka_producer
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def lifespan(app: KafkaApp):
|
|
88
|
+
logging.info("Lifespan: запуск")
|
|
89
|
+
try:
|
|
90
|
+
await kafka_producer.start()
|
|
91
|
+
yield
|
|
92
|
+
logging.info("Lifespan: выполнен")
|
|
93
|
+
finally:
|
|
94
|
+
await kafka_producer.stop()
|
|
95
|
+
logging.info("Lifespan: остановка")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
app = KafkaApp(
|
|
99
|
+
title="Kafka Gateway",
|
|
100
|
+
description="Kafka-based microservice",
|
|
101
|
+
bootstrap_servers="127.0.0.1:9092",
|
|
102
|
+
lifespan=lifespan,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
app.include_handler(base_handler)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
### Entry point main app
|
|
110
|
+
``` python
|
|
111
|
+
# main.py
|
|
112
|
+
import asyncio
|
|
113
|
+
from logging_config import setup_logging
|
|
114
|
+
from api.kafka.lifespan import app
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
setup_logging()
|
|
118
|
+
asyncio.run(app.run())
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .logging_utils import suppress_external_logs
|
|
4
|
+
from .app import KafkaApp
|
|
5
|
+
from .handler import KafkaHandler
|
|
6
|
+
from .message import KafkaMessage
|
|
7
|
+
from .producer import KafkaProducer
|
|
8
|
+
|
|
9
|
+
suppress_external_logs()
|
|
10
|
+
|
|
11
|
+
__all__ = ["KafkaApp", "KafkaHandler", "KafkaMessage", "KafkaProducer"]
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str):
|
|
14
|
+
if name in __all__:
|
|
15
|
+
return globals()[name]
|
|
16
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name}")
|
|
17
|
+
|
|
18
|
+
def __dir__():
|
|
19
|
+
return __all__ + [n for n in globals() if n.startswith("_")]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# fastkafka\admin.py
|
|
2
|
+
import logging
|
|
3
|
+
from aiokafka.admin import AIOKafkaAdminClient, NewTopic
|
|
4
|
+
from aiokafka.errors import TopicAlreadyExistsError
|
|
5
|
+
from .services.base import BaseKafkaService
|
|
6
|
+
from .services.retry import retry_on_connection
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KafkaAdminService(BaseKafkaService):
|
|
12
|
+
def __init__(self, bootstrap_servers: str = "localhost:9092") -> None:
|
|
13
|
+
self._bootstrap = bootstrap_servers
|
|
14
|
+
self._client: AIOKafkaAdminClient | None = None
|
|
15
|
+
|
|
16
|
+
async def start(self) -> None:
|
|
17
|
+
await self._start()
|
|
18
|
+
|
|
19
|
+
async def stop(self) -> None:
|
|
20
|
+
await self._stop()
|
|
21
|
+
|
|
22
|
+
@retry_on_connection()
|
|
23
|
+
async def _start(self) -> None:
|
|
24
|
+
self._client = AIOKafkaAdminClient(bootstrap_servers=self._bootstrap)
|
|
25
|
+
await self._client.start()
|
|
26
|
+
logger.info("KafkaAdminService started")
|
|
27
|
+
|
|
28
|
+
async def create_topic(self, topic: str) -> None:
|
|
29
|
+
if not self._client:
|
|
30
|
+
raise RuntimeError("Admin client not initialized")
|
|
31
|
+
try:
|
|
32
|
+
await self._client.create_topics(
|
|
33
|
+
new_topics=[
|
|
34
|
+
NewTopic(name=topic, num_partitions=1, replication_factor=1)
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
logger.info("Topic created: %s", topic)
|
|
38
|
+
except TopicAlreadyExistsError:
|
|
39
|
+
logger.debug("Topic already exists: %s", topic)
|
|
40
|
+
except Exception:
|
|
41
|
+
logger.exception("Error creating topic %s", topic)
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
async def _stop(self) -> None:
|
|
45
|
+
if self._client:
|
|
46
|
+
await self._client.close()
|
|
47
|
+
logger.info("KafkaAdminService stopped")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# fastkafka\app.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import signal
|
|
5
|
+
from typing import Callable
|
|
6
|
+
from contextlib import AbstractAsyncContextManager
|
|
7
|
+
|
|
8
|
+
from .producer import KafkaProducer
|
|
9
|
+
from .admin import KafkaAdminService
|
|
10
|
+
from .consumer import KafkaConsumerService
|
|
11
|
+
from .registry import handlers_registry
|
|
12
|
+
from .handler import KafkaHandler
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KafkaApp:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
title: str,
|
|
21
|
+
description: str,
|
|
22
|
+
bootstrap_servers: str = "localhost:9092",
|
|
23
|
+
lifespan: Callable[["KafkaApp"], AbstractAsyncContextManager] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.title = title
|
|
26
|
+
self.description = description
|
|
27
|
+
self.bootstrap = bootstrap_servers
|
|
28
|
+
self._lifespan = lifespan(self) if lifespan else None
|
|
29
|
+
|
|
30
|
+
self._producer = KafkaProducer(self.bootstrap)
|
|
31
|
+
self._admin = KafkaAdminService(self.bootstrap)
|
|
32
|
+
self._consumer: KafkaConsumerService | None = None
|
|
33
|
+
self._groups: list[KafkaHandler] = []
|
|
34
|
+
|
|
35
|
+
def include_handler(self, handler: KafkaHandler) -> None:
|
|
36
|
+
self._groups.append(handler)
|
|
37
|
+
logger.debug("Included handler group %s", handler.prefix)
|
|
38
|
+
|
|
39
|
+
async def start(self) -> None:
|
|
40
|
+
logger.info("Starting %s", self.title)
|
|
41
|
+
try:
|
|
42
|
+
if self._lifespan:
|
|
43
|
+
await self._lifespan.__aenter__()
|
|
44
|
+
await self._admin.start()
|
|
45
|
+
for topic in handlers_registry:
|
|
46
|
+
await self._admin.create_topic(topic)
|
|
47
|
+
self._consumer = KafkaConsumerService(
|
|
48
|
+
topics=list(handlers_registry), bootstrap_servers=self.bootstrap
|
|
49
|
+
)
|
|
50
|
+
await self._consumer.start()
|
|
51
|
+
logger.info("%s started", self.title)
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.exception("KafkaApp.start failed")
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
async def stop(self) -> None:
|
|
57
|
+
logger.info("Stopping %s", self.title)
|
|
58
|
+
try:
|
|
59
|
+
if self._consumer:
|
|
60
|
+
await self._consumer.stop()
|
|
61
|
+
await self._admin.stop()
|
|
62
|
+
if self._lifespan:
|
|
63
|
+
await self._lifespan.__aexit__(None, None, None)
|
|
64
|
+
logger.info("%s stopped", self.title)
|
|
65
|
+
except Exception:
|
|
66
|
+
logger.exception("KafkaApp.stop failed")
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
async def run(self) -> None:
|
|
70
|
+
loop = asyncio.get_running_loop()
|
|
71
|
+
shutdown_event = asyncio.Event()
|
|
72
|
+
try:
|
|
73
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
74
|
+
loop.add_signal_handler(sig, shutdown_event.set)
|
|
75
|
+
except (NotImplementedError, AttributeError):
|
|
76
|
+
signal.signal(signal.SIGINT, lambda *_: shutdown_event.set())
|
|
77
|
+
await self.start()
|
|
78
|
+
await shutdown_event.wait()
|
|
79
|
+
await self.stop()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# fastkafka\consumer.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import orjson
|
|
4
|
+
import logging
|
|
5
|
+
from aiokafka import AIOKafkaConsumer
|
|
6
|
+
from .services.base import BaseKafkaService
|
|
7
|
+
from .registry import handlers_registry
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KafkaConsumerService(BaseKafkaService):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
topics: list[str],
|
|
16
|
+
bootstrap_servers: str,
|
|
17
|
+
worker_count: int = 4,
|
|
18
|
+
max_concurrency: int = 100,
|
|
19
|
+
):
|
|
20
|
+
self._topics = topics
|
|
21
|
+
self._bootstrap = bootstrap_servers
|
|
22
|
+
self._worker_count = worker_count
|
|
23
|
+
self._consumer: AIOKafkaConsumer | None = None
|
|
24
|
+
self._queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
|
25
|
+
self._workers: list[asyncio.Task] = []
|
|
26
|
+
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
27
|
+
self._metrics_task: asyncio.Task | None = None
|
|
28
|
+
|
|
29
|
+
async def start(self) -> None:
|
|
30
|
+
self._consumer = AIOKafkaConsumer(
|
|
31
|
+
*self._topics,
|
|
32
|
+
bootstrap_servers=self._bootstrap,
|
|
33
|
+
group_id="fastkafka_group",
|
|
34
|
+
auto_offset_reset="latest",
|
|
35
|
+
value_deserializer=lambda v: orjson.loads(v),
|
|
36
|
+
)
|
|
37
|
+
await self._consumer.start()
|
|
38
|
+
logger.info("KafkaConsumerService started on topics %s", self._topics)
|
|
39
|
+
|
|
40
|
+
asyncio.create_task(self._consume_loop())
|
|
41
|
+
self._workers = [
|
|
42
|
+
asyncio.create_task(self._worker_loop()) for _ in range(self._worker_count)
|
|
43
|
+
]
|
|
44
|
+
self._metrics_task = asyncio.create_task(self._metrics_loop())
|
|
45
|
+
|
|
46
|
+
async def stop(self) -> None:
|
|
47
|
+
if self._consumer:
|
|
48
|
+
await self._consumer.stop()
|
|
49
|
+
logger.info("Consumer stopped")
|
|
50
|
+
for w in self._workers:
|
|
51
|
+
w.cancel()
|
|
52
|
+
await asyncio.gather(*self._workers, return_exceptions=True)
|
|
53
|
+
if self._metrics_task:
|
|
54
|
+
self._metrics_task.cancel()
|
|
55
|
+
await asyncio.gather(self._metrics_task, return_exceptions=True)
|
|
56
|
+
|
|
57
|
+
async def _consume_loop(self) -> None:
|
|
58
|
+
try:
|
|
59
|
+
async for msg in self._consumer:
|
|
60
|
+
headers = {k: (v.decode() if v else "") for k, v in (msg.headers or [])}
|
|
61
|
+
key = msg.key.decode() if msg.key else None
|
|
62
|
+
await self._queue.put((msg.topic, msg.value, headers, key))
|
|
63
|
+
except Exception:
|
|
64
|
+
logger.exception("Error in consume loop")
|
|
65
|
+
|
|
66
|
+
async def _worker_loop(self) -> None:
|
|
67
|
+
while True:
|
|
68
|
+
topic, data, headers, key = await self._queue.get()
|
|
69
|
+
for handler in handlers_registry.get(topic, []):
|
|
70
|
+
await self._semaphore.acquire()
|
|
71
|
+
asyncio.create_task(self._safe_invoke(handler, data, headers, key))
|
|
72
|
+
self._queue.task_done()
|
|
73
|
+
|
|
74
|
+
async def _safe_invoke(self, handler, data, headers, key):
|
|
75
|
+
try:
|
|
76
|
+
await handler.handle(data, headers, key)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning("Handler error on topic %s: %s", handler.topic, e)
|
|
79
|
+
finally:
|
|
80
|
+
self._semaphore.release()
|
|
81
|
+
|
|
82
|
+
async def _metrics_loop(self):
|
|
83
|
+
while True:
|
|
84
|
+
logger.debug(
|
|
85
|
+
f"[Kafka] Queue size: {self._queue.qsize()} | Workers: {len(self._workers)}"
|
|
86
|
+
)
|
|
87
|
+
await asyncio.sleep(10)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# fastkafka\dependencies.py
|
|
2
|
+
import logging
|
|
3
|
+
from .admin import KafkaAdminService
|
|
4
|
+
from .consumer import KafkaConsumerService
|
|
5
|
+
from .registry import handlers_registry
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
_admin = KafkaAdminService()
|
|
9
|
+
_consumer: KafkaConsumerService | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def start_kafka(bootstrap_servers: str) -> None:
|
|
13
|
+
global _consumer
|
|
14
|
+
try:
|
|
15
|
+
await _admin.start()
|
|
16
|
+
for topic in handlers_registry:
|
|
17
|
+
await _admin.create_topic(topic)
|
|
18
|
+
_consumer = KafkaConsumerService(
|
|
19
|
+
topics=list(handlers_registry), bootstrap_servers=bootstrap_servers
|
|
20
|
+
)
|
|
21
|
+
await _consumer.start()
|
|
22
|
+
logger.info("Dependencies started")
|
|
23
|
+
except Exception:
|
|
24
|
+
logger.exception("Failed to start Kafka dependencies")
|
|
25
|
+
raise
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def stop_kafka() -> None:
|
|
29
|
+
try:
|
|
30
|
+
if _consumer:
|
|
31
|
+
await _consumer.stop()
|
|
32
|
+
await _admin.stop()
|
|
33
|
+
logger.info("Dependencies stopped")
|
|
34
|
+
except Exception:
|
|
35
|
+
logger.exception("Failed to stop Kafka dependencies")
|
|
36
|
+
raise
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# fastkafka\di\__init__.py
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# fastkafka\di\di_container.py
|
|
2
|
+
import logging
|
|
3
|
+
from inspect import isclass, signature
|
|
4
|
+
from typing import Any, Callable, Type
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
_singletons: dict[Type[Any], Any] = {}
|
|
9
|
+
_factories: dict[Type[Any], Callable[..., Any]] = {}
|
|
10
|
+
_resolving_stack: set[Type[Any]] = set()
|
|
11
|
+
_sig_cache: dict[Type[Any], Any] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_singleton(cls: Type[Any], instance: Any) -> None:
|
|
15
|
+
_singletons[cls] = instance
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_factory(cls: Type[Any], factory: Callable[..., Any]) -> None:
|
|
19
|
+
_factories[cls] = factory
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve(cls: Type[Any]) -> Any:
|
|
23
|
+
if cls in _singletons:
|
|
24
|
+
return _singletons[cls]
|
|
25
|
+
if cls in _factories:
|
|
26
|
+
return _factories[cls]()
|
|
27
|
+
if not isclass(cls):
|
|
28
|
+
raise TypeError(f"Cannot resolve non-class type: {cls}")
|
|
29
|
+
if cls in _resolving_stack:
|
|
30
|
+
raise RuntimeError(f"Circular dependency detected: {cls.__name__}")
|
|
31
|
+
|
|
32
|
+
_resolving_stack.add(cls)
|
|
33
|
+
try:
|
|
34
|
+
sig = _sig_cache.get(cls) or signature(cls.__init__)
|
|
35
|
+
_sig_cache[cls] = sig
|
|
36
|
+
kwargs = {
|
|
37
|
+
name: resolve(param.annotation)
|
|
38
|
+
for name, param in sig.parameters.items()
|
|
39
|
+
if name != "self"
|
|
40
|
+
}
|
|
41
|
+
return cls(**kwargs)
|
|
42
|
+
finally:
|
|
43
|
+
_resolving_stack.remove(cls)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# fastkafka\handler.py
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Callable, Any
|
|
4
|
+
from .registry import kafka_handler
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KafkaHandler:
|
|
10
|
+
def __init__(self, prefix: str = "") -> None:
|
|
11
|
+
self.prefix = prefix
|
|
12
|
+
|
|
13
|
+
def __call__(
|
|
14
|
+
self, topic: str, data_model: Any = None
|
|
15
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
16
|
+
return kafka_handler(topic, data_model)
|
|
17
|
+
|
|
18
|
+
def include_handler(self, other: "KafkaHandler") -> None:
|
|
19
|
+
self.prefix += f".{other.prefix}" if other.prefix else ""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# fastkafka\logging_utils.py
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def suppress_external_logs() -> None:
|
|
6
|
+
suppressed = [
|
|
7
|
+
"aiokafka",
|
|
8
|
+
"kafka",
|
|
9
|
+
"asyncio",
|
|
10
|
+
"aiokafka.consumer.fetcher",
|
|
11
|
+
"aiokafka.cluster",
|
|
12
|
+
"aiokafka.producer.producer",
|
|
13
|
+
"aiokafka.consumer.group_coordinator",
|
|
14
|
+
"aiokafka.consumer.subscription_state",
|
|
15
|
+
]
|
|
16
|
+
for name in suppressed:
|
|
17
|
+
lg = logging.getLogger(name)
|
|
18
|
+
lg.setLevel(logging.CRITICAL)
|
|
19
|
+
lg.propagate = False
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# fastkafka\producer.py
|
|
2
|
+
import orjson
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
from aiokafka import AIOKafkaProducer
|
|
6
|
+
from .services.base import BaseKafkaService
|
|
7
|
+
from .services.retry import retry_on_connection
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _serialize_headers(headers: dict[str, str]) -> list[tuple[str, bytes]]:
|
|
13
|
+
return [(k, v.encode()) for k, v in headers.items()]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KafkaProducer(BaseKafkaService):
|
|
17
|
+
def __init__(self, bootstrap_servers: str) -> None:
|
|
18
|
+
self.bootstrap_servers = bootstrap_servers
|
|
19
|
+
self._producer: AIOKafkaProducer | None = None
|
|
20
|
+
|
|
21
|
+
@retry_on_connection()
|
|
22
|
+
async def start(self) -> None:
|
|
23
|
+
self._producer = AIOKafkaProducer(
|
|
24
|
+
bootstrap_servers=self.bootstrap_servers,
|
|
25
|
+
value_serializer=lambda v: orjson.dumps(v),
|
|
26
|
+
)
|
|
27
|
+
await self._producer.start()
|
|
28
|
+
logger.info("KafkaProducer started")
|
|
29
|
+
|
|
30
|
+
@retry_on_connection()
|
|
31
|
+
async def send_message(
|
|
32
|
+
self,
|
|
33
|
+
topic: str,
|
|
34
|
+
data: dict[str, Any],
|
|
35
|
+
headers: dict[str, str] | None = None,
|
|
36
|
+
key: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not self._producer:
|
|
39
|
+
raise RuntimeError("Producer not started")
|
|
40
|
+
try:
|
|
41
|
+
kafka_headers = _serialize_headers(headers or {})
|
|
42
|
+
key_bytes = key.encode() if key else None
|
|
43
|
+
await self._producer.send_and_wait(
|
|
44
|
+
topic=topic,
|
|
45
|
+
value=data,
|
|
46
|
+
headers=kafka_headers,
|
|
47
|
+
key=key_bytes,
|
|
48
|
+
)
|
|
49
|
+
except Exception:
|
|
50
|
+
logger.exception("Failed to send message")
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
async def stop(self) -> None:
|
|
54
|
+
if self._producer:
|
|
55
|
+
await self._producer.flush()
|
|
56
|
+
await self._producer.stop()
|
|
57
|
+
logger.info("KafkaProducer stopped")
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# fastkafka\registry.py
|
|
2
|
+
import logging
|
|
3
|
+
from inspect import signature, iscoroutinefunction
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from fastkafka.message import KafkaMessage
|
|
7
|
+
from fastkafka.di.di_container import resolve
|
|
8
|
+
|
|
9
|
+
__all__ = ["kafka_handler"]
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
handlers_registry: dict[str, list["CompiledHandler"]] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CompiledHandler:
|
|
16
|
+
__slots__ = ("topic", "func", "sig", "data_model", "dependencies")
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, topic: str, func: Callable[..., Any], data_model: type[BaseModel] | None
|
|
20
|
+
):
|
|
21
|
+
self.topic = topic
|
|
22
|
+
self.func = func
|
|
23
|
+
self.sig = signature(func)
|
|
24
|
+
self.data_model = data_model
|
|
25
|
+
|
|
26
|
+
self.dependencies: dict[str, Any] = {}
|
|
27
|
+
for name, param in self.sig.parameters.items():
|
|
28
|
+
if param.annotation not in (KafkaMessage, data_model):
|
|
29
|
+
self.dependencies[name] = resolve(param.annotation)
|
|
30
|
+
|
|
31
|
+
async def handle(
|
|
32
|
+
self, raw_data: Any, headers: dict[str, str] | None, key: str | None
|
|
33
|
+
):
|
|
34
|
+
msg_data = self.data_model(**raw_data) if self.data_model else raw_data
|
|
35
|
+
message = KafkaMessage(
|
|
36
|
+
topic=self.topic, data=msg_data, headers=headers or {}, key=key
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
kwargs = {
|
|
40
|
+
name: (
|
|
41
|
+
self.dependencies.get(name)
|
|
42
|
+
if param.annotation not in (KafkaMessage, self.data_model)
|
|
43
|
+
else message
|
|
44
|
+
)
|
|
45
|
+
for name, param in self.sig.parameters.items()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return await self.func(**kwargs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def kafka_handler(topic: str, data_model: type[BaseModel] | None = None):
|
|
52
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
53
|
+
if not iscoroutinefunction(func):
|
|
54
|
+
raise TypeError("Handler must be async")
|
|
55
|
+
handlers_registry.setdefault(topic, []).append(
|
|
56
|
+
CompiledHandler(topic, func, data_model)
|
|
57
|
+
)
|
|
58
|
+
logger.debug("Registered handler %s for topic %s", func.__name__, topic)
|
|
59
|
+
return func
|
|
60
|
+
|
|
61
|
+
return decorator
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# fastkafka\services\__init__.py
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# fastkafka\services\retry.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
from aiokafka.errors import KafkaConnectionError
|
|
5
|
+
from typing import Callable, TypeVar, Awaitable
|
|
6
|
+
|
|
7
|
+
__all__ = ["retry_on_connection"]
|
|
8
|
+
|
|
9
|
+
Logger = logging.getLogger(__name__)
|
|
10
|
+
F = TypeVar("F", bound=Callable[..., Awaitable[None]])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def retry_on_connection(delay: int = 5) -> Callable[[F], F]:
|
|
14
|
+
def decorator(fn: F) -> F:
|
|
15
|
+
async def wrapper(*args, **kwargs):
|
|
16
|
+
while True:
|
|
17
|
+
try:
|
|
18
|
+
return await fn(*args, **kwargs)
|
|
19
|
+
except KafkaConnectionError as e:
|
|
20
|
+
Logger.warning(
|
|
21
|
+
"Retrying %s due to connection error: %s", fn.__name__, e
|
|
22
|
+
)
|
|
23
|
+
await asyncio.sleep(delay)
|
|
24
|
+
|
|
25
|
+
return wrapper # type: ignore
|
|
26
|
+
|
|
27
|
+
return decorator
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastkafka2
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Next-generation FastAPI-like DX for Kafka (version 2)
|
|
5
|
+
Author: Ruslan Ramazanov
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pydantic>=2.6
|
|
9
|
+
Requires-Dist: anyio>=4
|
|
10
|
+
Requires-Dist: aiokafka>=0.10
|
|
11
|
+
Requires-Dist: typing-extensions>=4.8
|
|
12
|
+
|
|
13
|
+
# fastkafka2
|
|
14
|
+
|
|
15
|
+
Next-generation FastAPI-like DX for Kafka (version 2).
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
``` bash
|
|
20
|
+
pip install fastkafka2
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Use example
|
|
24
|
+
### Project arch
|
|
25
|
+
```shell
|
|
26
|
+
├── api/
|
|
27
|
+
│ ├── kafka/
|
|
28
|
+
│ │ ├── handlers/
|
|
29
|
+
│ │ │ ├── example/
|
|
30
|
+
│ │ │ │ ├─ schemas.py
|
|
31
|
+
│ │ │ │ └─ handler.py
|
|
32
|
+
│ │ │ └── base_handler.py
|
|
33
|
+
│ │ └── lifespan.py
|
|
34
|
+
│
|
|
35
|
+
├── main.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Schemas
|
|
39
|
+
``` python
|
|
40
|
+
# api\kafka\handlers\example\schemas.py
|
|
41
|
+
from pydantic import BaseModel
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ExampleSchema(BaseModel):
|
|
45
|
+
msg: str
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Handler
|
|
49
|
+
``` python
|
|
50
|
+
# api\kafka\handlers\example\handler.py
|
|
51
|
+
import logging
|
|
52
|
+
|
|
53
|
+
from fastkafka.handler import KafkaHandler
|
|
54
|
+
from fastkafka.message import KafkaMessage
|
|
55
|
+
from fastkafka.producer import KafkaProducer
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
handler = KafkaHandler()
|
|
59
|
+
|
|
60
|
+
kafka_producer = KafkaProducer(bootstrap_servers="127.0.0.1:9092")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@handler("example")
|
|
64
|
+
async def example_handler(message: KafkaMessage):
|
|
65
|
+
t = int(message.headers.get("try")) + 1
|
|
66
|
+
logging.info(f"Пришло: {message}")
|
|
67
|
+
await kafka_producer.send_message(
|
|
68
|
+
topic="example-2", data={"msg": "wddwd"}, headers={"try": f"{t}"}, key=None
|
|
69
|
+
)
|
|
70
|
+
logging.info(f"Отправил: {f'{t}'}")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
### Grouping of handlers
|
|
75
|
+
``` python
|
|
76
|
+
# api\kafka\handlers\base_handler.py
|
|
77
|
+
from api.kafka.handlers.example.handler import handler as example_handler
|
|
78
|
+
|
|
79
|
+
from fastkafka.handler import KafkaHandler
|
|
80
|
+
|
|
81
|
+
base_handler = KafkaHandler()
|
|
82
|
+
|
|
83
|
+
base_handler.include_handler(example_handler)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
### Lifespan fastkafka app
|
|
88
|
+
``` python
|
|
89
|
+
# api/kafka/lifespan.py
|
|
90
|
+
import logging
|
|
91
|
+
from contextlib import asynccontextmanager
|
|
92
|
+
from fastkafka.app import KafkaApp
|
|
93
|
+
from api.kafka.handlers.base_handler import base_handler
|
|
94
|
+
|
|
95
|
+
from api.kafka.handlers.example.handler import kafka_producer
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@asynccontextmanager
|
|
99
|
+
async def lifespan(app: KafkaApp):
|
|
100
|
+
logging.info("Lifespan: запуск")
|
|
101
|
+
try:
|
|
102
|
+
await kafka_producer.start()
|
|
103
|
+
yield
|
|
104
|
+
logging.info("Lifespan: выполнен")
|
|
105
|
+
finally:
|
|
106
|
+
await kafka_producer.stop()
|
|
107
|
+
logging.info("Lifespan: остановка")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
app = KafkaApp(
|
|
111
|
+
title="Kafka Gateway",
|
|
112
|
+
description="Kafka-based microservice",
|
|
113
|
+
bootstrap_servers="127.0.0.1:9092",
|
|
114
|
+
lifespan=lifespan,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
app.include_handler(base_handler)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
### Entry point main app
|
|
122
|
+
``` python
|
|
123
|
+
# main.py
|
|
124
|
+
import asyncio
|
|
125
|
+
from logging_config import setup_logging
|
|
126
|
+
from api.kafka.lifespan import app
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
setup_logging()
|
|
130
|
+
asyncio.run(app.run())
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
fastkafka/__init__.py
|
|
4
|
+
fastkafka/admin.py
|
|
5
|
+
fastkafka/app.py
|
|
6
|
+
fastkafka/consumer.py
|
|
7
|
+
fastkafka/dependencies.py
|
|
8
|
+
fastkafka/handler.py
|
|
9
|
+
fastkafka/logging_utils.py
|
|
10
|
+
fastkafka/message.py
|
|
11
|
+
fastkafka/producer.py
|
|
12
|
+
fastkafka/py.typed
|
|
13
|
+
fastkafka/registry.py
|
|
14
|
+
fastkafka2.egg-info/PKG-INFO
|
|
15
|
+
fastkafka2.egg-info/SOURCES.txt
|
|
16
|
+
fastkafka2.egg-info/dependency_links.txt
|
|
17
|
+
fastkafka2.egg-info/requires.txt
|
|
18
|
+
fastkafka2.egg-info/top_level.txt
|
|
19
|
+
fastkafka/di/__init__.py
|
|
20
|
+
fastkafka/di/di_container.py
|
|
21
|
+
fastkafka/services/__init__.py
|
|
22
|
+
fastkafka/services/base.py
|
|
23
|
+
fastkafka/services/retry.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastkafka
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastkafka2"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Next-generation FastAPI-like DX for Kafka (version 2)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Ruslan Ramazanov" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydantic>=2.6",
|
|
16
|
+
"anyio>=4",
|
|
17
|
+
"aiokafka>=0.10",
|
|
18
|
+
"typing-extensions>=4.8",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["."]
|
|
23
|
+
include = ["fastkafka*"]
|