pypersistable 1.0.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.
- pypersistable-1.0.0/.github/workflows/release.yml +19 -0
- pypersistable-1.0.0/LICENSE +21 -0
- pypersistable-1.0.0/PKG-INFO +214 -0
- pypersistable-1.0.0/README.md +165 -0
- pypersistable-1.0.0/examples/__init__.py +0 -0
- pypersistable-1.0.0/examples/deal_model.py +78 -0
- pypersistable-1.0.0/pyproject.toml +48 -0
- pypersistable-1.0.0/setup.cfg +4 -0
- pypersistable-1.0.0/src/pypersistable/__init__.py +20 -0
- pypersistable-1.0.0/src/pypersistable/anyschema.py +75 -0
- pypersistable-1.0.0/src/pypersistable/discriminated_union.py +111 -0
- pypersistable-1.0.0/src/pypersistable/fs_store.py +97 -0
- pypersistable-1.0.0/src/pypersistable/object_store.py +198 -0
- pypersistable-1.0.0/src/pypersistable/persistable.py +77 -0
- pypersistable-1.0.0/src/pypersistable/reference.py +58 -0
- pypersistable-1.0.0/src/pypersistable/type_registry.py +95 -0
- pypersistable-1.0.0/src/pypersistable/typed_cache.py +9 -0
- pypersistable-1.0.0/src/pypersistable.egg-info/PKG-INFO +214 -0
- pypersistable-1.0.0/src/pypersistable.egg-info/SOURCES.txt +23 -0
- pypersistable-1.0.0/src/pypersistable.egg-info/dependency_links.txt +1 -0
- pypersistable-1.0.0/src/pypersistable.egg-info/requires.txt +11 -0
- pypersistable-1.0.0/src/pypersistable.egg-info/scm_file_list.json +20 -0
- pypersistable-1.0.0/src/pypersistable.egg-info/scm_version.json +8 -0
- pypersistable-1.0.0/src/pypersistable.egg-info/top_level.txt +1 -0
- pypersistable-1.0.0/uv.lock +467 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
- uses: astral-sh/setup-uv@v6
|
|
18
|
+
- run: uv build
|
|
19
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Young
|
|
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,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypersistable
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Core Object Model
|
|
5
|
+
Author-email: Nick Young <nick.young.github@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Nick Young
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: homepage, https://github.com/nickyoung-github/persistable
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Requires-Python: >=3.13
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Requires-Dist: anyschema
|
|
39
|
+
Requires-Dist: duckdb
|
|
40
|
+
Requires-Dist: fsspec
|
|
41
|
+
Requires-Dist: narwhals
|
|
42
|
+
Requires-Dist: orjson
|
|
43
|
+
Requires-Dist: pyarrow
|
|
44
|
+
Requires-Dist: pydantic>2.0
|
|
45
|
+
Provides-Extra: test
|
|
46
|
+
Requires-Dist: pytest; extra == "test"
|
|
47
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# pypersistable: Define and persist python objects with minimal boilerplate
|
|
51
|
+
|
|
52
|
+
Any object that can be handled by `pydantic.TypeAdaptor` will work (pydantic models, dataclasses etc.)
|
|
53
|
+
Objects can be persisted as JSON or parquet (with some caveats).
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
## Defining persistable objects
|
|
57
|
+
|
|
58
|
+
### `TypeRegistry`
|
|
59
|
+
Persistable types must be registered with `TypeRegistry`. This can be done explicitly or using entry points.
|
|
60
|
+
While we could have avoided a registry, the alternative is persisting implementation-specific info,
|
|
61
|
+
such as the module and class names, making code refactoring very difficult.
|
|
62
|
+
You can optionally supply a `root` parameter to group types, e.g., you might specify a root of `Animals`
|
|
63
|
+
for classes `Dog`, `Cat`, `Bunyip` etc. See [DiscriminatedUnion](#discriminatedunion) for another use of root.
|
|
64
|
+
|
|
65
|
+
### `Persistable`
|
|
66
|
+
`Persistable` is the abstract base class for persistable objects. All it does it provide an `object_id`
|
|
67
|
+
for serialisation and ways to form that id for retrieval. There are two species of `Persistable`:
|
|
68
|
+
|
|
69
|
+
- `Unique` - As the name suggests, this is the base for types where all fields form the id. E.g.,
|
|
70
|
+
```python
|
|
71
|
+
class Address(Unique):
|
|
72
|
+
street_number: str # Could be 221B, for example
|
|
73
|
+
street_name: str
|
|
74
|
+
town: str
|
|
75
|
+
postcode: str
|
|
76
|
+
country: Country
|
|
77
|
+
```
|
|
78
|
+
- `Identifiable` - A base class for objects where a subset of fields form their id. Such fields
|
|
79
|
+
are declared using `IdField`. E.g.,
|
|
80
|
+
```python
|
|
81
|
+
class Customer(Identifiable):
|
|
82
|
+
name: IdField[str]
|
|
83
|
+
address: Address
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `Reference`
|
|
87
|
+
In classes which have user-defined types as attributes, do we want those nested objects to be persisted
|
|
88
|
+
by value in the parent object's payload or saved separately, with the parent persisting a reference?
|
|
89
|
+
The answer is very much, "it depends". If you definitely want the nested payload to be embedded, that is
|
|
90
|
+
easy: simply declare your type like `Customer` above. However, you could instead re-write it as:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
class Customer(Identifiable):
|
|
94
|
+
name: IdField[str]
|
|
95
|
+
address: Reference[Address]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This means that if we had multiple customers (e.g. family members) living at the same address,
|
|
99
|
+
when persisting those customers, the `Address` would be persisted once and shared, with a reference being
|
|
100
|
+
saved in the `Customer` payload.
|
|
101
|
+
|
|
102
|
+
You can force references to be serialised by value via the `by_reference` paramter on `ObjectStore.write`.
|
|
103
|
+
`ObjectStore.read` will always check for persisted references.
|
|
104
|
+
|
|
105
|
+
### `DiscriminatedUnion`
|
|
106
|
+
Polymorphism in persisted classes tends to work quite badly and should be avoided. Persistable objects
|
|
107
|
+
often also get used in contexts where we need to know a schema, such as web services.
|
|
108
|
+
`fastapi` based services, using pydantic models is a good example.
|
|
109
|
+
|
|
110
|
+
A better approach is to declare the valid types using a python `Union`. pydantic can try and infer the
|
|
111
|
+
actual type on deserialisation but it can be preferable to indicate that explicitly.
|
|
112
|
+
`DiscriminatedUnion` does this for you by:
|
|
113
|
+
- Including a type specifier in the JSON schema
|
|
114
|
+
- Adding that type specifier (which uses `TypeRegistry.registered_name`) to the serialisation payload
|
|
115
|
+
- Extracting and removing the type specifier from the deserialisation payload and selecting the appropriate type
|
|
116
|
+
|
|
117
|
+
`DiscriminatedUnion` can be combined with `__subclasses__` with one major caveat:
|
|
118
|
+
you need to ensure that all subclasses of your type have been imported or you will get inconsistent results.
|
|
119
|
+
`TypeRegistry.types_for_root` can help you here, e.g.,
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
Animals = DiscriminatedUnion[TypeRegistry.types_for_root("Animals")]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Stack Overflow and its ilk are littered with mediocre implementations of this pattern, so if you want
|
|
126
|
+
to use `DiscriminatedUnion` standalone, you can supply your own `Discriminator` and use it without
|
|
127
|
+
`TypeRegistry`:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
Animals = DiscriminatedUnion[
|
|
131
|
+
Animal.__subclasses__(),
|
|
132
|
+
Discriminator(
|
|
133
|
+
field="_type",
|
|
134
|
+
discriminator=lambda t: t.__qualname__
|
|
135
|
+
)
|
|
136
|
+
]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Storing persistable objects
|
|
140
|
+
|
|
141
|
+
`ObjectStore` is an abstract base class for implementing an object store, such as a database.
|
|
142
|
+
A file system-based store `FSStore` is provided. Objects are written into directories, formed
|
|
143
|
+
from the store's root, the `TypeRegistry` root and registered name.
|
|
144
|
+
Each write creates a new file, whose name is the timestamp of the write, which corresponds to
|
|
145
|
+
`MetaData.entry_time` in the persisted record.
|
|
146
|
+
|
|
147
|
+
Reading uses `duckdb` to read the stored parquet/json files. Currently only querying by an object's id
|
|
148
|
+
is supported, but this approach also allows for querying by attributes of the object in future.
|
|
149
|
+
|
|
150
|
+
A nightly job to compact the timestamped files into a single file will improve read performance.
|
|
151
|
+
|
|
152
|
+
`FSStore` uses [fsspec](https://pypi.org/project/fsspec/), which supports a wide range of file systems.
|
|
153
|
+
For parquet schema generation it uses [anyschema](https://pypi.org/project/anyschema/), extended with
|
|
154
|
+
some handling for dataclasses and references.
|
|
155
|
+
|
|
156
|
+
> [Note]
|
|
157
|
+
> Parquet cannot handle union types natively. Currently they are only support via a `Reference`, e.g.:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
class Foo:
|
|
161
|
+
animal: Reference[DiscriminatedUnion[Dog, Cat, Wildebeest]]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Writing
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
store = FSStore(r"file:///users/hades/ObjectStore", format=FSStoreFormat.Parquet)
|
|
168
|
+
best_boy = Dog(name="Cerberus")
|
|
169
|
+
store.write([best_boy])
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Will result in:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
/users/hades/ObjectStore # FSStore root
|
|
176
|
+
/Animals # TypeRegistry root
|
|
177
|
+
/Dog # TypeRegistry registered_name
|
|
178
|
+
/2026-11-08T13:01:55.249821+00:00.parquet # File name
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
If you save an object which contains references, multiple objects may be created:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
blofeld = Customer(
|
|
185
|
+
name="Ernst Stavro Blofeld",
|
|
186
|
+
address=Address(
|
|
187
|
+
street_numer=1,
|
|
188
|
+
street_name="The Hollowed Out Volcano",
|
|
189
|
+
town="",
|
|
190
|
+
postcode="HV1 1ZZ",
|
|
191
|
+
country=Country.Krakatoa
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
store.write([blofeld])
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
This would create:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
/users/hades/ObjectStore # FSStore root
|
|
201
|
+
/Customer # TypeRegistry registered_name
|
|
202
|
+
/2026-11-08T13:01:55.249821+00:00.parquet # File name
|
|
203
|
+
/Address # TypeRegistry registered_name
|
|
204
|
+
/2026-11-08T13:01:55.249821+00:00.parquet # File name
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### Reading
|
|
208
|
+
Objects are read by creating an id with `make_object_id`:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
best_boy_id = Dog.make_object_id(name="Cerberus")
|
|
212
|
+
loaded = store.read([best_boy_id])
|
|
213
|
+
best_boy = loaded[best_boy_id]
|
|
214
|
+
```
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# pypersistable: Define and persist python objects with minimal boilerplate
|
|
2
|
+
|
|
3
|
+
Any object that can be handled by `pydantic.TypeAdaptor` will work (pydantic models, dataclasses etc.)
|
|
4
|
+
Objects can be persisted as JSON or parquet (with some caveats).
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## Defining persistable objects
|
|
8
|
+
|
|
9
|
+
### `TypeRegistry`
|
|
10
|
+
Persistable types must be registered with `TypeRegistry`. This can be done explicitly or using entry points.
|
|
11
|
+
While we could have avoided a registry, the alternative is persisting implementation-specific info,
|
|
12
|
+
such as the module and class names, making code refactoring very difficult.
|
|
13
|
+
You can optionally supply a `root` parameter to group types, e.g., you might specify a root of `Animals`
|
|
14
|
+
for classes `Dog`, `Cat`, `Bunyip` etc. See [DiscriminatedUnion](#discriminatedunion) for another use of root.
|
|
15
|
+
|
|
16
|
+
### `Persistable`
|
|
17
|
+
`Persistable` is the abstract base class for persistable objects. All it does it provide an `object_id`
|
|
18
|
+
for serialisation and ways to form that id for retrieval. There are two species of `Persistable`:
|
|
19
|
+
|
|
20
|
+
- `Unique` - As the name suggests, this is the base for types where all fields form the id. E.g.,
|
|
21
|
+
```python
|
|
22
|
+
class Address(Unique):
|
|
23
|
+
street_number: str # Could be 221B, for example
|
|
24
|
+
street_name: str
|
|
25
|
+
town: str
|
|
26
|
+
postcode: str
|
|
27
|
+
country: Country
|
|
28
|
+
```
|
|
29
|
+
- `Identifiable` - A base class for objects where a subset of fields form their id. Such fields
|
|
30
|
+
are declared using `IdField`. E.g.,
|
|
31
|
+
```python
|
|
32
|
+
class Customer(Identifiable):
|
|
33
|
+
name: IdField[str]
|
|
34
|
+
address: Address
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### `Reference`
|
|
38
|
+
In classes which have user-defined types as attributes, do we want those nested objects to be persisted
|
|
39
|
+
by value in the parent object's payload or saved separately, with the parent persisting a reference?
|
|
40
|
+
The answer is very much, "it depends". If you definitely want the nested payload to be embedded, that is
|
|
41
|
+
easy: simply declare your type like `Customer` above. However, you could instead re-write it as:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
class Customer(Identifiable):
|
|
45
|
+
name: IdField[str]
|
|
46
|
+
address: Reference[Address]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This means that if we had multiple customers (e.g. family members) living at the same address,
|
|
50
|
+
when persisting those customers, the `Address` would be persisted once and shared, with a reference being
|
|
51
|
+
saved in the `Customer` payload.
|
|
52
|
+
|
|
53
|
+
You can force references to be serialised by value via the `by_reference` paramter on `ObjectStore.write`.
|
|
54
|
+
`ObjectStore.read` will always check for persisted references.
|
|
55
|
+
|
|
56
|
+
### `DiscriminatedUnion`
|
|
57
|
+
Polymorphism in persisted classes tends to work quite badly and should be avoided. Persistable objects
|
|
58
|
+
often also get used in contexts where we need to know a schema, such as web services.
|
|
59
|
+
`fastapi` based services, using pydantic models is a good example.
|
|
60
|
+
|
|
61
|
+
A better approach is to declare the valid types using a python `Union`. pydantic can try and infer the
|
|
62
|
+
actual type on deserialisation but it can be preferable to indicate that explicitly.
|
|
63
|
+
`DiscriminatedUnion` does this for you by:
|
|
64
|
+
- Including a type specifier in the JSON schema
|
|
65
|
+
- Adding that type specifier (which uses `TypeRegistry.registered_name`) to the serialisation payload
|
|
66
|
+
- Extracting and removing the type specifier from the deserialisation payload and selecting the appropriate type
|
|
67
|
+
|
|
68
|
+
`DiscriminatedUnion` can be combined with `__subclasses__` with one major caveat:
|
|
69
|
+
you need to ensure that all subclasses of your type have been imported or you will get inconsistent results.
|
|
70
|
+
`TypeRegistry.types_for_root` can help you here, e.g.,
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
Animals = DiscriminatedUnion[TypeRegistry.types_for_root("Animals")]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Stack Overflow and its ilk are littered with mediocre implementations of this pattern, so if you want
|
|
77
|
+
to use `DiscriminatedUnion` standalone, you can supply your own `Discriminator` and use it without
|
|
78
|
+
`TypeRegistry`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
Animals = DiscriminatedUnion[
|
|
82
|
+
Animal.__subclasses__(),
|
|
83
|
+
Discriminator(
|
|
84
|
+
field="_type",
|
|
85
|
+
discriminator=lambda t: t.__qualname__
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Storing persistable objects
|
|
91
|
+
|
|
92
|
+
`ObjectStore` is an abstract base class for implementing an object store, such as a database.
|
|
93
|
+
A file system-based store `FSStore` is provided. Objects are written into directories, formed
|
|
94
|
+
from the store's root, the `TypeRegistry` root and registered name.
|
|
95
|
+
Each write creates a new file, whose name is the timestamp of the write, which corresponds to
|
|
96
|
+
`MetaData.entry_time` in the persisted record.
|
|
97
|
+
|
|
98
|
+
Reading uses `duckdb` to read the stored parquet/json files. Currently only querying by an object's id
|
|
99
|
+
is supported, but this approach also allows for querying by attributes of the object in future.
|
|
100
|
+
|
|
101
|
+
A nightly job to compact the timestamped files into a single file will improve read performance.
|
|
102
|
+
|
|
103
|
+
`FSStore` uses [fsspec](https://pypi.org/project/fsspec/), which supports a wide range of file systems.
|
|
104
|
+
For parquet schema generation it uses [anyschema](https://pypi.org/project/anyschema/), extended with
|
|
105
|
+
some handling for dataclasses and references.
|
|
106
|
+
|
|
107
|
+
> [Note]
|
|
108
|
+
> Parquet cannot handle union types natively. Currently they are only support via a `Reference`, e.g.:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
class Foo:
|
|
112
|
+
animal: Reference[DiscriminatedUnion[Dog, Cat, Wildebeest]]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Writing
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
store = FSStore(r"file:///users/hades/ObjectStore", format=FSStoreFormat.Parquet)
|
|
119
|
+
best_boy = Dog(name="Cerberus")
|
|
120
|
+
store.write([best_boy])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Will result in:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
/users/hades/ObjectStore # FSStore root
|
|
127
|
+
/Animals # TypeRegistry root
|
|
128
|
+
/Dog # TypeRegistry registered_name
|
|
129
|
+
/2026-11-08T13:01:55.249821+00:00.parquet # File name
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If you save an object which contains references, multiple objects may be created:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
blofeld = Customer(
|
|
136
|
+
name="Ernst Stavro Blofeld",
|
|
137
|
+
address=Address(
|
|
138
|
+
street_numer=1,
|
|
139
|
+
street_name="The Hollowed Out Volcano",
|
|
140
|
+
town="",
|
|
141
|
+
postcode="HV1 1ZZ",
|
|
142
|
+
country=Country.Krakatoa
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
store.write([blofeld])
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This would create:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
/users/hades/ObjectStore # FSStore root
|
|
152
|
+
/Customer # TypeRegistry registered_name
|
|
153
|
+
/2026-11-08T13:01:55.249821+00:00.parquet # File name
|
|
154
|
+
/Address # TypeRegistry registered_name
|
|
155
|
+
/2026-11-08T13:01:55.249821+00:00.parquet # File name
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
#### Reading
|
|
159
|
+
Objects are read by creating an id with `make_object_id`:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
best_boy_id = Dog.make_object_id(name="Cerberus")
|
|
163
|
+
loaded = store.read([best_boy_id])
|
|
164
|
+
best_boy = loaded[best_boy_id]
|
|
165
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import StrEnum, auto
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pypersistable import (
|
|
7
|
+
Discriminated,
|
|
8
|
+
Identifiable,
|
|
9
|
+
IdField,
|
|
10
|
+
Reference,
|
|
11
|
+
TypeRegistry,
|
|
12
|
+
Unique,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__registry = TypeRegistry()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(kw_only=True)
|
|
19
|
+
class Counterparty(Identifiable):
|
|
20
|
+
name: IdField[str]
|
|
21
|
+
location: str
|
|
22
|
+
contact_name: str
|
|
23
|
+
|
|
24
|
+
__registry.register("Counterparty", Counterparty)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(kw_only=True)
|
|
28
|
+
class CustomerBook(Identifiable):
|
|
29
|
+
counterparty: IdField[Counterparty]
|
|
30
|
+
sales_coverage: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
__registry.register("CustomerBook", CustomerBook)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(kw_only=True)
|
|
37
|
+
class TradingBook(Identifiable):
|
|
38
|
+
name: IdField[str]
|
|
39
|
+
owner: str
|
|
40
|
+
|
|
41
|
+
__registry.register("TradingBook", TradingBook)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(kw_only=True)
|
|
45
|
+
class Option(Unique):
|
|
46
|
+
ticker: str
|
|
47
|
+
strike: float
|
|
48
|
+
maturity: dt.datetime
|
|
49
|
+
|
|
50
|
+
__registry.register("Option", Option, Path("Instrument"))
|
|
51
|
+
|
|
52
|
+
Instrument = Discriminated[Path("Instrument")]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TradeAction(StrEnum):
|
|
56
|
+
Buy = auto()
|
|
57
|
+
Sell = auto()
|
|
58
|
+
Pay = auto()
|
|
59
|
+
Receive = auto()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(kw_only=True)
|
|
63
|
+
class Position(Unique):
|
|
64
|
+
quantity: float
|
|
65
|
+
action: TradeAction
|
|
66
|
+
instrument: Reference[Instrument]
|
|
67
|
+
|
|
68
|
+
__registry.register("Position", Position)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(kw_only=True)
|
|
72
|
+
class Deal(Identifiable):
|
|
73
|
+
deal_id: IdField[str]
|
|
74
|
+
book_from: Reference[TradingBook]
|
|
75
|
+
book_to: Reference[TradingBook | CustomerBook]
|
|
76
|
+
position: list[Position]
|
|
77
|
+
|
|
78
|
+
__registry.register("Deal", Deal)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
[project]
|
|
4
|
+
name = "pypersistable"
|
|
5
|
+
description = "Core Object Model"
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "Nick Young", email = "nick.young.github@gmail.com"}
|
|
8
|
+
]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {file = "LICENSE"}
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
'Programming Language :: Python :: 3 :: Only',
|
|
17
|
+
'Programming Language :: Python :: 3.13',
|
|
18
|
+
'Programming Language :: Python :: 3.14',
|
|
19
|
+
"Operating System :: OS Independent"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
dynamic = ["version"]
|
|
23
|
+
|
|
24
|
+
dependencies = [
|
|
25
|
+
"anyschema",
|
|
26
|
+
"duckdb",
|
|
27
|
+
"fsspec",
|
|
28
|
+
"narwhals",
|
|
29
|
+
"orjson",
|
|
30
|
+
"pyarrow",
|
|
31
|
+
"pydantic>2.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
test = [
|
|
36
|
+
"pytest",
|
|
37
|
+
"pytest-cov"
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
homepage = "https://github.com/nickyoung-github/persistable"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["setuptools>=64", "setuptools-scm>=8"]
|
|
45
|
+
build-backend = "setuptools.build_meta"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools_scm]
|
|
48
|
+
version_file = "persistable/_version.py"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .discriminated_union import Discriminated, DiscriminatedUnion, Discriminator
|
|
2
|
+
from .fs_store import FSStore, FSStoreFormat
|
|
3
|
+
from .object_store import ObjectStore
|
|
4
|
+
from .persistable import Identifiable, IdField, Unique
|
|
5
|
+
from .reference import Reference
|
|
6
|
+
from .type_registry import TypeRegistry
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"Discriminated",
|
|
10
|
+
"DiscriminatedUnion",
|
|
11
|
+
"Discriminator",
|
|
12
|
+
"FSStore",
|
|
13
|
+
"FSStoreFormat",
|
|
14
|
+
"IdField",
|
|
15
|
+
"Identifiable",
|
|
16
|
+
"ObjectStore",
|
|
17
|
+
"Reference",
|
|
18
|
+
"TypeRegistry",
|
|
19
|
+
"Unique",
|
|
20
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from types import NoneType
|
|
3
|
+
|
|
4
|
+
import narwhals as nw
|
|
5
|
+
from anyschema._dependencies import is_dataclass
|
|
6
|
+
from anyschema.adapters import dataclass_adapter
|
|
7
|
+
from anyschema.exceptions import UnsupportedDTypeError
|
|
8
|
+
from anyschema.parsers import (
|
|
9
|
+
AnnotatedStep,
|
|
10
|
+
ForwardRefStep,
|
|
11
|
+
PyTypeStep,
|
|
12
|
+
UnionTypeStep,
|
|
13
|
+
make_pipeline,
|
|
14
|
+
)
|
|
15
|
+
from anyschema.parsers._base import ParserStep
|
|
16
|
+
from anyschema.parsers.annotated_types import AnnotatedTypesStep
|
|
17
|
+
from anyschema.parsers.pydantic import PydanticTypeStep
|
|
18
|
+
from anyschema.typing import FieldConstraints, FieldMetadata, FieldType
|
|
19
|
+
from narwhals.dtypes import DType
|
|
20
|
+
|
|
21
|
+
from .persistable import Persistable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DataclassTypeStep(ParserStep):
|
|
25
|
+
def parse(
|
|
26
|
+
self,
|
|
27
|
+
input_type: FieldType,
|
|
28
|
+
constraints: FieldConstraints,
|
|
29
|
+
metadata: FieldMetadata,
|
|
30
|
+
) -> DType | None:
|
|
31
|
+
if is_dataclass(input_type):
|
|
32
|
+
return nw.Struct(
|
|
33
|
+
[
|
|
34
|
+
nw.Field(
|
|
35
|
+
name=field_name,
|
|
36
|
+
dtype=self.pipeline.parse(field_info, field_constraints, field_metadata, strict=True),
|
|
37
|
+
)
|
|
38
|
+
for field_name, field_info, field_constraints, field_metadata in dataclass_adapter(input_type)
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _UnionTypeStep(UnionTypeStep):
|
|
44
|
+
# Replaces the anyschema one, so we can handle references
|
|
45
|
+
|
|
46
|
+
def _parse_union(self, union: tuple[FieldType, ...]) -> FieldType:
|
|
47
|
+
if len(union) != 2:
|
|
48
|
+
msg = "Union with more than two types is not supported."
|
|
49
|
+
raise UnsupportedDTypeError(msg)
|
|
50
|
+
|
|
51
|
+
field0, field1 = union
|
|
52
|
+
|
|
53
|
+
if field0 is not NoneType and field1 is not NoneType:
|
|
54
|
+
msg = "Union with mixed types is not supported."
|
|
55
|
+
|
|
56
|
+
for f in (field0, field1):
|
|
57
|
+
if f is not Path and isinstance(f, type) and not issubclass(f, Persistable):
|
|
58
|
+
raise UnsupportedDTypeError(msg)
|
|
59
|
+
|
|
60
|
+
return str
|
|
61
|
+
|
|
62
|
+
return field1 if field0 is NoneType else field0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
PARSER_STEPS = (
|
|
66
|
+
ForwardRefStep(),
|
|
67
|
+
_UnionTypeStep(),
|
|
68
|
+
AnnotatedStep(),
|
|
69
|
+
AnnotatedTypesStep(),
|
|
70
|
+
DataclassTypeStep(),
|
|
71
|
+
PydanticTypeStep(),
|
|
72
|
+
PyTypeStep()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
PIPELINE = make_pipeline(PARSER_STEPS)
|