data-ingestor 0.2.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.
- data_ingestor-0.2.0.dist-info/METADATA +162 -0
- data_ingestor-0.2.0.dist-info/RECORD +31 -0
- data_ingestor-0.2.0.dist-info/WHEEL +4 -0
- data_ingestor-0.2.0.dist-info/entry_points.txt +2 -0
- ingestor/__init__.py +15 -0
- ingestor/__main__.py +6 -0
- ingestor/adapters/__init__.py +0 -0
- ingestor/adapters/batch/__init__.py +5 -0
- ingestor/adapters/batch/file_source.py +43 -0
- ingestor/adapters/console/__init__.py +4 -0
- ingestor/adapters/console/json_sink.py +24 -0
- ingestor/adapters/console/sink.py +28 -0
- ingestor/adapters/kafka/__init__.py +14 -0
- ingestor/adapters/kafka/connection.py +81 -0
- ingestor/adapters/kafka/info.py +37 -0
- ingestor/adapters/kafka/sink.py +48 -0
- ingestor/adapters/kafka/source.py +80 -0
- ingestor/adapters/sse/__init__.py +5 -0
- ingestor/adapters/sse/source.py +106 -0
- ingestor/adapters/tee/__init__.py +5 -0
- ingestor/adapters/tee/sink.py +21 -0
- ingestor/adapters/ws/__init__.py +5 -0
- ingestor/adapters/ws/source.py +99 -0
- ingestor/app.py +32 -0
- ingestor/cli.py +52 -0
- ingestor/ports/__init__.py +8 -0
- ingestor/ports/connection.py +36 -0
- ingestor/ports/record.py +21 -0
- ingestor/ports/sink.py +24 -0
- ingestor/ports/source.py +26 -0
- ingestor/testing.py +52 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: data-ingestor
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Data ingestion (streaming, batch, micro-batch) with a ports-and-adapters architecture
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: confluent-kafka>=2.15.0
|
|
7
|
+
Provides-Extra: sse
|
|
8
|
+
Requires-Dist: httpx>=0.27; extra == 'sse'
|
|
9
|
+
Provides-Extra: ws
|
|
10
|
+
Requires-Dist: websocket-client>=1.8; extra == 'ws'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Ingestor
|
|
14
|
+
|
|
15
|
+
A Python **data ingestion** app that reads records from a source (**Source**) and writes them to a destination (**Sink**). The same logic serves several modes: **streaming** (Kafka), **batch** (file) and **micro-batch** (by limit/window).
|
|
16
|
+
|
|
17
|
+
The architecture is **ports and adapters**: the logic depends only on interfaces (`ingestor.ports`), and each tool lives isolated in its own adapter (`ingestor.adapters.*`). Swapping source or destination - or using in-memory fakes in tests - does not touch the logic.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python 3.14 or newer.
|
|
22
|
+
- `uv` installed to manage dependencies.
|
|
23
|
+
- Docker with Docker Compose and Docker Desktop running (only for the Kafka mode).
|
|
24
|
+
- Port `9092` free for local Kafka.
|
|
25
|
+
|
|
26
|
+
## How to run
|
|
27
|
+
|
|
28
|
+
### 1. Clone the project
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/rikemorais/ingestor.git
|
|
32
|
+
cd ingestor
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2. Install dependencies
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 3. Start Kafka
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
docker compose up -d
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Wait for the broker to start. To follow the logs:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
docker compose logs -f kafka
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Press `Ctrl+C` to leave the logs. Kafka keeps running.
|
|
54
|
+
|
|
55
|
+
### 4. Run the app
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv run ingestor
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> `ingestor` is the command defined in `pyproject.toml` (`[project.scripts]`). Equivalent alternative: `uv run python -m ingestor`.
|
|
62
|
+
|
|
63
|
+
The demo seeds a few records into the topic and then **ingests from Kafka to the console**. With local Kafka available, the expected output is:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
Connected to Kafka: 1 broker(s).
|
|
67
|
+
Ingested: origin=demo-messages key=1 value=Hello Kafka #1
|
|
68
|
+
Ingested: origin=demo-messages key=2 value=Hello Kafka #2
|
|
69
|
+
Ingested: origin=demo-messages key=3 value=Hello Kafka #3
|
|
70
|
+
Ingested: origin=demo-messages key=4 value=Hello Kafka #4
|
|
71
|
+
Ingested: origin=demo-messages key=5 value=Hello Kafka #5
|
|
72
|
+
Ingested 5 records.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The connection uses `localhost:9092` by default and waits up to 10 seconds for a response.
|
|
76
|
+
|
|
77
|
+
> Note: `KafkaSource` consumes in the `ingestor` group and, after a few consecutive empty polls (`max_empty_polls`), stops - that's why the demo terminates instead of waiting forever. Running again with the same group won't re-read already-read messages (the offset is saved). To re-consume everything, change the group in `src/ingestor/adapters/kafka/connection.py` or recreate the topic.
|
|
78
|
+
|
|
79
|
+
## Use another address
|
|
80
|
+
|
|
81
|
+
Set `KAFKA_BOOTSTRAP_SERVERS` when running:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
KAFKA_BOOTSTRAP_SERVERS=server:9092 uv run ingestor
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Replace `server:9092` with your broker's address. The current setup uses an unauthenticated connection; SASL/TLS clusters need adjustments in the adapter.
|
|
88
|
+
|
|
89
|
+
## Run the tests (no Kafka)
|
|
90
|
+
|
|
91
|
+
The tests use in-memory implementations (fakes) and a file source, so they **need neither Docker nor a broker**:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uv run python -m unittest discover -v
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
They exercise the same `ingest` function with a `FakeConnection` and with a `FileSource` (batch) - proving in practice that the logic does not depend on the Kafka lib.
|
|
98
|
+
|
|
99
|
+
> Tip: when adding/moving files under `src/`, if `uv run` complains about a module not found, run once `uv sync --reinstall-package data-ingestor`.
|
|
100
|
+
|
|
101
|
+
## Stop Kafka
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
docker compose stop
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
To start the same container again:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
docker compose start
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## If the connection fails
|
|
114
|
+
|
|
115
|
+
Check that Docker is running and inspect the broker's state and logs:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
docker compose ps
|
|
119
|
+
docker compose logs --tail=50 kafka
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
If Kafka is still starting up, wait and run again. Also check the address and that port `9092` is free. On failure, the app prints the error and exits with code `1`.
|
|
123
|
+
|
|
124
|
+
## Structure
|
|
125
|
+
|
|
126
|
+
`src/` layout, with the code in an `ingestor` package organized as ports × adapters:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
ingestor/
|
|
130
|
+
├── src/
|
|
131
|
+
│ └── ingestor/
|
|
132
|
+
│ ├── __init__.py
|
|
133
|
+
│ ├── app.py # use case: ingest(source, sink, limit)
|
|
134
|
+
│ ├── cli.py # Composition Root: picks and injects the adapters
|
|
135
|
+
│ ├── __main__.py # enables `python -m ingestor`
|
|
136
|
+
│ ├── ports/ # interfaces + domain (tool-agnostic)
|
|
137
|
+
│ │ ├── record.py # Record (value, key, origin)
|
|
138
|
+
│ │ ├── source.py # Source: read() -> Iterator[Record]
|
|
139
|
+
│ │ ├── sink.py # Sink: write(record)
|
|
140
|
+
│ │ └── connection.py # Connection: source() / sink()
|
|
141
|
+
│ └── adapters/ # concrete implementations (one folder per tool)
|
|
142
|
+
│ ├── kafka/ # streaming: KafkaSource, KafkaSink, KafkaConnection, KafkaInfoCluster
|
|
143
|
+
│ ├── console/ # generic destination: ConsoleSink
|
|
144
|
+
│ └── batch/ # batch: FileSource (reads a file line by line)
|
|
145
|
+
├── tests/
|
|
146
|
+
│ ├── __init__.py
|
|
147
|
+
│ ├── fakes.py # ListSource, MemorySink, FakeConnection
|
|
148
|
+
│ └── test_ingestor.py # unittest tests
|
|
149
|
+
├── docker-compose.yml # local Kafka
|
|
150
|
+
├── pyproject.toml # dependencies, build and the `ingestor` command
|
|
151
|
+
└── uv.lock
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**How the layers relate:** `app.py` depends only on `ingestor.ports` (Source, Sink, Connection); the adapters in `adapters/` implement those ports; and only `cli.py` knows the concrete classes, wiring everything together. This way no specific lib (like `confluent_kafka`) "leaks" into the logic or the tests.
|
|
155
|
+
|
|
156
|
+
**The three modes, the same logic:**
|
|
157
|
+
|
|
158
|
+
| Mode | Source | Does `ingest` change? |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| Streaming | `KafkaSource` | no |
|
|
161
|
+
| Batch | `FileSource` | no |
|
|
162
|
+
| Micro-batch | any Source + `limit` | no |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
ingestor/__init__.py,sha256=POOTDxaaJ7b0Mz_8DhMLthIYafbCKOA8MxjbMwMF83U,557
|
|
2
|
+
ingestor/__main__.py,sha256=h6fAzlUnDiT9KRd2Qtv32nGtI_CvXdrzST7JejjdC5A,134
|
|
3
|
+
ingestor/app.py,sha256=9ORxW4a9LAWcji9cYAJf041jXVqj466qva0N2UkDqoI,938
|
|
4
|
+
ingestor/cli.py,sha256=bS0cCkI2c9prr35BqDV1UorgaWvwvMudweVis2acF3g,1800
|
|
5
|
+
ingestor/testing.py,sha256=Lxlz9iaYvmRmoKETawSV1qVn9s1CYou18RmaiCfneE0,1381
|
|
6
|
+
ingestor/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
ingestor/adapters/batch/__init__.py,sha256=qm5F0mKGnyiJUF57Vm7_tvshwOpjnVVL643I6-61LJY,145
|
|
8
|
+
ingestor/adapters/batch/file_source.py,sha256=s7D0zsv6goapZ80dMC3s8LzA4ACjK7W3inIIOrKGV3E,1320
|
|
9
|
+
ingestor/adapters/console/__init__.py,sha256=ZOF7AyOaebGjjd8xcsa1jT8JpYiBQLTbBkN7cKwplrY,177
|
|
10
|
+
ingestor/adapters/console/json_sink.py,sha256=LzZ5GLMPLN-hir0wYz-8XaZxh7dQjJwK5vYxqQqXvgQ,691
|
|
11
|
+
ingestor/adapters/console/sink.py,sha256=EcW-eXgF_xoyDWCbldkvyzvq8GPAzgUarJfeKMymi0U,806
|
|
12
|
+
ingestor/adapters/kafka/__init__.py,sha256=UulxZ9vQi-00n1tVBdN_9VI-iuATNsVbKhvRzz4Yaug,431
|
|
13
|
+
ingestor/adapters/kafka/connection.py,sha256=dL8e7YiWOlqCjZZyCYol61RKOR_Qx2RFmRxiLzvJSLI,2579
|
|
14
|
+
ingestor/adapters/kafka/info.py,sha256=JCVrleU2lvRydkqZJeM4ukF5UChi0cbPAdHk4yCyHJc,1012
|
|
15
|
+
ingestor/adapters/kafka/sink.py,sha256=DnfGhcTGjTgAc_dzZqu0hI8V2R-UfCH5kOcQcP_wA28,1566
|
|
16
|
+
ingestor/adapters/kafka/source.py,sha256=KqHhXg3n6Ql56jyl2L1vsX_VrdYNA5CNxDP-kOScHDs,2683
|
|
17
|
+
ingestor/adapters/sse/__init__.py,sha256=JCl5ZKznpXDS1AoHwfE5f-n3jLSoENMETVkDwy1sk4M,140
|
|
18
|
+
ingestor/adapters/sse/source.py,sha256=aEZhFvf7Gke9sSQmQILBeFPP_1wC557bvezGBGc7d4o,4111
|
|
19
|
+
ingestor/adapters/tee/__init__.py,sha256=mXAJr-tzlFHLEFMmYFSSEtmU-zZfzqub4NUrv67pmtU,126
|
|
20
|
+
ingestor/adapters/tee/sink.py,sha256=KRHAl57Z1McXtkEs8V8pFsos6YBr-7gl9Hm-Bs-KlYY,541
|
|
21
|
+
ingestor/adapters/ws/__init__.py,sha256=W36UK_HbnItNFGYDX0N2NyCGFL-7bWWgw7t5EWnmgj0,160
|
|
22
|
+
ingestor/adapters/ws/source.py,sha256=o36RF7AEdw_jQr9ASckwITOpBrLQdrJSIEY_jKBV_Dc,3635
|
|
23
|
+
ingestor/ports/__init__.py,sha256=5G-T8Ob8LfH6Cg4PJrZgTFI4xbBqskd7jm_whCiZnv0,326
|
|
24
|
+
ingestor/ports/connection.py,sha256=UQ4M9J-6PB2OqyJuCt4z183eTOzcKt2gYBw8zG3QeqM,850
|
|
25
|
+
ingestor/ports/record.py,sha256=VCv3q5gT_LaxgxsnURetCSa41JKXz8iW-0zwLIvmEAI,524
|
|
26
|
+
ingestor/ports/sink.py,sha256=xoGDM54bIXflJAd4Z61EQt3UsWzBclTymOeRh4hCFQs,570
|
|
27
|
+
ingestor/ports/source.py,sha256=xeJvd7OznLkCw_yHRGCUApumSIfwZyaFiJr2MKOHjl4,674
|
|
28
|
+
data_ingestor-0.2.0.dist-info/METADATA,sha256=cSU6rSi357BLZduD_2FAHIbZWQN8d1dxu5EDdr_Bjho,5927
|
|
29
|
+
data_ingestor-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
30
|
+
data_ingestor-0.2.0.dist-info/entry_points.txt,sha256=hS0XLj0TGKr7OZY3k7au0TDjPOTZv8IgYj8gaKwU8JE,47
|
|
31
|
+
data_ingestor-0.2.0.dist-info/RECORD,,
|
ingestor/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Ingestor: read records from sources (streaming, batch, micro-batch) and write them to sinks.
|
|
2
|
+
|
|
3
|
+
Organized as ports & adapters:
|
|
4
|
+
- `ports`: Source, Sink, Connection and the Record domain model.
|
|
5
|
+
- `adapters`: concrete implementations (kafka, console, ...).
|
|
6
|
+
- `app`: the `ingest` use case (Source -> Sink).
|
|
7
|
+
- `cli`: the Composition Root.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("data-ingestor")
|
|
14
|
+
except PackageNotFoundError: # pragma: no cover - only when running uninstalled
|
|
15
|
+
__version__ = "0.0.0"
|
ingestor/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Batch source: reads a text file, one record per line.
|
|
2
|
+
|
|
3
|
+
Shows batch entering the system without touching ``ingest``: just another Source.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
from ingestor.ports.record import Record
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileSource:
|
|
14
|
+
"""A finite Source that reads a text file line by line."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, path: str | Path, encoding: str = "utf-8") -> None:
|
|
17
|
+
"""Store the file path.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
path: Path to the text file to read.
|
|
21
|
+
encoding: Text encoding used to open the file.
|
|
22
|
+
"""
|
|
23
|
+
self._path = Path(path)
|
|
24
|
+
self._encoding = encoding
|
|
25
|
+
self._file: TextIO | None = None
|
|
26
|
+
|
|
27
|
+
def read(self) -> Iterator[Record]:
|
|
28
|
+
"""Open the file and yield one record per line.
|
|
29
|
+
|
|
30
|
+
Yields:
|
|
31
|
+
A record whose ``value`` is the line (newline stripped) and whose
|
|
32
|
+
``origin`` is the file name.
|
|
33
|
+
"""
|
|
34
|
+
self._file = self._path.open("r", encoding=self._encoding)
|
|
35
|
+
origin = self._path.name
|
|
36
|
+
for line in self._file:
|
|
37
|
+
yield Record(value=line.rstrip("\n"), origin=origin)
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
"""Close the file if it is open."""
|
|
41
|
+
if self._file is not None:
|
|
42
|
+
self._file.close()
|
|
43
|
+
self._file = None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Console sink for displaying selected fields from JSON records."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from ingestor.ports.record import Record
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonFieldsConsoleSink:
|
|
10
|
+
"""Print selected JSON fields from each record."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, fields: Iterable[str]) -> None:
|
|
13
|
+
self._fields = tuple(fields)
|
|
14
|
+
|
|
15
|
+
def write(self, record: Record) -> None:
|
|
16
|
+
try:
|
|
17
|
+
payload = json.loads(record.value)
|
|
18
|
+
values = " ".join(f"{field}={payload.get(field, '-')}" for field in self._fields)
|
|
19
|
+
print(values)
|
|
20
|
+
except (TypeError, json.JSONDecodeError):
|
|
21
|
+
print(record.value)
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
pass
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A simple sink that prints records - handy for demos and debugging."""
|
|
2
|
+
|
|
3
|
+
from ingestor.ports.record import Record
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConsoleSink:
|
|
7
|
+
"""A Sink that writes each record to standard output.
|
|
8
|
+
|
|
9
|
+
Depends on no tool, showing that the Sink port is truly agnostic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, include_metadata: bool = True) -> None:
|
|
13
|
+
self._include_metadata = include_metadata
|
|
14
|
+
|
|
15
|
+
def write(self, record: Record) -> None:
|
|
16
|
+
"""Print a record to stdout.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
record: The record to print.
|
|
20
|
+
"""
|
|
21
|
+
if self._include_metadata:
|
|
22
|
+
print(f"Ingested: Origin({record.origin}) Key({record.key}) Value: {record.value}")
|
|
23
|
+
else:
|
|
24
|
+
print(record.value)
|
|
25
|
+
|
|
26
|
+
def close(self) -> None:
|
|
27
|
+
"""No-op: there is nothing to flush."""
|
|
28
|
+
pass
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Kafka adapter: all Kafka-specific code lives here."""
|
|
2
|
+
|
|
3
|
+
from ingestor.adapters.kafka.connection import KafkaConnection, KafkaConnectionInfo
|
|
4
|
+
from ingestor.adapters.kafka.info import KafkaInfoCluster
|
|
5
|
+
from ingestor.adapters.kafka.sink import KafkaSink
|
|
6
|
+
from ingestor.adapters.kafka.source import KafkaSource
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"KafkaConnection",
|
|
10
|
+
"KafkaConnectionInfo",
|
|
11
|
+
"KafkaSource",
|
|
12
|
+
"KafkaSink",
|
|
13
|
+
"KafkaInfoCluster",
|
|
14
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Kafka connection: builds KafkaSource and KafkaSink.
|
|
2
|
+
|
|
3
|
+
The only place that joins the Kafka config, the concrete adapters, the
|
|
4
|
+
AdminClient and the metadata translation. The rest of the system sees only
|
|
5
|
+
the Connection interface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from confluent_kafka.admin import AdminClient
|
|
11
|
+
|
|
12
|
+
from ingestor.adapters.kafka.info import KafkaInfoCluster
|
|
13
|
+
from ingestor.adapters.kafka.sink import KafkaSink
|
|
14
|
+
from ingestor.adapters.kafka.source import KafkaSource
|
|
15
|
+
from ingestor.ports.connection import Connection
|
|
16
|
+
from ingestor.ports.sink import Sink
|
|
17
|
+
from ingestor.ports.source import Source
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class KafkaConnectionInfo:
|
|
22
|
+
"""Kafka-specific connection config.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
brokers: Broker addresses to connect to.
|
|
26
|
+
topics: Topics the source subscribes to; the first is the sink default.
|
|
27
|
+
group: Consumer group id.
|
|
28
|
+
from_beginning: Whether the source reads from the start of the topic.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
brokers: list[str]
|
|
32
|
+
topics: list[str] = field(default_factory=list)
|
|
33
|
+
group: str = "ingestor"
|
|
34
|
+
from_beginning: bool = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KafkaConnection(Connection):
|
|
38
|
+
"""Builds Kafka adapters from a KafkaConnectionInfo."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, info: KafkaConnectionInfo) -> None:
|
|
41
|
+
"""Store the config and derive the broker string.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
info: The Kafka connection config.
|
|
45
|
+
"""
|
|
46
|
+
self._info = info
|
|
47
|
+
self._servers = ",".join(info.brokers)
|
|
48
|
+
|
|
49
|
+
def connect(self, timeout: float = 10.0) -> KafkaInfoCluster:
|
|
50
|
+
"""Fetch cluster metadata (a Kafka-only extra, not part of the port).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
timeout: Seconds to wait for the metadata response.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A summary of the cluster.
|
|
57
|
+
"""
|
|
58
|
+
cluster = AdminClient({"bootstrap.servers": self._servers}).list_topics(timeout=timeout)
|
|
59
|
+
return KafkaInfoCluster.from_cluster(cluster)
|
|
60
|
+
|
|
61
|
+
def source(self) -> Source:
|
|
62
|
+
"""Build a KafkaSource for the configured topics.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A streaming Source.
|
|
66
|
+
"""
|
|
67
|
+
return KafkaSource(
|
|
68
|
+
bootstrap_servers=self._servers,
|
|
69
|
+
topics=self._info.topics,
|
|
70
|
+
group=self._info.group,
|
|
71
|
+
from_beginning=self._info.from_beginning,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def sink(self) -> Sink:
|
|
75
|
+
"""Build a KafkaSink targeting the first configured topic.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
An output Sink.
|
|
79
|
+
"""
|
|
80
|
+
topic = self._info.topics[0] if self._info.topics else None
|
|
81
|
+
return KafkaSink(bootstrap_servers=self._servers, default_topic=topic)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Kafka-specific cluster summary (metadata)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class KafkaInfoCluster:
|
|
9
|
+
"""Cluster metadata translated from confluent's ClusterMetadata.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
brokers: Broker addresses discovered in the cluster.
|
|
13
|
+
topics: Topic names discovered in the cluster, sorted.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
brokers: list[str]
|
|
17
|
+
topics: list[str]
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def broker_count(self) -> int:
|
|
21
|
+
"""Number of brokers in the cluster."""
|
|
22
|
+
return len(self.brokers)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_cluster(cls, cluster: Any) -> Self:
|
|
26
|
+
"""Build a summary from confluent_kafka's ClusterMetadata.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
cluster: The ClusterMetadata returned by ``list_topics``.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A domain-level summary of the cluster.
|
|
33
|
+
"""
|
|
34
|
+
return cls(
|
|
35
|
+
brokers=[str(broker) for broker in cluster.brokers.values()],
|
|
36
|
+
topics=sorted(cluster.topics.keys()),
|
|
37
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Kafka sink: writes records by producing to a topic."""
|
|
2
|
+
|
|
3
|
+
from confluent_kafka import Producer
|
|
4
|
+
|
|
5
|
+
from ingestor.ports.record import Record
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KafkaSink:
|
|
9
|
+
"""An output Sink backed by a confluent-kafka Producer."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
bootstrap_servers: str = "localhost:9092",
|
|
14
|
+
default_topic: str | None = None,
|
|
15
|
+
):
|
|
16
|
+
"""Create the producer.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
bootstrap_servers: Comma-separated broker addresses.
|
|
20
|
+
default_topic: Topic used when a record has no ``origin``.
|
|
21
|
+
"""
|
|
22
|
+
self._producer = Producer({"bootstrap.servers": bootstrap_servers})
|
|
23
|
+
self._default_topic = default_topic
|
|
24
|
+
|
|
25
|
+
def write(self, record: Record) -> None:
|
|
26
|
+
"""Produce a record to Kafka.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
record: The record to produce. Its ``origin`` is used as the topic,
|
|
30
|
+
falling back to ``default_topic``.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If no destination topic can be determined.
|
|
34
|
+
"""
|
|
35
|
+
topic = record.origin or self._default_topic
|
|
36
|
+
if topic is None:
|
|
37
|
+
raise ValueError("No destination topic: set record.origin or default_topic.")
|
|
38
|
+
self._producer.produce(topic, value=record.value, key=record.key)
|
|
39
|
+
self._producer.poll(0)
|
|
40
|
+
|
|
41
|
+
def close(self, timeout: float = 10.0) -> None:
|
|
42
|
+
"""Flush pending messages to the broker.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
timeout: Maximum seconds to wait for delivery before giving up, so a
|
|
46
|
+
dead broker cannot block forever.
|
|
47
|
+
"""
|
|
48
|
+
self._producer.flush(timeout)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Kafka source: consumes messages and yields them as records."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
|
|
5
|
+
from confluent_kafka import Consumer, KafkaError, KafkaException
|
|
6
|
+
|
|
7
|
+
from ingestor.ports.record import Record
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KafkaSource:
|
|
11
|
+
"""A streaming Source backed by a confluent-kafka Consumer."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
bootstrap_servers: str = "localhost:9092",
|
|
16
|
+
topics: list[str] | None = None,
|
|
17
|
+
group: str = "ingestor",
|
|
18
|
+
from_beginning: bool = True,
|
|
19
|
+
max_empty_polls: int = 10,
|
|
20
|
+
timeout: float = 1.0,
|
|
21
|
+
):
|
|
22
|
+
"""Create the consumer.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
bootstrap_servers: Comma-separated broker addresses.
|
|
26
|
+
topics: Topics to subscribe to.
|
|
27
|
+
group: Consumer group id (controls committed offsets).
|
|
28
|
+
from_beginning: Read from the start of the topic when the group has
|
|
29
|
+
no saved offset; otherwise read only new messages.
|
|
30
|
+
max_empty_polls: Stop after this many consecutive empty polls, so
|
|
31
|
+
demos and batch-style runs terminate.
|
|
32
|
+
timeout: Seconds to wait on each poll.
|
|
33
|
+
"""
|
|
34
|
+
self._topics = topics or []
|
|
35
|
+
self._max_empty_polls = max_empty_polls
|
|
36
|
+
self._timeout = timeout
|
|
37
|
+
self._consumer = Consumer(
|
|
38
|
+
{
|
|
39
|
+
"bootstrap.servers": bootstrap_servers,
|
|
40
|
+
"group.id": group,
|
|
41
|
+
"auto.offset.reset": "earliest" if from_beginning else "latest",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def read(self) -> Iterator[Record]:
|
|
46
|
+
"""Subscribe and yield records until the topic goes quiet.
|
|
47
|
+
|
|
48
|
+
Yields:
|
|
49
|
+
Each consumed message translated into a Record.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
KafkaException: If the broker returns a non-EOF error.
|
|
53
|
+
"""
|
|
54
|
+
self._consumer.subscribe(self._topics)
|
|
55
|
+
empty = 0
|
|
56
|
+
while empty < self._max_empty_polls:
|
|
57
|
+
message = self._consumer.poll(self._timeout)
|
|
58
|
+
if message is None:
|
|
59
|
+
empty += 1
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
error = message.error()
|
|
63
|
+
if error is not None:
|
|
64
|
+
if error.code() == KafkaError._PARTITION_EOF:
|
|
65
|
+
empty += 1
|
|
66
|
+
continue
|
|
67
|
+
raise KafkaException(error)
|
|
68
|
+
|
|
69
|
+
empty = 0
|
|
70
|
+
value = message.value()
|
|
71
|
+
key = message.key()
|
|
72
|
+
yield Record(
|
|
73
|
+
value=value.decode("utf-8") if value is not None else "",
|
|
74
|
+
key=key.decode("utf-8") if key is not None else None,
|
|
75
|
+
origin=message.topic(),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
"""Close the underlying consumer."""
|
|
80
|
+
self._consumer.close()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Server-Sent Events (SSE) source: consumes any text/event-stream endpoint.
|
|
2
|
+
|
|
3
|
+
True streaming: it holds the HTTP connection open and yields a Record for each
|
|
4
|
+
event the server pushes, as it arrives. Reconnects automatically on drops so it
|
|
5
|
+
can run continuously. Example: Wikimedia EventStreams.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ingestor.ports.record import Record
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SSESource:
|
|
18
|
+
"""A streaming Source for any SSE (text/event-stream) endpoint."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
url: str,
|
|
23
|
+
origin: str | None = None,
|
|
24
|
+
connect_timeout: float = 10.0,
|
|
25
|
+
user_agent: str = "ingestor/0.1 (https://github.com/rikemorais/ingestor)",
|
|
26
|
+
reconnect: bool = True,
|
|
27
|
+
reconnect_delay: float = 3.0,
|
|
28
|
+
):
|
|
29
|
+
"""Configure the stream.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
url: The SSE endpoint to connect to.
|
|
33
|
+
origin: Value used as each record's ``origin``; defaults to the URL.
|
|
34
|
+
connect_timeout: Seconds to wait for the connection to open (reads
|
|
35
|
+
have no timeout, since the stream stays open between events).
|
|
36
|
+
user_agent: Sent as the ``User-Agent`` header. Some providers (such
|
|
37
|
+
as Wikimedia) reject requests without a descriptive one.
|
|
38
|
+
reconnect: Reconnect automatically if the connection drops or ends.
|
|
39
|
+
reconnect_delay: Seconds to wait before each reconnection attempt.
|
|
40
|
+
"""
|
|
41
|
+
self._url = url
|
|
42
|
+
self._origin = origin or url
|
|
43
|
+
self._connect_timeout = connect_timeout
|
|
44
|
+
self._user_agent = user_agent
|
|
45
|
+
self._reconnect = reconnect
|
|
46
|
+
self._reconnect_delay = reconnect_delay
|
|
47
|
+
self._running = True
|
|
48
|
+
self._client: httpx.Client | None = None
|
|
49
|
+
|
|
50
|
+
def read(self) -> Iterator[Record]:
|
|
51
|
+
"""Yield one Record per SSE event, reconnecting on drops.
|
|
52
|
+
|
|
53
|
+
Yields:
|
|
54
|
+
A record whose ``value`` is the event's ``data`` payload, ``key`` is
|
|
55
|
+
the event id (if sent) and ``origin`` is the configured origin.
|
|
56
|
+
"""
|
|
57
|
+
while self._running:
|
|
58
|
+
try:
|
|
59
|
+
yield from self._stream_once()
|
|
60
|
+
except httpx.HTTPError as error:
|
|
61
|
+
if not self._reconnect or not self._running:
|
|
62
|
+
raise
|
|
63
|
+
print(
|
|
64
|
+
f"[SSESource] connection lost ({error}); "
|
|
65
|
+
f"reconnecting in {self._reconnect_delay}s",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
if not self._reconnect:
|
|
69
|
+
break
|
|
70
|
+
if self._running:
|
|
71
|
+
time.sleep(self._reconnect_delay)
|
|
72
|
+
|
|
73
|
+
def _stream_once(self) -> Iterator[Record]:
|
|
74
|
+
headers = {
|
|
75
|
+
"Accept": "text/event-stream",
|
|
76
|
+
"User-Agent": self._user_agent,
|
|
77
|
+
}
|
|
78
|
+
with httpx.Client(timeout=httpx.Timeout(self._connect_timeout, read=None)) as client:
|
|
79
|
+
self._client = client
|
|
80
|
+
with client.stream("GET", self._url, headers=headers) as response:
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
data: list[str] = []
|
|
83
|
+
event_id: str | None = None
|
|
84
|
+
for line in response.iter_lines():
|
|
85
|
+
if line == "":
|
|
86
|
+
if data:
|
|
87
|
+
yield Record(
|
|
88
|
+
value="\n".join(data),
|
|
89
|
+
key=event_id,
|
|
90
|
+
origin=self._origin,
|
|
91
|
+
)
|
|
92
|
+
data = []
|
|
93
|
+
event_id = None
|
|
94
|
+
elif line.startswith("data:"):
|
|
95
|
+
data.append(line[len("data:") :].lstrip())
|
|
96
|
+
elif line.startswith("id:"):
|
|
97
|
+
event_id = line[len("id:") :].strip()
|
|
98
|
+
# comment (":...") and other fields (event:, retry:) fall through
|
|
99
|
+
self._client = None
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
"""Stop reconnecting and close the HTTP connection."""
|
|
103
|
+
self._running = False
|
|
104
|
+
if self._client is not None:
|
|
105
|
+
self._client.close()
|
|
106
|
+
self._client = None
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Sink adapter that writes each record to multiple sinks."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from ingestor.ports.record import Record
|
|
6
|
+
from ingestor.ports.sink import Sink
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TeeSink:
|
|
10
|
+
"""Forward every record to all configured sinks."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, sinks: Iterable[Sink]) -> None:
|
|
13
|
+
self._sinks = tuple(sinks)
|
|
14
|
+
|
|
15
|
+
def write(self, record: Record) -> None:
|
|
16
|
+
for sink in self._sinks:
|
|
17
|
+
sink.write(record)
|
|
18
|
+
|
|
19
|
+
def close(self) -> None:
|
|
20
|
+
for sink in self._sinks:
|
|
21
|
+
sink.close()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""WebSocket source: consumes any WebSocket endpoint as a stream of records.
|
|
2
|
+
|
|
3
|
+
True streaming: it keeps the socket open and yields a Record for each message
|
|
4
|
+
the server pushes. Reconnects automatically on drops so it can run continuously.
|
|
5
|
+
Example: the Coinbase market-data feed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
from websocket import WebSocket, WebSocketException, create_connection
|
|
14
|
+
|
|
15
|
+
from ingestor.ports.record import Record
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebSocketSource:
|
|
19
|
+
"""A streaming Source for any WebSocket endpoint."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
url: str,
|
|
24
|
+
subscribe: dict | str | None = None,
|
|
25
|
+
origin: str | None = None,
|
|
26
|
+
open_timeout: float = 10.0,
|
|
27
|
+
reconnect: bool = True,
|
|
28
|
+
reconnect_delay: float = 3.0,
|
|
29
|
+
):
|
|
30
|
+
"""Configure the stream.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
url: The ``wss://`` endpoint to connect to.
|
|
34
|
+
subscribe: Message sent right after connecting (a dict is JSON
|
|
35
|
+
encoded). Used by feeds that need a subscription, like Coinbase.
|
|
36
|
+
origin: Value used as each record's ``origin``; defaults to the URL.
|
|
37
|
+
open_timeout: Seconds to wait for the connection to open.
|
|
38
|
+
reconnect: Reconnect automatically if the connection drops.
|
|
39
|
+
reconnect_delay: Seconds to wait before each reconnection attempt.
|
|
40
|
+
"""
|
|
41
|
+
self._url = url
|
|
42
|
+
self._subscribe = subscribe
|
|
43
|
+
self._origin = origin or url
|
|
44
|
+
self._open_timeout = open_timeout
|
|
45
|
+
self._reconnect = reconnect
|
|
46
|
+
self._reconnect_delay = reconnect_delay
|
|
47
|
+
self._running = True
|
|
48
|
+
self._ws: WebSocket | None = None
|
|
49
|
+
|
|
50
|
+
def read(self) -> Iterator[Record]:
|
|
51
|
+
"""Yield one Record per message, reconnecting on drops.
|
|
52
|
+
|
|
53
|
+
Yields:
|
|
54
|
+
A record whose ``value`` is the raw message text and ``origin`` is
|
|
55
|
+
the configured origin.
|
|
56
|
+
"""
|
|
57
|
+
while self._running:
|
|
58
|
+
try:
|
|
59
|
+
yield from self._stream_once()
|
|
60
|
+
except (WebSocketException, OSError) as error:
|
|
61
|
+
if not self._reconnect or not self._running:
|
|
62
|
+
raise
|
|
63
|
+
print(
|
|
64
|
+
f"[WebSocketSource] connection lost ({error}); "
|
|
65
|
+
f"reconnecting in {self._reconnect_delay}s",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
if not self._reconnect:
|
|
69
|
+
break # pragma: no cover - _stream_once only returns by raising
|
|
70
|
+
if self._running:
|
|
71
|
+
time.sleep(self._reconnect_delay)
|
|
72
|
+
|
|
73
|
+
def _stream_once(self) -> Iterator[Record]:
|
|
74
|
+
self._ws = create_connection(self._url, timeout=self._open_timeout)
|
|
75
|
+
try:
|
|
76
|
+
if self._subscribe is not None:
|
|
77
|
+
message = (
|
|
78
|
+
self._subscribe
|
|
79
|
+
if isinstance(self._subscribe, str)
|
|
80
|
+
else json.dumps(self._subscribe)
|
|
81
|
+
)
|
|
82
|
+
self._ws.send(message)
|
|
83
|
+
self._ws.settimeout(None) # block waiting for pushed messages
|
|
84
|
+
while True:
|
|
85
|
+
frame = self._ws.recv()
|
|
86
|
+
if frame:
|
|
87
|
+
text = frame if isinstance(frame, str) else frame.decode("utf-8", "replace")
|
|
88
|
+
yield Record(value=text, origin=self._origin)
|
|
89
|
+
finally:
|
|
90
|
+
if self._ws is not None:
|
|
91
|
+
self._ws.close()
|
|
92
|
+
self._ws = None
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
"""Stop reconnecting and close the WebSocket connection."""
|
|
96
|
+
self._running = False
|
|
97
|
+
if self._ws is not None:
|
|
98
|
+
self._ws.close()
|
|
99
|
+
self._ws = None
|
ingestor/app.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Core ingestion use case: read from a Source and write to a Sink.
|
|
2
|
+
|
|
3
|
+
Talks only to the ports. The same ``ingest`` serves streaming, batch and
|
|
4
|
+
micro-batch - only the injected Source changes (and ``limit``, for micro-batch).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ingestor.ports import Sink, Source
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ingest(source: Source, sink: Sink, limit: int | None = None) -> int:
|
|
11
|
+
"""Read every record from ``source`` and write it to ``sink``.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
source: Where records are read from.
|
|
15
|
+
sink: Where records are written to.
|
|
16
|
+
limit: Stop after this many records; ``None`` reads until the source is
|
|
17
|
+
exhausted.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The number of records ingested.
|
|
21
|
+
"""
|
|
22
|
+
total = 0
|
|
23
|
+
try:
|
|
24
|
+
for record in source.read():
|
|
25
|
+
sink.write(record)
|
|
26
|
+
total += 1
|
|
27
|
+
if limit is not None and total >= limit:
|
|
28
|
+
break
|
|
29
|
+
finally:
|
|
30
|
+
source.close()
|
|
31
|
+
sink.close()
|
|
32
|
+
return total
|
ingestor/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Composition Root: picks the concrete tools and injects them into the logic.
|
|
2
|
+
|
|
3
|
+
Demo: seeds a few records into Kafka and then ingests from Kafka to the console.
|
|
4
|
+
To change source/destination, just build another Source/Sink here - app.py stays.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from confluent_kafka import KafkaException
|
|
11
|
+
|
|
12
|
+
from ingestor.adapters.console import ConsoleSink
|
|
13
|
+
from ingestor.adapters.kafka import KafkaConnection, KafkaConnectionInfo
|
|
14
|
+
from ingestor.app import ingest
|
|
15
|
+
from ingestor.ports import Record
|
|
16
|
+
|
|
17
|
+
TOPIC = "demo-messages"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
"""Run the Kafka-to-console demo.
|
|
22
|
+
|
|
23
|
+
Seeds a few records into the topic, then ingests them to the console.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Process exit code: ``0`` on success, ``1`` if Kafka cannot be reached.
|
|
27
|
+
"""
|
|
28
|
+
server = os.getenv("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092")
|
|
29
|
+
connection = KafkaConnection(KafkaConnectionInfo(brokers=server.split(","), topics=[TOPIC]))
|
|
30
|
+
try:
|
|
31
|
+
info = connection.connect()
|
|
32
|
+
if info.broker_count == 0:
|
|
33
|
+
print(
|
|
34
|
+
"No Kafka brokers reachable. Is Kafka running? Start it with: docker compose up -d",
|
|
35
|
+
file=sys.stderr,
|
|
36
|
+
)
|
|
37
|
+
return 1
|
|
38
|
+
print(f"Connected to Kafka: {info.broker_count} broker(s).")
|
|
39
|
+
|
|
40
|
+
# (demo) seed a few records into the topic
|
|
41
|
+
kafka_sink = connection.sink()
|
|
42
|
+
for i in range(1, 6):
|
|
43
|
+
kafka_sink.write(Record(value=f"Hello Kafka #{i}", key=str(i), origin=TOPIC))
|
|
44
|
+
kafka_sink.close()
|
|
45
|
+
|
|
46
|
+
# ingest from Kafka to the console
|
|
47
|
+
total = ingest(connection.source(), ConsoleSink(), limit=5)
|
|
48
|
+
print(f"Ingested {total} records.")
|
|
49
|
+
return 0
|
|
50
|
+
except KafkaException as error:
|
|
51
|
+
print(f"Failed to connect to Kafka: {error}", file=sys.stderr)
|
|
52
|
+
return 1
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The ports (interfaces) and the domain model, all in one place."""
|
|
2
|
+
|
|
3
|
+
from ingestor.ports.connection import Connection, ConnectionInfo
|
|
4
|
+
from ingestor.ports.record import Record
|
|
5
|
+
from ingestor.ports.sink import Sink
|
|
6
|
+
from ingestor.ports.source import Source
|
|
7
|
+
|
|
8
|
+
__all__ = ["Record", "Source", "Sink", "Connection", "ConnectionInfo"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Connection port: builds a tool's Source and Sink.
|
|
2
|
+
|
|
3
|
+
Generic - it knows nothing about Kafka. Each tool implements this interface by
|
|
4
|
+
returning its own Source/Sink, which satisfy the abstract ports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from ingestor.ports.sink import Sink
|
|
10
|
+
from ingestor.ports.source import Source
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectionInfo(Protocol):
|
|
14
|
+
"""Marker for a tool's connection config (Kafka, Rabbit, file, ...)."""
|
|
15
|
+
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Connection(Protocol):
|
|
20
|
+
"""Factory that builds a Source and a Sink for one tool."""
|
|
21
|
+
|
|
22
|
+
def source(self) -> Source:
|
|
23
|
+
"""Build the input adapter.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A Source that yields records from the tool.
|
|
27
|
+
"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def sink(self) -> Sink:
|
|
31
|
+
"""Build the output adapter.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A Sink that writes records to the tool.
|
|
35
|
+
"""
|
|
36
|
+
...
|
ingestor/ports/record.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Domain record that flows through ingestion.
|
|
2
|
+
|
|
3
|
+
Tool-agnostic: it knows nothing about Kafka, files or any other source.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Record:
|
|
11
|
+
"""A single unit of data read from a source or written to a sink.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
value: The record payload.
|
|
15
|
+
key: Optional partitioning/identity key.
|
|
16
|
+
origin: Where the record came from (topic, file name, table...).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
value: str
|
|
20
|
+
key: str | None = None
|
|
21
|
+
origin: str | None = None
|
ingestor/ports/sink.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Output port: a destination that receives records (data lake, Kafka, console...)."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from ingestor.ports.record import Record
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Sink(Protocol):
|
|
9
|
+
"""Contract for anything that consumes records."""
|
|
10
|
+
|
|
11
|
+
def write(self, record: Record) -> None:
|
|
12
|
+
"""Write a single record to the destination.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
record: The record to persist.
|
|
16
|
+
"""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def close(self) -> None:
|
|
20
|
+
"""Flush and release resources.
|
|
21
|
+
|
|
22
|
+
Make sure everything was written before closing.
|
|
23
|
+
"""
|
|
24
|
+
...
|
ingestor/ports/source.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Input port: a source that yields records.
|
|
2
|
+
|
|
3
|
+
Streaming, batch and micro-batch are all Sources - only how ``read`` iterates
|
|
4
|
+
changes: continuous for streaming, finite for batch, windowed for micro-batch.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from ingestor.ports.record import Record
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Source(Protocol):
|
|
14
|
+
"""Contract for anything that produces records."""
|
|
15
|
+
|
|
16
|
+
def read(self) -> Iterator[Record]:
|
|
17
|
+
"""Yield records one by one.
|
|
18
|
+
|
|
19
|
+
Yields:
|
|
20
|
+
The next record read from the underlying system.
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
"""Release any resources held by the source."""
|
|
26
|
+
...
|
ingestor/testing.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""In-memory test doubles of the ports.
|
|
2
|
+
|
|
3
|
+
These satisfy the Source, Sink and Connection ports without any real tool, so you
|
|
4
|
+
can test an ingestion pipeline without Kafka or Docker, in this project or in your
|
|
5
|
+
own. Import them as ``from ingestor.testing import ListSource, MemorySink``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
|
|
10
|
+
from ingestor.ports import Record
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ListSource:
|
|
14
|
+
"""Finite source: yields a ready list of records (batch-like)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, records: list[Record]) -> None:
|
|
17
|
+
self._records = records
|
|
18
|
+
self.closed = False
|
|
19
|
+
|
|
20
|
+
def read(self) -> Iterator[Record]:
|
|
21
|
+
yield from self._records
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
self.closed = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MemorySink:
|
|
28
|
+
"""Sink that collects records in memory, for inspection."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self.records: list[Record] = []
|
|
32
|
+
self.closed = False
|
|
33
|
+
|
|
34
|
+
def write(self, record: Record) -> None:
|
|
35
|
+
self.records.append(record)
|
|
36
|
+
|
|
37
|
+
def close(self) -> None:
|
|
38
|
+
self.closed = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeConnection:
|
|
42
|
+
"""Connection without any Kafka - proves tool swappability."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, records: list[Record]) -> None:
|
|
45
|
+
self._records = records
|
|
46
|
+
self.used_sink = MemorySink()
|
|
47
|
+
|
|
48
|
+
def source(self) -> ListSource:
|
|
49
|
+
return ListSource(self._records)
|
|
50
|
+
|
|
51
|
+
def sink(self) -> MemorySink:
|
|
52
|
+
return self.used_sink
|