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.
@@ -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
+ [![pypi](https://img.shields.io/pypi/v/ddaerospike.svg)](https://pypi.python.org/pypi/ddaerospike)
26
+ [![downloads](https://static.pepy.tech/badge/ddaerospike/month)](https://pepy.tech/project/ddaerospike)
27
+ [![versions](https://img.shields.io/pypi/pyversions/ddaerospike.svg)](https://github.com/davyddd/ddaerospike)
28
+ [![codecov](https://codecov.io/gh/davyddd/ddaerospike/branch/main/graph/badge.svg)](https://app.codecov.io/github/davyddd/ddaerospike)
29
+ [![license](https://img.shields.io/github/license/davyddd/ddaerospike.svg)](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
+ [![pypi](https://img.shields.io/pypi/v/ddaerospike.svg)](https://pypi.python.org/pypi/ddaerospike)
4
+ [![downloads](https://static.pepy.tech/badge/ddaerospike/month)](https://pepy.tech/project/ddaerospike)
5
+ [![versions](https://img.shields.io/pypi/pyversions/ddaerospike.svg)](https://github.com/davyddd/ddaerospike)
6
+ [![codecov](https://codecov.io/gh/davyddd/ddaerospike/branch/main/graph/badge.svg)](https://app.codecov.io/github/davyddd/ddaerospike)
7
+ [![license](https://img.shields.io/github/license/davyddd/ddaerospike.svg)](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,3 @@
1
+ from . import data, dsn, feature_flags, filters, manager, policies
2
+
3
+ __all__ = ('data', 'dsn', 'feature_flags', 'filters', 'manager', 'policies')
@@ -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',)