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.
Files changed (63) hide show
  1. {hexkit-4.4.0/src/hexkit.egg-info → hexkit-4.5.0}/PKG-INFO +1 -1
  2. {hexkit-4.4.0 → hexkit-4.5.0}/pyproject.toml +1 -1
  3. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/opentelemetry_setup.py +35 -2
  4. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/testutils.py +117 -16
  5. {hexkit-4.4.0 → hexkit-4.5.0/src/hexkit.egg-info}/PKG-INFO +1 -1
  6. {hexkit-4.4.0 → hexkit-4.5.0}/LICENSE +0 -0
  7. {hexkit-4.4.0 → hexkit-4.5.0}/MANIFEST.in +0 -0
  8. {hexkit-4.4.0 → hexkit-4.5.0}/README.md +0 -0
  9. {hexkit-4.4.0 → hexkit-4.5.0}/setup.cfg +0 -0
  10. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/__init__.py +0 -0
  11. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/__main__.py +0 -0
  12. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/base.py +0 -0
  13. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/config.py +0 -0
  14. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/correlation.py +0 -0
  15. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/custom_types.py +0 -0
  16. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/log.py +0 -0
  17. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/__init__.py +0 -0
  18. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/dao.py +0 -0
  19. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/daopub.py +0 -0
  20. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/daosub.py +0 -0
  21. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/eventpub.py +0 -0
  22. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/eventsub.py +0 -0
  23. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/protocols/objstorage.py +0 -0
  24. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/__init__.py +0 -0
  25. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  26. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/config.py +0 -0
  27. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  28. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  29. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  30. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
  31. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  32. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  33. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  34. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/migrations/__init__.py +0 -0
  35. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/migrations/_manager.py +0 -0
  36. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/migrations/_utils.py +0 -0
  37. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/provider.py +0 -0
  38. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
  39. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  40. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/__init__.py +0 -0
  41. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/config.py +0 -0
  42. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/daopub.py +0 -0
  43. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/provider/persistent_pub.py +0 -0
  44. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  45. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/__init__.py +0 -0
  46. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/provider.py +0 -0
  47. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  48. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  49. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  50. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  51. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  52. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
  53. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
  54. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
  55. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
  56. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/testing/__init__.py +0 -0
  57. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  58. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/py.typed +0 -0
  59. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit/utils.py +0 -0
  60. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
  61. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  62. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/requires.txt +0 -0
  63. {hexkit-4.4.0 → hexkit-4.5.0}/src/hexkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexkit
3
- Version: 4.4.0
3
+ Version: 4.5.0
4
4
  Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
5
5
  Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
6
6
  License: Apache 2.0
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Intended Audience :: Developers",
25
25
  ]
26
26
  name = "hexkit"
27
- version = "4.4.0"
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
- admin_client = AIOKafkaAdminClient(bootstrap_servers=self.kafka_servers)
457
- await admin_client.start()
458
- try:
459
- if topics is None:
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
- records_to_delete = {
467
- TopicPartition(
468
- topic=topic_info["topic"], partition=partition_info["partition"]
469
- ): RecordsToDelete(before_offset=-1)
470
- for topic_info in topics_info
471
- for partition_info in topic_info["partitions"]
472
- }
473
- await admin_client.delete_records(records_to_delete, timeout_ms=10000)
474
- finally:
475
- await admin_client.close()
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexkit
3
- Version: 4.4.0
3
+ Version: 4.5.0
4
4
  Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
5
5
  Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
6
6
  License: Apache 2.0
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