hexkit 2.1.0__tar.gz → 2.2.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 (45) hide show
  1. {hexkit-2.1.0/src/hexkit.egg-info → hexkit-2.2.0}/PKG-INFO +4 -4
  2. {hexkit-2.1.0 → hexkit-2.2.0}/pyproject.toml +22 -20
  3. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/protocols/dao.py +35 -21
  4. hexkit-2.2.0/src/hexkit/protocols/dao_outbox.py +130 -0
  5. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/akafka/provider.py +16 -4
  6. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/akafka/testutils.py +7 -0
  7. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/mongodb/provider.py +123 -82
  8. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/mongodb/testutils.py +1 -0
  9. hexkit-2.2.0/src/hexkit/providers/mongokafka/__init__.py +19 -0
  10. hexkit-2.2.0/src/hexkit/providers/mongokafka/provider.py +461 -0
  11. {hexkit-2.1.0 → hexkit-2.2.0/src/hexkit.egg-info}/PKG-INFO +4 -4
  12. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit.egg-info/SOURCES.txt +3 -0
  13. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit.egg-info/requires.txt +3 -3
  14. {hexkit-2.1.0 → hexkit-2.2.0}/LICENSE +0 -0
  15. {hexkit-2.1.0 → hexkit-2.2.0}/MANIFEST.in +0 -0
  16. {hexkit-2.1.0 → hexkit-2.2.0}/README.md +0 -0
  17. {hexkit-2.1.0 → hexkit-2.2.0}/setup.cfg +0 -0
  18. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/__init__.py +0 -0
  19. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/__main__.py +0 -0
  20. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/base.py +0 -0
  21. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/config.py +0 -0
  22. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/correlation.py +0 -0
  23. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/custom_types.py +0 -0
  24. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/log.py +0 -0
  25. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/protocols/__init__.py +0 -0
  26. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/protocols/eventpub.py +0 -0
  27. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/protocols/eventsub.py +0 -0
  28. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/protocols/objstorage.py +0 -0
  29. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/__init__.py +0 -0
  30. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  31. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  32. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  33. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/__init__.py +0 -0
  34. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/provider.py +0 -0
  35. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  36. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  37. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  38. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  39. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  40. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/s3/testutils.py +0 -0
  41. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/testing/__init__.py +0 -0
  42. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  43. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit/utils.py +0 -0
  44. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  45. {hexkit-2.1.0 → hexkit-2.2.0}/src/hexkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hexkit
3
- Version: 2.1.0
3
+ Version: 2.2.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
@@ -30,13 +30,13 @@ Provides-Extra: mongodb
30
30
  Requires-Dist: motor<4,>=3.1.1; extra == "mongodb"
31
31
  Provides-Extra: test-akafka
32
32
  Requires-Dist: hexkit[akafka]; extra == "test-akafka"
33
- Requires-Dist: testcontainers[kafka]<4,>=3.7.1; extra == "test-akafka"
33
+ Requires-Dist: testcontainers[kafka]<5,>=4.3.3; extra == "test-akafka"
34
34
  Provides-Extra: test-s3
35
35
  Requires-Dist: hexkit[s3]; extra == "test-s3"
36
- Requires-Dist: testcontainers<4,>=3.7.1; extra == "test-s3"
36
+ Requires-Dist: testcontainers<5,>=4.3.3; extra == "test-s3"
37
37
  Provides-Extra: test-mongodb
38
38
  Requires-Dist: hexkit[mongodb]; extra == "test-mongodb"
39
- Requires-Dist: testcontainers[mongo]<4,>=3.7.1; extra == "test-mongodb"
39
+ Requires-Dist: testcontainers[mongo]<5,>=4.3.3; extra == "test-mongodb"
40
40
  Provides-Extra: test
41
41
  Requires-Dist: hexkit[test-akafka,test-mongodb,test-s3]; extra == "test"
42
42
  Provides-Extra: all
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Intended Audience :: Developers",
23
23
  ]
24
24
  name = "hexkit"
25
- version = "2.1.0"
25
+ version = "2.2.0"
26
26
  description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
27
27
  dependencies = [
28
28
  "pydantic >=2, <3",
@@ -47,15 +47,15 @@ mongodb = [
47
47
  ]
48
48
  test-akafka = [
49
49
  "hexkit[akafka]",
50
- "testcontainers[kafka] >=3.7.1, <4",
50
+ "testcontainers[kafka] >=4.3.3, <5",
51
51
  ]
52
52
  test-s3 = [
53
53
  "hexkit[s3]",
54
- "testcontainers >=3.7.1, <4",
54
+ "testcontainers >=4.3.3, <5",
55
55
  ]
56
56
  test-mongodb = [
57
57
  "hexkit[mongodb]",
58
- "testcontainers[mongo] >=3.7.1, <4",
58
+ "testcontainers[mongo] >=4.3.3, <5",
59
59
  ]
60
60
  test = [
61
61
  "hexkit[test-akafka,test-s3,test-mongodb]",
@@ -80,6 +80,21 @@ exclude = [
80
80
  "build",
81
81
  "dist",
82
82
  ]
83
+ line-length = 88
84
+ src = [
85
+ "src",
86
+ "tests",
87
+ "examples",
88
+ "scripts",
89
+ ]
90
+ target-version = "py39"
91
+
92
+ [tool.ruff.lint]
93
+ fixable = [
94
+ "UP",
95
+ "I",
96
+ "D",
97
+ ]
83
98
  ignore = [
84
99
  "E",
85
100
  "W",
@@ -97,7 +112,6 @@ ignore = [
97
112
  "D206",
98
113
  "D300",
99
114
  ]
100
- line-length = 88
101
115
  select = [
102
116
  "C90",
103
117
  "F",
@@ -111,23 +125,11 @@ select = [
111
125
  "SIM",
112
126
  "D",
113
127
  ]
114
- fixable = [
115
- "UP",
116
- "I",
117
- "D",
118
- ]
119
- src = [
120
- "src",
121
- "tests",
122
- "examples",
123
- "scripts",
124
- ]
125
- target-version = "py39"
126
128
 
127
- [tool.ruff.mccabe]
129
+ [tool.ruff.lint.mccabe]
128
130
  max-complexity = 10
129
131
 
130
- [tool.ruff.per-file-ignores]
132
+ [tool.ruff.lint.per-file-ignores]
131
133
  "scripts/*" = [
132
134
  "PL",
133
135
  "S",
@@ -153,7 +155,7 @@ max-complexity = 10
153
155
  "D",
154
156
  ]
155
157
 
156
- [tool.ruff.pydocstyle]
158
+ [tool.ruff.lint.pydocstyle]
157
159
  convention = "pep257"
158
160
 
159
161
  [tool.mypy]
@@ -268,10 +268,8 @@ async def uuid4_id_generator() -> AsyncGenerator[str, None]:
268
268
  yield str(uuid4())
269
269
 
270
270
 
271
- class DaoFactoryProtocol(ABC):
272
- """A protocol describing a factory to produce Data Access Objects (DAO) objects
273
- that are enclosed in transactional scopes.
274
- """
271
+ class DaoFactoryBase:
272
+ """A base for Data Access Objects (DAO) Factory protocols."""
275
273
 
276
274
  class IdFieldNotFoundError(ValueError):
277
275
  """Raised when the dto_model did not contain the expected id_field."""
@@ -335,6 +333,32 @@ class DaoFactoryProtocol(ABC):
335
333
  f"Provided index fields are invalid: {error}"
336
334
  ) from error
337
335
 
336
+ @classmethod
337
+ def _validate(
338
+ cls,
339
+ *,
340
+ dto_model: type[Dto],
341
+ id_field: str,
342
+ dto_creation_model: Optional[type[DtoCreation]],
343
+ fields_to_index: Optional[Collection[str]],
344
+ ) -> None:
345
+ """Validates the input parameters of the get_dao method."""
346
+ cls._validate_dto_model_id(dto_model=dto_model, id_field=id_field)
347
+ cls._validate_dto_creation_model(
348
+ dto_model=dto_model,
349
+ dto_creation_model=dto_creation_model,
350
+ id_field=id_field,
351
+ )
352
+ cls._validate_fields_to_index(
353
+ dto_model=dto_model, fields_to_index=fields_to_index
354
+ )
355
+
356
+
357
+ class DaoFactoryProtocol(DaoFactoryBase, ABC):
358
+ """A protocol describing a factory to produce Data Access Objects (DAO) objects
359
+ that are enclosed in transactional scopes.
360
+ """
361
+
338
362
  @overload
339
363
  async def get_dao(
340
364
  self,
@@ -344,8 +368,7 @@ class DaoFactoryProtocol(ABC):
344
368
  id_field: str,
345
369
  fields_to_index: Optional[Collection[str]] = None,
346
370
  id_generator: Optional[AsyncGenerator[str, None]] = None,
347
- ) -> DaoNaturalId[Dto]:
348
- ...
371
+ ) -> DaoNaturalId[Dto]: ...
349
372
 
350
373
  @overload
351
374
  async def get_dao(
@@ -357,8 +380,7 @@ class DaoFactoryProtocol(ABC):
357
380
  dto_creation_model: type[DtoCreation],
358
381
  fields_to_index: Optional[Collection[str]] = None,
359
382
  id_generator: Optional[AsyncGenerator[str, None]] = None,
360
- ) -> DaoSurrogateId[Dto, DtoCreation]:
361
- ...
383
+ ) -> DaoSurrogateId[Dto, DtoCreation]: ...
362
384
 
363
385
  async def get_dao(
364
386
  self,
@@ -408,16 +430,11 @@ class DaoFactoryProtocol(ABC):
408
430
  self.IdFieldNotFoundError:
409
431
  Raised when the dto_model did not contain the expected id_field.
410
432
  """
411
- self._validate_dto_model_id(dto_model=dto_model, id_field=id_field)
412
-
413
- self._validate_dto_creation_model(
433
+ self._validate(
414
434
  dto_model=dto_model,
415
- dto_creation_model=dto_creation_model,
416
435
  id_field=id_field,
417
- )
418
-
419
- self._validate_fields_to_index(
420
- dto_model=dto_model, fields_to_index=fields_to_index
436
+ dto_creation_model=dto_creation_model,
437
+ fields_to_index=fields_to_index,
421
438
  )
422
439
 
423
440
  if id_generator is None:
@@ -430,7 +447,6 @@ class DaoFactoryProtocol(ABC):
430
447
  id_field=id_field,
431
448
  fields_to_index=fields_to_index,
432
449
  dto_creation_model=dto_creation_model,
433
- # (above behavior by mypy seems incorrect)
434
450
  id_generator=id_generator,
435
451
  )
436
452
 
@@ -445,8 +461,7 @@ class DaoFactoryProtocol(ABC):
445
461
  dto_creation_model: None,
446
462
  fields_to_index: Optional[Collection[str]],
447
463
  id_generator: AsyncGenerator[str, None],
448
- ) -> DaoNaturalId[Dto]:
449
- ...
464
+ ) -> DaoNaturalId[Dto]: ...
450
465
 
451
466
  @overload
452
467
  @abstractmethod
@@ -459,8 +474,7 @@ class DaoFactoryProtocol(ABC):
459
474
  dto_creation_model: type[DtoCreation],
460
475
  fields_to_index: Optional[Collection[str]],
461
476
  id_generator: AsyncGenerator[str, None],
462
- ) -> DaoSurrogateId[Dto, DtoCreation]:
463
- ...
477
+ ) -> DaoSurrogateId[Dto, DtoCreation]: ...
464
478
 
465
479
  @abstractmethod
466
480
  async def _get_dao(
@@ -0,0 +1,130 @@
1
+ # Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2
+ # for the German Human Genome-Phenome Archive (GHGA)
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ """Protocol for creating Data Access Objects to perform CRUD (plus find) interactions
18
+ with the database plus automatically publish the changes as events using a modification
19
+ of the outbox pattern.
20
+ """
21
+
22
+ # ruff: noqa: PLR0913
23
+
24
+ import typing
25
+ from abc import ABC, abstractmethod
26
+ from collections.abc import Collection
27
+ from typing import Callable, Optional
28
+
29
+ from hexkit.custom_types import JsonObject
30
+ from hexkit.protocols.dao import (
31
+ DaoFactoryBase,
32
+ DaoNaturalId,
33
+ Dto,
34
+ )
35
+
36
+
37
+ class DaoOutbox(DaoNaturalId[Dto], typing.Protocol[Dto]):
38
+ """A Data Access Object (DAO) that automatically publishes changes according to the
39
+ outbox pattern.
40
+ """
41
+
42
+ async def publish_pending(self) -> None:
43
+ """Publishes all non-published changes."""
44
+ ...
45
+
46
+ async def republish(self) -> None:
47
+ """Republishes the state of all resources independent of whether they have
48
+ already been published or not.
49
+ """
50
+ ...
51
+
52
+
53
+ class DaoOutboxFactoryProtocol(DaoFactoryBase, ABC):
54
+ """A protocol describing a factory to produce Data Access Objects (DAO) objects
55
+ which automatically publish changes according to the outbox pattern.
56
+ """
57
+
58
+ async def get_dao(
59
+ self,
60
+ *,
61
+ name: str,
62
+ dto_model: type[Dto],
63
+ id_field: str,
64
+ fields_to_index: Optional[Collection[str]] = None,
65
+ dto_to_event: Callable[[Dto], JsonObject],
66
+ event_topic: str,
67
+ autopublish: bool = True,
68
+ ) -> DaoOutbox[Dto]:
69
+ """Constructs an Outbox DAO for interacting with resources in a database.
70
+
71
+ Args:
72
+ name:
73
+ The name of the resource type (roughly equivalent to the name of a
74
+ database table or collection).
75
+ dto_model:
76
+ A DTO (Data Transfer Object) model describing the shape of resources.
77
+ id_field:
78
+ The name of the field of the `dto_model` that serves as resource ID.
79
+ (DAO implementation might use this field as primary key.)
80
+ fields_to_index:
81
+ Optionally, provide any fields that should be indexed in addition to the
82
+ `id_field`. Defaults to None.
83
+ dto_to_event:
84
+ A function that takes a DTO and returns the payload for an event.
85
+ event_topic:
86
+ The topic to which events should be published.
87
+ autopublish:
88
+ Whether to automatically publish changes. Defaults to True.
89
+
90
+ Returns:
91
+ A DAO of type DaoOutbox, which requires ID specification upon resource
92
+ creation.
93
+
94
+ Raises:
95
+ self.IdFieldNotFoundError:
96
+ Raised when the dto_model did not contain the expected id_field.
97
+ """
98
+ self._validate(
99
+ dto_model=dto_model,
100
+ id_field=id_field,
101
+ dto_creation_model=None,
102
+ fields_to_index=fields_to_index,
103
+ )
104
+
105
+ return await self._get_dao(
106
+ name=name,
107
+ dto_model=dto_model,
108
+ id_field=id_field,
109
+ fields_to_index=fields_to_index,
110
+ dto_to_event=dto_to_event,
111
+ event_topic=event_topic,
112
+ autopublish=autopublish,
113
+ )
114
+
115
+ @abstractmethod
116
+ async def _get_dao(
117
+ self,
118
+ *,
119
+ name: str,
120
+ dto_model: type[Dto],
121
+ id_field: str,
122
+ fields_to_index: Optional[Collection[str]],
123
+ dto_to_event: Callable[[Dto], JsonObject],
124
+ event_topic: str,
125
+ autopublish: bool,
126
+ ) -> DaoOutbox[Dto]:
127
+ """*To be implemented by the provider. Input validation is done outside of this
128
+ method.*
129
+ """
130
+ ...
@@ -29,7 +29,7 @@ from typing import Any, Callable, Literal, Optional, Protocol, TypeVar
29
29
 
30
30
  from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
31
31
  from aiokafka.helpers import create_ssl_context
32
- from pydantic import Field
32
+ from pydantic import Field, SecretStr
33
33
  from pydantic_settings import BaseSettings
34
34
 
35
35
  from hexkit.base import InboundProviderBase
@@ -93,7 +93,7 @@ class KafkaConfig(BaseSettings):
93
93
  kafka_ssl_keyfile: str = Field(
94
94
  default="", description="Optional filename containing the client private key."
95
95
  )
96
- kafka_ssl_password: str = Field(
96
+ kafka_ssl_password: SecretStr = Field(
97
97
  default="",
98
98
  description="Optional password to be used for the client private key.",
99
99
  )
@@ -131,7 +131,7 @@ def generate_ssl_context(config: KafkaConfig) -> Optional[ssl.SSLContext]:
131
131
  cafile=config.kafka_ssl_cafile,
132
132
  certfile=config.kafka_ssl_certfile,
133
133
  keyfile=config.kafka_ssl_keyfile,
134
- password=config.kafka_ssl_password,
134
+ password=config.kafka_ssl_password.get_secret_value(),
135
135
  )
136
136
  if config.kafka_security_protocol == "SSL"
137
137
  else None
@@ -259,7 +259,7 @@ class KafkaEventPublisher(EventPublisherProtocol):
259
259
  raise
260
260
 
261
261
  correlation_id = new_correlation_id()
262
- logging.info("Generated new correlation id: %s", correlation_id)
262
+ logging.info("Generated new correlation ID: %s", correlation_id)
263
263
 
264
264
  validate_correlation_id(correlation_id)
265
265
 
@@ -311,6 +311,7 @@ class KafkaConsumerCompatible(Protocol):
311
311
  client_id: str,
312
312
  group_id: str,
313
313
  auto_offset_reset: Literal["earliest"],
314
+ enable_auto_commit: bool,
314
315
  key_deserializer: Callable[[bytes], str],
315
316
  value_deserializer: Callable[[bytes], str],
316
317
  ):
@@ -334,6 +335,10 @@ class KafkaConsumerCompatible(Protocol):
334
335
  """
335
336
  ...
336
337
 
338
+ async def commit(self, offsets=None):
339
+ """Commit offsets to Kafka Broker."""
340
+ ...
341
+
337
342
  async def start(self) -> None:
338
343
  """Setup the consumer."""
339
344
  ...
@@ -390,6 +395,7 @@ class KafkaEventSubscriber(InboundProviderBase):
390
395
  client_id=client_id,
391
396
  group_id=config.service_name,
392
397
  auto_offset_reset="earliest",
398
+ enable_auto_commit=False,
393
399
  key_deserializer=lambda event_key: event_key.decode("ascii"),
394
400
  value_deserializer=lambda event_value: json.loads(
395
401
  event_value.decode("ascii")
@@ -437,6 +443,8 @@ class KafkaEventSubscriber(InboundProviderBase):
437
443
  )
438
444
  except EventHeaderNotFoundError as err:
439
445
  logging.warning("Ignored an event: %s. %s", event_label, err.args[0])
446
+ # acknowledge event receipt
447
+ await self._consumer.commit()
440
448
  return
441
449
 
442
450
  if type_ in self._types_whitelist:
@@ -450,6 +458,8 @@ class KafkaEventSubscriber(InboundProviderBase):
450
458
  type_=type_,
451
459
  topic=event.topic,
452
460
  )
461
+ # acknowledge successfully processed event
462
+ await self._consumer.commit()
453
463
  except Exception:
454
464
  logging.error(
455
465
  "A fatal error occurred while processing the event: %s",
@@ -459,6 +469,8 @@ class KafkaEventSubscriber(InboundProviderBase):
459
469
 
460
470
  else:
461
471
  logging.info("Ignored event of type %s: %s", type_, event_label)
472
+ # acknowledge event receipt
473
+ await self._consumer.commit()
462
474
 
463
475
  async def run(self, forever: bool = True) -> None:
464
476
  """
@@ -18,6 +18,7 @@
18
18
 
19
19
  Please note, only use for testing purposes.
20
20
  """
21
+
21
22
  import json
22
23
  import warnings
23
24
  from collections.abc import AsyncGenerator, Sequence
@@ -301,6 +302,12 @@ class EventRecorder:
301
302
  consumer = self._get_consumer()
302
303
  await consumer.start()
303
304
 
305
+ # The number of events to consume is determined by the difference between the
306
+ # starting and final offsets. To avoid waiting for too many events (and thus
307
+ # waiting forever), seek_to_end so only events published inside the context of
308
+ # the event recorder are considered.
309
+ await consumer.seek_to_end()
310
+
304
311
  try:
305
312
  self._starting_offsets = await self._get_consumer_offsets(consumer=consumer)
306
313
  finally: