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.
- openframe_adapters_db_dynamodb-0.1.0/.gitignore +222 -0
- openframe_adapters_db_dynamodb-0.1.0/PKG-INFO +315 -0
- openframe_adapters_db_dynamodb-0.1.0/README.md +293 -0
- openframe_adapters_db_dynamodb-0.1.0/openframe/adapters/db/dynamodb/__init__.py +49 -0
- openframe_adapters_db_dynamodb-0.1.0/openframe/adapters/db/dynamodb/config.py +76 -0
- openframe_adapters_db_dynamodb-0.1.0/openframe/adapters/db/dynamodb/connection.py +211 -0
- openframe_adapters_db_dynamodb-0.1.0/openframe/adapters/db/dynamodb/plugin.py +217 -0
- openframe_adapters_db_dynamodb-0.1.0/openframe/adapters/db/dynamodb/repository.py +528 -0
- openframe_adapters_db_dynamodb-0.1.0/pyproject.toml +45 -0
- openframe_adapters_db_dynamodb-0.1.0/tests/conftest.py +82 -0
- openframe_adapters_db_dynamodb-0.1.0/tests/test_config.py +81 -0
- openframe_adapters_db_dynamodb-0.1.0/tests/test_connection.py +217 -0
- openframe_adapters_db_dynamodb-0.1.0/tests/test_plugin.py +349 -0
- openframe_adapters_db_dynamodb-0.1.0/tests/test_repository.py +344 -0
|
@@ -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
|