voltwire-db-types-json 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,221 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # Local tool state
221
+ .omc/
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.5
2
+ Name: voltwire-db-types-json
3
+ Version: 0.0.1
4
+ Summary: Custom SQLAlchemy JSONB column types backed by Pydantic models
5
+ Author-email: Hermann Steidel <hsteidel.software@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: <4.0,>=3.13
8
+ Requires-Dist: pydantic<3,>=2.9
9
+ Requires-Dist: sqlalchemy<3,>=2.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ <img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/db-types-json.svg" alt="" width="56" height="56" align="left">
13
+
14
+ # voltwire-db-types-json
15
+
16
+ Custom SQLAlchemy column types for PostgreSQL JSONB columns, backed by Pydantic models. Handles serialisation, deserialisation, and validation transparently — your columns read and write Pydantic model instances directly.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install voltwire-db-types-json
22
+ # or with Poetry:
23
+ poetry add voltwire-db-types-json
24
+ ```
25
+
26
+ ## Types
27
+
28
+ ### `JSONBList[T]` — JSONB array as a list of Pydantic models
29
+
30
+ Returns an empty list when the column value is `NULL`.
31
+
32
+ ```python
33
+ from pydantic import BaseModel
34
+ from sqlalchemy.orm import mapped_column, Mapped
35
+ from voltwire.db.types.json import JSONBList
36
+
37
+ class Tag(BaseModel):
38
+ name: str
39
+ colour: str
40
+
41
+ class Article(Base):
42
+ __tablename__ = "articles"
43
+ tags: Mapped[list[Tag]] = mapped_column(JSONBList(Tag), default=list)
44
+ ```
45
+
46
+ ### `JSONBObject[T]` — JSONB object as a single Pydantic model
47
+
48
+ Returns `None` when the column value is `NULL` or validation fails (logs an error, does not raise).
49
+
50
+ ```python
51
+ from voltwire.db.types.json import JSONBObject
52
+
53
+ class Address(BaseModel):
54
+ street: str
55
+ city: str
56
+
57
+ class User(Base):
58
+ __tablename__ = "users"
59
+ address: Mapped[Address | None] = mapped_column(JSONBObject(Address), nullable=True)
60
+ ```
61
+
62
+ ## Logging
63
+
64
+ Validation errors in `JSONBObject` are logged to the `voltwire.db.types.json.types` logger namespace and return `None` rather than raising — this prevents a single bad row from crashing the application.
65
+
66
+ ```python
67
+ import logging
68
+ logging.getLogger("voltwire.db.types.json").setLevel(logging.DEBUG)
69
+ ```
@@ -0,0 +1,58 @@
1
+ <img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/db-types-json.svg" alt="" width="56" height="56" align="left">
2
+
3
+ # voltwire-db-types-json
4
+
5
+ Custom SQLAlchemy column types for PostgreSQL JSONB columns, backed by Pydantic models. Handles serialisation, deserialisation, and validation transparently — your columns read and write Pydantic model instances directly.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install voltwire-db-types-json
11
+ # or with Poetry:
12
+ poetry add voltwire-db-types-json
13
+ ```
14
+
15
+ ## Types
16
+
17
+ ### `JSONBList[T]` — JSONB array as a list of Pydantic models
18
+
19
+ Returns an empty list when the column value is `NULL`.
20
+
21
+ ```python
22
+ from pydantic import BaseModel
23
+ from sqlalchemy.orm import mapped_column, Mapped
24
+ from voltwire.db.types.json import JSONBList
25
+
26
+ class Tag(BaseModel):
27
+ name: str
28
+ colour: str
29
+
30
+ class Article(Base):
31
+ __tablename__ = "articles"
32
+ tags: Mapped[list[Tag]] = mapped_column(JSONBList(Tag), default=list)
33
+ ```
34
+
35
+ ### `JSONBObject[T]` — JSONB object as a single Pydantic model
36
+
37
+ Returns `None` when the column value is `NULL` or validation fails (logs an error, does not raise).
38
+
39
+ ```python
40
+ from voltwire.db.types.json import JSONBObject
41
+
42
+ class Address(BaseModel):
43
+ street: str
44
+ city: str
45
+
46
+ class User(Base):
47
+ __tablename__ = "users"
48
+ address: Mapped[Address | None] = mapped_column(JSONBObject(Address), nullable=True)
49
+ ```
50
+
51
+ ## Logging
52
+
53
+ Validation errors in `JSONBObject` are logged to the `voltwire.db.types.json.types` logger namespace and return `None` rather than raising — this prevents a single bad row from crashing the application.
54
+
55
+ ```python
56
+ import logging
57
+ logging.getLogger("voltwire.db.types.json").setLevel(logging.DEBUG)
58
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "voltwire-db-types-json"
3
+ version = "0.0.1"
4
+ description = "Custom SQLAlchemy JSONB column types backed by Pydantic models"
5
+ authors = [{name = "Hermann Steidel", email = "hsteidel.software@gmail.com"}]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ requires-python = ">=3.13,<4.0"
9
+ dependencies = [
10
+ "sqlalchemy>=2.0,<3",
11
+ "pydantic>=2.9,<3",
12
+ ]
13
+
14
+ [dependency-groups]
15
+ dev = ["pytest>=8.0"]
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/voltwire"]
@@ -0,0 +1,8 @@
1
+ from voltwire.db.types.json.types import JSONBList, JSONBObject
2
+
3
+ __version__ = "0.0.0"
4
+
5
+ __all__ = [
6
+ "JSONBList",
7
+ "JSONBObject",
8
+ ]
@@ -0,0 +1,83 @@
1
+ import logging
2
+ from typing import Generic, TypeVar
3
+
4
+ from pydantic import BaseModel, ValidationError
5
+ from sqlalchemy.dialects.postgresql import JSONB
6
+ from sqlalchemy.types import TypeDecorator
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ T = TypeVar("T", bound=BaseModel)
11
+
12
+
13
+ class JSONBList(TypeDecorator, Generic[T]):
14
+ """SQLAlchemy column type for a JSONB array, deserialized as a list of Pydantic models.
15
+
16
+ Returns an empty list when the column value is ``NULL``.
17
+
18
+ Example::
19
+
20
+ class Tag(BaseModel):
21
+ name: str
22
+ colour: str
23
+
24
+ class Article(Base):
25
+ tags: Mapped[list[Tag]] = mapped_column(JSONBList(Tag), default=list)
26
+ """
27
+
28
+ impl = JSONB
29
+
30
+ def __init__(self, item_class: type[T]):
31
+ super().__init__()
32
+ self.item_class = item_class
33
+
34
+ def process_result_value(self, value, dialect) -> list[T]:
35
+ if value is None:
36
+ return []
37
+ return [self.item_class.model_validate(item) for item in value]
38
+
39
+ def process_bind_param(self, value: list[T] | None, dialect):
40
+ if value is None:
41
+ return None
42
+ return [item.model_dump(mode="json") for item in value]
43
+
44
+
45
+ class JSONBObject(TypeDecorator, Generic[T]):
46
+ """SQLAlchemy column type for a JSONB object, deserialised as a single Pydantic model.
47
+
48
+ Returns ``None`` when the column value is ``NULL`` or validation fails (logs an error).
49
+
50
+ Example::
51
+
52
+ class Address(BaseModel):
53
+ street: str
54
+ city: str
55
+
56
+ class User(Base):
57
+ address: Mapped[Address | None] = mapped_column(JSONBObject(Address), nullable=True)
58
+ """
59
+
60
+ impl = JSONB
61
+ cache_ok = True
62
+
63
+ def __init__(self, item_class: type[T]):
64
+ super().__init__()
65
+ self.item_class = item_class
66
+
67
+ def process_result_value(self, value, dialect) -> T | None:
68
+ if value is None:
69
+ return None
70
+ try:
71
+ return self.item_class.model_validate(value)
72
+ except ValidationError as e:
73
+ logger.error(
74
+ "JSONBObject validation failed for %s - returning None to prevent crash: %s",
75
+ self.item_class.__name__,
76
+ e,
77
+ )
78
+ return None
79
+
80
+ def process_bind_param(self, value: T | None, dialect):
81
+ if value is None:
82
+ return None
83
+ return value.model_dump(mode="json")
@@ -0,0 +1,64 @@
1
+ from pydantic import BaseModel
2
+
3
+ from voltwire.db.types.json import JSONBList, JSONBObject
4
+
5
+
6
+ # --- Fixtures ---
7
+
8
+ class Tag(BaseModel):
9
+ name: str
10
+ colour: str
11
+
12
+
13
+ class Address(BaseModel):
14
+ street: str
15
+ city: str
16
+
17
+
18
+ # --- JSONBList ---
19
+
20
+ class TestJSONBList:
21
+ def test_process_result_value_deserialises_list(self):
22
+ col = JSONBList(Tag)
23
+ result = col.process_result_value([{"name": "python", "colour": "blue"}], dialect=None)
24
+ assert result == [Tag(name="python", colour="blue")]
25
+
26
+ def test_process_result_value_returns_empty_list_for_null(self):
27
+ col = JSONBList(Tag)
28
+ assert col.process_result_value(None, dialect=None) == []
29
+
30
+ def test_process_bind_param_serialises_list(self):
31
+ col = JSONBList(Tag)
32
+ result = col.process_bind_param([Tag(name="python", colour="blue")], dialect=None)
33
+ assert result == [{"name": "python", "colour": "blue"}]
34
+
35
+ def test_process_bind_param_returns_none_for_null(self):
36
+ col = JSONBList(Tag)
37
+ assert col.process_bind_param(None, dialect=None) is None
38
+
39
+
40
+ # --- JSONBObject ---
41
+
42
+ class TestJSONBObject:
43
+ def test_process_result_value_deserialises_object(self):
44
+ col = JSONBObject(Address)
45
+ result = col.process_result_value({"street": "1 Main St", "city": "Springfield"}, dialect=None)
46
+ assert result == Address(street="1 Main St", city="Springfield")
47
+
48
+ def test_process_result_value_returns_none_for_null(self):
49
+ col = JSONBObject(Address)
50
+ assert col.process_result_value(None, dialect=None) is None
51
+
52
+ def test_process_result_value_returns_none_on_validation_error(self):
53
+ col = JSONBObject(Address)
54
+ result = col.process_result_value({"bad": "data"}, dialect=None)
55
+ assert result is None
56
+
57
+ def test_process_bind_param_serialises_object(self):
58
+ col = JSONBObject(Address)
59
+ result = col.process_bind_param(Address(street="1 Main St", city="Springfield"), dialect=None)
60
+ assert result == {"street": "1 Main St", "city": "Springfield"}
61
+
62
+ def test_process_bind_param_returns_none_for_null(self):
63
+ col = JSONBObject(Address)
64
+ assert col.process_bind_param(None, dialect=None) is None
@@ -0,0 +1,6 @@
1
+ from voltwire.db.types.json import __version__
2
+
3
+
4
+ def test_version():
5
+ assert isinstance(__version__, str)
6
+ assert len(__version__) > 0