pydantic-json-patch 0.1.0__py3-none-any.whl
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,27 @@
|
|
|
1
|
+
import typing as tp
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
|
|
4
|
+
from pydantic import Discriminator
|
|
5
|
+
|
|
6
|
+
from .models import AddOp, CopyOp, MoveOp, Op, RemoveOp, ReplaceOp, TestOp, Tokens
|
|
7
|
+
|
|
8
|
+
__version__ = version(__name__)
|
|
9
|
+
|
|
10
|
+
Operation: tp.TypeAlias = tp.Annotated[
|
|
11
|
+
tp.Union[AddOp, CopyOp, MoveOp, RemoveOp, ReplaceOp, TestOp], Discriminator("op")
|
|
12
|
+
]
|
|
13
|
+
JsonPatch: tp.TypeAlias = list[Operation]
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"AddOp",
|
|
18
|
+
"CopyOp",
|
|
19
|
+
"JsonPatch",
|
|
20
|
+
"MoveOp",
|
|
21
|
+
"Op",
|
|
22
|
+
"Operation",
|
|
23
|
+
"RemoveOp",
|
|
24
|
+
"ReplaceOp",
|
|
25
|
+
"TestOp",
|
|
26
|
+
"Tokens",
|
|
27
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import typing as tp
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, model_validator
|
|
6
|
+
import typing_extensions as tx
|
|
7
|
+
|
|
8
|
+
_JSON_POINTER = re.compile(r"^(?:/(?:[^/~]|~[01])+)*$")
|
|
9
|
+
|
|
10
|
+
T = tp.TypeVar("T")
|
|
11
|
+
Op: tp.TypeAlias = tp.Literal["add", "copy", "move", "remove", "replace", "test"]
|
|
12
|
+
Tokens: tp.TypeAlias = tuple[str, ...]
|
|
13
|
+
|
|
14
|
+
# region base models
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _BaseOp(BaseModel):
|
|
18
|
+
model_config = ConfigDict(
|
|
19
|
+
frozen=True,
|
|
20
|
+
model_title_generator=lambda t: f"JsonPatch{t.__name__}eration",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def create(cls, *, path: str | Tokens, **kwargs) -> tx.Self:
|
|
25
|
+
(op,) = tp.get_args(cls.model_fields["op"].annotation)
|
|
26
|
+
pointer = path if isinstance(path, str) else cls._dump_pointer(path)
|
|
27
|
+
return cls(op=op, path=pointer, **kwargs)
|
|
28
|
+
|
|
29
|
+
op: Op
|
|
30
|
+
"""The operation being represented."""
|
|
31
|
+
|
|
32
|
+
path: str = Field(examples=["/a/b/c"], pattern=_JSON_POINTER)
|
|
33
|
+
"""A JSON pointer representing the path to apply the operation to."""
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def path_tokens(self) -> Tokens:
|
|
37
|
+
"""The decoded tokens in the 'path' JSON pointer."""
|
|
38
|
+
return self._load_pointer(self.path)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _dump_pointer(pointer: Tokens) -> str:
|
|
42
|
+
return "/".join(
|
|
43
|
+
["", *(token.replace("~", "~0").replace("/", "~1") for token in pointer)]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _load_pointer(pointer: str) -> Tokens:
|
|
48
|
+
return tuple(
|
|
49
|
+
token.replace("~1", "/").replace("~0", "~")
|
|
50
|
+
for token in pointer.split("/")[1:]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _FromOp(_BaseOp):
|
|
55
|
+
@classmethod
|
|
56
|
+
def create(cls, *, path: str | Tokens, from_: str | Tokens) -> tx.Self: # type: ignore[invalid-method-override]
|
|
57
|
+
pointer = from_ if isinstance(from_, str) else cls._dump_pointer(from_)
|
|
58
|
+
return super().create(path=path, **{"from": pointer})
|
|
59
|
+
|
|
60
|
+
from_: str = Field(alias="from", examples=["/a/b/d"], pattern=_JSON_POINTER)
|
|
61
|
+
"""A JSON pointer representing the path to apply the operation from."""
|
|
62
|
+
|
|
63
|
+
@model_validator(mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _pre_validate(cls, data: tp.Any, info: ValidationInfo) -> tp.Any:
|
|
66
|
+
if (
|
|
67
|
+
info.mode != "json"
|
|
68
|
+
and isinstance(data, dict)
|
|
69
|
+
and "from_" in data
|
|
70
|
+
and "from" not in data
|
|
71
|
+
):
|
|
72
|
+
data["from"] = data.pop("from_")
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
@cached_property
|
|
76
|
+
def from_tokens(self) -> Tokens:
|
|
77
|
+
"""The decoded tokens in the 'from' JSON pointer."""
|
|
78
|
+
return self._load_pointer(self.from_)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _ValueOp(_BaseOp, tp.Generic[T]):
|
|
82
|
+
@classmethod
|
|
83
|
+
def create(cls, *, path: str | Tokens, value: T) -> tx.Self: # type: ignore[invalid-method-override]
|
|
84
|
+
return super().create(path=path, value=value)
|
|
85
|
+
|
|
86
|
+
value: T = Field(examples=[42])
|
|
87
|
+
"""The value to use in the operation."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# endregion
|
|
91
|
+
|
|
92
|
+
# region public models
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AddOp(_ValueOp, tp.Generic[T]):
|
|
96
|
+
op: tp.Literal["add"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CopyOp(_FromOp):
|
|
100
|
+
op: tp.Literal["copy"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MoveOp(_FromOp):
|
|
104
|
+
op: tp.Literal["move"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RemoveOp(_BaseOp):
|
|
108
|
+
@classmethod
|
|
109
|
+
def create(cls, *, path: str | Tokens) -> tx.Self: # type: ignore[invalid-method-override]
|
|
110
|
+
return super().create(path=path)
|
|
111
|
+
|
|
112
|
+
op: tp.Literal["remove"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ReplaceOp(_ValueOp, tp.Generic[T]):
|
|
116
|
+
op: tp.Literal["replace"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestOp(_ValueOp, tp.Generic[T]):
|
|
120
|
+
op: tp.Literal["test"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# endregion
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydantic_json_patch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pydantic models for implementing JSON Patch.
|
|
5
|
+
Author: Jonathan Sharpe
|
|
6
|
+
Author-email: Jonathan Sharpe <mail@jonrshar.pe>
|
|
7
|
+
License-Expression: ISC
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: Framework :: Pydantic
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
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
|
+
Classifier: Topic :: File Formats :: JSON
|
|
19
|
+
Classifier: Topic :: File Formats :: JSON :: JSON Schema
|
|
20
|
+
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Project-URL: repository, https://github.com/textbook/pydantic_json_patch
|
|
24
|
+
Project-URL: Issues, https://github.com/textbook/pydantic_json_patch/issues
|
|
25
|
+
Project-URL: Sponsor, https://ko-fi.com/textbook
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Pydantic JSON Patch
|
|
29
|
+
|
|
30
|
+
[Pydantic] models for implementing [JSON Patch].
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
_Pydantic JSON Patch_ is published to [PyPI], and can be installed with e.g.:
|
|
35
|
+
|
|
36
|
+
```shell
|
|
37
|
+
pip install pydantic-json-patch
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Models
|
|
41
|
+
|
|
42
|
+
A model is provided for each of the six JSON Patch operations:
|
|
43
|
+
|
|
44
|
+
- `AddOp`
|
|
45
|
+
- `CopyOp`
|
|
46
|
+
- `MoveOp`
|
|
47
|
+
- `RemoveOp`
|
|
48
|
+
- `ReplaceOp`
|
|
49
|
+
- `TestOp`
|
|
50
|
+
|
|
51
|
+
As repeating the op is a bit awkward (`CopyOp(op="copy", ...)`), a `create` factory method is available:
|
|
52
|
+
|
|
53
|
+
```pycon
|
|
54
|
+
>>> from pydantic_json_patch import AddOp
|
|
55
|
+
>>> op = AddOp.create(path="/foo/bar", value=123)
|
|
56
|
+
>>> op
|
|
57
|
+
AddOp(op='add', path='/foo/bar', value=123)
|
|
58
|
+
>>> op.model_dump_json()
|
|
59
|
+
'{"op":"add","path":"/foo/bar","value":123}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Additionally, there are two compound models:
|
|
63
|
+
|
|
64
|
+
- `Operation` is the union of all the operators; and
|
|
65
|
+
- `JsonPatch` represents a list of that union type.
|
|
66
|
+
|
|
67
|
+
### Pointer tokens
|
|
68
|
+
|
|
69
|
+
The `path` property (and `from` property, where present) of an operation is a [JSON Pointer].
|
|
70
|
+
This means that any `~` or `/` characters in property names need to be properly encoded.
|
|
71
|
+
To aid working with these, the models expose a read-only `path_tokens` property (and, where appropriate, `from_tokens`):
|
|
72
|
+
|
|
73
|
+
```pycon
|
|
74
|
+
>>> from pydantic_json_patch import CopyOp
|
|
75
|
+
>>> op = CopyOp.model_validate_json('{"op":"copy","path":"/foo/bar~1new","from":"/foo/bar~0old"}')
|
|
76
|
+
>>> op
|
|
77
|
+
CopyOp(op='copy', path='/foo/bar~1new', from_='/foo/bar~0old')
|
|
78
|
+
>>> op.path_tokens
|
|
79
|
+
('foo', 'bar/new')
|
|
80
|
+
>>> op.from_tokens
|
|
81
|
+
('foo', 'bar~old')
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Similarly, the `create` factory methods can accept tuples of tokens, and will encode them appropriately:
|
|
85
|
+
|
|
86
|
+
```pycon
|
|
87
|
+
>>> from pydantic_json_patch import TestOp
|
|
88
|
+
>>> op = TestOp.create(path=("annotations", "scope/value"), value=None)
|
|
89
|
+
>>> op
|
|
90
|
+
TestOp(op='test', path='/annotations/scope~1value', value=None)
|
|
91
|
+
>>> op.model_dump_json()
|
|
92
|
+
'{"op":"test","path":"/annotations/scope~1value","value":null}'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## FastAPI
|
|
96
|
+
|
|
97
|
+
You can use this package to validate a JSON Patch endpoint in a FastAPI application, for example:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import typing as tp
|
|
101
|
+
from uuid import UUID
|
|
102
|
+
|
|
103
|
+
from fastapi import Body, FastAPI
|
|
104
|
+
|
|
105
|
+
from pydantic_json_patch import JsonPatch
|
|
106
|
+
|
|
107
|
+
app = FastAPI()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.patch("/resource/{resource_id}")
|
|
111
|
+
def _(resource_id: UUID, operations: tp.Annotated[JsonPatch, Body()]) -> ...:
|
|
112
|
+
...
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
This will provide a sensible example of the request body:
|
|
116
|
+
|
|
117
|
+
[![Screenshot of Swagger UI request body example][swagger-example]][swagger-example]
|
|
118
|
+
|
|
119
|
+
and list the models along with the other schemas:
|
|
120
|
+
|
|
121
|
+
[![Screenshot of Swagger UI schema list][swagger-schemas]][swagger-schemas]
|
|
122
|
+
|
|
123
|
+
[fastapi]: https://fastapi.tiangolo.com/
|
|
124
|
+
[json patch]: https://datatracker.ietf.org/doc/html/rfc6902/
|
|
125
|
+
[json pointer]: https://datatracker.ietf.org/doc/html/rfc6901/
|
|
126
|
+
[pydantic]: https://docs.pydantic.dev/latest/
|
|
127
|
+
[pypi]: https://pypi.org/
|
|
128
|
+
[swagger-example]: docs/swagger-example.png
|
|
129
|
+
[swagger-schemas]: docs/swagger-schemas.png
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pydantic_json_patch/__init__.py,sha256=yLGECB55CsaTCxMxRbPCGPLDZQlGSB3KJmx6KBlRjt8,563
|
|
2
|
+
pydantic_json_patch/models.py,sha256=Sy_ZFBwf9ft-h1U0KbDAeAnvS9rF0Ejpi2D-tVxK6Q8,3443
|
|
3
|
+
pydantic_json_patch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
pydantic_json_patch-0.1.0.dist-info/WHEEL,sha256=jROcLULcdzropX2J55opKw4UHhPFREZax2XzS-Mvpxs,80
|
|
5
|
+
pydantic_json_patch-0.1.0.dist-info/METADATA,sha256=QGt2XbptttDMqnDWraK0tbm4Io4oG66NDg1l8cZphsc,3972
|
|
6
|
+
pydantic_json_patch-0.1.0.dist-info/RECORD,,
|