ddaerospike 0.0.1__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.
- ddaerospike-0.0.1/LICENSE +21 -0
- ddaerospike-0.0.1/PKG-INFO +227 -0
- ddaerospike-0.0.1/README.md +205 -0
- ddaerospike-0.0.1/ddaerospike/__init__.py +3 -0
- ddaerospike-0.0.1/ddaerospike/data.py +135 -0
- ddaerospike-0.0.1/ddaerospike/dsn.py +47 -0
- ddaerospike-0.0.1/ddaerospike/feature_flags.py +91 -0
- ddaerospike-0.0.1/ddaerospike/filters.py +315 -0
- ddaerospike-0.0.1/ddaerospike/manager.py +237 -0
- ddaerospike-0.0.1/ddaerospike/policies.py +99 -0
- ddaerospike-0.0.1/ddaerospike/py.typed +0 -0
- ddaerospike-0.0.1/pyproject.toml +57 -0
- ddaerospike-0.0.1/pyproject.toml.orig +51 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem Davydov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ddaerospike
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Aerospike helpers: typed records, filters and a set manager on top of aerospike-native
|
|
5
|
+
Keywords: python,aerospike,aerospike-native,ddaerospike
|
|
6
|
+
Author: davyddd
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: aerospike-native>=1.0,<2
|
|
16
|
+
Requires-Dist: dddesign>=2.1.1,<3
|
|
17
|
+
Requires-Dist: ddutils>=0.3.2,<0.4.0
|
|
18
|
+
Requires-Dist: pydantic>=2.10,<3
|
|
19
|
+
Requires-Python: >=3.11, <3.15
|
|
20
|
+
Project-URL: Repository, https://github.com/davyddd/ddaerospike
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ddaerospike
|
|
24
|
+
|
|
25
|
+
[](https://pypi.python.org/pypi/ddaerospike)
|
|
26
|
+
[](https://pepy.tech/project/ddaerospike)
|
|
27
|
+
[](https://github.com/davyddd/ddaerospike)
|
|
28
|
+
[](https://app.codecov.io/github/davyddd/ddaerospike)
|
|
29
|
+
[](https://github.com/davyddd/ddaerospike/blob/main/LICENSE)
|
|
30
|
+
|
|
31
|
+
Aerospike helpers: typed records, filters and a set manager on top of aerospike-native
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Install the library using pip:
|
|
36
|
+
```bash
|
|
37
|
+
pip install ddaerospike
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
fab build # build the dev image
|
|
46
|
+
fab tests # run pytest
|
|
47
|
+
fab linters # run ruff (with --fix), ty and complexipy
|
|
48
|
+
fab shell # IPython inside the container
|
|
49
|
+
fab bash # bash inside the container
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
|
|
53
|
+
run `copier update` to pull in template updates.
|
|
54
|
+
|
|
55
|
+
## AerospikeDsn
|
|
56
|
+
|
|
57
|
+
A settings field for a cluster: `aerospike://host[:port][,host[:port]]/<namespace>`, validated as a URL like
|
|
58
|
+
pydantic's `PostgresDsn`. The hosts are the seeds the client learns the rest of the cluster from (a missing port
|
|
59
|
+
is 3000), the path names the namespace and is required.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from pydantic_settings import BaseSettings
|
|
63
|
+
|
|
64
|
+
from ddaerospike.dsn import AerospikeDsn
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Settings(BaseSettings):
|
|
68
|
+
AEROSPIKE_URL: AerospikeDsn # AEROSPIKE_URL=aerospike://aerospike-0,aerospike-1/application
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
settings = Settings()
|
|
72
|
+
settings.AEROSPIKE_URL.seeds # 'aerospike-0:3000,aerospike-1:3000', what `new_client` takes
|
|
73
|
+
settings.AEROSPIKE_URL.namespace # 'application'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## AerospikeData
|
|
77
|
+
|
|
78
|
+
A record of a set, every field a bin. Scalars and plain containers are simple bins; a `BaseModel` / `RootModel`
|
|
79
|
+
field is a composite bin stored as a map or list. Datetimes are declared as `AerospikeTimestamp`: stored as integer
|
|
80
|
+
microseconds since the epoch (UTC), read back as an aware datetime, and compared the same way by the filters; a
|
|
81
|
+
bare `datetime` or `date` field is rejected at class definition, as is a bin name over 15 characters. Subclasses
|
|
82
|
+
map to and from the domain through `to_entity` / `from_entity`.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from pydantic import BaseModel
|
|
86
|
+
|
|
87
|
+
from ddaerospike.data import AerospikeData, AerospikeTimestamp
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Address(BaseModel):
|
|
91
|
+
city: str
|
|
92
|
+
zip_code: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ProfileData(AerospikeData[Profile]):
|
|
96
|
+
name: str
|
|
97
|
+
age: int | None = None
|
|
98
|
+
created_at: AerospikeTimestamp
|
|
99
|
+
address: Address | None = None # a composite bin
|
|
100
|
+
|
|
101
|
+
def to_entity(self, **kwargs) -> Profile:
|
|
102
|
+
return Profile(name=self.name, age=self.age, created_at=self.created_at)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_entity(cls, entity: Profile) -> 'ProfileData':
|
|
106
|
+
return cls(name=entity.name, age=entity.age, created_at=entity.created_at)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## AerospikeManager
|
|
110
|
+
|
|
111
|
+
ORM-like access to one set, typed by its `AerospikeData` model: `get`, `get_map`, `get_list`, `find_one`, `put`
|
|
112
|
+
and `delete`. It is a Django-style manager, not a dddesign repository: a context repository owns one and maps
|
|
113
|
+
records to entities. The client is obtained for every call from `client_factory`, a `ConnectionManagerFactory`
|
|
114
|
+
from [ddutils](https://github.com/davyddd/ddutils) called with `alias`, the way a
|
|
115
|
+
[ddsql](https://github.com/davyddd/ddsql) adapter runs a query inside `factory(alias=...)`: the factory owns which
|
|
116
|
+
cluster the alias means, the scope the client lives in (one per process and event loop, the client completes its
|
|
117
|
+
futures on the loop it was created in), its lazy creation and its close on shutdown.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import asyncio
|
|
121
|
+
import os
|
|
122
|
+
from enum import Enum
|
|
123
|
+
from functools import partial
|
|
124
|
+
|
|
125
|
+
from aerospike_native import Client, ClientPolicy, new_client
|
|
126
|
+
from ddutils.connections import ConnectionManagerFactory
|
|
127
|
+
from ddutils.scoped_registry import ScopedRegistry
|
|
128
|
+
|
|
129
|
+
from ddaerospike.manager import AerospikeManager
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AerospikeConnectionAlias(str, Enum):
|
|
133
|
+
PRIMARY = 'primary'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def create_client() -> Client:
|
|
137
|
+
policy = ClientPolicy()
|
|
138
|
+
policy.timeout = 5_000
|
|
139
|
+
return await new_client(policy, settings.AEROSPIKE_URL.seeds)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def client_scope() -> tuple[int, asyncio.AbstractEventLoop]:
|
|
143
|
+
return os.getpid(), asyncio.get_running_loop()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
client_registries = {
|
|
147
|
+
AerospikeConnectionAlias.PRIMARY: ScopedRegistry[Client](
|
|
148
|
+
create_func=create_client, scope_func=client_scope, destructor_method_name='close'
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
aerospike_client_factory = ConnectionManagerFactory(client_registries, default=AerospikeConnectionAlias.PRIMARY)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ProfileManager(AerospikeManager[ProfileData]):
|
|
155
|
+
client_factory = aerospike_client_factory
|
|
156
|
+
namespace = settings.AEROSPIKE_URL.namespace
|
|
157
|
+
set_name = 'profile'
|
|
158
|
+
ttl_seconds = 30 * 24 * 60 * 60 # set by every write; None leaves the record's TTL alone
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
profile_manager = ProfileManager()
|
|
162
|
+
|
|
163
|
+
await profile_manager.put(profile.id, ProfileData.from_entity(profile))
|
|
164
|
+
data = await profile_manager.get(profile.id) # ProfileData | None
|
|
165
|
+
await profile_manager.get_map([1, 2, 3]) # {'1': ProfileData, ...}
|
|
166
|
+
await profile_manager.put(profile.id, data, if_absent=True) # False when the record exists
|
|
167
|
+
await profile_manager.delete(profile.id)
|
|
168
|
+
|
|
169
|
+
await aerospike_client_factory.close() # on shutdown
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A bin set to `None` is removed from the record by the write; inside a composite bin the `None` values are left
|
|
173
|
+
out. The `read_policy_fields`, `batch_policy_fields`, `query_policy_fields` and `write_policy_fields` class
|
|
174
|
+
attributes (typed dicts from `ddaerospike.policies`) override the defaults: short timeouts, retries on reads only,
|
|
175
|
+
`send_key` on writes so queries can return the primary key.
|
|
176
|
+
|
|
177
|
+
## Filters
|
|
178
|
+
|
|
179
|
+
`AerospikeFilterCollection(Model)` holds the conditions of one query on a set, bound to its record model: at most
|
|
180
|
+
one secondary-index filter (a query runs on one index: equality on `int` / `str`, a range on `int`) and any number
|
|
181
|
+
of filter expressions, ANDed and evaluated on every record the index or the whole set yields. `add_filter` checks
|
|
182
|
+
the bin against the model and the value against the bin's scalar (an `int` widens to a `float` bin); values are
|
|
183
|
+
normalized the way the server compares them (enums to their value, UUIDs to strings, datetimes to
|
|
184
|
+
`AerospikeTimestamp` microseconds, `Decimal` to float), and every operator checks the type it accepts. A composite
|
|
185
|
+
bin only takes `is_null` / `is_not_null`, which take no value.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from ddaerospike.filters import AerospikeFilterCollection, AerospikeFilterOperator
|
|
189
|
+
|
|
190
|
+
filters = (
|
|
191
|
+
AerospikeFilterCollection(ProfileData)
|
|
192
|
+
.add_filter('name', AerospikeFilterOperator.EQUAL, 'john', use_index=True) # needs an index on `name`
|
|
193
|
+
.add_filter('age', AerospikeFilterOperator.BETWEEN, [18, 65])
|
|
194
|
+
.add_filter('address', AerospikeFilterOperator.IS_NOT_NULL)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
items = await profile_manager.get_list(filters, limit=100) # [AerospikeDataWithPK(pk='1', data=ProfileData(...)), ...]
|
|
198
|
+
first = await profile_manager.find_one(filters)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## AerospikeFeatureFlagStorage
|
|
202
|
+
|
|
203
|
+
The Aerospike backend of [ddutils](https://github.com/davyddd/ddutils) feature flags: one set, a flag per record
|
|
204
|
+
(`FeatureFlagData`: `enabled`, `proportion`, `payload` bins, the flag name is the primary key), through an
|
|
205
|
+
`AerospikeManager[FeatureFlagData]` the application wires. Client and server errors raise
|
|
206
|
+
`FeatureFlagStorageError`, so `FeatureFlags` keeps serving the last known values; a record that does not validate
|
|
207
|
+
is read as the defaults.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from ddaerospike.feature_flags import AerospikeFeatureFlagStorage, FeatureFlagData
|
|
211
|
+
from ddaerospike.manager import AerospikeManager
|
|
212
|
+
from ddutils.feature_flags import FeatureFlags
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class FeatureFlagManager(AerospikeManager[FeatureFlagData]):
|
|
216
|
+
client_factory = aerospike_client_factory
|
|
217
|
+
namespace = settings.AEROSPIKE_URL.namespace
|
|
218
|
+
set_name = 'feature_flag'
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
feature_flags = FeatureFlags(AerospikeFeatureFlagStorage(FeatureFlagManager()))
|
|
222
|
+
|
|
223
|
+
if await feature_flags.is_enabled('new_checkout'): # proportion 1.0: on or off for everyone
|
|
224
|
+
...
|
|
225
|
+
if await feature_flags.is_enabled('new_ranking', user_id): # on for `proportion` of the users
|
|
226
|
+
...
|
|
227
|
+
```
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# ddaerospike
|
|
2
|
+
|
|
3
|
+
[](https://pypi.python.org/pypi/ddaerospike)
|
|
4
|
+
[](https://pepy.tech/project/ddaerospike)
|
|
5
|
+
[](https://github.com/davyddd/ddaerospike)
|
|
6
|
+
[](https://app.codecov.io/github/davyddd/ddaerospike)
|
|
7
|
+
[](https://github.com/davyddd/ddaerospike/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
Aerospike helpers: typed records, filters and a set manager on top of aerospike-native
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install the library using pip:
|
|
14
|
+
```bash
|
|
15
|
+
pip install ddaerospike
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Development
|
|
19
|
+
|
|
20
|
+
The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
fab build # build the dev image
|
|
24
|
+
fab tests # run pytest
|
|
25
|
+
fab linters # run ruff (with --fix), ty and complexipy
|
|
26
|
+
fab shell # IPython inside the container
|
|
27
|
+
fab bash # bash inside the container
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
|
|
31
|
+
run `copier update` to pull in template updates.
|
|
32
|
+
|
|
33
|
+
## AerospikeDsn
|
|
34
|
+
|
|
35
|
+
A settings field for a cluster: `aerospike://host[:port][,host[:port]]/<namespace>`, validated as a URL like
|
|
36
|
+
pydantic's `PostgresDsn`. The hosts are the seeds the client learns the rest of the cluster from (a missing port
|
|
37
|
+
is 3000), the path names the namespace and is required.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from pydantic_settings import BaseSettings
|
|
41
|
+
|
|
42
|
+
from ddaerospike.dsn import AerospikeDsn
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Settings(BaseSettings):
|
|
46
|
+
AEROSPIKE_URL: AerospikeDsn # AEROSPIKE_URL=aerospike://aerospike-0,aerospike-1/application
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
settings = Settings()
|
|
50
|
+
settings.AEROSPIKE_URL.seeds # 'aerospike-0:3000,aerospike-1:3000', what `new_client` takes
|
|
51
|
+
settings.AEROSPIKE_URL.namespace # 'application'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## AerospikeData
|
|
55
|
+
|
|
56
|
+
A record of a set, every field a bin. Scalars and plain containers are simple bins; a `BaseModel` / `RootModel`
|
|
57
|
+
field is a composite bin stored as a map or list. Datetimes are declared as `AerospikeTimestamp`: stored as integer
|
|
58
|
+
microseconds since the epoch (UTC), read back as an aware datetime, and compared the same way by the filters; a
|
|
59
|
+
bare `datetime` or `date` field is rejected at class definition, as is a bin name over 15 characters. Subclasses
|
|
60
|
+
map to and from the domain through `to_entity` / `from_entity`.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from pydantic import BaseModel
|
|
64
|
+
|
|
65
|
+
from ddaerospike.data import AerospikeData, AerospikeTimestamp
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Address(BaseModel):
|
|
69
|
+
city: str
|
|
70
|
+
zip_code: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProfileData(AerospikeData[Profile]):
|
|
74
|
+
name: str
|
|
75
|
+
age: int | None = None
|
|
76
|
+
created_at: AerospikeTimestamp
|
|
77
|
+
address: Address | None = None # a composite bin
|
|
78
|
+
|
|
79
|
+
def to_entity(self, **kwargs) -> Profile:
|
|
80
|
+
return Profile(name=self.name, age=self.age, created_at=self.created_at)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_entity(cls, entity: Profile) -> 'ProfileData':
|
|
84
|
+
return cls(name=entity.name, age=entity.age, created_at=entity.created_at)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## AerospikeManager
|
|
88
|
+
|
|
89
|
+
ORM-like access to one set, typed by its `AerospikeData` model: `get`, `get_map`, `get_list`, `find_one`, `put`
|
|
90
|
+
and `delete`. It is a Django-style manager, not a dddesign repository: a context repository owns one and maps
|
|
91
|
+
records to entities. The client is obtained for every call from `client_factory`, a `ConnectionManagerFactory`
|
|
92
|
+
from [ddutils](https://github.com/davyddd/ddutils) called with `alias`, the way a
|
|
93
|
+
[ddsql](https://github.com/davyddd/ddsql) adapter runs a query inside `factory(alias=...)`: the factory owns which
|
|
94
|
+
cluster the alias means, the scope the client lives in (one per process and event loop, the client completes its
|
|
95
|
+
futures on the loop it was created in), its lazy creation and its close on shutdown.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import asyncio
|
|
99
|
+
import os
|
|
100
|
+
from enum import Enum
|
|
101
|
+
from functools import partial
|
|
102
|
+
|
|
103
|
+
from aerospike_native import Client, ClientPolicy, new_client
|
|
104
|
+
from ddutils.connections import ConnectionManagerFactory
|
|
105
|
+
from ddutils.scoped_registry import ScopedRegistry
|
|
106
|
+
|
|
107
|
+
from ddaerospike.manager import AerospikeManager
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AerospikeConnectionAlias(str, Enum):
|
|
111
|
+
PRIMARY = 'primary'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def create_client() -> Client:
|
|
115
|
+
policy = ClientPolicy()
|
|
116
|
+
policy.timeout = 5_000
|
|
117
|
+
return await new_client(policy, settings.AEROSPIKE_URL.seeds)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def client_scope() -> tuple[int, asyncio.AbstractEventLoop]:
|
|
121
|
+
return os.getpid(), asyncio.get_running_loop()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
client_registries = {
|
|
125
|
+
AerospikeConnectionAlias.PRIMARY: ScopedRegistry[Client](
|
|
126
|
+
create_func=create_client, scope_func=client_scope, destructor_method_name='close'
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
aerospike_client_factory = ConnectionManagerFactory(client_registries, default=AerospikeConnectionAlias.PRIMARY)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ProfileManager(AerospikeManager[ProfileData]):
|
|
133
|
+
client_factory = aerospike_client_factory
|
|
134
|
+
namespace = settings.AEROSPIKE_URL.namespace
|
|
135
|
+
set_name = 'profile'
|
|
136
|
+
ttl_seconds = 30 * 24 * 60 * 60 # set by every write; None leaves the record's TTL alone
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
profile_manager = ProfileManager()
|
|
140
|
+
|
|
141
|
+
await profile_manager.put(profile.id, ProfileData.from_entity(profile))
|
|
142
|
+
data = await profile_manager.get(profile.id) # ProfileData | None
|
|
143
|
+
await profile_manager.get_map([1, 2, 3]) # {'1': ProfileData, ...}
|
|
144
|
+
await profile_manager.put(profile.id, data, if_absent=True) # False when the record exists
|
|
145
|
+
await profile_manager.delete(profile.id)
|
|
146
|
+
|
|
147
|
+
await aerospike_client_factory.close() # on shutdown
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A bin set to `None` is removed from the record by the write; inside a composite bin the `None` values are left
|
|
151
|
+
out. The `read_policy_fields`, `batch_policy_fields`, `query_policy_fields` and `write_policy_fields` class
|
|
152
|
+
attributes (typed dicts from `ddaerospike.policies`) override the defaults: short timeouts, retries on reads only,
|
|
153
|
+
`send_key` on writes so queries can return the primary key.
|
|
154
|
+
|
|
155
|
+
## Filters
|
|
156
|
+
|
|
157
|
+
`AerospikeFilterCollection(Model)` holds the conditions of one query on a set, bound to its record model: at most
|
|
158
|
+
one secondary-index filter (a query runs on one index: equality on `int` / `str`, a range on `int`) and any number
|
|
159
|
+
of filter expressions, ANDed and evaluated on every record the index or the whole set yields. `add_filter` checks
|
|
160
|
+
the bin against the model and the value against the bin's scalar (an `int` widens to a `float` bin); values are
|
|
161
|
+
normalized the way the server compares them (enums to their value, UUIDs to strings, datetimes to
|
|
162
|
+
`AerospikeTimestamp` microseconds, `Decimal` to float), and every operator checks the type it accepts. A composite
|
|
163
|
+
bin only takes `is_null` / `is_not_null`, which take no value.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from ddaerospike.filters import AerospikeFilterCollection, AerospikeFilterOperator
|
|
167
|
+
|
|
168
|
+
filters = (
|
|
169
|
+
AerospikeFilterCollection(ProfileData)
|
|
170
|
+
.add_filter('name', AerospikeFilterOperator.EQUAL, 'john', use_index=True) # needs an index on `name`
|
|
171
|
+
.add_filter('age', AerospikeFilterOperator.BETWEEN, [18, 65])
|
|
172
|
+
.add_filter('address', AerospikeFilterOperator.IS_NOT_NULL)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
items = await profile_manager.get_list(filters, limit=100) # [AerospikeDataWithPK(pk='1', data=ProfileData(...)), ...]
|
|
176
|
+
first = await profile_manager.find_one(filters)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## AerospikeFeatureFlagStorage
|
|
180
|
+
|
|
181
|
+
The Aerospike backend of [ddutils](https://github.com/davyddd/ddutils) feature flags: one set, a flag per record
|
|
182
|
+
(`FeatureFlagData`: `enabled`, `proportion`, `payload` bins, the flag name is the primary key), through an
|
|
183
|
+
`AerospikeManager[FeatureFlagData]` the application wires. Client and server errors raise
|
|
184
|
+
`FeatureFlagStorageError`, so `FeatureFlags` keeps serving the last known values; a record that does not validate
|
|
185
|
+
is read as the defaults.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from ddaerospike.feature_flags import AerospikeFeatureFlagStorage, FeatureFlagData
|
|
189
|
+
from ddaerospike.manager import AerospikeManager
|
|
190
|
+
from ddutils.feature_flags import FeatureFlags
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class FeatureFlagManager(AerospikeManager[FeatureFlagData]):
|
|
194
|
+
client_factory = aerospike_client_factory
|
|
195
|
+
namespace = settings.AEROSPIKE_URL.namespace
|
|
196
|
+
set_name = 'feature_flag'
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
feature_flags = FeatureFlags(AerospikeFeatureFlagStorage(FeatureFlagManager()))
|
|
200
|
+
|
|
201
|
+
if await feature_flags.is_enabled('new_checkout'): # proportion 1.0: on or off for everyone
|
|
202
|
+
...
|
|
203
|
+
if await feature_flags.is_enabled('new_ranking', user_id): # on for `proportion` of the users
|
|
204
|
+
...
|
|
205
|
+
```
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from datetime import UTC, date, datetime, timedelta
|
|
2
|
+
from typing import Annotated, Any, Generic, NamedTuple, Self, TypeVar, get_origin
|
|
3
|
+
|
|
4
|
+
from dddesign.structure.domains.constants import BaseEnum
|
|
5
|
+
from dddesign.structure.domains.dto import DataTransferObject
|
|
6
|
+
from ddutils.annotation_helpers import get_annotation_without_optional, is_subclass
|
|
7
|
+
from pydantic import AfterValidator, BaseModel, BeforeValidator, PlainSerializer
|
|
8
|
+
|
|
9
|
+
# Server-side limit on the length of a bin name
|
|
10
|
+
BIN_NAME_MAX_LENGTH = 15
|
|
11
|
+
|
|
12
|
+
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
|
13
|
+
_MICROSECOND = timedelta(microseconds=1)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def timestamp_to_bin(value: datetime) -> int:
|
|
17
|
+
"""A datetime as it is stored in a bin: integer microseconds since the epoch, so it compares as a number."""
|
|
18
|
+
if value.tzinfo is None:
|
|
19
|
+
raise ValueError('Aerospike timestamps must be timezone-aware')
|
|
20
|
+
return (value - _EPOCH) // _MICROSECOND
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _timestamp_from_bin(value: Any) -> Any:
|
|
24
|
+
return _EPOCH + timedelta(microseconds=value) if isinstance(value, int) and not isinstance(value, bool) else value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _as_utc(value: datetime) -> datetime:
|
|
28
|
+
if value.tzinfo is None:
|
|
29
|
+
raise ValueError('Aerospike timestamps must be timezone-aware')
|
|
30
|
+
return value.astimezone(UTC)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_TIMESTAMP_SERIALIZER = PlainSerializer(timestamp_to_bin, return_type=int)
|
|
34
|
+
|
|
35
|
+
# The only way to declare a datetime bin: stored as integer microseconds (UTC), read back as an aware datetime,
|
|
36
|
+
# and the same representation `AerospikeFilter` compares against. A bare `datetime` / `date` field is rejected.
|
|
37
|
+
AerospikeTimestamp = Annotated[datetime, BeforeValidator(_timestamp_from_bin), AfterValidator(_as_utc), _TIMESTAMP_SERIALIZER]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_composite_annotation(annotation: Any) -> bool:
|
|
41
|
+
"""A `BaseModel` bin (a `RootModel` is one), optional or not. A union of several types or a `Literal` is a simple bin."""
|
|
42
|
+
try:
|
|
43
|
+
inner = get_annotation_without_optional(annotation)
|
|
44
|
+
except TypeError:
|
|
45
|
+
return False
|
|
46
|
+
return is_subclass(inner, BaseModel)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_bare_date_annotation(annotation: Any, metadata: list[Any]) -> bool:
|
|
50
|
+
"""A `datetime` / `date` bin (optional or not) declared without `AerospikeTimestamp`."""
|
|
51
|
+
try:
|
|
52
|
+
inner = get_annotation_without_optional(annotation)
|
|
53
|
+
except TypeError:
|
|
54
|
+
return False
|
|
55
|
+
if not is_subclass(inner, date):
|
|
56
|
+
return False
|
|
57
|
+
# pydantic unpacks a top-level `Annotated` into `metadata`; one inside `| None` stays in the annotation
|
|
58
|
+
if get_origin(inner) is Annotated:
|
|
59
|
+
metadata = [*metadata, *inner.__metadata__]
|
|
60
|
+
return not any(item is _TIMESTAMP_SERIALIZER for item in metadata)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
EntityT = TypeVar('EntityT')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AerospikeBinType(str, BaseEnum):
|
|
67
|
+
SIMPLE = 'simple'
|
|
68
|
+
COMPOSITE = 'composite'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AerospikeData(DataTransferObject, Generic[EntityT]):
|
|
72
|
+
"""
|
|
73
|
+
A record of an Aerospike set: every field is a bin.
|
|
74
|
+
|
|
75
|
+
Scalars and plain containers are simple bins. A `BaseModel` / `RootModel` field (a nested object, a typed
|
|
76
|
+
list or dict) is a composite bin, stored as a map or list; `AerospikeManager` strips its `None` values
|
|
77
|
+
before writing, so an unset optional nested field costs nothing. Datetimes are declared as
|
|
78
|
+
`AerospikeTimestamp` (inside composite models too, so they are stored the same way).
|
|
79
|
+
|
|
80
|
+
Subclasses map to and from the domain through `to_entity` / `from_entity`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
|
85
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
86
|
+
too_long = tuple(field for field in cls.model_fields if len(field) > BIN_NAME_MAX_LENGTH)
|
|
87
|
+
if too_long:
|
|
88
|
+
raise TypeError(f'Aerospike bin names must be at most {BIN_NAME_MAX_LENGTH} characters, got {too_long!r}')
|
|
89
|
+
bare_dates = tuple(
|
|
90
|
+
field for field, info in cls.model_fields.items() if _is_bare_date_annotation(info.annotation, info.metadata)
|
|
91
|
+
)
|
|
92
|
+
if bare_dates:
|
|
93
|
+
raise TypeError(f'Datetime bins must be declared as `AerospikeTimestamp`, got {bare_dates!r}')
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def get_bins(cls) -> dict[str, AerospikeBinType]:
|
|
97
|
+
return {
|
|
98
|
+
field: AerospikeBinType.COMPOSITE if _is_composite_annotation(info.annotation) else AerospikeBinType.SIMPLE
|
|
99
|
+
for field, info in cls.model_fields.items()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def filter_bins(self, bins: dict[str, AerospikeBinType]) -> dict[str, AerospikeBinType]:
|
|
103
|
+
"""Hook: the bins this instance writes. Override to leave some out depending on the data."""
|
|
104
|
+
return bins
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def factory(cls, data: dict[str, Any]) -> Self:
|
|
108
|
+
return cls.model_validate(data)
|
|
109
|
+
|
|
110
|
+
def to_entity(self, **kwargs: Any) -> EntityT:
|
|
111
|
+
raise NotImplementedError
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_entity(cls, entity: EntityT) -> Self:
|
|
115
|
+
raise NotImplementedError
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Bound to the real class, not a forward reference: pydantic builds a schema for `type[AerospikeDataT]` fields
|
|
119
|
+
AerospikeDataT = TypeVar('AerospikeDataT', bound=AerospikeData)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AerospikeDataWithPK(NamedTuple, Generic[AerospikeDataT]):
|
|
123
|
+
# None when the record was written without `send_key`: the server then holds only the key digest
|
|
124
|
+
pk: str | None
|
|
125
|
+
data: AerospikeDataT
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = (
|
|
129
|
+
'AerospikeBinType',
|
|
130
|
+
'AerospikeData',
|
|
131
|
+
'AerospikeDataWithPK',
|
|
132
|
+
'AerospikeTimestamp',
|
|
133
|
+
'BIN_NAME_MAX_LENGTH',
|
|
134
|
+
'timestamp_to_bin',
|
|
135
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any, cast
|
|
2
|
+
|
|
3
|
+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler, UrlConstraints
|
|
4
|
+
from pydantic.json_schema import JsonSchemaValue
|
|
5
|
+
from pydantic.networks import _BaseMultiHostUrl
|
|
6
|
+
from pydantic_core.core_schema import AfterValidatorFunctionSchema, CoreSchema, no_info_after_validator_function
|
|
7
|
+
|
|
8
|
+
DEFAULT_PORT = 3000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AerospikeDsn(_BaseMultiHostUrl):
|
|
12
|
+
"""`aerospike://host[:port][,host[:port]]/<namespace>`.
|
|
13
|
+
|
|
14
|
+
The hosts are the seeds the client learns the rest of the cluster from; a missing port is 3000.
|
|
15
|
+
The path names the namespace and is required. Built like pydantic's own `PostgresDsn`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
_constraints = UrlConstraints(allowed_schemes=['aerospike'], host_required=True, default_port=DEFAULT_PORT)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> CoreSchema:
|
|
22
|
+
return no_info_after_validator_function(_require_namespace, super().__get_pydantic_core_schema__(source, handler))
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
|
|
26
|
+
# Peel off the after-validator above, the base class expects its own function-wrap schema
|
|
27
|
+
inner = cast('AfterValidatorFunctionSchema', core_schema)['schema']
|
|
28
|
+
return super().__get_pydantic_json_schema__(inner, handler)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def namespace(self) -> str:
|
|
32
|
+
return (self.path or '').strip('/')
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def seeds(self) -> str:
|
|
36
|
+
"""`host:port,host:port`, the form `aerospike_native.new_client` takes."""
|
|
37
|
+
# `default_port` fills the port of a single host only; in a host list the others come back as None
|
|
38
|
+
return ','.join(f'{host["host"]}:{host["port"] or DEFAULT_PORT}' for host in self.hosts())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _require_namespace(dsn: AerospikeDsn) -> AerospikeDsn:
|
|
42
|
+
if not dsn.namespace:
|
|
43
|
+
raise ValueError('the path must name the namespace, e.g. aerospike://host:3000/application_db')
|
|
44
|
+
return dsn
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ('AerospikeDsn',)
|