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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ingestor = ingestor.cli:main
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
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m ingestor``."""
2
+
3
+ from ingestor.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,5 @@
1
+ """Batch adapters: finite sources (files, tables, ...)."""
2
+
3
+ from ingestor.adapters.batch.file_source import FileSource
4
+
5
+ __all__ = ["FileSource"]
@@ -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,4 @@
1
+ from ingestor.adapters.console.json_sink import JsonFieldsConsoleSink
2
+ from ingestor.adapters.console.sink import ConsoleSink
3
+
4
+ __all__ = ["ConsoleSink", "JsonFieldsConsoleSink"]
@@ -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,5 @@
1
+ """SSE adapters: streaming sources over Server-Sent Events."""
2
+
3
+ from ingestor.adapters.sse.source import SSESource
4
+
5
+ __all__ = ["SSESource"]
@@ -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,5 @@
1
+ """Adapters that fan records out to multiple sinks."""
2
+
3
+ from ingestor.adapters.tee.sink import TeeSink
4
+
5
+ __all__ = ["TeeSink"]
@@ -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,5 @@
1
+ """WebSocket adapters: streaming sources over WebSocket connections."""
2
+
3
+ from ingestor.adapters.ws.source import WebSocketSource
4
+
5
+ __all__ = ["WebSocketSource"]
@@ -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
+ ...
@@ -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
+ ...
@@ -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