robotframework-okw-kafka 0.1.1__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.
- robotframework_okw_kafka-0.1.1/LICENSE +17 -0
- robotframework_okw_kafka-0.1.1/PKG-INFO +134 -0
- robotframework_okw_kafka-0.1.1/README.md +101 -0
- robotframework_okw_kafka-0.1.1/pyproject.toml +48 -0
- robotframework_okw_kafka-0.1.1/setup.cfg +4 -0
- robotframework_okw_kafka-0.1.1/src/okw_kafka/__init__.py +5 -0
- robotframework_okw_kafka-0.1.1/src/okw_kafka/kafka_context.py +339 -0
- robotframework_okw_kafka-0.1.1/src/okw_kafka/library.py +528 -0
- robotframework_okw_kafka-0.1.1/src/robotframework_okw_kafka.egg-info/PKG-INFO +134 -0
- robotframework_okw_kafka-0.1.1/src/robotframework_okw_kafka.egg-info/SOURCES.txt +12 -0
- robotframework_okw_kafka-0.1.1/src/robotframework_okw_kafka.egg-info/dependency_links.txt +1 -0
- robotframework_okw_kafka-0.1.1/src/robotframework_okw_kafka.egg-info/requires.txt +4 -0
- robotframework_okw_kafka-0.1.1/src/robotframework_okw_kafka.egg-info/top_level.txt +1 -0
- robotframework_okw_kafka-0.1.1/tests/test_contract.py +382 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 19 November 2007
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2026 Zoltan Hrabovszki
|
|
5
|
+
|
|
6
|
+
This program is free software: you can redistribute it and/or modify
|
|
7
|
+
it under the terms of the GNU Affero General Public License as
|
|
8
|
+
published by the Free Software Foundation, either version 3 of the
|
|
9
|
+
License, or (at your option) any later version.
|
|
10
|
+
|
|
11
|
+
This program is distributed in the hope that it will be useful,
|
|
12
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
GNU Affero General Public License for more details.
|
|
15
|
+
|
|
16
|
+
You should have received a copy of the GNU Affero General Public License
|
|
17
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robotframework-okw-kafka
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Robot Framework keyword library for Apache Kafka integration testing — produce, consume, and verify messages using the OKW keyword pattern.
|
|
5
|
+
Author-email: Zoltan Hrabovszki <hrabovszki@googlemail.com>
|
|
6
|
+
License-Expression: AGPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/Hrabovszki1023/robotframework-okw-kafka
|
|
8
|
+
Project-URL: Repository, https://github.com/Hrabovszki1023/robotframework-okw-kafka
|
|
9
|
+
Project-URL: Documentation, https://hrabovszki1023.github.io/robotframework-okw-kafka/
|
|
10
|
+
Project-URL: Keyword Documentation, https://hrabovszki1023.github.io/robotframework-okw-kafka/
|
|
11
|
+
Project-URL: Runnable Examples, https://github.com/Hrabovszki1023/okw-examples/tree/main/kafka/
|
|
12
|
+
Keywords: robotframework,kafka,testing,okw,keyword-driven
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: Robot Framework
|
|
15
|
+
Classifier: Framework :: Robot Framework :: Library
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: robotframework>=6.0
|
|
29
|
+
Requires-Dist: confluent-kafka>=2.0
|
|
30
|
+
Requires-Dist: okw-contract-utils>=0.2.0
|
|
31
|
+
Requires-Dist: PyYAML>=6.0
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# robotframework-okw-kafka
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/robotframework-okw-kafka/)
|
|
37
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
38
|
+
|
|
39
|
+
Robot Framework keyword library for Apache Kafka integration testing —
|
|
40
|
+
produce, consume, and verify messages using the OKW keyword pattern.
|
|
41
|
+
|
|
42
|
+
> Deutsche Version: [README_de.md](README_de.md)
|
|
43
|
+
|
|
44
|
+
## Signal vs. NOISE
|
|
45
|
+
|
|
46
|
+
| Signal (your test) | NOISE (hidden in YAML) |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `KafkaSetValue $.action create` | Bootstrap servers, serializers |
|
|
49
|
+
| `KafkaConsume order-events TIMEOUT=30` | Consumer group, offset config |
|
|
50
|
+
| `KafkaVerifyValue $.status completed` | SSL, authentication, partitions |
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install robotframework-okw-kafka
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Minimal Example
|
|
59
|
+
|
|
60
|
+
```robot
|
|
61
|
+
*** Settings ***
|
|
62
|
+
Library okw_kafka.library.OkwKafkaLibrary
|
|
63
|
+
|
|
64
|
+
*** Test Cases ***
|
|
65
|
+
Order Event Roundtrip
|
|
66
|
+
KafkaStart kafka-local
|
|
67
|
+
KafkaSetTopic order-commands
|
|
68
|
+
KafkaSetValue $.action create
|
|
69
|
+
KafkaSetValue $.item Widget-42
|
|
70
|
+
KafkaProduce
|
|
71
|
+
|
|
72
|
+
KafkaConsume order-events FILTER=$.item=Widget-42 TIMEOUT=30
|
|
73
|
+
KafkaVerifyValue $.status created
|
|
74
|
+
KafkaMemorizeValue $.orderId ORDER_ID
|
|
75
|
+
|
|
76
|
+
KafkaStop
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Keyword Pattern
|
|
80
|
+
|
|
81
|
+
The same pattern across all OKW technologies:
|
|
82
|
+
|
|
83
|
+
| Phase | GUI | REST | SSH | Kafka |
|
|
84
|
+
|---|---|---|---|---|
|
|
85
|
+
| Scope | `SelectWindow` | `RESTSelectEndpoint` | `Open Remote Session` | `KafkaSetTopic` |
|
|
86
|
+
| Input | `SetValue` | `RESTSetValue` | `Set Remote` | `KafkaSetValue` |
|
|
87
|
+
| Action | `ClickOn` | `RESTSendRequest` | `Execute Remote` | `KafkaProduce` |
|
|
88
|
+
| Verify | `VerifyValue` | `RESTVerifyValue` | `Verify Remote Response` | `KafkaVerifyValue` |
|
|
89
|
+
|
|
90
|
+
## YAML Configuration
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
# configs/kafka-local.yaml
|
|
94
|
+
bootstrap_servers: "localhost:9092"
|
|
95
|
+
consumer_group: "okw-test"
|
|
96
|
+
auto_offset_reset: "latest"
|
|
97
|
+
value_serializer: "json"
|
|
98
|
+
value_deserializer: "json"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Keywords
|
|
102
|
+
|
|
103
|
+
### Session
|
|
104
|
+
- `KafkaStart` / `KafkaStop`
|
|
105
|
+
|
|
106
|
+
### Produce
|
|
107
|
+
- `KafkaSetTopic`, `KafkaSetValue`, `KafkaSetValueAsString`
|
|
108
|
+
- `KafkaSetHeader`, `KafkaSetKey`, `KafkaProduce`
|
|
109
|
+
|
|
110
|
+
### Consume
|
|
111
|
+
- `KafkaConsume` (timeout-based polling, optional JSONPath filter)
|
|
112
|
+
- `KafkaConsumeLatest`
|
|
113
|
+
|
|
114
|
+
### Verify
|
|
115
|
+
- `KafkaVerifyValue` / `KafkaVerifyValueWCM` / `KafkaVerifyValueREGX`
|
|
116
|
+
- `KafkaVerifyHeader`, `KafkaVerifyKey`
|
|
117
|
+
|
|
118
|
+
### Memorize
|
|
119
|
+
- `KafkaMemorizeValue`, `KafkaMemorizeKey`
|
|
120
|
+
|
|
121
|
+
### Admin
|
|
122
|
+
- `KafkaVerifyTopicExists`, `KafkaVerifyPartitionCount`, `KafkaLogTopics`
|
|
123
|
+
|
|
124
|
+
## Keyword Documentation
|
|
125
|
+
|
|
126
|
+
[Libdoc (GitHub Pages)](https://hrabovszki1023.github.io/robotframework-okw-kafka/)
|
|
127
|
+
|
|
128
|
+
## Runnable Examples
|
|
129
|
+
|
|
130
|
+
[okw-examples/kafka/](https://github.com/Hrabovszki1023/okw-examples/tree/main/kafka/)
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
AGPL-3.0 — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# robotframework-okw-kafka
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/robotframework-okw-kafka/)
|
|
4
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
5
|
+
|
|
6
|
+
Robot Framework keyword library for Apache Kafka integration testing —
|
|
7
|
+
produce, consume, and verify messages using the OKW keyword pattern.
|
|
8
|
+
|
|
9
|
+
> Deutsche Version: [README_de.md](README_de.md)
|
|
10
|
+
|
|
11
|
+
## Signal vs. NOISE
|
|
12
|
+
|
|
13
|
+
| Signal (your test) | NOISE (hidden in YAML) |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `KafkaSetValue $.action create` | Bootstrap servers, serializers |
|
|
16
|
+
| `KafkaConsume order-events TIMEOUT=30` | Consumer group, offset config |
|
|
17
|
+
| `KafkaVerifyValue $.status completed` | SSL, authentication, partitions |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install robotframework-okw-kafka
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Minimal Example
|
|
26
|
+
|
|
27
|
+
```robot
|
|
28
|
+
*** Settings ***
|
|
29
|
+
Library okw_kafka.library.OkwKafkaLibrary
|
|
30
|
+
|
|
31
|
+
*** Test Cases ***
|
|
32
|
+
Order Event Roundtrip
|
|
33
|
+
KafkaStart kafka-local
|
|
34
|
+
KafkaSetTopic order-commands
|
|
35
|
+
KafkaSetValue $.action create
|
|
36
|
+
KafkaSetValue $.item Widget-42
|
|
37
|
+
KafkaProduce
|
|
38
|
+
|
|
39
|
+
KafkaConsume order-events FILTER=$.item=Widget-42 TIMEOUT=30
|
|
40
|
+
KafkaVerifyValue $.status created
|
|
41
|
+
KafkaMemorizeValue $.orderId ORDER_ID
|
|
42
|
+
|
|
43
|
+
KafkaStop
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Keyword Pattern
|
|
47
|
+
|
|
48
|
+
The same pattern across all OKW technologies:
|
|
49
|
+
|
|
50
|
+
| Phase | GUI | REST | SSH | Kafka |
|
|
51
|
+
|---|---|---|---|---|
|
|
52
|
+
| Scope | `SelectWindow` | `RESTSelectEndpoint` | `Open Remote Session` | `KafkaSetTopic` |
|
|
53
|
+
| Input | `SetValue` | `RESTSetValue` | `Set Remote` | `KafkaSetValue` |
|
|
54
|
+
| Action | `ClickOn` | `RESTSendRequest` | `Execute Remote` | `KafkaProduce` |
|
|
55
|
+
| Verify | `VerifyValue` | `RESTVerifyValue` | `Verify Remote Response` | `KafkaVerifyValue` |
|
|
56
|
+
|
|
57
|
+
## YAML Configuration
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
# configs/kafka-local.yaml
|
|
61
|
+
bootstrap_servers: "localhost:9092"
|
|
62
|
+
consumer_group: "okw-test"
|
|
63
|
+
auto_offset_reset: "latest"
|
|
64
|
+
value_serializer: "json"
|
|
65
|
+
value_deserializer: "json"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Keywords
|
|
69
|
+
|
|
70
|
+
### Session
|
|
71
|
+
- `KafkaStart` / `KafkaStop`
|
|
72
|
+
|
|
73
|
+
### Produce
|
|
74
|
+
- `KafkaSetTopic`, `KafkaSetValue`, `KafkaSetValueAsString`
|
|
75
|
+
- `KafkaSetHeader`, `KafkaSetKey`, `KafkaProduce`
|
|
76
|
+
|
|
77
|
+
### Consume
|
|
78
|
+
- `KafkaConsume` (timeout-based polling, optional JSONPath filter)
|
|
79
|
+
- `KafkaConsumeLatest`
|
|
80
|
+
|
|
81
|
+
### Verify
|
|
82
|
+
- `KafkaVerifyValue` / `KafkaVerifyValueWCM` / `KafkaVerifyValueREGX`
|
|
83
|
+
- `KafkaVerifyHeader`, `KafkaVerifyKey`
|
|
84
|
+
|
|
85
|
+
### Memorize
|
|
86
|
+
- `KafkaMemorizeValue`, `KafkaMemorizeKey`
|
|
87
|
+
|
|
88
|
+
### Admin
|
|
89
|
+
- `KafkaVerifyTopicExists`, `KafkaVerifyPartitionCount`, `KafkaLogTopics`
|
|
90
|
+
|
|
91
|
+
## Keyword Documentation
|
|
92
|
+
|
|
93
|
+
[Libdoc (GitHub Pages)](https://hrabovszki1023.github.io/robotframework-okw-kafka/)
|
|
94
|
+
|
|
95
|
+
## Runnable Examples
|
|
96
|
+
|
|
97
|
+
[okw-examples/kafka/](https://github.com/Hrabovszki1023/okw-examples/tree/main/kafka/)
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
AGPL-3.0 — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "robotframework-okw-kafka"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Robot Framework keyword library for Apache Kafka integration testing — produce, consume, and verify messages using the OKW keyword pattern."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "AGPL-3.0-only"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Zoltan Hrabovszki", email = "hrabovszki@googlemail.com"},
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: Robot Framework",
|
|
18
|
+
"Framework :: Robot Framework :: Library",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Software Development :: Testing",
|
|
28
|
+
]
|
|
29
|
+
keywords = ["robotframework", "kafka", "testing", "okw", "keyword-driven"]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"robotframework>=6.0",
|
|
32
|
+
"confluent-kafka>=2.0",
|
|
33
|
+
"okw-contract-utils>=0.2.0",
|
|
34
|
+
"PyYAML>=6.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/Hrabovszki1023/robotframework-okw-kafka"
|
|
39
|
+
Repository = "https://github.com/Hrabovszki1023/robotframework-okw-kafka"
|
|
40
|
+
Documentation = "https://hrabovszki1023.github.io/robotframework-okw-kafka/"
|
|
41
|
+
"Keyword Documentation" = "https://hrabovszki1023.github.io/robotframework-okw-kafka/"
|
|
42
|
+
"Runnable Examples" = "https://github.com/Hrabovszki1023/okw-examples/tree/main/kafka/"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""KafkaContext — mutable state for one Kafka session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import confluent_kafka
|
|
11
|
+
import confluent_kafka.admin
|
|
12
|
+
except ImportError:
|
|
13
|
+
confluent_kafka = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KafkaContext:
|
|
17
|
+
"""Holds producer, consumer, admin client and message build state."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: dict, backend: str = "confluent"):
|
|
20
|
+
self._config = config
|
|
21
|
+
self._backend = backend
|
|
22
|
+
|
|
23
|
+
self._producer = None
|
|
24
|
+
self._consumer = None
|
|
25
|
+
self._admin = None
|
|
26
|
+
|
|
27
|
+
self._value_serializer = config.get("value_serializer", "json")
|
|
28
|
+
self._value_deserializer = config.get("value_deserializer", "json")
|
|
29
|
+
|
|
30
|
+
self._current_topic: str | None = None
|
|
31
|
+
self._message_body: dict = {}
|
|
32
|
+
self._message_headers: dict[str, str] = {}
|
|
33
|
+
self._message_key: str | None = None
|
|
34
|
+
|
|
35
|
+
self._last_message_value: dict | str | None = None
|
|
36
|
+
self._last_message_key: str | None = None
|
|
37
|
+
self._last_message_headers: dict[str, str] = {}
|
|
38
|
+
|
|
39
|
+
self._stub_messages: dict[str, list] = defaultdict(list)
|
|
40
|
+
|
|
41
|
+
if self._backend == "confluent":
|
|
42
|
+
self._ensure_confluent()
|
|
43
|
+
|
|
44
|
+
def _ensure_confluent(self):
|
|
45
|
+
if confluent_kafka is None:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"confluent-kafka is not installed. "
|
|
48
|
+
"Install it with: pip install confluent-kafka"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def _build_confluent_config(self, *, consumer: bool = False) -> dict:
|
|
52
|
+
cfg = {"bootstrap.servers": self._config.get("bootstrap_servers", "localhost:9092")}
|
|
53
|
+
|
|
54
|
+
if self._config.get("security_protocol"):
|
|
55
|
+
cfg["security.protocol"] = self._config["security_protocol"]
|
|
56
|
+
if self._config.get("sasl_mechanism"):
|
|
57
|
+
cfg["sasl.mechanism"] = self._config["sasl_mechanism"]
|
|
58
|
+
if self._config.get("sasl_username"):
|
|
59
|
+
cfg["sasl.username"] = self._config["sasl_username"]
|
|
60
|
+
if self._config.get("sasl_password"):
|
|
61
|
+
cfg["sasl.password"] = self._config["sasl_password"]
|
|
62
|
+
if self._config.get("ssl_ca_location"):
|
|
63
|
+
cfg["ssl.ca.location"] = self._config["ssl_ca_location"]
|
|
64
|
+
|
|
65
|
+
if consumer:
|
|
66
|
+
cfg["group.id"] = self._config.get("consumer_group", "okw-test")
|
|
67
|
+
cfg["auto.offset.reset"] = self._config.get("auto_offset_reset", "latest")
|
|
68
|
+
cfg["enable.auto.commit"] = False
|
|
69
|
+
|
|
70
|
+
return cfg
|
|
71
|
+
|
|
72
|
+
# ── Producer ────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def _ensure_producer(self):
|
|
75
|
+
if self._backend == "stub":
|
|
76
|
+
return
|
|
77
|
+
if self._producer is None:
|
|
78
|
+
self._producer = confluent_kafka.Producer(self._build_confluent_config())
|
|
79
|
+
|
|
80
|
+
def produce(self, topic: str, value: dict | str, key: str | None,
|
|
81
|
+
headers: dict[str, str]):
|
|
82
|
+
serialized = self._serialize(value)
|
|
83
|
+
key_bytes = key.encode("utf-8") if key else None
|
|
84
|
+
header_tuples = [(k, v.encode("utf-8")) for k, v in headers.items()]
|
|
85
|
+
|
|
86
|
+
if self._backend == "stub":
|
|
87
|
+
self._stub_messages[topic].append({
|
|
88
|
+
"value": value,
|
|
89
|
+
"key": key,
|
|
90
|
+
"headers": dict(headers),
|
|
91
|
+
})
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
self._ensure_producer()
|
|
95
|
+
self._producer.produce(
|
|
96
|
+
topic,
|
|
97
|
+
value=serialized,
|
|
98
|
+
key=key_bytes,
|
|
99
|
+
headers=header_tuples if header_tuples else None,
|
|
100
|
+
)
|
|
101
|
+
self._producer.flush(timeout=10)
|
|
102
|
+
|
|
103
|
+
def _serialize(self, value) -> bytes:
|
|
104
|
+
if self._value_serializer == "json":
|
|
105
|
+
return json.dumps(value).encode("utf-8")
|
|
106
|
+
return str(value).encode("utf-8")
|
|
107
|
+
|
|
108
|
+
def _deserialize(self, data: bytes):
|
|
109
|
+
if self._value_deserializer == "json":
|
|
110
|
+
return json.loads(data.decode("utf-8"))
|
|
111
|
+
return data.decode("utf-8")
|
|
112
|
+
|
|
113
|
+
# ── Consumer ────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def consume(self, topic: str, filter_fn=None, timeout: int = 30):
|
|
116
|
+
if self._backend == "stub":
|
|
117
|
+
return self._stub_consume(topic, filter_fn)
|
|
118
|
+
|
|
119
|
+
self._ensure_confluent()
|
|
120
|
+
if self._consumer is None:
|
|
121
|
+
self._consumer = confluent_kafka.Consumer(
|
|
122
|
+
self._build_confluent_config(consumer=True)
|
|
123
|
+
)
|
|
124
|
+
self._consumer.subscribe([topic])
|
|
125
|
+
|
|
126
|
+
deadline = time.time() + timeout
|
|
127
|
+
while time.time() < deadline:
|
|
128
|
+
msg = self._consumer.poll(1.0)
|
|
129
|
+
if msg is None:
|
|
130
|
+
continue
|
|
131
|
+
if msg.error():
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
value = self._deserialize(msg.value())
|
|
135
|
+
if filter_fn and not filter_fn(value):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
self._store_consumed(value, msg.key(), msg.headers())
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"KafkaConsume: No matching message on '{topic}' within {timeout}s."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def consume_latest(self, topic: str, timeout: int = 30):
|
|
146
|
+
if self._backend == "stub":
|
|
147
|
+
return self._stub_consume_latest(topic)
|
|
148
|
+
|
|
149
|
+
self._ensure_confluent()
|
|
150
|
+
if self._consumer is None:
|
|
151
|
+
self._consumer = confluent_kafka.Consumer(
|
|
152
|
+
self._build_confluent_config(consumer=True)
|
|
153
|
+
)
|
|
154
|
+
self._consumer.subscribe([topic])
|
|
155
|
+
|
|
156
|
+
# Seek to end, then poll for new messages
|
|
157
|
+
deadline = time.time() + timeout
|
|
158
|
+
while time.time() < deadline:
|
|
159
|
+
msg = self._consumer.poll(1.0)
|
|
160
|
+
if msg is None:
|
|
161
|
+
continue
|
|
162
|
+
if msg.error():
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
value = self._deserialize(msg.value())
|
|
166
|
+
self._store_consumed(value, msg.key(), msg.headers())
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"KafkaConsumeLatest: No message on '{topic}' within {timeout}s."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _store_consumed(self, value, raw_key, raw_headers):
|
|
174
|
+
self._last_message_value = value
|
|
175
|
+
self._last_message_key = raw_key.decode("utf-8") if isinstance(raw_key, bytes) else raw_key
|
|
176
|
+
self._last_message_headers = {}
|
|
177
|
+
if raw_headers:
|
|
178
|
+
for k, v in raw_headers:
|
|
179
|
+
self._last_message_headers[k] = v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
|
180
|
+
|
|
181
|
+
def _stub_consume(self, topic: str, filter_fn=None):
|
|
182
|
+
messages = self._stub_messages.get(topic, [])
|
|
183
|
+
for msg in messages:
|
|
184
|
+
if filter_fn and not filter_fn(msg["value"]):
|
|
185
|
+
continue
|
|
186
|
+
self._last_message_value = msg["value"]
|
|
187
|
+
self._last_message_key = msg["key"]
|
|
188
|
+
self._last_message_headers = msg["headers"]
|
|
189
|
+
return
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
f"KafkaConsume (stub): No matching message on '{topic}'."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _stub_consume_latest(self, topic: str):
|
|
195
|
+
messages = self._stub_messages.get(topic, [])
|
|
196
|
+
if not messages:
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
f"KafkaConsumeLatest (stub): No messages on '{topic}'."
|
|
199
|
+
)
|
|
200
|
+
msg = messages[-1]
|
|
201
|
+
self._last_message_value = msg["value"]
|
|
202
|
+
self._last_message_key = msg["key"]
|
|
203
|
+
self._last_message_headers = msg["headers"]
|
|
204
|
+
|
|
205
|
+
# ── Admin ───────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def _ensure_admin(self):
|
|
208
|
+
if self._backend == "stub":
|
|
209
|
+
return
|
|
210
|
+
self._ensure_confluent()
|
|
211
|
+
if self._admin is None:
|
|
212
|
+
self._admin = confluent_kafka.admin.AdminClient(
|
|
213
|
+
self._build_confluent_config()
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def list_topics(self, timeout: float = 10) -> list[str]:
|
|
217
|
+
if self._backend == "stub":
|
|
218
|
+
return sorted(self._stub_messages.keys())
|
|
219
|
+
|
|
220
|
+
self._ensure_admin()
|
|
221
|
+
metadata = self._admin.list_topics(timeout=timeout)
|
|
222
|
+
return sorted(metadata.topics.keys())
|
|
223
|
+
|
|
224
|
+
def get_topic_partitions(self, topic: str, timeout: float = 10) -> int:
|
|
225
|
+
if self._backend == "stub":
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
self._ensure_admin()
|
|
229
|
+
metadata = self._admin.list_topics(topic=topic, timeout=timeout)
|
|
230
|
+
if topic not in metadata.topics:
|
|
231
|
+
raise RuntimeError(f"Topic '{topic}' not found.")
|
|
232
|
+
return len(metadata.topics[topic].partitions)
|
|
233
|
+
|
|
234
|
+
# ── Value resolution (JSONPath-like) ────────────────────────
|
|
235
|
+
|
|
236
|
+
def resolve_value(self, jsonpath: str) -> str:
|
|
237
|
+
if self._last_message_value is None:
|
|
238
|
+
raise RuntimeError("No consumed message available.")
|
|
239
|
+
path = self._strip_jsonpath(jsonpath)
|
|
240
|
+
return str(self._resolve_path(self._last_message_value, path))
|
|
241
|
+
|
|
242
|
+
def set_value(self, jsonpath: str, value, force_string: bool = False):
|
|
243
|
+
path = self._strip_jsonpath(jsonpath)
|
|
244
|
+
typed = value if force_string else self._auto_type(value)
|
|
245
|
+
self._set_nested(self._message_body, path, typed)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _strip_jsonpath(path: str) -> str:
|
|
249
|
+
if path.startswith("$."):
|
|
250
|
+
return path[2:]
|
|
251
|
+
if path.startswith("$"):
|
|
252
|
+
return path[1:]
|
|
253
|
+
return path
|
|
254
|
+
|
|
255
|
+
def reset_message(self):
|
|
256
|
+
self._message_body = {}
|
|
257
|
+
self._message_headers = {}
|
|
258
|
+
self._message_key = None
|
|
259
|
+
|
|
260
|
+
# ── Helpers (same as RestContext) ────────────────────────────
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _resolve_path(obj, path: str):
|
|
264
|
+
for part in path.split("."):
|
|
265
|
+
if "[" in part:
|
|
266
|
+
key, idx_str = part.split("[", 1)
|
|
267
|
+
idx = int(idx_str.rstrip("]"))
|
|
268
|
+
if key:
|
|
269
|
+
obj = obj[key]
|
|
270
|
+
obj = obj[idx]
|
|
271
|
+
else:
|
|
272
|
+
obj = obj[part]
|
|
273
|
+
return obj
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _set_nested(target: dict, path: str, value):
|
|
277
|
+
parts = path.split(".")
|
|
278
|
+
for part in parts[:-1]:
|
|
279
|
+
if "[" in part:
|
|
280
|
+
key, idx_str = part.split("[", 1)
|
|
281
|
+
idx = int(idx_str.rstrip("]"))
|
|
282
|
+
if key:
|
|
283
|
+
target.setdefault(key, [])
|
|
284
|
+
target = target[key]
|
|
285
|
+
while len(target) <= idx:
|
|
286
|
+
target.append({})
|
|
287
|
+
target = target[idx]
|
|
288
|
+
else:
|
|
289
|
+
target.setdefault(part, {})
|
|
290
|
+
target = target[part]
|
|
291
|
+
leaf = parts[-1]
|
|
292
|
+
if "[" in leaf:
|
|
293
|
+
key, idx_str = leaf.split("[", 1)
|
|
294
|
+
idx = int(idx_str.rstrip("]"))
|
|
295
|
+
if key:
|
|
296
|
+
target.setdefault(key, [])
|
|
297
|
+
target = target[key]
|
|
298
|
+
while len(target) <= idx:
|
|
299
|
+
target.append(None)
|
|
300
|
+
target[idx] = value
|
|
301
|
+
else:
|
|
302
|
+
target[leaf] = value
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _auto_type(value: str):
|
|
306
|
+
if not isinstance(value, str):
|
|
307
|
+
return value
|
|
308
|
+
if value.lower() == "true":
|
|
309
|
+
return True
|
|
310
|
+
if value.lower() == "false":
|
|
311
|
+
return False
|
|
312
|
+
if value.lower() == "null" or value == "$NULL":
|
|
313
|
+
return None
|
|
314
|
+
try:
|
|
315
|
+
return int(value)
|
|
316
|
+
except ValueError:
|
|
317
|
+
pass
|
|
318
|
+
try:
|
|
319
|
+
return float(value)
|
|
320
|
+
except ValueError:
|
|
321
|
+
pass
|
|
322
|
+
return value
|
|
323
|
+
|
|
324
|
+
# ── Teardown ────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
def close(self):
|
|
327
|
+
if self._consumer:
|
|
328
|
+
try:
|
|
329
|
+
self._consumer.close()
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
self._consumer = None
|
|
333
|
+
if self._producer:
|
|
334
|
+
try:
|
|
335
|
+
self._producer.flush(timeout=5)
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
self._producer = None
|
|
339
|
+
self._admin = None
|