pydantic-store 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 mySociety
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
16
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
19
+ OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydantic-store
3
+ Version: 0.1.0
4
+ Summary: Helper models and backports
5
+ License: MIT
6
+ License-File: LICENSE.md
7
+ Author: mySociety
8
+ Author-email: alex.parsons@mysociety.org
9
+ Requires-Python: >=3.9,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Dist: pydantic (>=2.0)
19
+ Project-URL: Homepage, https://github.com/mysociety/pydantic-store
20
+ Project-URL: Repository, https://github.com/mysociety/pydantic-store
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pydantic-store
24
+
25
+ A collection of Pydantic models and storage approaches for ease of reading to/from files or creating caches.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install pydantic-store
31
+ ```
32
+
33
+ ### Optional Dependencies
34
+
35
+ Additional dependencies needed for storing as YAML or TOML (not included to keep the dependency limited to pydantic)
36
+
37
+ ```bash
38
+ # For YAML support
39
+ pip install ruamel.yaml
40
+
41
+ # For TOML writing support
42
+ pip install tomli-w
43
+
44
+ # For TOML reading on Python < 3.11
45
+ pip install tomli
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ```python
51
+ from pydantic_store import BaseModel, ListModel, DictModel, JsonStore, PydanticDBM
52
+
53
+ # Define your models
54
+ class User(BaseModel):
55
+ name: str
56
+ age: int
57
+ email: str
58
+
59
+ # Create and save to file
60
+ user = User(name="Alice", age=30, email="alice@example.com")
61
+ user.to_file("user.json")
62
+
63
+ # Load from file
64
+ loaded_user = User.from_file("user.json")
65
+ ```
66
+
67
+ ## Model Types
68
+
69
+ ### BaseModel
70
+
71
+ Enhanced Pydantic BaseModel with file I/O capabilities.
72
+
73
+ ```python
74
+ from pydantic_store import BaseModel
75
+
76
+ class Config(BaseModel):
77
+ database_url: str
78
+ debug: bool = False
79
+ max_connections: int = 10
80
+
81
+ # Save to different formats
82
+ config = Config(database_url="postgresql://localhost/mydb")
83
+ config.to_file("config.json") # JSON format
84
+ config.to_file("config.yaml") # YAML format
85
+ config.to_file("config.toml") # TOML format
86
+
87
+ # Load from file (format auto-detected by extension)
88
+ config = Config.from_file("config.yaml")
89
+ ```
90
+
91
+ ### RootModel
92
+
93
+ A generic root model for wrapping single values with validation.
94
+
95
+ ```python
96
+ from pydantic_store import RootModel
97
+
98
+ class Port(RootModel[int]):
99
+ pass
100
+
101
+ port = Port(8080)
102
+ port.to_file("port.json") # Saves: 8080
103
+ loaded_port = Port.from_file("port.json")
104
+ ```
105
+
106
+ ### ListModel
107
+
108
+ A list-like model that behaves like a Python list while providing Pydantic validation.
109
+
110
+ ```python
111
+ from pydantic_store import ListModel
112
+
113
+ TodoList = ListModel[str]
114
+
115
+ todos = TodoList(["Buy groceries", "Walk the dog"])
116
+
117
+ # Use like a regular list
118
+ todos.append("Read a book")
119
+ todos.extend(["Exercise", "Cook dinner"])
120
+ print(len(todos)) # 5
121
+ print(todos[0]) # "Buy groceries"
122
+
123
+ # Persist to file
124
+ todos.to_file("todos.json")
125
+
126
+ # Load from file
127
+ loaded_todos = TodoList.from_file("todos.json")
128
+ ```
129
+
130
+ ### DictModel
131
+
132
+ A dictionary-like model that behaves like a Python dict with validation.
133
+
134
+ ```python
135
+ from pydantic_store import DictModel
136
+
137
+ Settings = DictModel[str, int]
138
+
139
+ settings = Settings({"timeout": 30, "retries": 3})
140
+
141
+ # Use like a regular dict
142
+ settings["max_workers"] = 4
143
+ settings.update({"cache_size": 1000})
144
+ print(settings.keys())
145
+ print(len(settings))
146
+
147
+ # Persist to file
148
+ settings.to_file("settings.yaml")
149
+ ```
150
+
151
+ ### PydanticDBM
152
+
153
+ A wrapper around the sqlite DBM backend (backported from 3.14) to store and retrieve pydantic models.
154
+
155
+ ```python
156
+ from pydantic_store import PydanticDBM
157
+ from pydantic import BaseModel
158
+
159
+ class User(BaseModel):
160
+ name: str
161
+ age: int
162
+
163
+ UserDBM = PydanticDBM[User]
164
+
165
+ # Method 1: Type subscription
166
+ with UserDBM("users.db") as db:
167
+ user = User(name="Alice", age=30)
168
+ db["alice"] = user
169
+ retrieved_user = db["alice"] # Automatically validated as User
170
+
171
+ # Method 2: Explicit storage format
172
+ with PydanticDBM("users.db", storage_format=User) as db:
173
+ db["bob"] = User(name="Bob", age=25)
174
+ ```
175
+
176
+ ### JsonStore
177
+
178
+ A persistent dictionary that automatically saves changes to disk.
179
+
180
+ ```python
181
+ from pydantic_store import JsonStore
182
+ from pathlib import Path
183
+
184
+ # Connect to a JSON file (creates if doesn't exist)
185
+ store = JsonStore[str].connect(Path("data.json"))
186
+
187
+ # Changes are automatically persisted
188
+ store["user:1"] = "Alice"
189
+ store["user:2"] = "Bob"
190
+
191
+ # Data is immediately written to data.json
192
+ print(store["user:1"]) # "Alice"
193
+ ```
194
+
195
+ ### Supported Formats
196
+
197
+ pydantic-store supports multiple file formats with automatic format detection:
198
+
199
+ - **JSON**: Always available
200
+ - **YAML**: Human-readable format (requires `ruamel.yaml`)
201
+ - **TOML**: Configuration-friendly format (requires `tomli` for reading, `tomli-w` for writing)
202
+
203
+
204
+ You can also specify the format:
205
+
206
+ ```python
207
+ model.to_file("data.txt", file_format="json")
208
+ ```
209
+
210
+ ## Licence
211
+
212
+ MIT Licence - see LICENSE.md for details.
@@ -0,0 +1,190 @@
1
+ # pydantic-store
2
+
3
+ A collection of Pydantic models and storage approaches for ease of reading to/from files or creating caches.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pydantic-store
9
+ ```
10
+
11
+ ### Optional Dependencies
12
+
13
+ Additional dependencies needed for storing as YAML or TOML (not included to keep the dependency limited to pydantic)
14
+
15
+ ```bash
16
+ # For YAML support
17
+ pip install ruamel.yaml
18
+
19
+ # For TOML writing support
20
+ pip install tomli-w
21
+
22
+ # For TOML reading on Python < 3.11
23
+ pip install tomli
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```python
29
+ from pydantic_store import BaseModel, ListModel, DictModel, JsonStore, PydanticDBM
30
+
31
+ # Define your models
32
+ class User(BaseModel):
33
+ name: str
34
+ age: int
35
+ email: str
36
+
37
+ # Create and save to file
38
+ user = User(name="Alice", age=30, email="alice@example.com")
39
+ user.to_file("user.json")
40
+
41
+ # Load from file
42
+ loaded_user = User.from_file("user.json")
43
+ ```
44
+
45
+ ## Model Types
46
+
47
+ ### BaseModel
48
+
49
+ Enhanced Pydantic BaseModel with file I/O capabilities.
50
+
51
+ ```python
52
+ from pydantic_store import BaseModel
53
+
54
+ class Config(BaseModel):
55
+ database_url: str
56
+ debug: bool = False
57
+ max_connections: int = 10
58
+
59
+ # Save to different formats
60
+ config = Config(database_url="postgresql://localhost/mydb")
61
+ config.to_file("config.json") # JSON format
62
+ config.to_file("config.yaml") # YAML format
63
+ config.to_file("config.toml") # TOML format
64
+
65
+ # Load from file (format auto-detected by extension)
66
+ config = Config.from_file("config.yaml")
67
+ ```
68
+
69
+ ### RootModel
70
+
71
+ A generic root model for wrapping single values with validation.
72
+
73
+ ```python
74
+ from pydantic_store import RootModel
75
+
76
+ class Port(RootModel[int]):
77
+ pass
78
+
79
+ port = Port(8080)
80
+ port.to_file("port.json") # Saves: 8080
81
+ loaded_port = Port.from_file("port.json")
82
+ ```
83
+
84
+ ### ListModel
85
+
86
+ A list-like model that behaves like a Python list while providing Pydantic validation.
87
+
88
+ ```python
89
+ from pydantic_store import ListModel
90
+
91
+ TodoList = ListModel[str]
92
+
93
+ todos = TodoList(["Buy groceries", "Walk the dog"])
94
+
95
+ # Use like a regular list
96
+ todos.append("Read a book")
97
+ todos.extend(["Exercise", "Cook dinner"])
98
+ print(len(todos)) # 5
99
+ print(todos[0]) # "Buy groceries"
100
+
101
+ # Persist to file
102
+ todos.to_file("todos.json")
103
+
104
+ # Load from file
105
+ loaded_todos = TodoList.from_file("todos.json")
106
+ ```
107
+
108
+ ### DictModel
109
+
110
+ A dictionary-like model that behaves like a Python dict with validation.
111
+
112
+ ```python
113
+ from pydantic_store import DictModel
114
+
115
+ Settings = DictModel[str, int]
116
+
117
+ settings = Settings({"timeout": 30, "retries": 3})
118
+
119
+ # Use like a regular dict
120
+ settings["max_workers"] = 4
121
+ settings.update({"cache_size": 1000})
122
+ print(settings.keys())
123
+ print(len(settings))
124
+
125
+ # Persist to file
126
+ settings.to_file("settings.yaml")
127
+ ```
128
+
129
+ ### PydanticDBM
130
+
131
+ A wrapper around the sqlite DBM backend (backported from 3.14) to store and retrieve pydantic models.
132
+
133
+ ```python
134
+ from pydantic_store import PydanticDBM
135
+ from pydantic import BaseModel
136
+
137
+ class User(BaseModel):
138
+ name: str
139
+ age: int
140
+
141
+ UserDBM = PydanticDBM[User]
142
+
143
+ # Method 1: Type subscription
144
+ with UserDBM("users.db") as db:
145
+ user = User(name="Alice", age=30)
146
+ db["alice"] = user
147
+ retrieved_user = db["alice"] # Automatically validated as User
148
+
149
+ # Method 2: Explicit storage format
150
+ with PydanticDBM("users.db", storage_format=User) as db:
151
+ db["bob"] = User(name="Bob", age=25)
152
+ ```
153
+
154
+ ### JsonStore
155
+
156
+ A persistent dictionary that automatically saves changes to disk.
157
+
158
+ ```python
159
+ from pydantic_store import JsonStore
160
+ from pathlib import Path
161
+
162
+ # Connect to a JSON file (creates if doesn't exist)
163
+ store = JsonStore[str].connect(Path("data.json"))
164
+
165
+ # Changes are automatically persisted
166
+ store["user:1"] = "Alice"
167
+ store["user:2"] = "Bob"
168
+
169
+ # Data is immediately written to data.json
170
+ print(store["user:1"]) # "Alice"
171
+ ```
172
+
173
+ ### Supported Formats
174
+
175
+ pydantic-store supports multiple file formats with automatic format detection:
176
+
177
+ - **JSON**: Always available
178
+ - **YAML**: Human-readable format (requires `ruamel.yaml`)
179
+ - **TOML**: Configuration-friendly format (requires `tomli` for reading, `tomli-w` for writing)
180
+
181
+
182
+ You can also specify the format:
183
+
184
+ ```python
185
+ model.to_file("data.txt", file_format="json")
186
+ ```
187
+
188
+ ## Licence
189
+
190
+ MIT Licence - see LICENSE.md for details.
@@ -0,0 +1,30 @@
1
+ [tool.poetry]
2
+ name = "pydantic-store"
3
+ version = "0.1.0"
4
+ description = "Helper models and backports"
5
+ authors = ["mySociety <alex.parsons@mysociety.org>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ homepage = "https://github.com/mysociety/pydantic-store"
9
+ repository = "https://github.com/mysociety/pydantic-store"
10
+ include = [
11
+ "LICENSE.md",
12
+ ]
13
+
14
+ [tool.poetry_bumpversion.file."src/pydantic_store/__init__.py"]
15
+
16
+ [tool.poetry.dependencies]
17
+ python = "^3.9"
18
+ pydantic = ">=2.0"
19
+
20
+ [tool.poetry.group.dev.dependencies]
21
+ pytest = "^7.1.2"
22
+ pytest-cov = "^3.0.0"
23
+ pylint = "^2.12.2"
24
+ ruff = "^0.4.4"
25
+ pyright = "^1.1"
26
+ toml = "^0.10.2"
27
+
28
+ [build-system]
29
+ requires = ["poetry-core>=1.0.0"]
30
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,19 @@
1
+ """
2
+ Helper models and backports
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "BaseModel",
11
+ "RootModel",
12
+ "ListModel",
13
+ "DictModel",
14
+ "JsonStore",
15
+ "PydanticDBM",
16
+ ]
17
+
18
+ from .dbm import PydanticDBM
19
+ from .models import BaseModel, DictModel, JsonStore, ListModel, RootModel
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal, Optional, TypeVar, Union
5
+
6
+ from pydantic import TypeAdapter
7
+
8
+ from .dbm_sqlite import _Database # type: ignore
9
+
10
+ T = TypeVar("T")
11
+ PathLike = Union[str, Path]
12
+ FlagOptions = Literal["r", "w", "c", "n"]
13
+
14
+
15
+ class PydanticDBM(_Database[T]):
16
+ default_storage_format = None
17
+
18
+ def __class_getitem__(cls, storage_format: type[T]):
19
+ class _child(cls):
20
+ default_storage_format = storage_format
21
+
22
+ _child.__name__ = cls.__name__
23
+
24
+ return _child
25
+
26
+ def __init__(
27
+ self,
28
+ path: PathLike,
29
+ /,
30
+ *,
31
+ flag: FlagOptions = "c",
32
+ mode: int = 0o600,
33
+ storage_format: Optional[type[T]] = None,
34
+ ):
35
+ super().__init__(path, flag=flag, mode=mode)
36
+ self.storage_format = storage_format or self.default_storage_format
37
+ if not self.storage_format:
38
+ raise ValueError(
39
+ "storage_format must be provided either as argument or class attribute"
40
+ )
41
+ self.type_adapter = TypeAdapter(self.storage_format)
42
+
43
+ def __getitem__(self, key: str) -> T:
44
+ return self.type_adapter.validate_json(super().__getitem__(key)) # type: ignore
45
+
46
+ def __setitem__(self, key: str, value: T) -> None:
47
+ super().__setitem__(key, self.type_adapter.dump_json(value)) # type: ignore
48
+
49
+
50
+ def open(
51
+ filename: PathLike,
52
+ /,
53
+ flag: FlagOptions = "c",
54
+ mode: int = 0o600,
55
+ storage_format: type[T] = str,
56
+ ) -> PydanticDBM[T]:
57
+ return PydanticDBM(filename, flag=flag, mode=mode, storage_format=storage_format)
@@ -0,0 +1,165 @@
1
+ """
2
+ Backport of dbm.sqlite3 module for Pydantic IO.
3
+ Added type annotations and generics support.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sqlite3
10
+ from collections.abc import Iterator, KeysView, MutableMapping
11
+ from contextlib import closing, suppress
12
+ from pathlib import Path
13
+ from types import TracebackType
14
+ from typing import Any, Literal, TypeVar, Union
15
+
16
+ BUILD_TABLE = """
17
+ CREATE TABLE IF NOT EXISTS Dict (
18
+ key BLOB UNIQUE NOT NULL,
19
+ value BLOB NOT NULL
20
+ )
21
+ """
22
+ GET_SIZE = "SELECT COUNT (key) FROM Dict"
23
+ LOOKUP_KEY = "SELECT value FROM Dict WHERE key = CAST(? AS BLOB)"
24
+ STORE_KV = "REPLACE INTO Dict (key, value) VALUES (CAST(? AS BLOB), CAST(? AS BLOB))"
25
+ DELETE_KEY = "DELETE FROM Dict WHERE key = CAST(? AS BLOB)"
26
+ ITER_KEYS = "SELECT key FROM Dict"
27
+
28
+
29
+ class error(OSError):
30
+ pass
31
+
32
+
33
+ _ERR_CLOSED = "DBM object has already been closed"
34
+ _ERR_REINIT = "DBM object does not support reinitialization"
35
+
36
+
37
+ def _normalize_uri(path: Union[str, os.PathLike[str]]) -> str:
38
+ path = Path(path)
39
+ uri = path.absolute().as_uri()
40
+ while "//" in uri:
41
+ uri = uri.replace("//", "/")
42
+ return uri
43
+
44
+
45
+ T = TypeVar("T")
46
+
47
+
48
+ class _Database(MutableMapping[str, T]):
49
+ def __init__(
50
+ self, path: Union[str, os.PathLike[str]], /, *, flag: str, mode: int
51
+ ) -> None:
52
+ if hasattr(self, "_cx"):
53
+ raise error(_ERR_REINIT)
54
+
55
+ path = os.fsdecode(path)
56
+ if flag == "r":
57
+ flag = "ro"
58
+ elif flag == "w":
59
+ flag = "rw"
60
+ elif flag == "c":
61
+ flag = "rwc"
62
+ Path(path).touch(mode=mode, exist_ok=True)
63
+ elif flag == "n":
64
+ flag = "rwc"
65
+ Path(path).unlink(missing_ok=True)
66
+ Path(path).touch(mode=mode)
67
+ else:
68
+ raise ValueError(f"Flag must be one of 'r', 'w', 'c', or 'n', not {flag!r}")
69
+
70
+ # We use the URI format when opening the database.
71
+ uri = _normalize_uri(path)
72
+ uri = f"{uri}?mode={flag}"
73
+ if flag == "ro":
74
+ # Add immutable=1 to allow read-only SQLite access even if wal/shm missing
75
+ uri += "&immutable=1"
76
+
77
+ try:
78
+ self._cx = sqlite3.connect(uri, isolation_level=None, uri=True)
79
+ except sqlite3.Error as exc:
80
+ raise error(str(exc))
81
+
82
+ if flag != "ro":
83
+ # This is an optimization only; it's ok if it fails.
84
+ with suppress(sqlite3.OperationalError):
85
+ self._cx.execute("PRAGMA journal_mode = wal")
86
+
87
+ if flag == "rwc":
88
+ self._execute(BUILD_TABLE)
89
+
90
+ def _execute(self, *args: Any, **kwargs: Any) -> closing[sqlite3.Cursor]:
91
+ if not self._cx:
92
+ raise error(_ERR_CLOSED)
93
+ try:
94
+ return closing(self._cx.execute(*args, **kwargs))
95
+ except sqlite3.Error as exc:
96
+ raise error(str(exc))
97
+
98
+ def __len__(self) -> int:
99
+ with self._execute(GET_SIZE) as cu:
100
+ row = cu.fetchone()
101
+ return row[0]
102
+
103
+ def __getitem__(self, key: str) -> T:
104
+ with self._execute(LOOKUP_KEY, (key,)) as cu:
105
+ row = cu.fetchone()
106
+ if not row:
107
+ raise KeyError(key)
108
+ return row[0]
109
+
110
+ def __setitem__(self, key: str, value: T) -> None:
111
+ self._execute(STORE_KV, (key, value))
112
+
113
+ def __delitem__(self, key: str) -> None:
114
+ with self._execute(DELETE_KEY, (key,)) as cu:
115
+ if not cu.rowcount:
116
+ raise KeyError(key)
117
+
118
+ def __iter__(self) -> Iterator[str]:
119
+ try:
120
+ with self._execute(ITER_KEYS) as cu:
121
+ for row in cu:
122
+ yield row[0].decode("utf-8")
123
+ except sqlite3.Error as exc:
124
+ raise error(str(exc))
125
+
126
+ def close(self) -> None:
127
+ if self._cx:
128
+ self._cx.close()
129
+ self._cx = None
130
+
131
+ def keys(self) -> KeysView[str]:
132
+ return super().keys()
133
+
134
+ def __enter__(self) -> _Database[T]:
135
+ return self
136
+
137
+ def __exit__(
138
+ self,
139
+ exc_type: type[BaseException] | None,
140
+ exc_val: BaseException | None,
141
+ exc_tb: TracebackType | None,
142
+ ) -> None:
143
+ self.close()
144
+
145
+
146
+ def open(
147
+ filename: Union[str, os.PathLike[str]],
148
+ /,
149
+ flag: Literal["r", "w", "c", "n"] = "r",
150
+ mode: int = 0o666,
151
+ ) -> _Database[bytes]:
152
+ """Open a dbm.sqlite3 database and return the dbm object.
153
+
154
+ The 'filename' parameter is the name of the database file.
155
+
156
+ The optional 'flag' parameter can be one of ...:
157
+ 'r' (default): open an existing database for read only access
158
+ 'w': open an existing database for read/write access
159
+ 'c': create a database if it does not exist; open for read/write access
160
+ 'n': always create a new, empty database; open for read/write access
161
+
162
+ The optional 'mode' parameter is the Unix file access mode of the database;
163
+ only used when creating a new database. Default: 0o666.
164
+ """
165
+ return _Database(filename, flag=flag, mode=mode)
@@ -0,0 +1,387 @@
1
+ """
2
+ Helper models and backports
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ import json
10
+ from collections.abc import MutableMapping, MutableSequence
11
+ from pathlib import Path
12
+ from typing import (
13
+ Any,
14
+ Callable,
15
+ Iterable,
16
+ Literal,
17
+ Mapping,
18
+ Optional,
19
+ Protocol,
20
+ TypeVar,
21
+ Union,
22
+ overload,
23
+ )
24
+
25
+ from pydantic import BaseModel as PydanticBaseModel
26
+ from pydantic import Field, PrivateAttr
27
+ from pydantic import RootModel as PydanticRootModel
28
+ from typing_extensions import Self
29
+
30
+ # Optional imports for YAML and TOML support
31
+ try:
32
+ from ruamel.yaml import YAML # type: ignore
33
+
34
+ yaml = YAML()
35
+ yaml.preserve_quotes = True
36
+ yaml.width = 4096 # Avoid line wrapping
37
+ except ImportError:
38
+ yaml = None # type: ignore
39
+
40
+ try:
41
+ import tomllib # type: ignore
42
+ except ImportError:
43
+ try:
44
+ import tomli as tomllib # type: ignore
45
+ except ImportError:
46
+ tomllib = None # type: ignore
47
+
48
+ try:
49
+ import tomli_w # type: ignore
50
+ except ImportError:
51
+ tomli_w = None # type: ignore
52
+
53
+ ExtraValues = Literal["allow", "ignore", "forbid"]
54
+ FileFormats = Literal["json", "yaml", "toml", "auto"]
55
+
56
+
57
+ T = TypeVar("T")
58
+ K = TypeVar("K")
59
+ PathLike = Union[str, Path]
60
+ IncEx = Union[
61
+ set[int],
62
+ set[str],
63
+ Mapping[int, Union["IncEx", bool]],
64
+ Mapping[str, Union["IncEx", bool]],
65
+ ]
66
+
67
+
68
+ def file_format_from_file(file_path: Path) -> FileFormats:
69
+ ext = file_path.suffix.lower()
70
+ if ext == ".json":
71
+ return "json"
72
+ elif ext in {".yaml", ".yml"}:
73
+ return "yaml"
74
+ elif ext == ".toml":
75
+ return "toml"
76
+ else:
77
+ raise ValueError(f"Unsupported file extension: {ext}")
78
+
79
+
80
+ class BaseModelLike(Protocol):
81
+ def model_dump(
82
+ self,
83
+ *,
84
+ include: IncEx | None = None,
85
+ exclude: IncEx | None = None,
86
+ context: Any | None = None,
87
+ by_alias: bool = False,
88
+ exclude_unset: bool = False,
89
+ exclude_defaults: bool = False,
90
+ exclude_none: bool = False,
91
+ round_trip: bool = False,
92
+ warnings: bool = True,
93
+ serialize_as_any: bool = False,
94
+ ) -> dict[str, Any]: ...
95
+
96
+ def model_dump_json(
97
+ self,
98
+ *,
99
+ indent: int | None = None,
100
+ ensure_ascii: bool = False,
101
+ include: IncEx | None = None,
102
+ exclude: IncEx | None = None,
103
+ context: Any | None = None,
104
+ by_alias: bool | None = None,
105
+ exclude_unset: bool = False,
106
+ exclude_defaults: bool = False,
107
+ exclude_none: bool = False,
108
+ exclude_computed_fields: bool = False,
109
+ round_trip: bool = False,
110
+ warnings: bool | Literal["none", "warn", "error"] = True,
111
+ fallback: Callable[[Any], Any] | None = None,
112
+ serialize_as_any: bool = False,
113
+ ) -> str: ...
114
+
115
+ @classmethod
116
+ def model_validate(
117
+ cls,
118
+ obj: Any,
119
+ *,
120
+ strict: bool | None = None,
121
+ extra: ExtraValues | None = None,
122
+ from_attributes: bool | None = None,
123
+ context: Any | None = None,
124
+ by_alias: bool | None = None,
125
+ by_name: bool | None = None,
126
+ ) -> Self: ...
127
+
128
+ @classmethod
129
+ def model_validate_json(
130
+ cls,
131
+ json_data: str | bytes | bytearray,
132
+ *,
133
+ strict: bool | None = None,
134
+ extra: ExtraValues | None = None,
135
+ context: Any | None = None,
136
+ by_alias: bool | None = None,
137
+ by_name: bool | None = None,
138
+ ) -> Self: ...
139
+
140
+
141
+ TypeBaseModelLike = TypeVar("TypeBaseModelLike", bound=BaseModelLike)
142
+
143
+
144
+ def yaml_to_json(yaml_str: str) -> str:
145
+ """
146
+ Convert a YAML string to a JSON string.
147
+
148
+ Args:
149
+ yaml_str: YAML string to convert
150
+
151
+ Returns:
152
+ JSON string representation
153
+
154
+ Raises:
155
+ ImportError: If ruamel.yaml is not available
156
+ """
157
+ if yaml is None:
158
+ raise ImportError(
159
+ "ruamel.yaml is required for YAML support. Install it with: pip install ruamel.yaml"
160
+ )
161
+ parsed_data = yaml.load(yaml_str) # type: ignore
162
+ return json.dumps(parsed_data)
163
+
164
+
165
+ def toml_to_json(toml_str: str) -> str:
166
+ """
167
+ Convert a TOML string to a JSON string.
168
+
169
+ Args:
170
+ toml_str: TOML string to convert
171
+
172
+ Returns:
173
+ JSON string representation
174
+
175
+ Raises:
176
+ ImportError: If tomllib/tomli is not available
177
+ """
178
+ if tomllib is None:
179
+ raise ImportError(
180
+ "tomllib (Python 3.11+) or tomli is required for TOML reading support. "
181
+ "For Python < 3.11, install with: pip install tomli"
182
+ )
183
+ parsed_data = tomllib.loads(toml_str) # type: ignore
184
+ return json.dumps(parsed_data)
185
+
186
+
187
+ class IOMixin:
188
+ def to_file(
189
+ self: BaseModelLike, file_path: PathLike, file_format: FileFormats = "auto"
190
+ ) -> None:
191
+ """
192
+ Save the model to a file in the specified format.
193
+
194
+ Args:
195
+ file_path: Path to the output file.
196
+ file_format: Format to save the file in ("json", "yaml", or "toml").
197
+
198
+ Raises:
199
+ ImportError: If the required library for the format is not installed.
200
+ ValueError: If an unsupported file format is specified.
201
+ """
202
+ file_path = Path(file_path)
203
+ file_path.parent.mkdir(parents=True, exist_ok=True)
204
+
205
+ if file_format == "auto":
206
+ file_format = file_format_from_file(file_path)
207
+
208
+ if file_format == "json":
209
+ txt = self.model_dump_json(indent=2)
210
+ elif file_format == "yaml":
211
+ if yaml is None:
212
+ raise ImportError(
213
+ "ruamel.yaml is required for YAML support. Install it with: pip install ruamel.yaml"
214
+ )
215
+ # Convert to dict first, then to YAML
216
+ data = self.model_dump()
217
+ from io import StringIO
218
+
219
+ stream = StringIO()
220
+ yaml.dump(data, stream) # type: ignore
221
+ txt = stream.getvalue()
222
+ elif file_format == "toml":
223
+ if tomli_w is None:
224
+ raise ImportError(
225
+ "tomli-w is required for TOML writing support. Install it with: pip install tomli-w"
226
+ )
227
+ # Convert to dict first, then to TOML
228
+ data = self.model_dump()
229
+ # TOML requires a table (dict) at the root level, not arrays
230
+ if isinstance(data, list):
231
+ raise ValueError(
232
+ "TOML format does not support arrays at the root level. "
233
+ "Use JSON or YAML format for list/array data."
234
+ )
235
+ txt = tomli_w.dumps(data) # type: ignore
236
+ else:
237
+ raise ValueError(f"Unsupported file format: {file_format}")
238
+
239
+ file_path.write_text(txt) # type: ignore
240
+
241
+ @classmethod
242
+ def from_file(
243
+ cls: type[TypeBaseModelLike],
244
+ file_path: PathLike,
245
+ file_format: FileFormats = "auto",
246
+ ) -> TypeBaseModelLike:
247
+ """
248
+ Load the model from a file in the specified format.
249
+
250
+ Args:
251
+ file_path: Path to the input file.
252
+ file_format: Format of the input file ("json", "yaml", or "toml").
253
+
254
+ Returns:
255
+ An instance of the model.
256
+
257
+ Raises:
258
+ ImportError: If the required library for the format is not installed.
259
+ ValueError: If an unsupported file format is specified.
260
+ """
261
+ file_path = Path(file_path)
262
+
263
+ if file_format == "auto":
264
+ file_format = file_format_from_file(file_path)
265
+ if file_format == "json":
266
+ json_data = file_path.read_text()
267
+ elif file_format == "yaml":
268
+ yaml_data = file_path.read_text()
269
+ json_data = yaml_to_json(yaml_data)
270
+ elif file_format == "toml":
271
+ toml_data = file_path.read_text()
272
+ json_data = toml_to_json(toml_data)
273
+ else:
274
+ raise ValueError(f"Unsupported file format: {file_format}")
275
+
276
+ return cls.model_validate_json(json_data)
277
+
278
+
279
+ class BaseModel(PydanticBaseModel, IOMixin): ...
280
+
281
+
282
+ class RootModel(PydanticRootModel[T], IOMixin): ...
283
+
284
+
285
+ class ListModel(RootModel[list[T]], MutableSequence[T]):
286
+ root: list[T] = Field(default_factory=list) # type: ignore
287
+
288
+ def append(self, value: T) -> None:
289
+ self.root.append(value)
290
+
291
+ def extend(self, values: Iterable[T]) -> None:
292
+ self.root.extend(values)
293
+
294
+ @overload
295
+ def __getitem__(self, index: int) -> T: ...
296
+
297
+ @overload
298
+ def __getitem__(self, index: slice) -> list[T]: ...
299
+
300
+ def __getitem__(self, index: int | slice) -> T | list[T]:
301
+ return self.root[index]
302
+
303
+ @overload
304
+ def __setitem__(self, index: int, value: T) -> None: ...
305
+
306
+ @overload
307
+ def __setitem__(self, index: slice, value: Iterable[T]) -> None: ...
308
+
309
+ def __setitem__(self, index: int | slice, value: T | Iterable[T]) -> None:
310
+ self.root[index] = value # type: ignore
311
+
312
+ def __len__(self) -> int:
313
+ return len(self.root)
314
+
315
+ def __iter__(self): # type: ignore
316
+ return iter(self.root)
317
+
318
+ def __contains__(self, item: object) -> bool:
319
+ return item in self.root
320
+
321
+ def insert(self, index: int, value: T) -> None:
322
+ self.root.insert(index, value)
323
+
324
+ def __delitem__(self, index: int | slice) -> None:
325
+ del self.root[index]
326
+
327
+
328
+ class DictModel(RootModel[dict[K, T]], MutableMapping[K, T]):
329
+ root: dict[K, T] = Field(default_factory=dict) # type: ignore
330
+
331
+ def __contains__(self, key: object) -> bool:
332
+ return key in self.root
333
+
334
+ def items(self):
335
+ return self.root.items()
336
+
337
+ def keys(self):
338
+ return self.root.keys()
339
+
340
+ def values(self):
341
+ return self.root.values()
342
+
343
+ def __getitem__(self, key: K) -> T:
344
+ return self.root[key]
345
+
346
+ def __setitem__(self, key: K, value: T) -> None:
347
+ self.root[key] = value
348
+
349
+ def __delitem__(self, key: K) -> None:
350
+ del self.root[key]
351
+
352
+ def __len__(self) -> int:
353
+ return len(self.root)
354
+
355
+ def __iter__(self): # type: ignore
356
+ return iter(self.root)
357
+
358
+
359
+ class JsonStore(DictModel[str, T]):
360
+ _file_path: Optional[Path] = PrivateAttr(default=None)
361
+
362
+ def __setitem__(self, key: str, value: T) -> None:
363
+ self.root[key] = value
364
+ self.save_store()
365
+
366
+ def __init__(
367
+ self,
368
+ *args: Any,
369
+ file_path: Optional[Path] = None,
370
+ **kwargs: Any,
371
+ ) -> None:
372
+ super().__init__(*args, **kwargs)
373
+ self._file_path = file_path
374
+
375
+ def save_store(self) -> None:
376
+ if self._file_path is None:
377
+ raise ValueError("File path is not set.")
378
+ self.to_file(self._file_path)
379
+
380
+ @classmethod
381
+ def connect(cls, file_path: Path) -> JsonStore[T]:
382
+ if not file_path.exists():
383
+ obj = cls(file_path=file_path)
384
+ obj.save_store()
385
+ obj = cls.from_file(file_path)
386
+ obj._file_path = file_path
387
+ return obj
File without changes