hexkit 4.4.0__tar.gz → 4.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hexkit-4.4.0/src/hexkit.egg-info → hexkit-4.5.0}/PKG-INFO +1 -1
- {hexkit-4.4.0 → hexkit-4.5.0}/pyproject.toml +1 -1
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/opentelemetry_setup.py +35 -2
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/testutils.py +117 -16
- {hexkit-4.4.0 → hexkit-4.5.0/src/hexkit.egg-info}/PKG-INFO +1 -1
- {hexkit-4.4.0 → hexkit-4.5.0}/LICENSE +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/MANIFEST.in +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/README.md +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/setup.cfg +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/__main__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/base.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/config.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/correlation.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/log.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/dao.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/daopub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/objstorage.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/migrations/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/migrations/_manager.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/migrations/_utils.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/provider.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/config.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/daopub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/persistent_pub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/provider.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/py.typed +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/utils.py +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/requires.txt +0 -0
- {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/top_level.txt +0 -0
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Intended Audience :: Developers",
|
|
25
25
|
]
|
|
26
26
|
name = "hexkit"
|
|
27
|
-
version = "4.
|
|
27
|
+
version = "4.5.0"
|
|
28
28
|
description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
|
|
29
29
|
dependencies = [
|
|
30
30
|
"opentelemetry-api >=1.31.1, <2",
|
|
@@ -14,17 +14,50 @@
|
|
|
14
14
|
# limitations under the License.
|
|
15
15
|
"""OpenTelemetry specific configuration code. This is gated behind the opentelemetry extra."""
|
|
16
16
|
|
|
17
|
+
import os
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
17
20
|
from opentelemetry import trace
|
|
18
21
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
22
|
+
from opentelemetry.sdk.environment_variables import OTEL_EXPORTER_OTLP_PROTOCOL
|
|
19
23
|
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
|
20
24
|
from opentelemetry.sdk.trace import TracerProvider
|
|
21
25
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
22
26
|
|
|
23
27
|
|
|
24
|
-
def configure_tracer(service_name: str):
|
|
25
|
-
"""Set up a global tracer for a specific service."""
|
|
28
|
+
def configure_tracer(service_name: str, protocol: str = "http/protobuf"):
|
|
29
|
+
"""Set up a global tracer for a specific service using the given exporter protocol."""
|
|
30
|
+
# opentelemetry distro sets this to grpc, but in the current context http/protobuf is preferred
|
|
31
|
+
os.environ.setdefault(OTEL_EXPORTER_OTLP_PROTOCOL, protocol)
|
|
32
|
+
|
|
26
33
|
resource = Resource(attributes={SERVICE_NAME: service_name})
|
|
27
34
|
trace_provider = TracerProvider(resource=resource)
|
|
28
35
|
processor = BatchSpanProcessor(OTLPSpanExporter())
|
|
29
36
|
trace_provider.add_span_processor(processor)
|
|
30
37
|
trace.set_tracer_provider(trace_provider)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SpanTracer:
|
|
41
|
+
"""Custom tracer class providing a decorator to autpopulate span names."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, name):
|
|
44
|
+
self.tracer = trace.get_tracer(name)
|
|
45
|
+
|
|
46
|
+
def start_span(
|
|
47
|
+
self, *, record_exception: bool = False, set_status_on_exception: bool = False
|
|
48
|
+
):
|
|
49
|
+
"""Decorator function starting a span populated with the __qualname__ of the wrapped function"""
|
|
50
|
+
|
|
51
|
+
def outer_wrapper(function: Callable):
|
|
52
|
+
def traced_function(*args, **kwargs):
|
|
53
|
+
name = function.__qualname__
|
|
54
|
+
with self.tracer.start_as_current_span(
|
|
55
|
+
name,
|
|
56
|
+
record_exception=record_exception,
|
|
57
|
+
set_status_on_exception=set_status_on_exception,
|
|
58
|
+
):
|
|
59
|
+
return function(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
return traced_function
|
|
62
|
+
|
|
63
|
+
return outer_wrapper
|
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
Please note, only use for testing purposes.
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
+
import asyncio
|
|
22
23
|
import json
|
|
23
24
|
from collections.abc import AsyncGenerator, Generator, Mapping, Sequence
|
|
24
25
|
from contextlib import asynccontextmanager
|
|
25
26
|
from dataclasses import dataclass
|
|
26
27
|
from functools import partial
|
|
27
|
-
from typing import Any, Optional, Union
|
|
28
|
+
from typing import Any, Optional, Union, cast
|
|
29
|
+
|
|
30
|
+
from aiokafka.admin.config_resource import ConfigResource, ConfigResourceType
|
|
28
31
|
|
|
29
32
|
try:
|
|
30
33
|
from typing import Self
|
|
@@ -411,6 +414,7 @@ class KafkaFixture:
|
|
|
411
414
|
self.config = config
|
|
412
415
|
self.kafka_servers = kafka_servers
|
|
413
416
|
self.publisher = publisher
|
|
417
|
+
self.admin_client: Optional[AIOKafkaAdminClient] = None
|
|
414
418
|
|
|
415
419
|
async def publish_event(
|
|
416
420
|
self,
|
|
@@ -442,6 +446,80 @@ class KafkaFixture:
|
|
|
442
446
|
capture_headers=capture_headers,
|
|
443
447
|
)
|
|
444
448
|
|
|
449
|
+
@asynccontextmanager
|
|
450
|
+
async def get_admin_client(self):
|
|
451
|
+
"""An async context manager that provides a running AIOKafkaAdminClient
|
|
452
|
+
as a singleton instance.
|
|
453
|
+
|
|
454
|
+
If a running client already exists, it is yielded directly.
|
|
455
|
+
If not, this is considered the root use of the CM, so we start a new client,
|
|
456
|
+
yield it, then close the client within a `finally` block.
|
|
457
|
+
"""
|
|
458
|
+
if self.admin_client:
|
|
459
|
+
yield self.admin_client
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# No running client exists, so create one and make sure to clean up afterward.
|
|
463
|
+
self.admin_client = AIOKafkaAdminClient(bootstrap_servers=self.kafka_servers)
|
|
464
|
+
await self.admin_client.start()
|
|
465
|
+
try:
|
|
466
|
+
yield self.admin_client
|
|
467
|
+
finally:
|
|
468
|
+
await self.admin_client.close()
|
|
469
|
+
self.admin_client = None
|
|
470
|
+
|
|
471
|
+
async def get_cleanup_policy(self, *, topic: str) -> Optional[str]:
|
|
472
|
+
"""Get the current cleanup policy for a topic or None if the topic doesn't exist"""
|
|
473
|
+
return await self.get_topic_config(topic=topic, config_name="cleanup.policy")
|
|
474
|
+
|
|
475
|
+
async def get_topic_config(self, *, topic: str, config_name: str) -> Optional[str]:
|
|
476
|
+
"""Get the current config for a topic or None if the topic/config doesn't exist"""
|
|
477
|
+
async with self.get_admin_client() as admin_client:
|
|
478
|
+
# fetch a list containing a response for each requested topic
|
|
479
|
+
config_list = await admin_client.describe_configs(
|
|
480
|
+
[ConfigResource(ConfigResourceType.TOPIC, name=topic)]
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# We only requested one topic, so get the first/only element from the list
|
|
484
|
+
config_response = config_list[0]
|
|
485
|
+
|
|
486
|
+
# Each config response has a 'resources' attribute
|
|
487
|
+
resources = config_response.resources
|
|
488
|
+
|
|
489
|
+
# The resources attr is a list containing one tuple for each requested topic.
|
|
490
|
+
# We only requested one topic, so our target is the first element.
|
|
491
|
+
requested_resource = resources[0]
|
|
492
|
+
|
|
493
|
+
# The resource itself is a list, and the topic config is at the end
|
|
494
|
+
configs = requested_resource[-1]
|
|
495
|
+
value = None
|
|
496
|
+
config_name = config_name.lower()
|
|
497
|
+
|
|
498
|
+
# The `configs` item is a list of tuples, where each tuple is a config item
|
|
499
|
+
# The first item is the config name, the second is the value, and the rest are ancillary
|
|
500
|
+
for item in configs:
|
|
501
|
+
if item[0].lower() == config_name:
|
|
502
|
+
value = item[1]
|
|
503
|
+
break
|
|
504
|
+
return value
|
|
505
|
+
|
|
506
|
+
async def set_cleanup_policy(self, *, topic: str, policy: str):
|
|
507
|
+
"""Set the cleanup policy for the given topic. The topic must already exist."""
|
|
508
|
+
await self.set_topic_config(topic=topic, config={"cleanup.policy": policy})
|
|
509
|
+
|
|
510
|
+
async def set_topic_config(self, *, topic: str, config: dict[str, Any]):
|
|
511
|
+
"""Set an arbitrary config value(s) for a topic"""
|
|
512
|
+
async with self.get_admin_client() as admin_client:
|
|
513
|
+
await admin_client.alter_configs(
|
|
514
|
+
config_resources=[
|
|
515
|
+
ConfigResource(
|
|
516
|
+
ConfigResourceType.TOPIC,
|
|
517
|
+
name=topic,
|
|
518
|
+
configs=config,
|
|
519
|
+
)
|
|
520
|
+
]
|
|
521
|
+
)
|
|
522
|
+
|
|
445
523
|
async def clear_topics(
|
|
446
524
|
self,
|
|
447
525
|
*,
|
|
@@ -453,26 +531,49 @@ class KafkaFixture:
|
|
|
453
531
|
If no topics are specified, all topics will be cleared, except internal topics
|
|
454
532
|
unless otherwise specified.
|
|
455
533
|
"""
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
topics = await admin_client.list_topics()
|
|
534
|
+
async with self.get_admin_client() as admin_client:
|
|
535
|
+
available_topics = await admin_client.list_topics()
|
|
536
|
+
if not topics:
|
|
537
|
+
topics = available_topics
|
|
461
538
|
elif isinstance(topics, str):
|
|
462
539
|
topics = [topics]
|
|
540
|
+
topics = cast(list, topics)
|
|
463
541
|
if exclude_internal:
|
|
464
542
|
topics = [topic for topic in topics if not topic.startswith("__")]
|
|
543
|
+
|
|
544
|
+
# Filter out any topics that don't exist
|
|
545
|
+
topics = [topic for topic in topics if topic in available_topics]
|
|
546
|
+
if not topics:
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
# Record the current cleanup policies before modifying them
|
|
465
550
|
topics_info = await admin_client.describe_topics(topics)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
551
|
+
for topic_info in topics_info:
|
|
552
|
+
topic = topic_info["topic"]
|
|
553
|
+
original_policy = await self.get_cleanup_policy(topic=topic)
|
|
554
|
+
if original_policy and "compact" in original_policy.split(","):
|
|
555
|
+
await self.set_cleanup_policy(topic=topic, policy="delete")
|
|
556
|
+
await asyncio.sleep(0.2) # brief pause to wait for policy changes
|
|
557
|
+
|
|
558
|
+
records_to_delete = {
|
|
559
|
+
TopicPartition(
|
|
560
|
+
topic=topic, partition=partition_info["partition"]
|
|
561
|
+
): RecordsToDelete(before_offset=-1)
|
|
562
|
+
for partition_info in topic_info["partitions"]
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
# Delete any events in the topic
|
|
566
|
+
try:
|
|
567
|
+
await admin_client.delete_records(
|
|
568
|
+
records_to_delete, timeout_ms=10000
|
|
569
|
+
)
|
|
570
|
+
finally:
|
|
571
|
+
# Always set compacted topics back to their original policy
|
|
572
|
+
if original_policy:
|
|
573
|
+
await self.set_cleanup_policy(
|
|
574
|
+
topic=topic,
|
|
575
|
+
policy=original_policy,
|
|
576
|
+
)
|
|
476
577
|
|
|
477
578
|
@asynccontextmanager
|
|
478
579
|
async def expect_events(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|