openframe-adapters-db-dynamodb 0.1.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.
@@ -0,0 +1,222 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # Miscellaneous
221
+ .DS_Store
222
+ .claude/
@@ -0,0 +1,315 @@
1
+ Metadata-Version: 2.5
2
+ Name: openframe-adapters-db-dynamodb
3
+ Version: 0.1.0
4
+ Summary: OpenFrame Microservice Suite — DynamoDB database adapter.
5
+ Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-adapters
6
+ Project-URL: Documentation, https://furious-meteors.github.io/openframe-adapters/
7
+ Project-URL: Repository, https://github.com/Furious-Meteors/openframe-adapters
8
+ Project-URL: Changelog, https://github.com/Furious-Meteors/openframe-adapters/blob/production/.github/CHANGELOG.md
9
+ Project-URL: Bug Tracker, https://github.com/Furious-Meteors/openframe-adapters/issues
10
+ Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
11
+ Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
12
+ License: MIT
13
+ Keywords: aioboto3,aws,dynamodb,hexagonal,microservice,openframe
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: aioboto3>=13.0
16
+ Requires-Dist: openframe-core<4,>=3.3
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
19
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # openframe-adapters-db-dynamodb
24
+
25
+ DynamoDB database adapter for the **OpenFrame Microservice Suite**.
26
+
27
+ Part of the `openframe-adapters` monorepo. Implements `BaseRepository[T]` and
28
+ `HealthCheck` from `openframe-core` using `aioboto3` (which wraps
29
+ `boto3`/`botocore` with genuine `aiohttp`-backed async I/O via
30
+ `aiobotocore` — see "A note on async style" below).
31
+
32
+ ---
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install openframe-adapters-db-dynamodb
38
+ ```
39
+
40
+ Required env vars:
41
+
42
+ ```
43
+ AWS_REGION=us-east-1
44
+ DYNAMODB_TABLE_NAME=items
45
+ ```
46
+
47
+ Optional, for local development against a local DynamoDB process instead of
48
+ real AWS:
49
+
50
+ ```
51
+ ENDPOINT_URL=http://localhost:8000
52
+ AWS_ACCESS_KEY_ID=local
53
+ AWS_SECRET_ACCESS_KEY=local
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Quick start
59
+
60
+ ### Raw dict mode
61
+
62
+ ```python
63
+ from openframe.adapters.db.dynamodb import DynamoDBSettings, DynamoDBRepository
64
+
65
+ settings = DynamoDBSettings() # reads AWS_REGION / DYNAMODB_TABLE_NAME from env
66
+ repo = DynamoDBRepository(settings, id_column="id")
67
+
68
+ item = await repo.get("abc-123") # dict | None
69
+ items, total = await repo.list(10, 0) # ([dict, ...], int)
70
+ created = await repo.create({"id": "abc-123", "name": "x"})
71
+ updated = await repo.update({"id": "abc-123", "name": "y"})
72
+ deleted = await repo.delete("abc-123") # bool
73
+ ```
74
+
75
+ ### Typed domain mode
76
+
77
+ ```python
78
+ from dataclasses import dataclass
79
+ from openframe.adapters.db.dynamodb import DynamoDBSettings, DynamoDBRepository
80
+
81
+ @dataclass
82
+ class Item:
83
+ id: str
84
+ name: str
85
+
86
+ class ItemRepository(DynamoDBRepository[Item]):
87
+ _id_column = "id"
88
+
89
+ def _row_to_entity(self, row) -> Item:
90
+ return Item(**row)
91
+
92
+ def _entity_to_row(self, entity: Item) -> dict:
93
+ return {"id": entity.id, "name": entity.name}
94
+
95
+ settings = DynamoDBSettings()
96
+ repo = ItemRepository(settings)
97
+ item: Item | None = await repo.get("abc-123")
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Wiring into an application
103
+
104
+ For a real service, wire `DynamoDBPlugin` (the `BasePort`-satisfying plugin
105
+ class) through `ApplicationBootstrap.compose()` from `openframe-core`. This
106
+ gives you proper lifecycle management — `initialize()` / `health()` /
107
+ `shutdown()` — for free, instead of constructing `DynamoDBRepository`
108
+ directly and managing the resource yourself:
109
+
110
+ ```python
111
+ from openframe.core.runtime import ApplicationBootstrap
112
+ from openframe.core.ports import Capability
113
+ from openframe.adapters.db.dynamodb import DynamoDBPlugin, DynamoDBSettings
114
+
115
+ settings = DynamoDBSettings() # reads AWS_REGION / DYNAMODB_TABLE_NAME from env
116
+ plugin = DynamoDBPlugin(settings, id_column="id")
117
+
118
+ async with ApplicationBootstrap.compose(plugin) as app:
119
+ repo = app.get(Capability.PERSISTENCE) # -> DynamoDBRepository
120
+ item = await repo.get("abc-123")
121
+ # resource is closed automatically on exit (plugin.shutdown() ran)
122
+ ```
123
+
124
+ `compose()` calls `plugin.initialize()` on entry and `plugin.shutdown()` on
125
+ exit, so the resource is created, health-checked, and torn down without any
126
+ manual lifecycle code. Requires `openframe-core>=3.3`.
127
+
128
+ Reach for a subclassed `ApplicationBootstrap` (with a `configure()` method)
129
+ only when you need per-port `config=`/`init_timeout=` or conditional
130
+ registration order; use `app.registry` as an escape hatch for anything
131
+ neither tier covers. The `DynamoDBRepository(settings)` construction shown
132
+ above under "Quick start" remains valid for tests, scripts, or any context
133
+ that doesn't need plugin lifecycle management.
134
+
135
+ ---
136
+
137
+ ## Configuration
138
+
139
+ All settings are read from environment variables.
140
+
141
+ | Env var | Type | Default | Description |
142
+ |---|---|---|---|
143
+ | `AWS_REGION` | `str` | **required** | AWS region, e.g. `us-east-1` |
144
+ | `DYNAMODB_TABLE_NAME` | `str` | **required** | DynamoDB table name |
145
+ | `ENDPOINT_URL` | `str \| None` | `None` | Override endpoint, e.g. `http://localhost:8000` for local DynamoDB |
146
+ | `AWS_ACCESS_KEY_ID` | `str \| None` | `None` | Explicit access key; omit to use the standard AWS credential chain |
147
+ | `AWS_SECRET_ACCESS_KEY` | `str \| None` | `None` | Explicit secret key |
148
+ | `AWS_SESSION_TOKEN` | `str \| None` | `None` | Explicit session token (temporary creds) |
149
+ | `CONNECTION_TIMEOUT` | `float` | `30.0` | Resource creation timeout (s) |
150
+ | `OPERATION_TIMEOUT` | `float` | `10.0` | Per-operation timeout (s) |
151
+ | `MAX_RETRIES` | `int` | `3` | Max retry attempts |
152
+
153
+ ---
154
+
155
+ ## A note on async style, and what "connection caching" means here
156
+
157
+ `aioboto3` is classified as a **wrapper**-style driver in this ecosystem's
158
+ driver taxonomy (as opposed to natively-async drivers like `asyncpg`, or
159
+ executor-style drivers that thread-wrap a blocking C library). That
160
+ classification is about the *shape* of the API, not about whether the I/O
161
+ is real: under the hood, `aioboto3` delegates to `aiobotocore`, which
162
+ replaces botocore's blocking `urllib3` HTTP stack with genuine
163
+ `aiohttp`-backed async I/O (see `aiobotocore.httpsession.AIOHTTPSession`,
164
+ verified against the installed package). Calls made through this adapter
165
+ are not thread-wrapped synchronous `boto3` calls — they are real
166
+ non-blocking network requests.
167
+
168
+ DynamoDB itself, unlike Postgres/MySQL, has no concept of a persistent TCP
169
+ connection pool — it's a managed, stateless, HTTP-based AWS service. This
170
+ package's `connection.py` still caches something per settings
171
+ (`get_dynamodb_table()` / `_table_cache`), but what it caches is a
172
+ **session/resource/Table object**, not a connection pool. Entering
173
+ `aioboto3.Session().resource("dynamodb", ...)` sets up an internal
174
+ `aiohttp.ClientSession` but performs no network call by itself — the first
175
+ actual round-trip happens on the first real operation. The cache exists to
176
+ avoid re-creating that session and re-resolving credentials on every call,
177
+ not to bound concurrent connections the way a Postgres pool's `pool_size`
178
+ does; aiohttp's own connector handles concurrent-request pooling
179
+ transparently underneath the single cached `Table` object.
180
+
181
+ ---
182
+
183
+ ## Why the resource interface, not the client interface
184
+
185
+ `aioboto3` exposes DynamoDB through two interfaces: the low-level
186
+ **client** (`session.client("dynamodb", ...)`), whose methods require
187
+ building DynamoDB's verbose `{"S": "value"}`-style attribute-value maps by
188
+ hand, and the higher-level **resource** (`session.resource("dynamodb",
189
+ ...)`), whose `Table` object exposes `get_item`/`put_item`/`delete_item`/
190
+ `query`/`scan` working directly with plain Python dicts via boto3's
191
+ built-in type serializer/deserializer. This package uses the resource
192
+ interface's `Table` object exclusively — it maps directly onto
193
+ `BaseRepository[T]`'s plain-dict CRUD shape, with no manual
194
+ attribute-value marshalling required.
195
+
196
+ ---
197
+
198
+ ## DynamoDB-specific repository semantics
199
+
200
+ - **`list(limit, offset)`**: DynamoDB has no native integer offset — scans
201
+ paginate via an opaque `LastEvaluatedKey`, not a skip count. This
202
+ implementation scans the full table (honouring the `(limit, offset)`
203
+ contract exactly) and discards the first `offset` items in Python. Correct,
204
+ but its cost scales with `offset + limit` items scanned — not a substitute
205
+ for DynamoDB's own key-based pagination in a latency-sensitive path.
206
+ - **`update(entity)`**: a plain `put_item` would silently *create* a new
207
+ item if the key doesn't exist. To honour `BaseRepository`'s "returns
208
+ `None` for a missing entity" contract, `update()` issues a conditional
209
+ `put_item` (`ConditionExpression="attribute_exists(...)"`) and translates
210
+ a `ConditionalCheckFailedException` into `None` instead of raising.
211
+ - **`create(entity)`**: `put_item` has no response body, and DynamoDB has
212
+ no auto-increment equivalent, so `create()` returns the entity exactly as
213
+ passed in rather than re-fetching it.
214
+
215
+ ---
216
+
217
+ ## Health checks
218
+
219
+ `DynamoDBRepository` implements the `HealthCheck` protocol from
220
+ `openframe-core`.
221
+
222
+ ```python
223
+ alive = await repo.health() # PluginHealth snapshot -- describe_table liveness check
224
+ ```
225
+
226
+ `health()` never raises — it returns a `PluginHealth` with `status=FAILED` on
227
+ any failure instead.
228
+
229
+ ---
230
+
231
+ ## Exception hierarchy
232
+
233
+ All exceptions are `AdapterError` subclasses from `openframe.core.exceptions`.
234
+ Raw `botocore`/`aioboto3` exceptions never escape the adapter.
235
+
236
+ | Situation | DynamoDB error code | Exception |
237
+ |---|---|---|
238
+ | Cannot reach DynamoDB (network-level, request never sent) | `EndpointConnectionError`/`ConnectionError` | `AdapterConnectionError` |
239
+ | Request throttled / capacity exceeded (transient) | `ProvisionedThroughputExceededException`, `ThrottlingException`, `RequestLimitExceeded` | `AdapterConnectionError` (retryable) |
240
+ | Missing/invalid region or credentials | `NoCredentialsError`/`NoRegionError`/`PartialCredentialsError` | `AdapterConfigurationError` |
241
+ | Bad input / table doesn't exist / other service rejection | `ValidationException`, `ResourceNotFoundException`, etc. | `AdapterQueryError` |
242
+ | `update()` target doesn't exist | `ConditionalCheckFailedException` | returns `None` (not raised) |
243
+ | Operation exceeded timeout | — | `AdapterTimeoutError` |
244
+
245
+ **A note on `botocore.exceptions.ClientError`:** DynamoDB funnels nearly
246
+ every *service-side* error — a missing table, bad input, throttling, a
247
+ failed conditional check — through this single exception class. The only
248
+ way to tell them apart is `exc.response["Error"]["Code"]`, never the Python
249
+ exception type. Genuine *local*/network-level failures, where the request
250
+ never reached AWS at all, raise a completely different hierarchy
251
+ (`EndpointConnectionError`/`ConnectionError`) and are checked first — see
252
+ `repository.py`'s `_wrap_botocore()` and `connection.py`'s module docstring
253
+ for the full classification.
254
+
255
+ Throttling errors (`ProvisionedThroughputExceededException`/
256
+ `ThrottlingException`) are mapped onto `AdapterConnectionError` rather than
257
+ `AdapterQueryError`: `openframe-core`'s exception taxonomy has no dedicated
258
+ "throttled" subclass, and `AdapterConnectionError`'s `retryable=True`
259
+ default communicates exactly what callers need — back off and retry — even
260
+ though no actual connection was lost.
261
+
262
+ ---
263
+
264
+ ## Development
265
+
266
+ ```bash
267
+ # from the package directory
268
+ pip install -e ".[dev]"
269
+ python -m pytest tests/ -v
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Protocol conformance
275
+
276
+ ```python
277
+ from openframe.core.ports import BaseRepository
278
+ from openframe.core.health import HealthCheck
279
+
280
+ repo = DynamoDBRepository(settings, id_column="id")
281
+ assert isinstance(repo, BaseRepository) # True -- structural check
282
+ assert isinstance(repo, HealthCheck) # True -- structural check
283
+ ```
284
+
285
+ No inheritance from either Protocol is required or used.
286
+
287
+ ---
288
+
289
+ ## Resilience (optional)
290
+
291
+ `openframe-core>=3.4` ships `openframe.core.resilience`. Wrap the repository
292
+ from the outside — exactly like `TracingProxy` — with no adapter code
293
+ changes required:
294
+
295
+ ```python
296
+ from openframe.core.resilience import CircuitBreakerProxy
297
+ from openframe.core.telemetry import TracingProxy
298
+
299
+ repo = plugin.get_repository()
300
+ protected = CircuitBreakerProxy(
301
+ TracingProxy(repo, prefix="repository.item"),
302
+ failure_threshold=5,
303
+ reset_timeout=30.0,
304
+ )
305
+ ```
306
+
307
+ Compose the circuit breaker around the traced repository (not the reverse)
308
+ so a short-circuited call never produces a misleading adapter span for a
309
+ call that never reached the adapter.
310
+
311
+ ---
312
+
313
+ ## License
314
+
315
+ MIT