hexkit 7.1.0__tar.gz → 7.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 (66) hide show
  1. {hexkit-7.1.0/src/hexkit.egg-info → hexkit-7.2.0}/PKG-INFO +14 -3
  2. {hexkit-7.1.0 → hexkit-7.2.0}/README.md +13 -2
  3. {hexkit-7.1.0 → hexkit-7.2.0}/pyproject.toml +1 -1
  4. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/testing/__init__.py +11 -0
  5. hexkit-7.2.0/src/hexkit/providers/testing/dao.py +146 -0
  6. hexkit-7.2.0/src/hexkit/providers/testing/s3.py +402 -0
  7. {hexkit-7.1.0 → hexkit-7.2.0/src/hexkit.egg-info}/PKG-INFO +14 -3
  8. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit.egg-info/SOURCES.txt +3 -1
  9. {hexkit-7.1.0 → hexkit-7.2.0}/LICENSE +0 -0
  10. {hexkit-7.1.0 → hexkit-7.2.0}/MANIFEST.in +0 -0
  11. {hexkit-7.1.0 → hexkit-7.2.0}/setup.cfg +0 -0
  12. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/__init__.py +0 -0
  13. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/__main__.py +0 -0
  14. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/base.py +0 -0
  15. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/config.py +0 -0
  16. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/correlation.py +0 -0
  17. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/custom_types.py +0 -0
  18. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/log.py +0 -0
  19. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/opentelemetry.py +0 -0
  20. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/protocols/__init__.py +0 -0
  21. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/protocols/dao.py +0 -0
  22. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/protocols/daopub.py +0 -0
  23. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/protocols/daosub.py +0 -0
  24. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/protocols/eventpub.py +0 -0
  25. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/protocols/eventsub.py +0 -0
  26. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/protocols/objstorage.py +0 -0
  27. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/__init__.py +0 -0
  28. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  29. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/config.py +0 -0
  30. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  31. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  32. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  33. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
  34. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  35. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  36. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/akafka/testutils.py +0 -0
  37. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  38. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongodb/migrations/__init__.py +0 -0
  39. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongodb/migrations/_manager.py +0 -0
  40. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongodb/migrations/_utils.py +0 -0
  41. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongodb/migrations/helpers.py +0 -0
  42. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongodb/provider.py +0 -0
  43. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
  44. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  45. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongokafka/provider/__init__.py +0 -0
  46. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongokafka/provider/config.py +0 -0
  47. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongokafka/provider/daopub.py +0 -0
  48. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongokafka/provider/persistent_pub.py +0 -0
  49. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  50. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/__init__.py +0 -0
  51. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/provider.py +0 -0
  52. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  53. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  54. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  55. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  56. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  57. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
  58. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
  59. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
  60. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
  61. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  62. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/py.typed +0 -0
  63. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit/utils.py +0 -0
  64. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  65. {hexkit-7.1.0 → hexkit-7.2.0}/src/hexkit.egg-info/requires.txt +0 -0
  66. {hexkit-7.1.0 → hexkit-7.2.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: 7.1.0
3
+ Version: 7.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
@@ -78,10 +78,12 @@ Dynamic: license-file
78
78
  [![Coverage Status](https://coveralls.io/repos/github/ghga-de/hexkit/badge.svg?branch=main)](https://coveralls.io/github/ghga-de/hexkit?branch=main)
79
79
 
80
80
  # hexkit
81
+
81
82
  A chassis library for building domain-focused, infrastructure-agnostic, and event-driven
82
83
  microservices in Python.
83
84
 
84
85
  ## In a Nutshell
86
+
85
87
  This project implements the Triple Hexagonal Architecture pattern which is an
86
88
  optimization of the ordinary
87
89
  [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) for
@@ -110,6 +112,7 @@ a limited collection of protocol-provider pairs that are of immediate interest t
110
112
  authors. We like to add support for more protocols and technologies over time.
111
113
 
112
114
  The following protocols and providers are currently available:
115
+
113
116
  | Protocol | Providers |
114
117
  |---|---|
115
118
  | Event Publishing | Apache Kafka |
@@ -140,15 +143,18 @@ of jumping directly into it. You can find the protocols being defined at
140
143
  A more elaborate documentation and tutorial is on the way.
141
144
 
142
145
  ## Installation
146
+
143
147
  This package is available at PyPI:
144
- https://pypi.org/project/hexkit
148
+ <https://pypi.org/project/hexkit>
145
149
 
146
150
  You can install it from there using:
147
- ```
151
+
152
+ ```sh
148
153
  pip install hexkit
149
154
  ```
150
155
 
151
156
  The following extras are available:
157
+
152
158
  - `akafka`: when using the Apache Kafka-based event publishing or subscription
153
159
  - `s3`: when interacting with S3-compatible Object Storages
154
160
  - `mongodb`: when using mongodb as backend for the DAO protocol
@@ -158,6 +164,7 @@ The following extras are available:
158
164
  - `all`: a union of all the above
159
165
 
160
166
  ## Development
167
+
161
168
  For setting up the development environment, we rely on the
162
169
  [devcontainer feature](https://code.visualstudio.com/docs/remote/containers) of vscode.
163
170
 
@@ -167,6 +174,7 @@ Then, you just have to open this repo in vscode and run the command
167
174
  `Remote-Containers: Reopen in Container` from the vscode "Command Palette".
168
175
 
169
176
  This will give you a full-fledged, pre-configured development environment including:
177
+
170
178
  - infrastructural dependencies of the service (databases, etc.)
171
179
  - all relevant vscode extensions pre-installed
172
180
  - pre-configured linting and auto-formatting
@@ -175,12 +183,14 @@ This will give you a full-fledged, pre-configured development environment includ
175
183
 
176
184
  Additionally, the following convenience command is available inside the devcontainer
177
185
  (type it in the integrated terminal of VS Code):
186
+
178
187
  - `dev_install` - install the lib with all development dependencies and pre-commit hooks
179
188
  (please run that if you are starting the devcontainer for the first time
180
189
  or if added any python dependencies to the [`./pyproject.toml`](./pyproject.toml))
181
190
 
182
191
  If you prefer not to use vscode, you could get a similar setup (without the editor
183
192
  specific features) by running the following commands:
193
+
184
194
  ``` bash
185
195
  # Execute in the repo's root dir:
186
196
  cd ./.devcontainer
@@ -192,4 +202,5 @@ docker run -it hexkit /bin/bash
192
202
  ```
193
203
 
194
204
  ## License
205
+
195
206
  This repository is free to use and modify according to the [Apache 2.0 License](./LICENSE).
@@ -4,10 +4,12 @@
4
4
  [![Coverage Status](https://coveralls.io/repos/github/ghga-de/hexkit/badge.svg?branch=main)](https://coveralls.io/github/ghga-de/hexkit?branch=main)
5
5
 
6
6
  # hexkit
7
+
7
8
  A chassis library for building domain-focused, infrastructure-agnostic, and event-driven
8
9
  microservices in Python.
9
10
 
10
11
  ## In a Nutshell
12
+
11
13
  This project implements the Triple Hexagonal Architecture pattern which is an
12
14
  optimization of the ordinary
13
15
  [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) for
@@ -36,6 +38,7 @@ a limited collection of protocol-provider pairs that are of immediate interest t
36
38
  authors. We like to add support for more protocols and technologies over time.
37
39
 
38
40
  The following protocols and providers are currently available:
41
+
39
42
  | Protocol | Providers |
40
43
  |---|---|
41
44
  | Event Publishing | Apache Kafka |
@@ -66,15 +69,18 @@ of jumping directly into it. You can find the protocols being defined at
66
69
  A more elaborate documentation and tutorial is on the way.
67
70
 
68
71
  ## Installation
72
+
69
73
  This package is available at PyPI:
70
- https://pypi.org/project/hexkit
74
+ <https://pypi.org/project/hexkit>
71
75
 
72
76
  You can install it from there using:
73
- ```
77
+
78
+ ```sh
74
79
  pip install hexkit
75
80
  ```
76
81
 
77
82
  The following extras are available:
83
+
78
84
  - `akafka`: when using the Apache Kafka-based event publishing or subscription
79
85
  - `s3`: when interacting with S3-compatible Object Storages
80
86
  - `mongodb`: when using mongodb as backend for the DAO protocol
@@ -84,6 +90,7 @@ The following extras are available:
84
90
  - `all`: a union of all the above
85
91
 
86
92
  ## Development
93
+
87
94
  For setting up the development environment, we rely on the
88
95
  [devcontainer feature](https://code.visualstudio.com/docs/remote/containers) of vscode.
89
96
 
@@ -93,6 +100,7 @@ Then, you just have to open this repo in vscode and run the command
93
100
  `Remote-Containers: Reopen in Container` from the vscode "Command Palette".
94
101
 
95
102
  This will give you a full-fledged, pre-configured development environment including:
103
+
96
104
  - infrastructural dependencies of the service (databases, etc.)
97
105
  - all relevant vscode extensions pre-installed
98
106
  - pre-configured linting and auto-formatting
@@ -101,12 +109,14 @@ This will give you a full-fledged, pre-configured development environment includ
101
109
 
102
110
  Additionally, the following convenience command is available inside the devcontainer
103
111
  (type it in the integrated terminal of VS Code):
112
+
104
113
  - `dev_install` - install the lib with all development dependencies and pre-commit hooks
105
114
  (please run that if you are starting the devcontainer for the first time
106
115
  or if added any python dependencies to the [`./pyproject.toml`](./pyproject.toml))
107
116
 
108
117
  If you prefer not to use vscode, you could get a similar setup (without the editor
109
118
  specific features) by running the following commands:
119
+
110
120
  ``` bash
111
121
  # Execute in the repo's root dir:
112
122
  cd ./.devcontainer
@@ -118,4 +128,5 @@ docker run -it hexkit /bin/bash
118
128
  ```
119
129
 
120
130
  ## License
131
+
121
132
  This repository is free to use and modify according to the [Apache 2.0 License](./LICENSE).
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Intended Audience :: Developers",
24
24
  ]
25
25
  name = "hexkit"
26
- version = "7.1.0"
26
+ version = "7.2.0"
27
27
  description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
28
28
  dependencies = [
29
29
  "opentelemetry-api >=1.37, <2",
@@ -21,3 +21,14 @@ the standard library.
21
21
 
22
22
  ATTENTION: Do not use these providers in production.
23
23
  """
24
+
25
+ from .dao import MockDAOEmptyError, new_mock_dao_class
26
+ from .eventpub import InMemEventPublisher, InMemEventStore, TopicExhaustedError
27
+
28
+ __all__ = [
29
+ "InMemEventPublisher",
30
+ "InMemEventStore",
31
+ "MockDAOEmptyError",
32
+ "TopicExhaustedError",
33
+ "new_mock_dao_class",
34
+ ]
@@ -0,0 +1,146 @@
1
+ # Copyright 2021 - 2025 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
+ """A mock (in-memory) DAO"""
17
+
18
+ from collections.abc import AsyncIterator, Mapping
19
+ from contextlib import suppress
20
+ from copy import deepcopy
21
+ from typing import Any, Generic, TypeVar
22
+ from unittest.mock import AsyncMock, Mock
23
+
24
+ from pydantic import BaseModel
25
+
26
+ from hexkit.custom_types import ID
27
+ from hexkit.protocols.dao import (
28
+ MultipleHitsFoundError,
29
+ NoHitsFoundError,
30
+ ResourceAlreadyExistsError,
31
+ ResourceNotFoundError,
32
+ )
33
+
34
+ __all__ = ["MockDAOEmptyError", "new_mock_dao_class"]
35
+
36
+ DTO = TypeVar("DTO", bound=BaseModel)
37
+
38
+
39
+ class MockDAOEmptyError(RuntimeError):
40
+ """Raised when attempting to access the `latest` property of an empty mock DAO"""
41
+
42
+
43
+ class BaseInMemDao(Generic[DTO]):
44
+ """DAO with proper typing and in-memory storage for use in testing"""
45
+
46
+ _id_field: str
47
+ publish_pending = AsyncMock()
48
+ republish = AsyncMock()
49
+ with_transaction = Mock()
50
+
51
+ def __init__(self) -> None:
52
+ self.resources: dict[ID, DTO] = {}
53
+
54
+ @property
55
+ def latest(self) -> DTO:
56
+ """Return the most recently inserted resource.
57
+
58
+ Raises a MockDAOEmptyError if there are no resources stored.
59
+ """
60
+ try:
61
+ return deepcopy(next(reversed(self.resources.values())))
62
+ except StopIteration as err:
63
+ raise MockDAOEmptyError() from err
64
+
65
+ async def get_by_id(self, id_: ID) -> DTO:
66
+ """Get the resource via ID.
67
+
68
+ Raises a ResourceNotFoundError if no resource with a matching ID is found.
69
+ """
70
+ with suppress(KeyError):
71
+ return deepcopy(self.resources[id_])
72
+ raise ResourceNotFoundError(id_=id_)
73
+
74
+ async def find_one(self, *, mapping: Mapping[str, Any]) -> DTO:
75
+ """Find the resource that matches the specified mapping.
76
+
77
+ Raises:
78
+ NoHitsFoundError: If no matching resource is found.
79
+ MultipleHitsFoundError: If more than one matching resource is found.
80
+ """
81
+ hits = self.find_all(mapping=mapping)
82
+ try:
83
+ dto = await hits.__anext__()
84
+ except StopAsyncIteration as error:
85
+ raise NoHitsFoundError(mapping=mapping) from error
86
+
87
+ try:
88
+ _ = await hits.__anext__()
89
+ except StopAsyncIteration:
90
+ # This is expected:
91
+ return dto
92
+
93
+ raise MultipleHitsFoundError(mapping=mapping)
94
+
95
+ async def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[DTO]:
96
+ """Find all resources that match the specified mapping."""
97
+ for resource in self.resources.values():
98
+ if all(getattr(resource, k) == v for k, v in mapping.items()):
99
+ yield deepcopy(resource)
100
+
101
+ async def insert(self, dto: DTO) -> None:
102
+ """Insert a resource.
103
+
104
+ Raises a ResourceAlreadyExistsError if a resource with a matching ID exists already.
105
+ """
106
+ dto_id = getattr(dto, self._id_field)
107
+ if dto_id in self.resources:
108
+ raise ResourceAlreadyExistsError(id_=dto_id)
109
+ self.resources[dto_id] = deepcopy(dto)
110
+
111
+ async def update(self, dto: DTO) -> None:
112
+ """Update a resource.
113
+
114
+ Raises a ResourceNotFoundError if no resource with a matching ID is found.
115
+ """
116
+ dto_id = getattr(dto, self._id_field)
117
+ if dto_id not in self.resources:
118
+ raise ResourceNotFoundError(id_=getattr(dto, self._id_field))
119
+ self.resources[dto_id] = deepcopy(dto)
120
+
121
+ async def delete(self, id_: ID) -> None:
122
+ """Delete a resource by ID.
123
+
124
+ Raises a ResourceNotFoundError if no resource with a matching ID is found.
125
+ """
126
+ if id_ not in self.resources:
127
+ raise ResourceNotFoundError(id_=id_)
128
+ del self.resources[id_]
129
+
130
+ async def upsert(self, dto: DTO) -> None:
131
+ """Upsert a resource."""
132
+ dto_id = getattr(dto, self._id_field)
133
+ self.resources[dto_id] = deepcopy(dto)
134
+
135
+
136
+ def new_mock_dao_class(
137
+ *, dto_model: type[DTO], id_field: str
138
+ ) -> type[BaseInMemDao[DTO]]:
139
+ """Produce a mock DAO for the given DTO model and ID field"""
140
+
141
+ class MockDao(BaseInMemDao[DTO]):
142
+ """Mock dao that stores data in memory"""
143
+
144
+ _id_field: str = id_field
145
+
146
+ return MockDao
@@ -0,0 +1,402 @@
1
+ # Copyright 2021 - 2025 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
+ """Mock object storage class"""
17
+
18
+ from collections import defaultdict
19
+ from uuid import uuid4
20
+
21
+ from hexkit.protocols.objstorage import ObjectStorageProtocol, PresignedPostURL
22
+ from hexkit.providers.s3 import S3Config
23
+
24
+ UploadID = str
25
+ BucketID = str
26
+ ObjectID = str
27
+
28
+
29
+ class InMemObjectStorage(ObjectStorageProtocol):
30
+ """In-memory object storage mock just the method used in this service"""
31
+
32
+ def __init__(self, *, config: S3Config):
33
+ """Set bucket ID.
34
+
35
+ Type aliases are used here only to clarify dictionary structure.
36
+ """
37
+ self.buckets: dict[BucketID, set[ObjectID]] = defaultdict(set)
38
+ self.uploads: dict[BucketID, dict[ObjectID, set[UploadID]]] = defaultdict(dict)
39
+ self.endpoint_url = config.s3_endpoint_url
40
+
41
+ async def _does_bucket_exist(self, bucket_id: str) -> bool:
42
+ """Check whether a bucket with the specified ID (`bucket_id`) exists.
43
+
44
+ Returns `True` if it exists and `False` otherwise.
45
+ """
46
+ return bucket_id in self.buckets
47
+
48
+ async def _assert_bucket_exists(self, bucket_id: str):
49
+ """Assert that the bucket with the specified ID (`bucket_id`) exists.
50
+
51
+ Raises a `BucketNotFoundError` if the bucket does not exist.
52
+ """
53
+ if not await self.does_bucket_exist(bucket_id):
54
+ raise self.BucketNotFoundError(bucket_id=bucket_id)
55
+
56
+ async def _create_bucket(self, bucket_id: str) -> None:
57
+ """Create a bucket (= a structure that can hold multiple file objects) with the
58
+ specified unique ID.
59
+
60
+ Raises a `BucketAlreadyExistsError` if the bucket already exists.
61
+ """
62
+ if bucket_id in self.buckets:
63
+ raise self.BucketAlreadyExistsError(bucket_id=bucket_id)
64
+ self.buckets[bucket_id] = set()
65
+
66
+ async def _delete_bucket(
67
+ self, bucket_id: str, *, delete_content: bool = False
68
+ ) -> None:
69
+ """
70
+ Delete a bucket with the specified unique ID.
71
+
72
+ Raises a `BucketNotEmptyError` if the bucket is not empty and `delete_content`
73
+ is set to False.
74
+ """
75
+ await self._assert_bucket_exists(bucket_id)
76
+ if self.buckets[bucket_id] and not delete_content:
77
+ raise self.BucketNotEmptyError(bucket_id)
78
+ del self.buckets[bucket_id]
79
+
80
+ async def _list_all_object_ids(self, *, bucket_id: str) -> list[str]:
81
+ """Retrieve a list of IDs for all objects currently present in the specified bucket.
82
+
83
+ Raises a `BucketNotFoundError` if the bucket does not exist.
84
+ """
85
+ await self._assert_bucket_exists(bucket_id)
86
+ return sorted(self.buckets[bucket_id])
87
+
88
+ async def _does_object_exist(
89
+ self, *, bucket_id: str, object_id: str, object_md5sum: str | None = None
90
+ ) -> bool:
91
+ """Check whether an object with specified ID (`object_id`) exists in the bucket
92
+ with the specified id (`bucket_id`). Optionally, a md5 checksum (`object_md5sum`)
93
+ may be provided to check the objects content.
94
+ Return `True` if checks succeed and `False` otherwise.
95
+ """
96
+ if object_md5sum is not None:
97
+ raise NotImplementedError("MD5 checking is not yet implemented.")
98
+ return object_id in self.buckets[bucket_id]
99
+
100
+ async def _assert_object_exists(self, *, bucket_id: str, object_id: str) -> None:
101
+ """Asserts that the file with specified ID exists in the given bucket.
102
+
103
+ Raises:
104
+ `BucketNotFoundError`: If the bucket does not exist.
105
+ `ObjectNotFoundError`: If the object does not exist in the bucket.
106
+ """
107
+ # first check if bucket exists:
108
+ await self._assert_bucket_exists(bucket_id)
109
+
110
+ if not await self.does_object_exist(bucket_id=bucket_id, object_id=object_id):
111
+ raise self.ObjectNotFoundError(bucket_id=bucket_id, object_id=object_id)
112
+
113
+ async def _assert_object_not_exists(
114
+ self, *, bucket_id: str, object_id: str
115
+ ) -> None:
116
+ """Asserts that the file with specified ID does NOT exist in the given bucket.
117
+
118
+ Raises:
119
+ `BucketNotFoundError`: If the bucket does not exist.
120
+ `ObjectAlreadyExistsError`: If the object exists in the bucket.
121
+ """
122
+ # first check if bucket exists:
123
+ await self._assert_bucket_exists(bucket_id)
124
+
125
+ if await self.does_object_exist(bucket_id=bucket_id, object_id=object_id):
126
+ raise self.ObjectAlreadyExistsError(
127
+ bucket_id=bucket_id, object_id=object_id
128
+ )
129
+
130
+ async def _get_object_upload_url(
131
+ self,
132
+ *,
133
+ bucket_id: str,
134
+ object_id: str,
135
+ expires_after: int = 86400,
136
+ max_upload_size: int | None = None,
137
+ ) -> PresignedPostURL:
138
+ """Generates and returns a FAKE HTTP URL to upload a new file object with the
139
+ given id (`object_id`) to the bucket with the specified id (`bucket_id`).
140
+ You may also specify a custom expiry duration in seconds (`expires_after`) and
141
+ a maximum size (bytes) for uploads (`max_upload_size`).
142
+
143
+ This URL does not point to an actual resource or API, it is merely for testing.
144
+
145
+ Raises:
146
+ `BucketNotFoundError`: If the bucket does not exist.
147
+ `ObjectAlreadyExistsError`: If the object exists in the bucket.
148
+ """
149
+ await self._assert_object_not_exists(bucket_id=bucket_id, object_id=object_id)
150
+ fields: dict[str, str] = {"expires_after": str(expires_after)}
151
+ if max_upload_size:
152
+ fields["max_upload_size"] = str(max_upload_size)
153
+ return PresignedPostURL(
154
+ url=f"https://s3.example.com/{bucket_id}/{object_id}", fields=fields
155
+ )
156
+
157
+ async def _list_multipart_upload_for_object(
158
+ self, *, bucket_id: str, object_id: str
159
+ ) -> list[str]:
160
+ """Lists all active multipart uploads for the given object. Returns a list of
161
+ their IDs.
162
+
163
+ (S3 allows multiple ongoing multi-part uploads.)
164
+
165
+ Raises a `BucketNotFoundError` if the bucket does not exist.
166
+ """
167
+ await self._assert_bucket_exists(bucket_id)
168
+ uploads_for_object = self.uploads[bucket_id].get(object_id, set())
169
+ return sorted(uploads_for_object)
170
+
171
+ async def _assert_no_multipart_upload(self, *, bucket_id: str, object_id: str):
172
+ """Ensure that there are no active multi-part uploads for the given object.
173
+
174
+ Raises:
175
+ `MultiPartUploadAlreadyExistsError`: If an upload exists for the object.
176
+ `BucketNotFoundError`: If the bucket does not exist.
177
+ """
178
+ upload_ids = await self._list_multipart_upload_for_object(
179
+ bucket_id=bucket_id, object_id=object_id
180
+ )
181
+ if upload_ids:
182
+ raise self.MultiPartUploadAlreadyExistsError(
183
+ bucket_id=bucket_id, object_id=object_id
184
+ )
185
+
186
+ async def _assert_multipart_upload_exists(
187
+ self,
188
+ *,
189
+ upload_id: str,
190
+ bucket_id: str,
191
+ object_id: str,
192
+ assert_exclusiveness: bool = True,
193
+ ) -> None:
194
+ """Checks if a multipart upload with the given ID exists and whether it maps
195
+ to the specified object and bucket.
196
+
197
+ By default, also verifies that this upload is the only upload active for
198
+ that file.
199
+
200
+ Raises:
201
+ `BucketNotFoundError`: If the bucket does not exist.
202
+ `MultiPartUploadNotFoundError`: If the upload ID doesn't exist.
203
+ `MultipleActiveUploadsError`: If `assert_exclusiveness` is True and there are
204
+ multiple open uploads for the object ID.
205
+ """
206
+ upload_ids = await self._list_multipart_upload_for_object(
207
+ bucket_id=bucket_id, object_id=object_id
208
+ )
209
+
210
+ if assert_exclusiveness and len(upload_ids) > 1:
211
+ raise self.MultipleActiveUploadsError(
212
+ bucket_id=bucket_id, object_id=object_id, upload_ids=upload_ids
213
+ )
214
+
215
+ if upload_id not in upload_ids:
216
+ raise self.MultiPartUploadNotFoundError(
217
+ upload_id=upload_id,
218
+ bucket_id=bucket_id,
219
+ object_id=object_id,
220
+ )
221
+
222
+ async def _delete_object(self, *, bucket_id: str, object_id: str) -> None:
223
+ """Delete an object with the specified id (`object_id`) in the bucket with the
224
+ specified id (`bucket_id`).
225
+
226
+ Raises:
227
+ `BucketNotFoundError`: If the bucket does not exist.
228
+ `ObjectNotFoundError`: If the object does not exist in the bucket.
229
+ """
230
+ await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
231
+ self.buckets[bucket_id].remove(object_id)
232
+
233
+ async def _init_multipart_upload(self, *, bucket_id: str, object_id: str) -> str:
234
+ """Initiates a multipart upload procedure. Returns the upload ID.
235
+
236
+ Raises:
237
+ `MultiPartUploadAlreadyExistsError`: If an upload exists for the object.
238
+ `BucketNotFoundError`: If the bucket does not exist.
239
+ """
240
+ await self._assert_no_multipart_upload(bucket_id=bucket_id, object_id=object_id)
241
+ upload_id = str(uuid4())
242
+ if object_id not in self.buckets[bucket_id]:
243
+ self.uploads[bucket_id][object_id] = set()
244
+ self.uploads[bucket_id][object_id].add(upload_id)
245
+ return upload_id
246
+
247
+ async def _get_part_upload_url( # noqa: PLR0913
248
+ self,
249
+ *,
250
+ upload_id: str,
251
+ bucket_id: str,
252
+ object_id: str,
253
+ part_number: int,
254
+ expires_after: int = 3600,
255
+ part_md5: str | None = None,
256
+ ) -> str:
257
+ """
258
+ Return a FAKE presigned part upload URL based on the bucket, object, and part no.
259
+
260
+ This URL does not point to an actual resource or API, it is merely for testing.
261
+
262
+ You may also specify a custom expiry duration in seconds (`expires_after`).
263
+ Please note: the part number must be a non-zero, positive integer.
264
+
265
+ Raises:
266
+ `BucketNotFoundError`: If the bucket does not exist.
267
+ `MultiPartUploadNotFoundError`: If the upload ID doesn't exist.
268
+ `MultipleActiveUploadsError`: If `assert_exclusiveness` is True and there
269
+ are multiple open uploads for the object ID.
270
+ `ValueError`: If `part_number` is less than 1 or exceeds MAX_FILE_PART_NUMBER.
271
+ """
272
+ if not 0 < part_number <= self.MAX_FILE_PART_NUMBER:
273
+ raise ValueError(
274
+ "The part number must be a non-zero positive integer"
275
+ + f" smaller or equal to {self.MAX_FILE_PART_NUMBER}"
276
+ )
277
+
278
+ await self._assert_multipart_upload_exists(
279
+ upload_id=upload_id, bucket_id=bucket_id, object_id=object_id
280
+ )
281
+
282
+ params = {
283
+ "Bucket": bucket_id,
284
+ "Key": object_id,
285
+ "UploadId": upload_id,
286
+ "PartNumber": part_number,
287
+ }
288
+ # add additional parameters if any were passed
289
+ if part_md5:
290
+ params["ContentMD5"] = part_md5
291
+ return (
292
+ f"https://s3.example.com/{bucket_id}/{object_id}/{upload_id}"
293
+ + f"/part_no_{part_number}?{expires_after=}"
294
+ )
295
+
296
+ async def _abort_multipart_upload(
297
+ self,
298
+ *,
299
+ upload_id: str,
300
+ bucket_id: str,
301
+ object_id: str,
302
+ ) -> None:
303
+ """Abort a multipart upload with the specified ID.
304
+
305
+ Raises:
306
+ `BucketNotFoundError`: If the bucket does not exist.
307
+ `MultiPartUploadNotFoundError`: If the upload ID doesn't exist.
308
+ `MultipleActiveUploadsError`: If `assert_exclusiveness` is True and there are
309
+ multiple open uploads for the object ID.
310
+ """
311
+ await self._assert_multipart_upload_exists(
312
+ upload_id=upload_id,
313
+ bucket_id=bucket_id,
314
+ object_id=object_id,
315
+ assert_exclusiveness=False,
316
+ )
317
+ self.uploads[bucket_id][object_id].remove(upload_id)
318
+
319
+ async def _complete_multipart_upload(
320
+ self,
321
+ *,
322
+ upload_id: str,
323
+ bucket_id: str,
324
+ object_id: str,
325
+ anticipated_part_quantity: int | None = None,
326
+ anticipated_part_size: int | None = None,
327
+ ) -> None:
328
+ """Completes a multipart upload with the specified ID."""
329
+ # Calling this abort function because in the mock, the logic is identical to
330
+ # what we would write here anyway -- in real life, completing an upload does not
331
+ # involve the abort logic
332
+ await self._abort_multipart_upload(
333
+ upload_id=upload_id,
334
+ bucket_id=bucket_id,
335
+ object_id=object_id,
336
+ )
337
+ self.buckets[bucket_id].add(object_id)
338
+
339
+ async def _get_object_download_url(
340
+ self, *, bucket_id: str, object_id: str, expires_after: int = 86400
341
+ ) -> str:
342
+ """Generates and returns a FAKE presigned HTTP-URL to download a file object with
343
+ the specified ID (`object_id`) from bucket with the specified id (`bucket_id`).
344
+ You may also specify a custom expiry duration in seconds (`expires_after`).
345
+
346
+ This URL does not point to an actual resource or API, it is merely for testing.
347
+
348
+ Raises:
349
+ `BucketNotFoundError`: If the bucket does not exist.
350
+ `ObjectNotFoundError`: If the object does not exist in the bucket.
351
+ """
352
+ await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
353
+ return f"https://s3.example.com/{bucket_id}/{object_id}?{expires_after=}"
354
+
355
+ async def _get_object_etag(self, *, bucket_id: str, object_id: str) -> str:
356
+ """Return the etag of an object, which is hardcoded to be the string
357
+ `"etag_for_{object_id}"`.
358
+
359
+ Raises:
360
+ `BucketNotFoundError`: If the bucket does not exist.
361
+ `ObjectNotFoundError`: If the object does not exist in the bucket.
362
+ """
363
+ await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
364
+ return f"etag_for_{object_id}"
365
+
366
+ async def _get_object_size(self, *, bucket_id: str, object_id: str) -> int:
367
+ """Returns the size of an object in bytes, which is hardcoded to `1024` for
368
+ testing purposes.
369
+
370
+ Raises:
371
+ `BucketNotFoundError`: If the bucket does not exist.
372
+ `ObjectNotFoundError`: If the object does not exist in the bucket.
373
+ """
374
+ await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
375
+ return 1024
376
+
377
+ async def _copy_object(
378
+ self,
379
+ *,
380
+ source_bucket_id: str,
381
+ source_object_id: str,
382
+ dest_bucket_id: str,
383
+ dest_object_id: str,
384
+ abort_failed: bool = True,
385
+ ) -> None:
386
+ """Copy an object from one bucket (`source_bucket_id` and `source_object_id`) to
387
+ another bucket (`dest_bucket_id` and `dest_object_id`).
388
+ This operation always succeeds if the object exists in the source bucket and
389
+ does not exist in the dest bucket.
390
+
391
+ Raises:
392
+ `BucketNotFoundError`: If the source or dest bucket does not exist.
393
+ `ObjectAlreadyExistsError`: If the object already exists in the dest bucket.
394
+ `ObjectNotFoundError`: If the object does not exist in the source bucket.
395
+ """
396
+ await self._assert_object_not_exists(
397
+ bucket_id=dest_bucket_id, object_id=dest_object_id
398
+ )
399
+ await self._assert_object_exists(
400
+ bucket_id=source_bucket_id, object_id=source_object_id
401
+ )
402
+ self.buckets[dest_bucket_id].add(dest_object_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexkit
3
- Version: 7.1.0
3
+ Version: 7.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
@@ -78,10 +78,12 @@ Dynamic: license-file
78
78
  [![Coverage Status](https://coveralls.io/repos/github/ghga-de/hexkit/badge.svg?branch=main)](https://coveralls.io/github/ghga-de/hexkit?branch=main)
79
79
 
80
80
  # hexkit
81
+
81
82
  A chassis library for building domain-focused, infrastructure-agnostic, and event-driven
82
83
  microservices in Python.
83
84
 
84
85
  ## In a Nutshell
86
+
85
87
  This project implements the Triple Hexagonal Architecture pattern which is an
86
88
  optimization of the ordinary
87
89
  [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) for
@@ -110,6 +112,7 @@ a limited collection of protocol-provider pairs that are of immediate interest t
110
112
  authors. We like to add support for more protocols and technologies over time.
111
113
 
112
114
  The following protocols and providers are currently available:
115
+
113
116
  | Protocol | Providers |
114
117
  |---|---|
115
118
  | Event Publishing | Apache Kafka |
@@ -140,15 +143,18 @@ of jumping directly into it. You can find the protocols being defined at
140
143
  A more elaborate documentation and tutorial is on the way.
141
144
 
142
145
  ## Installation
146
+
143
147
  This package is available at PyPI:
144
- https://pypi.org/project/hexkit
148
+ <https://pypi.org/project/hexkit>
145
149
 
146
150
  You can install it from there using:
147
- ```
151
+
152
+ ```sh
148
153
  pip install hexkit
149
154
  ```
150
155
 
151
156
  The following extras are available:
157
+
152
158
  - `akafka`: when using the Apache Kafka-based event publishing or subscription
153
159
  - `s3`: when interacting with S3-compatible Object Storages
154
160
  - `mongodb`: when using mongodb as backend for the DAO protocol
@@ -158,6 +164,7 @@ The following extras are available:
158
164
  - `all`: a union of all the above
159
165
 
160
166
  ## Development
167
+
161
168
  For setting up the development environment, we rely on the
162
169
  [devcontainer feature](https://code.visualstudio.com/docs/remote/containers) of vscode.
163
170
 
@@ -167,6 +174,7 @@ Then, you just have to open this repo in vscode and run the command
167
174
  `Remote-Containers: Reopen in Container` from the vscode "Command Palette".
168
175
 
169
176
  This will give you a full-fledged, pre-configured development environment including:
177
+
170
178
  - infrastructural dependencies of the service (databases, etc.)
171
179
  - all relevant vscode extensions pre-installed
172
180
  - pre-configured linting and auto-formatting
@@ -175,12 +183,14 @@ This will give you a full-fledged, pre-configured development environment includ
175
183
 
176
184
  Additionally, the following convenience command is available inside the devcontainer
177
185
  (type it in the integrated terminal of VS Code):
186
+
178
187
  - `dev_install` - install the lib with all development dependencies and pre-commit hooks
179
188
  (please run that if you are starting the devcontainer for the first time
180
189
  or if added any python dependencies to the [`./pyproject.toml`](./pyproject.toml))
181
190
 
182
191
  If you prefer not to use vscode, you could get a similar setup (without the editor
183
192
  specific features) by running the following commands:
193
+
184
194
  ``` bash
185
195
  # Execute in the repo's root dir:
186
196
  cd ./.devcontainer
@@ -192,4 +202,5 @@ docker run -it hexkit /bin/bash
192
202
  ```
193
203
 
194
204
  ## License
205
+
195
206
  This repository is free to use and modify according to the [Apache 2.0 License](./LICENSE).
@@ -59,4 +59,6 @@ src/hexkit/providers/s3/testutils/_fixtures.py
59
59
  src/hexkit/providers/s3/testutils/_typical_workflow.py
60
60
  src/hexkit/providers/s3/testutils/_utils.py
61
61
  src/hexkit/providers/testing/__init__.py
62
- src/hexkit/providers/testing/eventpub.py
62
+ src/hexkit/providers/testing/dao.py
63
+ src/hexkit/providers/testing/eventpub.py
64
+ src/hexkit/providers/testing/s3.py
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