Pyjoyment 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.
- Pyjo/App/ASGI.py +735 -0
- Pyjo/App/CGI.py +773 -0
- Pyjo/App/FastCGI.py +631 -0
- Pyjo/App/Lambda.py +987 -0
- Pyjo/App/WSGI.py +427 -0
- Pyjo/App/__init__.py +409 -0
- Pyjo/Asset/File.py +377 -0
- Pyjo/Asset/Memory.py +235 -0
- Pyjo/Asset/__init__.py +200 -0
- Pyjo/Base.py +215 -0
- Pyjo/Cache.py +119 -0
- Pyjo/Collection.py +570 -0
- Pyjo/Content/MultiPart.py +325 -0
- Pyjo/Content/Single.py +193 -0
- Pyjo/Content/__init__.py +806 -0
- Pyjo/Cookie/Request.py +90 -0
- Pyjo/Cookie/Response.py +294 -0
- Pyjo/Cookie/__init__.py +143 -0
- Pyjo/DOM/CSS.py +1019 -0
- Pyjo/DOM/HTML.py +550 -0
- Pyjo/DOM/__init__.py +1329 -0
- Pyjo/Date.py +294 -0
- Pyjo/EventEmitter.py +522 -0
- Pyjo/Exception.py +494 -0
- Pyjo/File.py +959 -0
- Pyjo/GetOpt.py +605 -0
- Pyjo/Headers.py +760 -0
- Pyjo/HelloWorld.py +77 -0
- Pyjo/Home.py +109 -0
- Pyjo/IOLoop/ChildProcess.py +591 -0
- Pyjo/IOLoop/Client.py +736 -0
- Pyjo/IOLoop/Datagram.py +982 -0
- Pyjo/IOLoop/Messages.py +664 -0
- Pyjo/IOLoop/Server.py +458 -0
- Pyjo/IOLoop/Stdio.py +446 -0
- Pyjo/IOLoop/Stream.py +644 -0
- Pyjo/IOLoop/Subprocess.py +419 -0
- Pyjo/IOLoop/TLS.py +360 -0
- Pyjo/IOLoop/__init__.py +1036 -0
- Pyjo/IOLoop/certs/README.md +41 -0
- Pyjo/IOLoop/certs/ca.crt +20 -0
- Pyjo/IOLoop/certs/server.crt +21 -0
- Pyjo/IOLoop/certs/server.key +28 -0
- Pyjo/JSON/Pointer.py +135 -0
- Pyjo/JSON/RPC.py +772 -0
- Pyjo/JSON/Schema.py +2506 -0
- Pyjo/JSON/__init__.py +573 -0
- Pyjo/Loader.py +342 -0
- Pyjo/Log.py +723 -0
- Pyjo/Message/Request.py +725 -0
- Pyjo/Message/Response.py +476 -0
- Pyjo/Message/__init__.py +830 -0
- Pyjo/Parameters.py +472 -0
- Pyjo/Path.py +419 -0
- Pyjo/Promise.py +894 -0
- Pyjo/Prove.py +1305 -0
- Pyjo/Reactor/Asyncio.py +854 -0
- Pyjo/Reactor/Poll.py +439 -0
- Pyjo/Reactor/__init__.py +525 -0
- Pyjo/SSE.py +119 -0
- Pyjo/Server/ASGI.py +486 -0
- Pyjo/Server/CGI.py +259 -0
- Pyjo/Server/Daemon.py +1007 -0
- Pyjo/Server/FastCGI.py +517 -0
- Pyjo/Server/Hot/__init__.py +687 -0
- Pyjo/Server/Hot/__main__.py +5 -0
- Pyjo/Server/Lambda.py +641 -0
- Pyjo/Server/Prefork.py +917 -0
- Pyjo/Server/Prethread.py +394 -0
- Pyjo/Server/Reload/Backend/Poll.py +114 -0
- Pyjo/Server/Reload/Backend/__init__.py +126 -0
- Pyjo/Server/Reload/__init__.py +482 -0
- Pyjo/Server/Reload/__main__.py +5 -0
- Pyjo/Server/Stdio.py +228 -0
- Pyjo/Server/WSGI.py +349 -0
- Pyjo/Server/__init__.py +396 -0
- Pyjo/String/Bytes.py +532 -0
- Pyjo/String/HTML.py +210 -0
- Pyjo/String/Mixin.py +333 -0
- Pyjo/String/Template.py +470 -0
- Pyjo/String/Unicode.py +553 -0
- Pyjo/String/__init__.py +0 -0
- Pyjo/TOML.py +232 -0
- Pyjo/Template.py +1290 -0
- Pyjo/Test/Pyjo.py +1454 -0
- Pyjo/Test/__init__.py +1233 -0
- Pyjo/Test/pytest_plugin.py +305 -0
- Pyjo/Transaction/HTTP.py +374 -0
- Pyjo/Transaction/WebSocket.py +709 -0
- Pyjo/Transaction/__init__.py +429 -0
- Pyjo/URL/Template.py +459 -0
- Pyjo/URL/__init__.py +651 -0
- Pyjo/Upload.py +139 -0
- Pyjo/UserAgent/CookieJar.py +391 -0
- Pyjo/UserAgent/Proxy.py +138 -0
- Pyjo/UserAgent/Server.py +239 -0
- Pyjo/UserAgent/Transactor.py +766 -0
- Pyjo/UserAgent/__init__.py +1243 -0
- Pyjo/Util.py +3567 -0
- Pyjo/WebSocket.py +332 -0
- Pyjo/YAML.py +1167 -0
- Pyjo/__init__.py +441 -0
- Pyjo/__main__.py +35 -0
- Pyjo/py.typed +0 -0
- Pyjoyment/Command/Author/__init__.py +20 -0
- Pyjoyment/Command/Author/generate/__init__.py +106 -0
- Pyjoyment/Command/Author/generate/app.py +299 -0
- Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
- Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
- Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
- Pyjoyment/Command/Author/generate/lite_app.py +113 -0
- Pyjoyment/Command/Author/generate/plugin.py +267 -0
- Pyjoyment/Command/Author/generate/pyproject.py +123 -0
- Pyjoyment/Command/Author/inflate.py +101 -0
- Pyjoyment/Command/__init__.py +332 -0
- Pyjoyment/Command/asgi.py +92 -0
- Pyjoyment/Command/cgi.py +90 -0
- Pyjoyment/Command/daemon.py +160 -0
- Pyjoyment/Command/eval.py +128 -0
- Pyjoyment/Command/fastcgi.py +99 -0
- Pyjoyment/Command/get.py +305 -0
- Pyjoyment/Command/lambda.py +117 -0
- Pyjoyment/Command/prefork.py +181 -0
- Pyjoyment/Command/prethread.py +126 -0
- Pyjoyment/Command/routes.py +133 -0
- Pyjoyment/Command/stdio.py +92 -0
- Pyjoyment/Command/version.py +141 -0
- Pyjoyment/Command/wsgi.py +93 -0
- Pyjoyment/Commands.py +546 -0
- Pyjoyment/Controller.py +1307 -0
- Pyjoyment/HelloWorld.py +56 -0
- Pyjoyment/Lite.py +494 -0
- Pyjoyment/Plugin/AllowedHosts.py +205 -0
- Pyjoyment/Plugin/Config.py +248 -0
- Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
- Pyjoyment/Plugin/EPLRenderer.py +134 -0
- Pyjoyment/Plugin/EPRenderer.py +212 -0
- Pyjoyment/Plugin/HeaderCondition.py +98 -0
- Pyjoyment/Plugin/Health.py +325 -0
- Pyjoyment/Plugin/JSONConfig.py +172 -0
- Pyjoyment/Plugin/Mount.py +223 -0
- Pyjoyment/Plugin/PyRenderer.py +209 -0
- Pyjoyment/Plugin/TOMLConfig.py +141 -0
- Pyjoyment/Plugin/TagHelpers.py +959 -0
- Pyjoyment/Plugin/YAMLConfig.py +178 -0
- Pyjoyment/Plugin/__init__.py +80 -0
- Pyjoyment/Plugins.py +215 -0
- Pyjoyment/Renderer.py +673 -0
- Pyjoyment/Routes/Match.py +251 -0
- Pyjoyment/Routes/Pattern.py +443 -0
- Pyjoyment/Routes/Route.py +827 -0
- Pyjoyment/Routes/__init__.py +419 -0
- Pyjoyment/Sessions.py +255 -0
- Pyjoyment/Static.py +421 -0
- Pyjoyment/Types.py +238 -0
- Pyjoyment/Validator/Validation.py +395 -0
- Pyjoyment/Validator/__init__.py +304 -0
- Pyjoyment/__init__.py +1083 -0
- Pyjoyment/__main__.py +5 -0
- Pyjoyment/py.typed +0 -0
- Pyjoyment/resources/public/favicon.ico +0 -0
- Pyjoyment/resources/public/favicon.svg +345 -0
- Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
- Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
- Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
- Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
- Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
- Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
- Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
- Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
- Pyjoyment/resources/public/pyjo/logo.png +0 -0
- Pyjoyment/resources/public/pyjo/logo.svg +346 -0
- Pyjoyment/resources/public/pyjo/notfound.png +0 -0
- Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
- Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
- Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
- Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
- Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
- Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
- Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
- Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
- pyjoyment-0.1.0.dist-info/METADATA +376 -0
- pyjoyment-0.1.0.dist-info/RECORD +200 -0
- pyjoyment-0.1.0.dist-info/WHEEL +4 -0
- pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
- pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/JSON/Schema.py
ADDED
|
@@ -0,0 +1,2506 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyjo.JSON.Schema - JSON Schema from type hints, and its validation
|
|
3
|
+
==================================================================
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
from Pyjo.JSON.Schema import Schema, z
|
|
7
|
+
|
|
8
|
+
def weather(city: str, days: int = 3) -> dict[str, float]:
|
|
9
|
+
\"\"\"Forecast of the weather.
|
|
10
|
+
|
|
11
|
+
:param city: Name of the city.
|
|
12
|
+
:param days: Number of days.
|
|
13
|
+
\"\"\"
|
|
14
|
+
|
|
15
|
+
print(Schema.parameters(weather).data)
|
|
16
|
+
print(Schema.returns(weather).data)
|
|
17
|
+
|
|
18
|
+
print(z.object(city=z.string().describe('Name of the city'), days=z.int().min(1).optional()).data)
|
|
19
|
+
|
|
20
|
+
result = Schema.parameters(weather).validate({'city': 'Warsaw', 'days': 'three'})
|
|
21
|
+
if not result:
|
|
22
|
+
print(result)
|
|
23
|
+
|
|
24
|
+
# Or import the module, as in Google Python Style Guide
|
|
25
|
+
|
|
26
|
+
import Pyjo.JSON.Schema
|
|
27
|
+
|
|
28
|
+
print(Pyjo.JSON.Schema.of(list[int]).data)
|
|
29
|
+
print(Pyjo.JSON.Schema.new({'type': 'string'}).data)
|
|
30
|
+
|
|
31
|
+
:mod:`Pyjo.JSON.Schema` makes `JSON Schema 2020-12
|
|
32
|
+
<https://json-schema.org/draft/2020-12>`_ from the type hints of Python, from
|
|
33
|
+
the annotations of a type or of the parameters and the return value of a
|
|
34
|
+
function, with the descriptions of its docstring, or builds it with a chain of
|
|
35
|
+
methods, after `zod <https://zod.dev>`_, so that the schema of a tool of
|
|
36
|
+
JavaScript ports as it is. Both give an object of :mod:`Pyjo.JSON.Schema`,
|
|
37
|
+
which holds the schema in :attr:`~Pyjo_JSON_Schema.data`, as :mod:`Pyjo.JSON`
|
|
38
|
+
encodes it, and so does a schema written by hand.
|
|
39
|
+
|
|
40
|
+
The types of the type hints, and the schemas that they give:
|
|
41
|
+
|
|
42
|
+
* ``bool``, ``int``, ``float``, ``str`` and ``None`` are ``boolean``,
|
|
43
|
+
``integer``, ``number``, ``string`` and ``null``, and ``Any`` and ``object``
|
|
44
|
+
are ``{}``, any value;
|
|
45
|
+
* ``list[T]``, ``set[T]`` and ``frozenset[T]`` are an ``array`` of ``items``,
|
|
46
|
+
the sets with ``uniqueItems``, ``tuple[A, B]`` an ``array`` of
|
|
47
|
+
``prefixItems``, and ``tuple[T, ...]`` one of ``items``;
|
|
48
|
+
* ``dict[str, T]`` is an ``object`` of ``additionalProperties``;
|
|
49
|
+
* ``T | None`` and ``Optional[T]`` add ``null`` to the ``type`` of a schema
|
|
50
|
+
that has only a type, and are an ``anyOf`` with ``null`` otherwise, and other
|
|
51
|
+
unions are ``anyOf``;
|
|
52
|
+
* ``Literal`` is a ``const`` or an ``enum``, and so is an :class:`enum.Enum` of
|
|
53
|
+
its values, with their ``type`` where they have one;
|
|
54
|
+
* a ``TypedDict`` is an ``object`` whose ``required`` are its required keys,
|
|
55
|
+
closed with ``closed=True`` of :pep:`728`, and a dataclass one whose
|
|
56
|
+
``required`` are the fields without a default;
|
|
57
|
+
* :class:`datetime.datetime`, :class:`datetime.date`, :class:`datetime.time`,
|
|
58
|
+
:class:`uuid.UUID` and :mod:`Pyjo.URL` are a ``string`` with the ``format``
|
|
59
|
+
``date-time``, ``date``, ``time``, ``uuid`` and ``uri``;
|
|
60
|
+
* ``Annotated[T, ...]`` adds a string as the ``description``, and the keywords
|
|
61
|
+
of a :class:`dict`, as ``minimum`` and ``pattern``, or of a schema, as
|
|
62
|
+
``z.email()``, without a class of a library of validation;
|
|
63
|
+
* a ``type`` alias is its value, and a ``NewType`` its type.
|
|
64
|
+
|
|
65
|
+
A ``TypedDict``, a dataclass, an :class:`enum.Enum` or a ``type`` alias that is
|
|
66
|
+
used more than once, or a type that refers to itself, goes to ``$defs``, where
|
|
67
|
+
a ``$ref`` refers to it. A schema that refers to itself refers to its root with
|
|
68
|
+
``#``, so that the root stays an ``object``, as the ``inputSchema`` of a tool
|
|
69
|
+
of MCP has to be. The description of a ``TypedDict``, a dataclass or an
|
|
70
|
+
:class:`enum.Enum` is the first paragraph of its docstring.
|
|
71
|
+
|
|
72
|
+
A type that has no schema, as ``bytes``, a ``TypeVar`` or a class of its own,
|
|
73
|
+
fails with a :mod:`Pyjo.Exception` that names it and where it is. The
|
|
74
|
+
annotations are those that :func:`typing.get_type_hints` gives, lazy as of
|
|
75
|
+
:pep:`649`, or strings with ``from __future__ import annotations``, so a name
|
|
76
|
+
that the module of a type does not have fails too.
|
|
77
|
+
|
|
78
|
+
A schema has no ``$schema``, as the schemas of MCP and OpenAPI, whose dialect is
|
|
79
|
+
known. :data:`DIALECT` names it on request, with
|
|
80
|
+
:meth:`~Pyjo_JSON_Schema.meta`. ::
|
|
81
|
+
|
|
82
|
+
# {'$schema': 'https://json-schema.org/draft/2020-12/schema', 'type': 'string'}
|
|
83
|
+
z.string().meta({'$schema': Pyjo.JSON.Schema.DIALECT}).data
|
|
84
|
+
|
|
85
|
+
The builder is a subset of zod, what JSON Schema has, without its
|
|
86
|
+
transformations, refinements, coercions and the types that are inferred from a
|
|
87
|
+
schema. Its methods are those of zod in the names of Python, ``multiple_of``
|
|
88
|
+
for ``multipleOf``, and ``z`` is the class itself, so that
|
|
89
|
+
``z.object({...})`` reads as in JavaScript. An ``object`` is as zod gives it
|
|
90
|
+
for the input, which a tool gets: a property that has a ``default`` is not
|
|
91
|
+
required, and the properties that it does not name are not forbidden, unless
|
|
92
|
+
the object is strict. Where zod gives another schema of the same, the method
|
|
93
|
+
says so.
|
|
94
|
+
|
|
95
|
+
Validation
|
|
96
|
+
----------
|
|
97
|
+
|
|
98
|
+
:meth:`~Pyjo_JSON_Schema.validate` validates a value, as :mod:`Pyjo.JSON`
|
|
99
|
+
decodes it, with a schema of any of the three forms, and gives a
|
|
100
|
+
:class:`Result`, which is false if the value is not valid, with the errors of
|
|
101
|
+
the value, each with the JSON Pointer of the value and of the keyword. ::
|
|
102
|
+
|
|
103
|
+
# /days: Expected integer - got string.
|
|
104
|
+
print(z.object(days=z.int()).validate({'days': 'three'}))
|
|
105
|
+
|
|
106
|
+
It is the validation of JSON Schema 2020-12 with the keywords that the schema of
|
|
107
|
+
a tool, of a body of an API or of a configuration has:
|
|
108
|
+
|
|
109
|
+
* ``type``, ``enum`` and ``const``;
|
|
110
|
+
* ``minimum``, ``maximum``, ``exclusiveMinimum``, ``exclusiveMaximum`` and
|
|
111
|
+
``multipleOf`` of numbers;
|
|
112
|
+
* ``minLength``, ``maxLength``, ``pattern`` and ``format`` of strings;
|
|
113
|
+
* ``items``, ``prefixItems``, ``minItems``, ``maxItems``, ``uniqueItems``,
|
|
114
|
+
``contains``, ``minContains`` and ``maxContains`` of arrays;
|
|
115
|
+
* ``properties``, ``required``, ``additionalProperties``,
|
|
116
|
+
``patternProperties``, ``propertyNames``, ``minProperties``,
|
|
117
|
+
``maxProperties``, ``dependentRequired`` and ``dependentSchemas`` of
|
|
118
|
+
objects;
|
|
119
|
+
* ``allOf``, ``anyOf``, ``oneOf``, ``not``, ``if``, ``then`` and ``else``;
|
|
120
|
+
* ``$ref`` to the schemas of the document, by a JSON Pointer, as to ``$defs``,
|
|
121
|
+
by their ``$anchor`` and by the ``$id`` of a schema in the schema, also to
|
|
122
|
+
itself, and to the schemas of the registry of
|
|
123
|
+
:meth:`~Pyjo_JSON_Schema.add_schema`, which it never downloads.
|
|
124
|
+
|
|
125
|
+
The keywords of 2020-12 that it does not support, ``$dynamicRef``,
|
|
126
|
+
``unevaluatedItems`` and ``unevaluatedProperties``, and a ``$schema`` of
|
|
127
|
+
another dialect, fail with a :mod:`Pyjo.Exception`, as a ``$ref`` to a schema
|
|
128
|
+
that it does not have does, and a keyword that 2020-12 does not have, as
|
|
129
|
+
``additionalItems`` of the older drafts, is ignored, as the specification says
|
|
130
|
+
of a keyword that it does not know. The annotations, as ``title``,
|
|
131
|
+
``default`` and ``contentMediaType``, validate nothing.
|
|
132
|
+
|
|
133
|
+
The values are those of JSON: ``bool`` is not an ``integer`` nor a ``number``,
|
|
134
|
+
a ``float`` without a fraction is an ``integer``, ``1`` and ``1.0`` are equal
|
|
135
|
+
for ``enum``, ``const`` and ``uniqueItems``, ``True`` and ``1`` are not, and a
|
|
136
|
+
``tuple`` is an ``array``. A value that JSON does not have, as ``bytes``, a
|
|
137
|
+
``set``, ``nan`` or a name of a property that is not a string, is an error of
|
|
138
|
+
its own, whatever the schema.
|
|
139
|
+
|
|
140
|
+
``format`` is an annotation, as JSON Schema has it by default, and an
|
|
141
|
+
assertion with ``formats``, for the formats ``date-time``, ``date``, ``time``
|
|
142
|
+
and ``duration`` of RFC 3339, ``email``, ``hostname``, ``ipv4``, ``ipv6``,
|
|
143
|
+
``uri``, ``uri-reference``, ``uuid``, ``regex``, ``json-pointer`` and
|
|
144
|
+
``relative-json-pointer``. Another format, as ``idn-email``, ``iri`` or
|
|
145
|
+
``uri-template``, stays an annotation.
|
|
146
|
+
|
|
147
|
+
A ``pattern`` is a regular expression of ECMAScript, which is one of
|
|
148
|
+
:mod:`re` with these translations: ``\\d``, ``\\w`` and ``\\b`` are of ASCII,
|
|
149
|
+
``\\s`` is the white space of ECMAScript, ``.`` matches no line terminator,
|
|
150
|
+
``$`` matches only at the end, a named group is ``(?<name>...)`` and its
|
|
151
|
+
backreference ``\\k<name>``, ``\\p{...}`` is a general category of Unicode,
|
|
152
|
+
or ``ASCII``, ``Any`` and ``Assigned``, and ``\\cX``, ``\\u{...}``, ``[]``
|
|
153
|
+
and ``[^]`` are as ECMAScript has them. What :mod:`re` does not have, as a
|
|
154
|
+
lookbehind of a variable width or ``\\p{Script=...}``, and the syntax of
|
|
155
|
+
Python that ECMAScript does not have, as ``(?P<name>...)``, fail with a
|
|
156
|
+
:mod:`Pyjo.Exception`.
|
|
157
|
+
|
|
158
|
+
See also
|
|
159
|
+
--------
|
|
160
|
+
|
|
161
|
+
:mod:`Pyjo.JSON`, :mod:`Pyjo.JSON.Pointer`, https://mojolicious.org.
|
|
162
|
+
|
|
163
|
+
It has no counterpart in Mojolicious, it is written from the specification of
|
|
164
|
+
JSON Schema 2020-12, its builder is inspired by `zod <https://zod.dev>`_, and
|
|
165
|
+
its validation is tested with the official test suite of JSON Schema,
|
|
166
|
+
`JSON-Schema-Test-Suite <https://github.com/json-schema-org/JSON-Schema-Test-Suite>`_.
|
|
167
|
+
|
|
168
|
+
Classes
|
|
169
|
+
-------
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
import Pyjo.Base
|
|
173
|
+
import Pyjo.Collection
|
|
174
|
+
import Pyjo.Exception
|
|
175
|
+
import Pyjo.JSON
|
|
176
|
+
import Pyjo.JSON.Pointer
|
|
177
|
+
import Pyjo.URL
|
|
178
|
+
|
|
179
|
+
from Pyjo.JSON import to_json
|
|
180
|
+
from Pyjo.Util import punycode_decode
|
|
181
|
+
|
|
182
|
+
import builtins
|
|
183
|
+
import collections
|
|
184
|
+
import contextlib
|
|
185
|
+
import dataclasses
|
|
186
|
+
import datetime
|
|
187
|
+
import enum
|
|
188
|
+
import fractions
|
|
189
|
+
import functools
|
|
190
|
+
import inspect
|
|
191
|
+
import ipaddress
|
|
192
|
+
import math
|
|
193
|
+
import re
|
|
194
|
+
import sys
|
|
195
|
+
import types
|
|
196
|
+
import typing
|
|
197
|
+
import unicodedata
|
|
198
|
+
import uuid
|
|
199
|
+
|
|
200
|
+
from collections.abc import Callable, Iterable
|
|
201
|
+
from typing import Any, Self, cast
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
type Anything = builtins.object
|
|
205
|
+
|
|
206
|
+
DIALECT = 'https://json-schema.org/draft/2020-12/schema'
|
|
207
|
+
"""The dialect of the schemas, JSON Schema 2020-12, as ``$schema`` names it."""
|
|
208
|
+
|
|
209
|
+
# The keywords of a constraint for each type, as min() and max() set them
|
|
210
|
+
_MIN = {'string': 'minLength', 'number': 'minimum', 'integer': 'minimum', 'array': 'minItems'}
|
|
211
|
+
_MAX = {'string': 'maxLength', 'number': 'maximum', 'integer': 'maximum', 'array': 'maxItems'}
|
|
212
|
+
|
|
213
|
+
# The qualifiers of the keys of a TypedDict, ReadOnly of Python 3.13 too
|
|
214
|
+
_QUALIFIERS = tuple(
|
|
215
|
+
q for q in (typing.Required, typing.NotRequired, getattr(typing, 'ReadOnly', None)) if q is not None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# The formats of the types of the standard library
|
|
219
|
+
_FORMATS: dict[type, str] = {
|
|
220
|
+
datetime.datetime: 'date-time',
|
|
221
|
+
datetime.date: 'date',
|
|
222
|
+
datetime.time: 'time',
|
|
223
|
+
uuid.UUID: 'uuid',
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# The sections of a docstring of the Google Python Style Guide
|
|
227
|
+
_RE_ARGS = re.compile(r'(?:Args|Arguments|Parameters|Params|Keyword Args|Keyword Arguments):')
|
|
228
|
+
_RE_RETURNS = re.compile(r'(?:Returns|Return|Yields):')
|
|
229
|
+
_RE_SECTION = re.compile(r'[A-Z][A-Za-z ]*:')
|
|
230
|
+
_RE_FIELD = re.compile(r':(param|parameter|arg|argument|key|keyword|returns?|type|rtype|raises?)\b([^:]*):\s*(.*)')
|
|
231
|
+
_RE_ITEM = re.compile(r'\*{0,2}(\w+)(?:\s*\([^)]*\))?:\s*(.*)')
|
|
232
|
+
_RE_RETURN_TYPE = re.compile(r'[\w.\[\], |]+:\s+(.*)')
|
|
233
|
+
|
|
234
|
+
_MISSING = builtins.object()
|
|
235
|
+
|
|
236
|
+
# The names of the classes that the class hides, as its methods int, enum and object do in the lazy annotations of
|
|
237
|
+
# PEP 649
|
|
238
|
+
type _Enum = enum.Enum
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class _stringmethod[S]:
|
|
242
|
+
"""A modifier of a string that the class has too, for a new string, as ``z.email()`` of zod."""
|
|
243
|
+
|
|
244
|
+
def __init__(self, func: Callable[[S], S]) -> None:
|
|
245
|
+
self.func = func
|
|
246
|
+
self.__doc__ = func.__doc__
|
|
247
|
+
|
|
248
|
+
def __get__(self, obj: S | None, objtype: Any = None) -> Callable[[], S]:
|
|
249
|
+
def bound() -> S:
|
|
250
|
+
return self.func(obj if obj is not None else objtype.string())
|
|
251
|
+
|
|
252
|
+
bound.__doc__ = self.__doc__
|
|
253
|
+
return bound
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Pyjo_JSON_Schema(Pyjo.Base.object):
|
|
257
|
+
"""
|
|
258
|
+
:mod:`Pyjo.JSON.Schema` inherits all methods from :mod:`Pyjo.Base` and
|
|
259
|
+
implements the following new ones.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
_keywords: dict[str, Any] | bool
|
|
263
|
+
_optional: bool
|
|
264
|
+
_lazy: '_Lazy | None'
|
|
265
|
+
_data: dict[str, Any] | bool | None
|
|
266
|
+
_schemas: 'dict[str, Pyjo_JSON_Schema | dict[str, Any] | bool]'
|
|
267
|
+
_index: '_Index | None'
|
|
268
|
+
|
|
269
|
+
def __init__(self, data: dict[str, Any] | bool | None = None) -> None:
|
|
270
|
+
"""::
|
|
271
|
+
|
|
272
|
+
schema = Pyjo.JSON.Schema.new()
|
|
273
|
+
schema = Pyjo.JSON.Schema.new({'type': 'string', 'minLength': 1})
|
|
274
|
+
|
|
275
|
+
Build new :mod:`Pyjo.JSON.Schema` object, of a schema written by hand,
|
|
276
|
+
which can hold other objects of :mod:`Pyjo.JSON.Schema`, or of any value
|
|
277
|
+
without arguments.
|
|
278
|
+
"""
|
|
279
|
+
self._keywords = {} if data is None else data
|
|
280
|
+
self._optional = False
|
|
281
|
+
self._lazy = None
|
|
282
|
+
self._data = None
|
|
283
|
+
self._schemas = {}
|
|
284
|
+
self._index = None
|
|
285
|
+
|
|
286
|
+
self.name: str | None = None
|
|
287
|
+
"""::
|
|
288
|
+
|
|
289
|
+
name = schema.name
|
|
290
|
+
schema.name = 'Category'
|
|
291
|
+
|
|
292
|
+
The name of the schema in ``$defs``, where it goes when a schema holds
|
|
293
|
+
it more than once, as the name of its type, or ``Schema`` and a number
|
|
294
|
+
without a name.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __json__(self) -> dict[str, Any] | bool:
|
|
298
|
+
"""::
|
|
299
|
+
|
|
300
|
+
data = schema.__json__()
|
|
301
|
+
|
|
302
|
+
The schema as :mod:`Pyjo.JSON` encodes it, :attr:`data`.
|
|
303
|
+
"""
|
|
304
|
+
return self.data
|
|
305
|
+
|
|
306
|
+
def __repr__(self) -> str:
|
|
307
|
+
return f'{type(self).__module__}.{type(self).__qualname__}({self.data!r})'
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def data(self) -> dict[str, Any] | bool:
|
|
311
|
+
"""::
|
|
312
|
+
|
|
313
|
+
data = schema.data
|
|
314
|
+
|
|
315
|
+
The schema as a :class:`dict`, with ``$defs`` for the types that are used
|
|
316
|
+
more than once or refer to themselves, or ``True`` or ``False`` for a
|
|
317
|
+
schema of any value or of none. ::
|
|
318
|
+
|
|
319
|
+
# {'type': 'array', 'items': {'type': 'integer'}}
|
|
320
|
+
Pyjo.JSON.Schema.of(list[int]).data
|
|
321
|
+
"""
|
|
322
|
+
if self._data is None:
|
|
323
|
+
self._data = _Emitter(self).emit()
|
|
324
|
+
return self._data
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def is_optional(self) -> bool:
|
|
328
|
+
"""::
|
|
329
|
+
|
|
330
|
+
boolean = schema.is_optional
|
|
331
|
+
|
|
332
|
+
True if the schema is of a property that an object does not require, as
|
|
333
|
+
:meth:`optional` and :meth:`default` make it.
|
|
334
|
+
"""
|
|
335
|
+
return self._optional
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def is_lazy(self) -> bool:
|
|
339
|
+
"""::
|
|
340
|
+
|
|
341
|
+
boolean = schema.is_lazy
|
|
342
|
+
|
|
343
|
+
True if the schema is the one that a function gives, as :meth:`lazy`
|
|
344
|
+
makes it.
|
|
345
|
+
"""
|
|
346
|
+
return self._lazy is not None
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def keywords(self) -> dict[str, Any] | bool:
|
|
350
|
+
"""::
|
|
351
|
+
|
|
352
|
+
keywords = schema.keywords
|
|
353
|
+
|
|
354
|
+
The keywords of the schema, which can hold other objects of
|
|
355
|
+
:mod:`Pyjo.JSON.Schema`, as :attr:`data` has them without them.
|
|
356
|
+
"""
|
|
357
|
+
return self._keywords
|
|
358
|
+
|
|
359
|
+
def resolve(self) -> 'Pyjo_JSON_Schema':
|
|
360
|
+
"""::
|
|
361
|
+
|
|
362
|
+
schema = schema.resolve()
|
|
363
|
+
|
|
364
|
+
The schema that the function of a lazy schema gives, or the schema
|
|
365
|
+
itself.
|
|
366
|
+
"""
|
|
367
|
+
return self._lazy.resolve(self) if self._lazy is not None else self
|
|
368
|
+
|
|
369
|
+
# Types
|
|
370
|
+
|
|
371
|
+
@classmethod
|
|
372
|
+
def of(cls, tp: Any) -> Self:
|
|
373
|
+
"""::
|
|
374
|
+
|
|
375
|
+
schema = Pyjo.JSON.Schema.of(list[int])
|
|
376
|
+
schema = Pyjo.JSON.Schema.of(Point)
|
|
377
|
+
|
|
378
|
+
The schema of a type of the type hints, or the schema itself, or a schema
|
|
379
|
+
written by hand as a :class:`dict`. ::
|
|
380
|
+
|
|
381
|
+
# {'type': ['string', 'null']}
|
|
382
|
+
Pyjo.JSON.Schema.of(str | None).data
|
|
383
|
+
|
|
384
|
+
# {'type': 'object', 'additionalProperties': {'type': 'number'}}
|
|
385
|
+
Pyjo.JSON.Schema.of(dict[str, float]).data
|
|
386
|
+
"""
|
|
387
|
+
return cast(Self, _Compiler(cls).compile(tp, ''))
|
|
388
|
+
|
|
389
|
+
@classmethod
|
|
390
|
+
def parameters(cls, function: Callable[..., Any], skip: int = 0) -> Self:
|
|
391
|
+
"""::
|
|
392
|
+
|
|
393
|
+
schema = Pyjo.JSON.Schema.parameters(tool)
|
|
394
|
+
schema = Pyjo.JSON.Schema.parameters(tool, skip=1)
|
|
395
|
+
|
|
396
|
+
The schema of the parameters of a function, as the ``inputSchema`` of a
|
|
397
|
+
tool, an ``object`` whose ``properties`` are the parameters, with the
|
|
398
|
+
ones without a default ``required``, and the first paragraph of its
|
|
399
|
+
docstring as its ``description``. The description of a parameter is the
|
|
400
|
+
one of the field ``:param x:`` of its docstring, in reStructuredText, or
|
|
401
|
+
of the section ``Args:`` of the Google Python Style Guide.
|
|
402
|
+
|
|
403
|
+
``skip`` leaves out the first parameters, as the context or the
|
|
404
|
+
controller that a tool or an action gets before its arguments; ``self``
|
|
405
|
+
of a bound method is already gone. ``**kwargs: T`` gives the
|
|
406
|
+
``additionalProperties`` of the schema of ``T``, and without it the
|
|
407
|
+
object has no other properties. A parameter without an annotation is of
|
|
408
|
+
any value. ``*args`` and a parameter that is only positional fail, as an
|
|
409
|
+
object of JSON has no members without a name. ::
|
|
410
|
+
|
|
411
|
+
def weather(c, city: str, days: int = 3):
|
|
412
|
+
\"\"\"Forecast of the weather.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
city: Name of the city.
|
|
416
|
+
\"\"\"
|
|
417
|
+
|
|
418
|
+
# {'type': 'object', 'description': 'Forecast of the weather.',
|
|
419
|
+
# 'properties': {'city': {'type': 'string', 'description': 'Name of the city.'},
|
|
420
|
+
# 'days': {'type': 'integer', 'default': 3}},
|
|
421
|
+
# 'required': ['city'], 'additionalProperties': False}
|
|
422
|
+
Pyjo.JSON.Schema.parameters(weather, skip=1).data
|
|
423
|
+
"""
|
|
424
|
+
return cast(Self, _Compiler(cls).parameters(function, skip))
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def returns(cls, function: Callable[..., Any]) -> Self | None:
|
|
428
|
+
"""::
|
|
429
|
+
|
|
430
|
+
schema = Pyjo.JSON.Schema.returns(tool)
|
|
431
|
+
|
|
432
|
+
The schema of the return value of a function, as the ``outputSchema`` of
|
|
433
|
+
a tool, with the description of the field ``:returns:`` of its
|
|
434
|
+
docstring, or of its section ``Returns:``, or ``None`` for a function
|
|
435
|
+
without the annotation of its return value. ::
|
|
436
|
+
|
|
437
|
+
def forecast(city: str) -> list[float]:
|
|
438
|
+
\"\"\"Temperatures.
|
|
439
|
+
|
|
440
|
+
:returns: One for each day.
|
|
441
|
+
\"\"\"
|
|
442
|
+
|
|
443
|
+
# {'type': 'array', 'items': {'type': 'number'}, 'description': 'One for each day.'}
|
|
444
|
+
Pyjo.JSON.Schema.returns(forecast).data
|
|
445
|
+
"""
|
|
446
|
+
return cast(Self | None, _Compiler(cls).returns(function))
|
|
447
|
+
|
|
448
|
+
# Builder
|
|
449
|
+
|
|
450
|
+
@classmethod
|
|
451
|
+
def string(cls) -> Self:
|
|
452
|
+
"""::
|
|
453
|
+
|
|
454
|
+
schema = z.string()
|
|
455
|
+
|
|
456
|
+
The schema of a string. ::
|
|
457
|
+
|
|
458
|
+
# {'type': 'string', 'minLength': 1, 'maxLength': 5}
|
|
459
|
+
z.string().min(1).max(5).data
|
|
460
|
+
"""
|
|
461
|
+
return cls({'type': 'string'})
|
|
462
|
+
|
|
463
|
+
@classmethod
|
|
464
|
+
def number(cls) -> Self:
|
|
465
|
+
"""::
|
|
466
|
+
|
|
467
|
+
schema = z.number()
|
|
468
|
+
|
|
469
|
+
The schema of a number. ::
|
|
470
|
+
|
|
471
|
+
# {'type': 'number', 'exclusiveMinimum': 0}
|
|
472
|
+
z.number().gt(0).data
|
|
473
|
+
"""
|
|
474
|
+
return cls({'type': 'number'})
|
|
475
|
+
|
|
476
|
+
@classmethod
|
|
477
|
+
def boolean(cls) -> Self:
|
|
478
|
+
"""::
|
|
479
|
+
|
|
480
|
+
schema = z.boolean()
|
|
481
|
+
|
|
482
|
+
The schema of ``true`` or ``false``.
|
|
483
|
+
"""
|
|
484
|
+
return cls({'type': 'boolean'})
|
|
485
|
+
|
|
486
|
+
@classmethod
|
|
487
|
+
def null(cls) -> Self:
|
|
488
|
+
"""::
|
|
489
|
+
|
|
490
|
+
schema = z.null()
|
|
491
|
+
|
|
492
|
+
The schema of ``null``.
|
|
493
|
+
"""
|
|
494
|
+
return cls({'type': 'null'})
|
|
495
|
+
|
|
496
|
+
@classmethod
|
|
497
|
+
def literal(cls, value: Any) -> Self:
|
|
498
|
+
"""::
|
|
499
|
+
|
|
500
|
+
schema = z.literal('celsius')
|
|
501
|
+
schema = z.literal(['celsius', 'fahrenheit'])
|
|
502
|
+
|
|
503
|
+
The schema of a value, as ``const``, or of one of a list of values, as
|
|
504
|
+
``enum``, with their ``type`` where they have one. ::
|
|
505
|
+
|
|
506
|
+
# {'type': 'string', 'const': 'celsius'}
|
|
507
|
+
z.literal('celsius').data
|
|
508
|
+
"""
|
|
509
|
+
values = cast(list[Any], value) if isinstance(value, (list, tuple)) else [value]
|
|
510
|
+
return cls(_literal(values))
|
|
511
|
+
|
|
512
|
+
@classmethod
|
|
513
|
+
def union(cls, options: Iterable[Any]) -> Self:
|
|
514
|
+
"""::
|
|
515
|
+
|
|
516
|
+
schema = z.union([z.string(), z.int()])
|
|
517
|
+
|
|
518
|
+
The schema of a value of one of the options, as ``anyOf``.
|
|
519
|
+
"""
|
|
520
|
+
return cls({'anyOf': [cls.of(option) for option in options]})
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def array(cls, element: Any = None) -> Self:
|
|
524
|
+
"""::
|
|
525
|
+
|
|
526
|
+
schema = z.array(z.string())
|
|
527
|
+
|
|
528
|
+
The schema of an array of elements, of any value without them. ::
|
|
529
|
+
|
|
530
|
+
# {'type': 'array', 'items': {'type': 'string'}, 'minItems': 1}
|
|
531
|
+
z.array(z.string()).nonempty().data
|
|
532
|
+
"""
|
|
533
|
+
keywords: dict[str, Any] = {'type': 'array'}
|
|
534
|
+
if element is not None:
|
|
535
|
+
keywords['items'] = cls.of(element)
|
|
536
|
+
return cls(keywords)
|
|
537
|
+
|
|
538
|
+
@classmethod
|
|
539
|
+
def tuple(cls, elements: Iterable[Any], rest: Any = None) -> Self:
|
|
540
|
+
"""::
|
|
541
|
+
|
|
542
|
+
schema = z.tuple([z.string(), z.int()])
|
|
543
|
+
schema = z.tuple([z.string()], z.int())
|
|
544
|
+
|
|
545
|
+
The schema of an array of the elements, each of its own, and of the
|
|
546
|
+
elements of ``rest`` after them, if any. zod also gives ``items: false``
|
|
547
|
+
to a tuple without ``rest``, which ``maxItems`` already says. ::
|
|
548
|
+
|
|
549
|
+
# {'type': 'array', 'prefixItems': [{'type': 'string'}, {'type': 'integer'}],
|
|
550
|
+
# 'minItems': 2, 'maxItems': 2}
|
|
551
|
+
z.tuple([z.string(), z.int()]).data
|
|
552
|
+
"""
|
|
553
|
+
prefix = [cls.of(element) for element in elements]
|
|
554
|
+
keywords: dict[str, Any] = {'type': 'array', 'prefixItems': prefix, 'minItems': len(prefix)}
|
|
555
|
+
if rest is None:
|
|
556
|
+
keywords['maxItems'] = len(prefix)
|
|
557
|
+
else:
|
|
558
|
+
keywords['items'] = cls.of(rest)
|
|
559
|
+
return cls(keywords)
|
|
560
|
+
|
|
561
|
+
@classmethod
|
|
562
|
+
def record(cls, key: Any, value: Any = None) -> Self:
|
|
563
|
+
"""::
|
|
564
|
+
|
|
565
|
+
schema = z.record(z.string(), z.int())
|
|
566
|
+
schema = z.record(z.int())
|
|
567
|
+
|
|
568
|
+
The schema of an object whose properties are all of a value, with the
|
|
569
|
+
schema of their names where it is not any string. zod also gives
|
|
570
|
+
``propertyNames: {'type': 'string'}``, which every name of JSON is. ::
|
|
571
|
+
|
|
572
|
+
# {'type': 'object', 'additionalProperties': {'type': 'integer'}}
|
|
573
|
+
z.record(z.string(), z.int()).data
|
|
574
|
+
"""
|
|
575
|
+
if value is None:
|
|
576
|
+
key, value = None, key
|
|
577
|
+
keywords: dict[str, Any] = {'type': 'object', 'additionalProperties': cls.of(value)}
|
|
578
|
+
if key is not None:
|
|
579
|
+
names = cls.of(key)
|
|
580
|
+
if names._keywords != {'type': 'string'}:
|
|
581
|
+
keywords['propertyNames'] = names
|
|
582
|
+
return cls(keywords)
|
|
583
|
+
|
|
584
|
+
@classmethod
|
|
585
|
+
def strict_object(cls, shape: dict[str, Any] | None = None, /, **kwargs: Any) -> Self:
|
|
586
|
+
"""::
|
|
587
|
+
|
|
588
|
+
schema = z.strict_object({'city': z.string()})
|
|
589
|
+
|
|
590
|
+
The schema of an object without other properties, :meth:`object` and
|
|
591
|
+
:meth:`strict`.
|
|
592
|
+
"""
|
|
593
|
+
return cls.object(shape, **kwargs).strict()
|
|
594
|
+
|
|
595
|
+
@classmethod
|
|
596
|
+
def loose_object(cls, shape: dict[str, Any] | None = None, /, **kwargs: Any) -> Self:
|
|
597
|
+
"""::
|
|
598
|
+
|
|
599
|
+
schema = z.loose_object({'city': z.string()})
|
|
600
|
+
|
|
601
|
+
The schema of an object that allows other properties, :meth:`object` and
|
|
602
|
+
:meth:`loose`.
|
|
603
|
+
"""
|
|
604
|
+
return cls.object(shape, **kwargs).loose()
|
|
605
|
+
|
|
606
|
+
@classmethod
|
|
607
|
+
def lazy(cls, getter: Callable[[], Any]) -> Self:
|
|
608
|
+
"""::
|
|
609
|
+
|
|
610
|
+
schema = z.lazy(lambda: category)
|
|
611
|
+
|
|
612
|
+
The schema that a function gives when :attr:`data` is made, so that a
|
|
613
|
+
schema can refer to itself, or to another one that is made after it. ::
|
|
614
|
+
|
|
615
|
+
category = z.object(name=z.string(), subcategories=z.array(z.lazy(lambda: category)))
|
|
616
|
+
|
|
617
|
+
# {'type': 'object',
|
|
618
|
+
# 'properties': {'name': {'type': 'string'},
|
|
619
|
+
# 'subcategories': {'type': 'array', 'items': {'$ref': '#'}}},
|
|
620
|
+
# 'required': ['name', 'subcategories']}
|
|
621
|
+
category.data
|
|
622
|
+
"""
|
|
623
|
+
schema = cls({})
|
|
624
|
+
schema._lazy = _Lazy(getter)
|
|
625
|
+
return schema
|
|
626
|
+
|
|
627
|
+
# Modifiers
|
|
628
|
+
|
|
629
|
+
def min(self, value: float) -> Self:
|
|
630
|
+
"""::
|
|
631
|
+
|
|
632
|
+
schema = schema.min(1)
|
|
633
|
+
|
|
634
|
+
The least length of a string, the least value of a number, or the least
|
|
635
|
+
number of elements of an array, as ``minLength``, ``minimum`` and
|
|
636
|
+
``minItems``.
|
|
637
|
+
"""
|
|
638
|
+
return self._constraint(_MIN, value, 'min')
|
|
639
|
+
|
|
640
|
+
def max(self, value: float) -> Self:
|
|
641
|
+
"""::
|
|
642
|
+
|
|
643
|
+
schema = schema.max(5)
|
|
644
|
+
|
|
645
|
+
The greatest length of a string, the greatest value of a number, or the
|
|
646
|
+
greatest number of elements of an array, as ``maxLength``, ``maximum``
|
|
647
|
+
and ``maxItems``.
|
|
648
|
+
"""
|
|
649
|
+
return self._constraint(_MAX, value, 'max')
|
|
650
|
+
|
|
651
|
+
def gte(self, value: float) -> Self:
|
|
652
|
+
"""::
|
|
653
|
+
|
|
654
|
+
schema = schema.gte(1)
|
|
655
|
+
|
|
656
|
+
The same as :meth:`min`.
|
|
657
|
+
"""
|
|
658
|
+
return self.min(value)
|
|
659
|
+
|
|
660
|
+
def lte(self, value: float) -> Self:
|
|
661
|
+
"""::
|
|
662
|
+
|
|
663
|
+
schema = schema.lte(5)
|
|
664
|
+
|
|
665
|
+
The same as :meth:`max`.
|
|
666
|
+
"""
|
|
667
|
+
return self.max(value)
|
|
668
|
+
|
|
669
|
+
def length(self, value: builtins.int) -> Self:
|
|
670
|
+
"""::
|
|
671
|
+
|
|
672
|
+
schema = schema.length(3)
|
|
673
|
+
|
|
674
|
+
The length of a string, or the number of elements of an array.
|
|
675
|
+
"""
|
|
676
|
+
return self.min(value).max(value)
|
|
677
|
+
|
|
678
|
+
def nonempty(self) -> Self:
|
|
679
|
+
"""::
|
|
680
|
+
|
|
681
|
+
schema = schema.nonempty()
|
|
682
|
+
|
|
683
|
+
A string or an array that is not empty, :meth:`min` of 1.
|
|
684
|
+
"""
|
|
685
|
+
return self.min(1)
|
|
686
|
+
|
|
687
|
+
def gt(self, value: float) -> Self:
|
|
688
|
+
"""::
|
|
689
|
+
|
|
690
|
+
schema = schema.gt(0)
|
|
691
|
+
|
|
692
|
+
A number greater than the value, ``exclusiveMinimum``.
|
|
693
|
+
"""
|
|
694
|
+
return self._number('exclusiveMinimum', value, 'gt')
|
|
695
|
+
|
|
696
|
+
def lt(self, value: float) -> Self:
|
|
697
|
+
"""::
|
|
698
|
+
|
|
699
|
+
schema = schema.lt(10)
|
|
700
|
+
|
|
701
|
+
A number less than the value, ``exclusiveMaximum``.
|
|
702
|
+
"""
|
|
703
|
+
return self._number('exclusiveMaximum', value, 'lt')
|
|
704
|
+
|
|
705
|
+
def multiple_of(self, value: float) -> Self:
|
|
706
|
+
"""::
|
|
707
|
+
|
|
708
|
+
schema = schema.multiple_of(2)
|
|
709
|
+
|
|
710
|
+
A number that is a multiple of the value, ``multipleOf``.
|
|
711
|
+
"""
|
|
712
|
+
return self._number('multipleOf', value, 'multiple_of')
|
|
713
|
+
|
|
714
|
+
def regex(self, pattern: str | re.Pattern[str]) -> Self:
|
|
715
|
+
"""::
|
|
716
|
+
|
|
717
|
+
schema = schema.regex(r'^[a-z]+$')
|
|
718
|
+
schema = schema.regex(re.compile(r'^[a-z]+$'))
|
|
719
|
+
|
|
720
|
+
A string that matches a regular expression, ``pattern``, which JSON
|
|
721
|
+
Schema has in the syntax of ECMAScript.
|
|
722
|
+
"""
|
|
723
|
+
return self._string('pattern', pattern.pattern if isinstance(pattern, re.Pattern) else pattern, 'regex')
|
|
724
|
+
|
|
725
|
+
@_stringmethod
|
|
726
|
+
def email(self) -> 'Pyjo_JSON_Schema':
|
|
727
|
+
"""::
|
|
728
|
+
|
|
729
|
+
schema = z.email()
|
|
730
|
+
schema = z.string().email()
|
|
731
|
+
|
|
732
|
+
A string of the format ``email``. zod also gives the ``pattern`` of its
|
|
733
|
+
own regular expression.
|
|
734
|
+
"""
|
|
735
|
+
return self._string('format', 'email', 'email')
|
|
736
|
+
|
|
737
|
+
@_stringmethod
|
|
738
|
+
def url(self) -> 'Pyjo_JSON_Schema':
|
|
739
|
+
"""::
|
|
740
|
+
|
|
741
|
+
schema = z.url()
|
|
742
|
+
schema = z.string().url()
|
|
743
|
+
|
|
744
|
+
A string of the format ``uri``.
|
|
745
|
+
"""
|
|
746
|
+
return self._string('format', 'uri', 'url')
|
|
747
|
+
|
|
748
|
+
@_stringmethod
|
|
749
|
+
def uuid(self) -> 'Pyjo_JSON_Schema':
|
|
750
|
+
"""::
|
|
751
|
+
|
|
752
|
+
schema = z.uuid()
|
|
753
|
+
schema = z.string().uuid()
|
|
754
|
+
|
|
755
|
+
A string of the format ``uuid``. zod also gives the ``pattern`` of its
|
|
756
|
+
own regular expression.
|
|
757
|
+
"""
|
|
758
|
+
return self._string('format', 'uuid', 'uuid')
|
|
759
|
+
|
|
760
|
+
def datetime(self) -> Self:
|
|
761
|
+
"""::
|
|
762
|
+
|
|
763
|
+
schema = schema.datetime()
|
|
764
|
+
|
|
765
|
+
A string of the format ``date-time``, as ``z.iso.datetime()``. zod also
|
|
766
|
+
gives the ``pattern`` of its own regular expression.
|
|
767
|
+
"""
|
|
768
|
+
return self._string('format', 'date-time', 'datetime')
|
|
769
|
+
|
|
770
|
+
def date(self) -> Self:
|
|
771
|
+
"""::
|
|
772
|
+
|
|
773
|
+
schema = schema.date()
|
|
774
|
+
|
|
775
|
+
A string of the format ``date``, as ``z.iso.date()``.
|
|
776
|
+
"""
|
|
777
|
+
return self._string('format', 'date', 'date')
|
|
778
|
+
|
|
779
|
+
def time(self) -> Self:
|
|
780
|
+
"""::
|
|
781
|
+
|
|
782
|
+
schema = schema.time()
|
|
783
|
+
|
|
784
|
+
A string of the format ``time``, as ``z.iso.time()``. zod gives only the
|
|
785
|
+
``pattern`` of its own regular expression.
|
|
786
|
+
"""
|
|
787
|
+
return self._string('format', 'time', 'time')
|
|
788
|
+
|
|
789
|
+
def strict(self) -> Self:
|
|
790
|
+
"""::
|
|
791
|
+
|
|
792
|
+
schema = schema.strict()
|
|
793
|
+
|
|
794
|
+
An object without other properties, ``additionalProperties: false``.
|
|
795
|
+
"""
|
|
796
|
+
return self._object(False, 'strict')
|
|
797
|
+
|
|
798
|
+
def loose(self) -> Self:
|
|
799
|
+
"""::
|
|
800
|
+
|
|
801
|
+
schema = schema.loose()
|
|
802
|
+
|
|
803
|
+
An object that allows other properties, ``additionalProperties: {}``.
|
|
804
|
+
"""
|
|
805
|
+
return self._object({}, 'loose')
|
|
806
|
+
|
|
807
|
+
def passthrough(self) -> Self:
|
|
808
|
+
"""::
|
|
809
|
+
|
|
810
|
+
schema = schema.passthrough()
|
|
811
|
+
|
|
812
|
+
The same as :meth:`loose`.
|
|
813
|
+
"""
|
|
814
|
+
return self.loose()
|
|
815
|
+
|
|
816
|
+
def optional(self) -> Self:
|
|
817
|
+
"""::
|
|
818
|
+
|
|
819
|
+
schema = schema.optional()
|
|
820
|
+
|
|
821
|
+
The schema of a property that an object does not require. ::
|
|
822
|
+
|
|
823
|
+
# {'type': 'object', 'properties': {'days': {'type': 'integer'}}}
|
|
824
|
+
z.object(days=z.int().optional()).data
|
|
825
|
+
"""
|
|
826
|
+
schema = self._copy()
|
|
827
|
+
schema._optional = True
|
|
828
|
+
return schema
|
|
829
|
+
|
|
830
|
+
def nullable(self) -> Self:
|
|
831
|
+
"""::
|
|
832
|
+
|
|
833
|
+
schema = schema.nullable()
|
|
834
|
+
|
|
835
|
+
The schema of the value or of ``null``, in its ``type`` where the
|
|
836
|
+
schema has only a type, and in ``anyOf`` otherwise. ::
|
|
837
|
+
|
|
838
|
+
# {'type': ['string', 'null']}
|
|
839
|
+
z.string().nullable().data
|
|
840
|
+
"""
|
|
841
|
+
schema = type(self)(_nullable(self))
|
|
842
|
+
schema._optional = self._optional
|
|
843
|
+
return schema
|
|
844
|
+
|
|
845
|
+
def nullish(self) -> Self:
|
|
846
|
+
"""::
|
|
847
|
+
|
|
848
|
+
schema = schema.nullish()
|
|
849
|
+
|
|
850
|
+
:meth:`nullable` and :meth:`optional`.
|
|
851
|
+
"""
|
|
852
|
+
return self.nullable().optional()
|
|
853
|
+
|
|
854
|
+
def default(self, value: Any) -> Self:
|
|
855
|
+
"""::
|
|
856
|
+
|
|
857
|
+
schema = schema.default(3)
|
|
858
|
+
|
|
859
|
+
The value of a property that is not given, ``default``, which an object
|
|
860
|
+
does not require, as zod has it for the input.
|
|
861
|
+
"""
|
|
862
|
+
return self.meta(default=value).optional()
|
|
863
|
+
|
|
864
|
+
def describe(self, description: str) -> Self:
|
|
865
|
+
"""::
|
|
866
|
+
|
|
867
|
+
schema = schema.describe('Name of the city')
|
|
868
|
+
|
|
869
|
+
The description of the value, ``description``.
|
|
870
|
+
"""
|
|
871
|
+
return self.meta(description=description)
|
|
872
|
+
|
|
873
|
+
def meta(self, keywords: dict[str, Any] | None = None, /, **kwargs: Any) -> Self:
|
|
874
|
+
"""::
|
|
875
|
+
|
|
876
|
+
schema = schema.meta({'$schema': Pyjo.JSON.Schema.DIALECT})
|
|
877
|
+
schema = schema.meta(title='City', examples=['Warsaw'])
|
|
878
|
+
|
|
879
|
+
Other keywords of the schema. zod keeps them as the metadata of the
|
|
880
|
+
schema too.
|
|
881
|
+
"""
|
|
882
|
+
schema = self._copy()
|
|
883
|
+
schema._keywords = {**cast(dict[str, Any], schema._keywords), **(keywords or {}), **kwargs}
|
|
884
|
+
return schema
|
|
885
|
+
|
|
886
|
+
# Validation
|
|
887
|
+
|
|
888
|
+
def add_schema(self, schema: 'Pyjo_JSON_Schema | dict[str, Any] | bool', uri: str | None = None) -> Self:
|
|
889
|
+
"""::
|
|
890
|
+
|
|
891
|
+
schema = schema.add_schema({'$id': 'https://example.com/address', 'type': 'object'})
|
|
892
|
+
schema = schema.add_schema(z.string(), 'https://example.com/name')
|
|
893
|
+
|
|
894
|
+
Add a schema to the registry of the schema, under its ``$id`` or the
|
|
895
|
+
URI, where a ``$ref`` of the schema finds it, and its ``$id`` and
|
|
896
|
+
``$anchor`` too.
|
|
897
|
+
"""
|
|
898
|
+
if uri is None:
|
|
899
|
+
data = schema.data if isinstance(schema, Pyjo_JSON_Schema) else schema
|
|
900
|
+
uri = data.get('$id') if isinstance(data, dict) else None
|
|
901
|
+
if not isinstance(uri, str):
|
|
902
|
+
raise Pyjo.Exception.new('JSON Schema has no $id of the schema to add')
|
|
903
|
+
self._schemas[_uri('', uri)[0]] = schema
|
|
904
|
+
self._index = None
|
|
905
|
+
return self
|
|
906
|
+
|
|
907
|
+
def parse(self, value: Any, formats: bool = False) -> Any:
|
|
908
|
+
"""::
|
|
909
|
+
|
|
910
|
+
value = schema.parse({'city': 'Warsaw'})
|
|
911
|
+
|
|
912
|
+
The value, if the schema validates it, as :meth:`validate` does, or a
|
|
913
|
+
:mod:`Pyjo.Exception` with the errors, one in each line.
|
|
914
|
+
"""
|
|
915
|
+
result = self.validate(value, formats)
|
|
916
|
+
if not result:
|
|
917
|
+
raise Pyjo.Exception.new(str(result))
|
|
918
|
+
return value
|
|
919
|
+
|
|
920
|
+
def validate(self, value: Any, formats: bool = False) -> 'Result':
|
|
921
|
+
"""::
|
|
922
|
+
|
|
923
|
+
result = schema.validate({'city': 'Warsaw'})
|
|
924
|
+
result = schema.validate({'date': '2026-10-23'}, formats=True)
|
|
925
|
+
|
|
926
|
+
Validate a value, as :mod:`Pyjo.JSON` decodes it, and return a
|
|
927
|
+
:class:`Result`, which is true if the value is valid, with the
|
|
928
|
+
:class:`Error` objects of the value otherwise. ``format`` is an
|
|
929
|
+
annotation, as JSON Schema has it by default, and an assertion with
|
|
930
|
+
``formats``. ::
|
|
931
|
+
|
|
932
|
+
# "/days: Expected integer - got string."
|
|
933
|
+
str(z.object(days=z.int()).validate({'days': 'three'}))
|
|
934
|
+
"""
|
|
935
|
+
if self._index is None:
|
|
936
|
+
self._index = _Index(self.data, self._schemas)
|
|
937
|
+
return Result(_Validator(self._index, formats).validate(value))
|
|
938
|
+
|
|
939
|
+
# Internals
|
|
940
|
+
|
|
941
|
+
def _copy(self) -> Self:
|
|
942
|
+
if isinstance(self._keywords, bool):
|
|
943
|
+
raise Pyjo.Exception.new(f'JSON Schema {self._keywords} has no keywords')
|
|
944
|
+
schema = type(self)(dict(self._keywords))
|
|
945
|
+
schema._optional = self._optional
|
|
946
|
+
schema._lazy = self._lazy
|
|
947
|
+
schema._schemas = dict(self._schemas)
|
|
948
|
+
return schema
|
|
949
|
+
|
|
950
|
+
def _types(self) -> list[str]:
|
|
951
|
+
types = self._keywords.get('type') if isinstance(self._keywords, dict) else None
|
|
952
|
+
return [types] if isinstance(types, str) else [t for t in cast(list[str], types or []) if t != 'null']
|
|
953
|
+
|
|
954
|
+
def _constraint(self, keywords: dict[str, str], value: float, method: str) -> Self:
|
|
955
|
+
for t in self._types():
|
|
956
|
+
if t in keywords:
|
|
957
|
+
return self.meta({keywords[t]: value})
|
|
958
|
+
raise Pyjo.Exception.new(f'JSON Schema has no {method} for {self._kind()}')
|
|
959
|
+
|
|
960
|
+
def _number(self, keyword: str, value: float, method: str) -> Self:
|
|
961
|
+
if not {'number', 'integer'} & set(self._types()):
|
|
962
|
+
raise Pyjo.Exception.new(f'JSON Schema has no {method} for {self._kind()}')
|
|
963
|
+
return self.meta({keyword: value})
|
|
964
|
+
|
|
965
|
+
def _string(self, keyword: str, value: str, method: str) -> Self:
|
|
966
|
+
if 'string' not in self._types():
|
|
967
|
+
raise Pyjo.Exception.new(f'JSON Schema has no {method} for {self._kind()}')
|
|
968
|
+
return self.meta({keyword: value})
|
|
969
|
+
|
|
970
|
+
def _object(self, additional: Any, method: str) -> Self:
|
|
971
|
+
if 'object' not in self._types():
|
|
972
|
+
raise Pyjo.Exception.new(f'JSON Schema has no {method} for {self._kind()}')
|
|
973
|
+
return self.meta(additionalProperties=additional)
|
|
974
|
+
|
|
975
|
+
def _kind(self) -> str:
|
|
976
|
+
types = self._types()
|
|
977
|
+
return f'the type {types[0]}' if types else 'a schema without a type'
|
|
978
|
+
|
|
979
|
+
# The names of builtins, last, as they hide them in the class
|
|
980
|
+
|
|
981
|
+
@classmethod
|
|
982
|
+
def any(cls) -> Self:
|
|
983
|
+
"""::
|
|
984
|
+
|
|
985
|
+
schema = z.any()
|
|
986
|
+
|
|
987
|
+
The schema of any value, ``{}``.
|
|
988
|
+
"""
|
|
989
|
+
return cls({})
|
|
990
|
+
|
|
991
|
+
@classmethod
|
|
992
|
+
def int(cls) -> Self:
|
|
993
|
+
"""::
|
|
994
|
+
|
|
995
|
+
schema = z.int()
|
|
996
|
+
|
|
997
|
+
The schema of an integer. zod also gives the least and the greatest
|
|
998
|
+
safe integer of JavaScript as its ``minimum`` and ``maximum``, which an
|
|
999
|
+
integer of Python does not have. ::
|
|
1000
|
+
|
|
1001
|
+
# {'type': 'integer', 'minimum': 1, 'maximum': 7}
|
|
1002
|
+
z.int().min(1).max(7).data
|
|
1003
|
+
"""
|
|
1004
|
+
return cls({'type': 'integer'})
|
|
1005
|
+
|
|
1006
|
+
@classmethod
|
|
1007
|
+
def enum(cls, values: Iterable[Any] | type[_Enum]) -> Self:
|
|
1008
|
+
"""::
|
|
1009
|
+
|
|
1010
|
+
schema = z.enum(['celsius', 'fahrenheit'])
|
|
1011
|
+
schema = z.enum(Unit)
|
|
1012
|
+
|
|
1013
|
+
The schema of one of the values, or of the values of an
|
|
1014
|
+
:class:`enum.Enum`, as ``enum``, with their ``type`` where they have
|
|
1015
|
+
one. ::
|
|
1016
|
+
|
|
1017
|
+
# {'type': 'string', 'enum': ['celsius', 'fahrenheit']}
|
|
1018
|
+
z.enum(['celsius', 'fahrenheit']).data
|
|
1019
|
+
"""
|
|
1020
|
+
if isinstance(values, type):
|
|
1021
|
+
return cls(_literal([member.value for member in values], True))
|
|
1022
|
+
return cls(_literal(builtins.list(values), True))
|
|
1023
|
+
|
|
1024
|
+
@classmethod
|
|
1025
|
+
def object(cls, shape: dict[str, Any] | None = None, /, **kwargs: Any) -> Self:
|
|
1026
|
+
"""::
|
|
1027
|
+
|
|
1028
|
+
schema = z.object({'city': z.string(), 'days': z.int().optional()})
|
|
1029
|
+
schema = z.object(city=z.string(), days=z.int().optional())
|
|
1030
|
+
|
|
1031
|
+
The schema of an object of the properties, which it requires, but the
|
|
1032
|
+
optional ones, as :meth:`optional` and :meth:`default` make them. A
|
|
1033
|
+
name that is a keyword of Python, or not a name of Python, goes in the
|
|
1034
|
+
:class:`dict`. zod also gives ``additionalProperties: false`` for the
|
|
1035
|
+
output, and this is the schema of the input, which a tool gets. ::
|
|
1036
|
+
|
|
1037
|
+
# {'type': 'object', 'properties': {'class': {'type': 'string'}}, 'required': ['class']}
|
|
1038
|
+
z.object({'class': z.string()}).data
|
|
1039
|
+
"""
|
|
1040
|
+
properties = {name: cls.of(value) for name, value in {**(shape or {}), **kwargs}.items()}
|
|
1041
|
+
keywords: dict[str, Any] = {'type': 'object', 'properties': properties}
|
|
1042
|
+
required = [name for name, value in properties.items() if not value._optional]
|
|
1043
|
+
if required:
|
|
1044
|
+
keywords['required'] = required
|
|
1045
|
+
return cls(keywords)
|
|
1046
|
+
|
|
1047
|
+
iso: '_ISO'
|
|
1048
|
+
"""::
|
|
1049
|
+
|
|
1050
|
+
schema = z.iso.datetime()
|
|
1051
|
+
schema = z.iso.date()
|
|
1052
|
+
schema = z.iso.time()
|
|
1053
|
+
|
|
1054
|
+
The schemas of the strings of the formats ``date-time``, ``date`` and
|
|
1055
|
+
``time``, as :meth:`datetime`, :meth:`date` and :meth:`time` of a string.
|
|
1056
|
+
"""
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
class Error(Pyjo.Base.object):
|
|
1060
|
+
"""
|
|
1061
|
+
An error of a value, which :meth:`~Pyjo_JSON_Schema.validate` gives, with
|
|
1062
|
+
where it is in the value and in the schema. ::
|
|
1063
|
+
|
|
1064
|
+
# "/days: Expected integer - got string."
|
|
1065
|
+
str(error)
|
|
1066
|
+
"""
|
|
1067
|
+
|
|
1068
|
+
def __init__(self, path: str = '', keyword_path: str = '', message: str = '') -> None:
|
|
1069
|
+
self.path = path
|
|
1070
|
+
"""::
|
|
1071
|
+
|
|
1072
|
+
path = error.path
|
|
1073
|
+
|
|
1074
|
+
The JSON Pointer of the value in the validated value, as
|
|
1075
|
+
:mod:`Pyjo.JSON.Pointer` takes it, or of the missing property of
|
|
1076
|
+
``required``.
|
|
1077
|
+
"""
|
|
1078
|
+
|
|
1079
|
+
self.keyword_path = keyword_path
|
|
1080
|
+
"""::
|
|
1081
|
+
|
|
1082
|
+
keyword_path = error.keyword_path
|
|
1083
|
+
|
|
1084
|
+
The JSON Pointer of the keyword in the schema, through the ``$ref``
|
|
1085
|
+
that the validation follows, as ``/properties/days/type``.
|
|
1086
|
+
"""
|
|
1087
|
+
|
|
1088
|
+
self.message = message
|
|
1089
|
+
"""::
|
|
1090
|
+
|
|
1091
|
+
message = error.message
|
|
1092
|
+
|
|
1093
|
+
What is wrong with the value.
|
|
1094
|
+
"""
|
|
1095
|
+
|
|
1096
|
+
self._expected: list[str] | None = None
|
|
1097
|
+
|
|
1098
|
+
def __json__(self) -> dict[str, str]:
|
|
1099
|
+
"""::
|
|
1100
|
+
|
|
1101
|
+
data = error.__json__()
|
|
1102
|
+
|
|
1103
|
+
The error as :mod:`Pyjo.JSON` encodes it, with ``path``,
|
|
1104
|
+
``keyword_path`` and ``message``.
|
|
1105
|
+
"""
|
|
1106
|
+
return {'path': self.path, 'keyword_path': self.keyword_path, 'message': self.message}
|
|
1107
|
+
|
|
1108
|
+
def __repr__(self) -> str:
|
|
1109
|
+
return f'{type(self).__module__}.{type(self).__qualname__}({self.__json__()!r})'
|
|
1110
|
+
|
|
1111
|
+
def __str__(self) -> str:
|
|
1112
|
+
"""::
|
|
1113
|
+
|
|
1114
|
+
string = str(error)
|
|
1115
|
+
|
|
1116
|
+
The path, with ``/`` for the whole value, and the message.
|
|
1117
|
+
"""
|
|
1118
|
+
return f'{self.path or "/"}: {self.message}'
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
class Result:
|
|
1122
|
+
"""
|
|
1123
|
+
The result of :meth:`~Pyjo_JSON_Schema.validate`, which is true if the
|
|
1124
|
+
value is valid, as the assertions of :mod:`Pyjo.Test` are. ::
|
|
1125
|
+
|
|
1126
|
+
result = schema.validate(value)
|
|
1127
|
+
if not result:
|
|
1128
|
+
for error in result.errors:
|
|
1129
|
+
print(error.path, error.message)
|
|
1130
|
+
"""
|
|
1131
|
+
|
|
1132
|
+
def __init__(self, errors: Iterable[Error] = ()) -> None:
|
|
1133
|
+
self.errors: Pyjo.Collection.object[Error] = Pyjo.Collection.new(*errors)
|
|
1134
|
+
"""::
|
|
1135
|
+
|
|
1136
|
+
errors = result.errors
|
|
1137
|
+
|
|
1138
|
+
The :class:`Error` objects of the value, in a :mod:`Pyjo.Collection`,
|
|
1139
|
+
empty if the value is valid.
|
|
1140
|
+
"""
|
|
1141
|
+
|
|
1142
|
+
def __bool__(self) -> bool:
|
|
1143
|
+
"""::
|
|
1144
|
+
|
|
1145
|
+
boolean = bool(result)
|
|
1146
|
+
|
|
1147
|
+
The value of :attr:`is_valid`.
|
|
1148
|
+
"""
|
|
1149
|
+
return self.is_valid
|
|
1150
|
+
|
|
1151
|
+
def __json__(self) -> dict[str, Any]:
|
|
1152
|
+
"""::
|
|
1153
|
+
|
|
1154
|
+
data = result.__json__()
|
|
1155
|
+
|
|
1156
|
+
The result as :mod:`Pyjo.JSON` encodes it, ``valid`` and the ``errors``.
|
|
1157
|
+
"""
|
|
1158
|
+
return {'valid': self.is_valid, 'errors': [error.__json__() for error in self.errors]}
|
|
1159
|
+
|
|
1160
|
+
def __repr__(self) -> str:
|
|
1161
|
+
return f'<{type(self).__module__}.{type(self).__qualname__} is_valid={self.is_valid}>'
|
|
1162
|
+
|
|
1163
|
+
def __str__(self) -> str:
|
|
1164
|
+
"""::
|
|
1165
|
+
|
|
1166
|
+
string = str(result)
|
|
1167
|
+
|
|
1168
|
+
The errors, one in each line, or an empty string if the value is
|
|
1169
|
+
valid.
|
|
1170
|
+
"""
|
|
1171
|
+
return '\n'.join(str(error) for error in self.errors)
|
|
1172
|
+
|
|
1173
|
+
@property
|
|
1174
|
+
def is_valid(self) -> bool:
|
|
1175
|
+
"""::
|
|
1176
|
+
|
|
1177
|
+
boolean = result.is_valid
|
|
1178
|
+
|
|
1179
|
+
True if the value is valid, without errors.
|
|
1180
|
+
"""
|
|
1181
|
+
return not len(self.errors)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
class _Lazy:
|
|
1185
|
+
"""The function of a lazy schema, and the schema that it gives, which the copies of the lazy schema share, so that
|
|
1186
|
+
they refer to the same schema."""
|
|
1187
|
+
|
|
1188
|
+
def __init__(self, getter: Callable[[], Any]) -> None:
|
|
1189
|
+
self.getter = getter
|
|
1190
|
+
self.target: Pyjo_JSON_Schema | None = None
|
|
1191
|
+
|
|
1192
|
+
def resolve(self, schema: Pyjo_JSON_Schema) -> Pyjo_JSON_Schema:
|
|
1193
|
+
if self.target is None:
|
|
1194
|
+
self.target = schema.of(self.getter())
|
|
1195
|
+
return self.target
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
class _ISO:
|
|
1199
|
+
def __get__(self, obj: Anything, objtype: type[Pyjo_JSON_Schema]) -> Self:
|
|
1200
|
+
self._cls = objtype
|
|
1201
|
+
return self
|
|
1202
|
+
|
|
1203
|
+
def datetime(self) -> Pyjo_JSON_Schema:
|
|
1204
|
+
return self._cls.string().datetime()
|
|
1205
|
+
|
|
1206
|
+
def date(self) -> Pyjo_JSON_Schema:
|
|
1207
|
+
return self._cls.string().date()
|
|
1208
|
+
|
|
1209
|
+
def time(self) -> Pyjo_JSON_Schema:
|
|
1210
|
+
return self._cls.string().time()
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
Pyjo_JSON_Schema.iso = _ISO()
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
class _Compiler:
|
|
1217
|
+
"""The schemas of the types of the type hints, the same object for the same type in one schema."""
|
|
1218
|
+
|
|
1219
|
+
def __init__(self, cls: type[Pyjo_JSON_Schema]) -> None:
|
|
1220
|
+
self.cls = cls
|
|
1221
|
+
self.done: dict[Any, Pyjo_JSON_Schema] = {}
|
|
1222
|
+
self.doing: set[Any] = set()
|
|
1223
|
+
|
|
1224
|
+
def parameters(self, function: Callable[..., Any], skip: int) -> Pyjo_JSON_Schema:
|
|
1225
|
+
signature, hints = _signature(function)
|
|
1226
|
+
description, params, _ = _docstring(function)
|
|
1227
|
+
properties: dict[str, Pyjo_JSON_Schema] = {}
|
|
1228
|
+
required: list[str] = []
|
|
1229
|
+
additional: Any = False
|
|
1230
|
+
for param in builtins.list(signature.parameters.values())[skip:]:
|
|
1231
|
+
where = f' in the parameter {param.name}'
|
|
1232
|
+
if param.kind is param.VAR_POSITIONAL:
|
|
1233
|
+
raise Pyjo.Exception.new(f'JSON Schema has no positional members for the parameter {param.name}')
|
|
1234
|
+
if param.kind is param.POSITIONAL_ONLY:
|
|
1235
|
+
raise Pyjo.Exception.new(f'JSON Schema has no positional members for the parameter {param.name}')
|
|
1236
|
+
schema = self.compile(hints.get(param.name, Any), where)
|
|
1237
|
+
if param.kind is param.VAR_KEYWORD:
|
|
1238
|
+
additional = schema
|
|
1239
|
+
continue
|
|
1240
|
+
if param.name in params:
|
|
1241
|
+
schema = schema.describe(params[param.name])
|
|
1242
|
+
if param.default is param.empty:
|
|
1243
|
+
required.append(param.name)
|
|
1244
|
+
elif (value := _json_value(param.default)) is not _MISSING:
|
|
1245
|
+
schema = schema.meta(default=value)
|
|
1246
|
+
properties[param.name] = schema
|
|
1247
|
+
keywords: dict[str, Any] = {'type': 'object'}
|
|
1248
|
+
if description:
|
|
1249
|
+
keywords['description'] = description
|
|
1250
|
+
keywords['properties'] = properties
|
|
1251
|
+
if required:
|
|
1252
|
+
keywords['required'] = required
|
|
1253
|
+
keywords['additionalProperties'] = additional
|
|
1254
|
+
return self.cls(keywords)
|
|
1255
|
+
|
|
1256
|
+
def returns(self, function: Callable[..., Any]) -> Pyjo_JSON_Schema | None:
|
|
1257
|
+
_, hints = _signature(function)
|
|
1258
|
+
if 'return' not in hints:
|
|
1259
|
+
return None
|
|
1260
|
+
schema = self.compile(hints['return'], ' in the return value')
|
|
1261
|
+
_, _, description = _docstring(function)
|
|
1262
|
+
return schema.describe(description) if description else schema
|
|
1263
|
+
|
|
1264
|
+
def compile(self, tp: Any, where: str) -> Pyjo_JSON_Schema:
|
|
1265
|
+
cls = self.cls
|
|
1266
|
+
if isinstance(tp, Pyjo_JSON_Schema):
|
|
1267
|
+
return tp
|
|
1268
|
+
if isinstance(tp, (dict, bool)):
|
|
1269
|
+
return cls.new(cast(dict[str, Any] | bool, tp))
|
|
1270
|
+
if tp is None or tp is type(None):
|
|
1271
|
+
return cls.null()
|
|
1272
|
+
if tp is Any or tp is builtins.object:
|
|
1273
|
+
return cls.any()
|
|
1274
|
+
if tp is bool:
|
|
1275
|
+
return cls.boolean()
|
|
1276
|
+
if tp is builtins.int:
|
|
1277
|
+
return cls.int()
|
|
1278
|
+
if tp is float:
|
|
1279
|
+
return cls.number()
|
|
1280
|
+
if tp is str:
|
|
1281
|
+
return cls.string()
|
|
1282
|
+
if isinstance(tp, type) and tp in _FORMATS:
|
|
1283
|
+
return cls.string().meta(format=_FORMATS[tp])
|
|
1284
|
+
if isinstance(tp, type) and issubclass(tp, Pyjo.URL.object):
|
|
1285
|
+
return cls.string().url()
|
|
1286
|
+
|
|
1287
|
+
origin, args = typing.get_origin(tp), typing.get_args(tp)
|
|
1288
|
+
if origin is typing.Annotated:
|
|
1289
|
+
return self.annotated(args[0], args[1:], where)
|
|
1290
|
+
if origin in (typing.Union, types.UnionType):
|
|
1291
|
+
return self.union(args, where)
|
|
1292
|
+
if origin is typing.Literal:
|
|
1293
|
+
return cls(_literal(builtins.list(args)))
|
|
1294
|
+
if origin is not None and origin in _QUALIFIERS:
|
|
1295
|
+
return self.compile(args[0], where)
|
|
1296
|
+
if tp in (list, set, frozenset, builtins.tuple) or origin in (list, set, frozenset):
|
|
1297
|
+
schema = cls.array(self.compile(args[0], where) if args else None)
|
|
1298
|
+
return schema.meta(uniqueItems=True) if set in (tp, origin) or frozenset in (tp, origin) else schema
|
|
1299
|
+
if origin is builtins.tuple:
|
|
1300
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
1301
|
+
return cls.array(self.compile(args[0], where))
|
|
1302
|
+
return cls.tuple([self.compile(arg, where) for arg in args if arg != ()])
|
|
1303
|
+
if tp is dict:
|
|
1304
|
+
return cls({'type': 'object'})
|
|
1305
|
+
if origin is dict:
|
|
1306
|
+
if args[0] is not str:
|
|
1307
|
+
raise Pyjo.Exception.new(f'JSON Schema has no names of {_name(args[0])}{where}')
|
|
1308
|
+
return cls.record(self.compile(args[1], where))
|
|
1309
|
+
if isinstance(tp, typing.TypeAliasType):
|
|
1310
|
+
return self.named(tp, where, lambda: self.compile(tp.__value__, where))
|
|
1311
|
+
if isinstance(tp, typing.NewType):
|
|
1312
|
+
return self.compile(tp.__supertype__, where)
|
|
1313
|
+
if isinstance(tp, type) and issubclass(tp, enum.Enum):
|
|
1314
|
+
return self.named(tp, where, lambda: self.enumeration(tp))
|
|
1315
|
+
if typing.is_typeddict(cast(Any, tp)):
|
|
1316
|
+
return self.named(tp, where, lambda: self.typeddict(tp, where))
|
|
1317
|
+
if isinstance(tp, type) and dataclasses.is_dataclass(tp):
|
|
1318
|
+
return self.named(tp, where, lambda: self.dataclass(tp, where))
|
|
1319
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support {_name(tp)}{where}')
|
|
1320
|
+
|
|
1321
|
+
def named(self, tp: Any, where: str, make: Callable[[], Pyjo_JSON_Schema]) -> Pyjo_JSON_Schema:
|
|
1322
|
+
# A type that is in the making refers to itself, the same type again is the same object, which goes to $defs
|
|
1323
|
+
if tp in self.done:
|
|
1324
|
+
return self.done[tp]
|
|
1325
|
+
if tp in self.doing:
|
|
1326
|
+
return self.cls.lazy(lambda: self.done[tp])
|
|
1327
|
+
self.doing.add(tp)
|
|
1328
|
+
try:
|
|
1329
|
+
schema = make()
|
|
1330
|
+
finally:
|
|
1331
|
+
self.doing.discard(tp)
|
|
1332
|
+
schema.name = tp.__name__
|
|
1333
|
+
self.done[tp] = schema
|
|
1334
|
+
return schema
|
|
1335
|
+
|
|
1336
|
+
def annotated(self, tp: Any, metadata: tuple[Any, ...], where: str) -> Pyjo_JSON_Schema:
|
|
1337
|
+
keywords: dict[str, Any] = {}
|
|
1338
|
+
for value in metadata:
|
|
1339
|
+
if isinstance(value, str):
|
|
1340
|
+
keywords['description'] = value
|
|
1341
|
+
elif isinstance(value, dict):
|
|
1342
|
+
keywords.update(cast(dict[str, Any], value))
|
|
1343
|
+
elif isinstance(value, Pyjo_JSON_Schema) and isinstance(value.keywords, dict):
|
|
1344
|
+
keywords.update(value.keywords)
|
|
1345
|
+
schema = self.compile(tp, where)
|
|
1346
|
+
return schema.meta(keywords) if keywords else schema
|
|
1347
|
+
|
|
1348
|
+
def union(self, args: tuple[Any, ...], where: str) -> Pyjo_JSON_Schema:
|
|
1349
|
+
options = [arg for arg in args if arg is not None and arg is not type(None)]
|
|
1350
|
+
schemas = [self.compile(arg, where) for arg in options]
|
|
1351
|
+
if len(schemas) == 1:
|
|
1352
|
+
return schemas[0].nullable() if len(options) < len(args) else schemas[0]
|
|
1353
|
+
if len(options) < len(args):
|
|
1354
|
+
schemas.append(self.cls.null())
|
|
1355
|
+
return self.cls({'anyOf': schemas})
|
|
1356
|
+
|
|
1357
|
+
def enumeration(self, tp: type[enum.Enum]) -> Pyjo_JSON_Schema:
|
|
1358
|
+
schema = self.cls.enum(tp)
|
|
1359
|
+
description = _description(tp)
|
|
1360
|
+
return schema.describe(description) if description else schema
|
|
1361
|
+
|
|
1362
|
+
def typeddict(self, tp: Any, where: str) -> Pyjo_JSON_Schema:
|
|
1363
|
+
hints = _hints(tp, f' of {_name(tp)}')
|
|
1364
|
+
required = cast(frozenset[str], tp.__required_keys__)
|
|
1365
|
+
properties = {name: self.compile(hint, f' in the key {name} of {_name(tp)}') for name, hint in hints.items()}
|
|
1366
|
+
keywords = self.object(tp, properties, [name for name in hints if name in required])
|
|
1367
|
+
if getattr(tp, '__closed__', None):
|
|
1368
|
+
keywords['additionalProperties'] = False
|
|
1369
|
+
extra = getattr(tp, '__extra_items__', None)
|
|
1370
|
+
if extra is not None and getattr(extra, '__name__', None) != 'NoExtraItems':
|
|
1371
|
+
keywords['additionalProperties'] = self.compile(extra, f' in the extra items of {_name(tp)}')
|
|
1372
|
+
return self.cls(keywords)
|
|
1373
|
+
|
|
1374
|
+
def dataclass(self, tp: type[Any], where: str) -> Pyjo_JSON_Schema:
|
|
1375
|
+
hints = _hints(tp, f' of {_name(tp)}')
|
|
1376
|
+
properties: dict[str, Pyjo_JSON_Schema] = {}
|
|
1377
|
+
required: list[str] = []
|
|
1378
|
+
for field in dataclasses.fields(tp):
|
|
1379
|
+
if not field.init:
|
|
1380
|
+
continue
|
|
1381
|
+
schema = self.compile(hints.get(field.name, Any), f' in the field {field.name} of {_name(tp)}')
|
|
1382
|
+
if field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING:
|
|
1383
|
+
required.append(field.name)
|
|
1384
|
+
elif (value := _json_value(field.default)) is not _MISSING:
|
|
1385
|
+
schema = schema.meta(default=value)
|
|
1386
|
+
properties[field.name] = schema
|
|
1387
|
+
return self.cls(self.object(tp, properties, required))
|
|
1388
|
+
|
|
1389
|
+
def object(self, tp: Any, properties: dict[str, Pyjo_JSON_Schema], required: list[str]) -> dict[str, Any]:
|
|
1390
|
+
keywords: dict[str, Any] = {'type': 'object'}
|
|
1391
|
+
description = _description(tp)
|
|
1392
|
+
if description:
|
|
1393
|
+
keywords['description'] = description
|
|
1394
|
+
keywords['properties'] = properties
|
|
1395
|
+
if required:
|
|
1396
|
+
keywords['required'] = required
|
|
1397
|
+
return keywords
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
class _Emitter:
|
|
1401
|
+
"""The data of a schema, with $defs for the named schemas used again and the ones that refer to themselves."""
|
|
1402
|
+
|
|
1403
|
+
def __init__(self, root: Pyjo_JSON_Schema) -> None:
|
|
1404
|
+
self.root = root
|
|
1405
|
+
self.targets: dict[builtins.int, Pyjo_JSON_Schema] = {}
|
|
1406
|
+
self.counts: collections.Counter[builtins.int] = collections.Counter()
|
|
1407
|
+
self.recursive: set[builtins.int] = set()
|
|
1408
|
+
self.needed: set[builtins.int] = set()
|
|
1409
|
+
self.names: dict[builtins.int, str] = {}
|
|
1410
|
+
self.defs: dict[str, Any] = {}
|
|
1411
|
+
|
|
1412
|
+
def emit(self) -> dict[str, Any] | bool:
|
|
1413
|
+
root = self.resolve(self.root)
|
|
1414
|
+
self.count(root, [])
|
|
1415
|
+
self.needed = self.recursive | {
|
|
1416
|
+
key for key, count in self.counts.items() if count > 1 and self.targets[key].name is not None
|
|
1417
|
+
}
|
|
1418
|
+
self.needed.discard(id(root))
|
|
1419
|
+
data = self.keywords(root)
|
|
1420
|
+
if self.root is not root:
|
|
1421
|
+
data = self.merge(data, self.keywords(self.root))
|
|
1422
|
+
if self.defs and isinstance(data, dict):
|
|
1423
|
+
data['$defs'] = self.defs
|
|
1424
|
+
return data
|
|
1425
|
+
|
|
1426
|
+
def resolve(self, schema: Pyjo_JSON_Schema) -> Pyjo_JSON_Schema:
|
|
1427
|
+
seen: set[builtins.int] = set()
|
|
1428
|
+
target = schema
|
|
1429
|
+
while target.is_lazy:
|
|
1430
|
+
if id(target) in seen:
|
|
1431
|
+
raise Pyjo.Exception.new('JSON Schema of a lazy schema that gives itself')
|
|
1432
|
+
seen.add(id(target))
|
|
1433
|
+
target = target.resolve()
|
|
1434
|
+
self.targets[id(target)] = target
|
|
1435
|
+
return target
|
|
1436
|
+
|
|
1437
|
+
def count(self, value: Any, stack: list[builtins.int]) -> None:
|
|
1438
|
+
if isinstance(value, Pyjo_JSON_Schema):
|
|
1439
|
+
target = self.resolve(value)
|
|
1440
|
+
key = id(target)
|
|
1441
|
+
if key in stack:
|
|
1442
|
+
self.recursive.add(key)
|
|
1443
|
+
return
|
|
1444
|
+
self.counts[key] += 1
|
|
1445
|
+
if self.counts[key] == 1:
|
|
1446
|
+
stack.append(key)
|
|
1447
|
+
self.count(target.keywords, stack)
|
|
1448
|
+
stack.pop()
|
|
1449
|
+
elif isinstance(value, dict):
|
|
1450
|
+
for item in cast(dict[str, Any], value).values():
|
|
1451
|
+
self.count(item, stack)
|
|
1452
|
+
elif isinstance(value, (list, builtins.tuple)):
|
|
1453
|
+
for item in cast(list[Any], value):
|
|
1454
|
+
self.count(item, stack)
|
|
1455
|
+
|
|
1456
|
+
def value(self, value: Any) -> Any:
|
|
1457
|
+
if isinstance(value, Pyjo_JSON_Schema):
|
|
1458
|
+
target = self.resolve(value)
|
|
1459
|
+
key = id(target)
|
|
1460
|
+
own = self.keywords(value) if value is not target else {}
|
|
1461
|
+
if key == id(self.resolve(self.root)):
|
|
1462
|
+
return self.merge({'$ref': '#'}, own)
|
|
1463
|
+
if key in self.needed:
|
|
1464
|
+
return self.merge({'$ref': f'#/$defs/{self.define(target)}'}, own)
|
|
1465
|
+
return self.merge(self.keywords(target), own)
|
|
1466
|
+
if isinstance(value, dict):
|
|
1467
|
+
return {name: self.value(item) for name, item in cast(dict[str, Any], value).items()}
|
|
1468
|
+
if isinstance(value, (list, builtins.tuple)):
|
|
1469
|
+
return [self.value(item) for item in cast(list[Any], value)]
|
|
1470
|
+
return value
|
|
1471
|
+
|
|
1472
|
+
def keywords(self, schema: Pyjo_JSON_Schema) -> dict[str, Any] | bool:
|
|
1473
|
+
if isinstance(schema.keywords, bool):
|
|
1474
|
+
return schema.keywords
|
|
1475
|
+
return {name: self.value(item) for name, item in schema.keywords.items()}
|
|
1476
|
+
|
|
1477
|
+
def define(self, target: Pyjo_JSON_Schema) -> str:
|
|
1478
|
+
key = id(target)
|
|
1479
|
+
if key not in self.names:
|
|
1480
|
+
base = target.name or 'Schema'
|
|
1481
|
+
name, n = base, 1
|
|
1482
|
+
while name in self.defs or name in self.names.values():
|
|
1483
|
+
n += 1
|
|
1484
|
+
name = f'{base}{n}'
|
|
1485
|
+
self.names[key] = name
|
|
1486
|
+
self.defs[name] = {}
|
|
1487
|
+
self.defs[name] = self.keywords(target)
|
|
1488
|
+
return self.names[key]
|
|
1489
|
+
|
|
1490
|
+
def merge(self, data: dict[str, Any] | bool, own: dict[str, Any] | bool) -> dict[str, Any] | bool:
|
|
1491
|
+
if isinstance(own, bool) or not own:
|
|
1492
|
+
return data
|
|
1493
|
+
if isinstance(data, bool):
|
|
1494
|
+
return own if data else {**own, 'not': {}}
|
|
1495
|
+
return {**data, **own}
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
class _Index:
|
|
1499
|
+
"""The schemas by their URI, of $id, $anchor and the registry, with the base URI of each schema."""
|
|
1500
|
+
|
|
1501
|
+
def __init__(
|
|
1502
|
+
self, root: dict[str, Any] | bool, schemas: dict[str, Pyjo_JSON_Schema | dict[str, Any] | bool]
|
|
1503
|
+
) -> None:
|
|
1504
|
+
self.resources: dict[str, dict[str, Any] | bool] = {}
|
|
1505
|
+
self.anchors: dict[str, dict[str, Any]] = {}
|
|
1506
|
+
self.bases: dict[builtins.int, str] = {}
|
|
1507
|
+
self.schemas = dict(schemas)
|
|
1508
|
+
self.keep: list[Any] = []
|
|
1509
|
+
self.patterns: dict[str, re.Pattern[str]] = {}
|
|
1510
|
+
self.root = root
|
|
1511
|
+
self.add(root, '')
|
|
1512
|
+
|
|
1513
|
+
def add(self, schema: Pyjo_JSON_Schema | dict[str, Any] | bool, uri: str) -> None:
|
|
1514
|
+
data = schema.data if isinstance(schema, Pyjo_JSON_Schema) else schema
|
|
1515
|
+
# The ids of the schemas are the keys of the bases, so the schemas stay
|
|
1516
|
+
self.keep.append(data)
|
|
1517
|
+
self.resources[uri] = data
|
|
1518
|
+
self.walk(data, uri, '')
|
|
1519
|
+
|
|
1520
|
+
def walk(self, schema: Any, base: str, where: str) -> None:
|
|
1521
|
+
if isinstance(schema, bool):
|
|
1522
|
+
return
|
|
1523
|
+
if not isinstance(schema, dict):
|
|
1524
|
+
raise Pyjo.Exception.new(f'JSON Schema has no schema in {where or "/"}')
|
|
1525
|
+
schema = cast(dict[str, Any], schema)
|
|
1526
|
+
dialect = schema.get('$schema')
|
|
1527
|
+
if dialect is not None and (not isinstance(dialect, str) or dialect.rstrip('#') != DIALECT):
|
|
1528
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support the dialect {dialect}')
|
|
1529
|
+
for keyword in _UNSUPPORTED:
|
|
1530
|
+
if keyword in schema:
|
|
1531
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support {keyword} in {where or "/"}')
|
|
1532
|
+
if isinstance(schema.get('$id'), str):
|
|
1533
|
+
base = _uri(base, schema['$id'])[0]
|
|
1534
|
+
self.resources.setdefault(base, schema)
|
|
1535
|
+
for keyword in ('$anchor', '$dynamicAnchor'):
|
|
1536
|
+
if isinstance(schema.get(keyword), str):
|
|
1537
|
+
self.anchors[f'{base}#{schema[keyword]}'] = schema
|
|
1538
|
+
self.bases[id(schema)] = base
|
|
1539
|
+
for keyword, value in schema.items():
|
|
1540
|
+
if keyword in _SCHEMA:
|
|
1541
|
+
self.walk(value, base, f'{where}/{keyword}')
|
|
1542
|
+
elif keyword in _SCHEMAS and isinstance(value, dict):
|
|
1543
|
+
for name, item in cast(dict[str, Any], value).items():
|
|
1544
|
+
self.walk(item, base, f'{where}/{keyword}/{_escape(name)}')
|
|
1545
|
+
elif keyword in _SCHEMA_LISTS and isinstance(value, list):
|
|
1546
|
+
for i, item in enumerate(cast(list[Any], value)):
|
|
1547
|
+
self.walk(item, base, f'{where}/{keyword}/{i}')
|
|
1548
|
+
elif keyword in _SCHEMAS or keyword in _SCHEMA_LISTS:
|
|
1549
|
+
raise Pyjo.Exception.new(f'JSON Schema has no schemas in {where}/{keyword}')
|
|
1550
|
+
|
|
1551
|
+
def pattern(self, source: str) -> re.Pattern[str]:
|
|
1552
|
+
# The regular expressions of the schemas, which are compiled once for the schema
|
|
1553
|
+
pattern = self.patterns.get(source)
|
|
1554
|
+
if pattern is None:
|
|
1555
|
+
pattern = self.patterns[source] = _regex(source)
|
|
1556
|
+
return pattern
|
|
1557
|
+
|
|
1558
|
+
def resolve(self, base: str, ref: str, where: str) -> tuple[dict[str, Any] | bool, str]:
|
|
1559
|
+
uri, fragment = _uri(base, ref)
|
|
1560
|
+
if uri not in self.resources and uri in self.schemas:
|
|
1561
|
+
self.add(self.schemas.pop(uri), uri)
|
|
1562
|
+
if uri not in self.resources:
|
|
1563
|
+
# An embedded resource of a schema of the registry, which has to be indexed to be found, of the schemas
|
|
1564
|
+
# that the validation supports
|
|
1565
|
+
while self.schemas:
|
|
1566
|
+
key, schema = self.schemas.popitem()
|
|
1567
|
+
with contextlib.suppress(Pyjo.Exception.object):
|
|
1568
|
+
self.add(schema, key)
|
|
1569
|
+
target: Any = None
|
|
1570
|
+
if fragment.startswith('/') or not fragment:
|
|
1571
|
+
pointer = Pyjo.JSON.Pointer.new(self.resources.get(uri))
|
|
1572
|
+
if uri in self.resources and pointer.contains(fragment):
|
|
1573
|
+
target = pointer.get(fragment)
|
|
1574
|
+
else:
|
|
1575
|
+
target = self.anchors.get(f'{uri}#{fragment}')
|
|
1576
|
+
if not isinstance(target, (dict, bool)):
|
|
1577
|
+
raise Pyjo.Exception.new(f'JSON Schema has no schema of the $ref {ref} in {where}')
|
|
1578
|
+
found = cast(dict[str, Any] | bool, target)
|
|
1579
|
+
return found, self.bases.get(id(found), uri)
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
@dataclasses.dataclass(slots=True)
|
|
1583
|
+
class _At:
|
|
1584
|
+
"""A value and a schema whose keywords validate it, with the JSON Pointers of the value and of the keyword."""
|
|
1585
|
+
|
|
1586
|
+
value: Any
|
|
1587
|
+
kind: str
|
|
1588
|
+
schema: dict[str, Any]
|
|
1589
|
+
base: str
|
|
1590
|
+
path: str
|
|
1591
|
+
where: str
|
|
1592
|
+
errors: list[Error]
|
|
1593
|
+
|
|
1594
|
+
def error(self, message: str, path: str | None = None, where: str | None = None) -> Error:
|
|
1595
|
+
error = Error(self.path if path is None else path, self.where if where is None else where, message)
|
|
1596
|
+
self.errors.append(error)
|
|
1597
|
+
return error
|
|
1598
|
+
|
|
1599
|
+
def child(self, name: Any) -> str:
|
|
1600
|
+
return f'{self.path}/{_escape(name)}'
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
class _Validator:
|
|
1604
|
+
"""The evaluation of a value against a schema, which collects the errors."""
|
|
1605
|
+
|
|
1606
|
+
def __init__(self, index: _Index, formats: bool) -> None:
|
|
1607
|
+
self.index = index
|
|
1608
|
+
self.formats = formats
|
|
1609
|
+
self.stack: set[tuple[builtins.int, str]] = set()
|
|
1610
|
+
|
|
1611
|
+
def validate(self, value: Any) -> list[Error]:
|
|
1612
|
+
errors: list[Error] = []
|
|
1613
|
+
_json_errors(value, '', errors)
|
|
1614
|
+
if not errors:
|
|
1615
|
+
self.evaluate(value, self.index.root, '', '', '', errors)
|
|
1616
|
+
return errors
|
|
1617
|
+
|
|
1618
|
+
def evaluate(
|
|
1619
|
+
self, value: Any, schema: dict[str, Any] | bool, base: str, path: str, where: str, errors: list[Error]
|
|
1620
|
+
) -> None:
|
|
1621
|
+
if schema is True:
|
|
1622
|
+
return
|
|
1623
|
+
if schema is False:
|
|
1624
|
+
errors.append(Error(path, where, 'Not allowed.'))
|
|
1625
|
+
return
|
|
1626
|
+
key = (id(schema), path)
|
|
1627
|
+
if key in self.stack:
|
|
1628
|
+
raise Pyjo.Exception.new(f'JSON Schema refers to itself in {where} without a value of its own')
|
|
1629
|
+
self.stack.add(key)
|
|
1630
|
+
try:
|
|
1631
|
+
at = _At(value, _instance_type(value), schema, self.index.bases.get(id(schema), base), path, where, errors)
|
|
1632
|
+
for keyword, argument in schema.items():
|
|
1633
|
+
check = _KEYWORDS.get(keyword)
|
|
1634
|
+
if check is not None:
|
|
1635
|
+
at.where = f'{where}/{keyword}'
|
|
1636
|
+
check(self, at, argument)
|
|
1637
|
+
finally:
|
|
1638
|
+
self.stack.discard(key)
|
|
1639
|
+
|
|
1640
|
+
def errors(
|
|
1641
|
+
self, value: Any, schema: Any, at: _At, path: str | None = None, where: str | None = None
|
|
1642
|
+
) -> list[Error]:
|
|
1643
|
+
errors: list[Error] = []
|
|
1644
|
+
self.evaluate(
|
|
1645
|
+
value, schema, at.base, at.path if path is None else path, at.where if where is None else where, errors
|
|
1646
|
+
)
|
|
1647
|
+
return errors
|
|
1648
|
+
|
|
1649
|
+
def apply(self, value: Any, schema: Any, at: _At, path: str | None = None, where: str | None = None) -> None:
|
|
1650
|
+
self.evaluate(
|
|
1651
|
+
value, schema, at.base, at.path if path is None else path, at.where if where is None else where, at.errors
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
# Types
|
|
1655
|
+
|
|
1656
|
+
def type(self, at: _At, argument: Any) -> None:
|
|
1657
|
+
types = [argument] if isinstance(argument, str) else cast(list[str], argument)
|
|
1658
|
+
if at.kind not in types and not (at.kind == 'integer' and 'number' in types):
|
|
1659
|
+
error = at.error(f'Expected {" or ".join(types)} - got {_type_name(at.value)}.')
|
|
1660
|
+
error._expected = types # pyright: ignore[reportPrivateUsage]
|
|
1661
|
+
|
|
1662
|
+
def enum(self, at: _At, argument: Any) -> None:
|
|
1663
|
+
if _canonical(at.value) not in [_canonical(item) for item in cast(list[Any], argument)]:
|
|
1664
|
+
at.error(f'Not in enum: {", ".join(to_json(item) for item in argument)}.')
|
|
1665
|
+
|
|
1666
|
+
def const(self, at: _At, argument: Any) -> None:
|
|
1667
|
+
if _canonical(at.value) != _canonical(argument):
|
|
1668
|
+
at.error(f'Not the const: {to_json(argument)}.')
|
|
1669
|
+
|
|
1670
|
+
# Numbers
|
|
1671
|
+
|
|
1672
|
+
def minimum(self, at: _At, argument: Any) -> None:
|
|
1673
|
+
if at.kind in _NUMBERS and at.value < argument:
|
|
1674
|
+
at.error(f'Less than the minimum {to_json(argument)}.')
|
|
1675
|
+
|
|
1676
|
+
def maximum(self, at: _At, argument: Any) -> None:
|
|
1677
|
+
if at.kind in _NUMBERS and at.value > argument:
|
|
1678
|
+
at.error(f'Greater than the maximum {to_json(argument)}.')
|
|
1679
|
+
|
|
1680
|
+
def exclusive_minimum(self, at: _At, argument: Any) -> None:
|
|
1681
|
+
if at.kind in _NUMBERS and at.value <= argument:
|
|
1682
|
+
at.error(f'Not greater than the exclusive minimum {to_json(argument)}.')
|
|
1683
|
+
|
|
1684
|
+
def exclusive_maximum(self, at: _At, argument: Any) -> None:
|
|
1685
|
+
if at.kind in _NUMBERS and at.value >= argument:
|
|
1686
|
+
at.error(f'Not less than the exclusive maximum {to_json(argument)}.')
|
|
1687
|
+
|
|
1688
|
+
def multiple_of(self, at: _At, argument: Any) -> None:
|
|
1689
|
+
if at.kind in _NUMBERS and (_fraction(at.value) / _fraction(argument)).denominator != 1:
|
|
1690
|
+
at.error(f'Not a multiple of {to_json(argument)}.')
|
|
1691
|
+
|
|
1692
|
+
# Strings
|
|
1693
|
+
|
|
1694
|
+
def min_length(self, at: _At, argument: Any) -> None:
|
|
1695
|
+
if at.kind == 'string' and len(at.value) < argument:
|
|
1696
|
+
at.error(f'Shorter than {to_json(argument)} characters.')
|
|
1697
|
+
|
|
1698
|
+
def max_length(self, at: _At, argument: Any) -> None:
|
|
1699
|
+
if at.kind == 'string' and len(at.value) > argument:
|
|
1700
|
+
at.error(f'Longer than {to_json(argument)} characters.')
|
|
1701
|
+
|
|
1702
|
+
def pattern(self, at: _At, argument: Any) -> None:
|
|
1703
|
+
if at.kind == 'string' and not self.index.pattern(argument).search(at.value):
|
|
1704
|
+
at.error(f'Does not match the pattern {argument}.')
|
|
1705
|
+
|
|
1706
|
+
def format(self, at: _At, argument: Any) -> None:
|
|
1707
|
+
check = _FORMAT_CHECKS.get(argument)
|
|
1708
|
+
if self.formats and at.kind == 'string' and check is not None and not check(at.value):
|
|
1709
|
+
at.error(f'Does not match the format {argument}.')
|
|
1710
|
+
|
|
1711
|
+
# Arrays
|
|
1712
|
+
|
|
1713
|
+
def items(self, at: _At, argument: Any) -> None:
|
|
1714
|
+
if at.kind == 'array':
|
|
1715
|
+
start = len(cast(list[Any], at.schema.get('prefixItems', [])))
|
|
1716
|
+
for i, item in enumerate(at.value[start:], start):
|
|
1717
|
+
self.apply(item, argument, at, at.child(i))
|
|
1718
|
+
|
|
1719
|
+
def prefix_items(self, at: _At, argument: Any) -> None:
|
|
1720
|
+
if at.kind == 'array':
|
|
1721
|
+
for i, (item, schema) in enumerate(zip(at.value, argument, strict=False)):
|
|
1722
|
+
self.apply(item, schema, at, at.child(i), f'{at.where}/{i}')
|
|
1723
|
+
|
|
1724
|
+
def min_items(self, at: _At, argument: Any) -> None:
|
|
1725
|
+
if at.kind == 'array' and len(at.value) < argument:
|
|
1726
|
+
at.error(f'Fewer than {to_json(argument)} items.')
|
|
1727
|
+
|
|
1728
|
+
def max_items(self, at: _At, argument: Any) -> None:
|
|
1729
|
+
if at.kind == 'array' and len(at.value) > argument:
|
|
1730
|
+
at.error(f'More than {to_json(argument)} items.')
|
|
1731
|
+
|
|
1732
|
+
def unique_items(self, at: _At, argument: Any) -> None:
|
|
1733
|
+
if at.kind == 'array' and argument is True:
|
|
1734
|
+
seen: dict[Any, builtins.int] = {}
|
|
1735
|
+
for i, item in enumerate(at.value):
|
|
1736
|
+
first = seen.setdefault(_canonical(item), i)
|
|
1737
|
+
if first != i:
|
|
1738
|
+
at.error(f'Items {first} and {i} are equal.')
|
|
1739
|
+
return
|
|
1740
|
+
|
|
1741
|
+
def contains(self, at: _At, argument: Any) -> None:
|
|
1742
|
+
if at.kind != 'array':
|
|
1743
|
+
return
|
|
1744
|
+
count = sum(not self.errors(item, argument, at, at.child(i)) for i, item in enumerate(at.value))
|
|
1745
|
+
least, most = at.schema.get('minContains', 1), at.schema.get('maxContains')
|
|
1746
|
+
parent = at.where.rpartition('/')[0]
|
|
1747
|
+
if count < least and 'minContains' in at.schema:
|
|
1748
|
+
at.error(f'Fewer than {to_json(least)} items match the schema of contains.', where=f'{parent}/minContains')
|
|
1749
|
+
elif count < least:
|
|
1750
|
+
at.error('No item matches the schema of contains.')
|
|
1751
|
+
if most is not None and count > most:
|
|
1752
|
+
at.error(f'More than {to_json(most)} items match the schema of contains.', where=f'{parent}/maxContains')
|
|
1753
|
+
|
|
1754
|
+
# Objects
|
|
1755
|
+
|
|
1756
|
+
def properties(self, at: _At, argument: Any) -> None:
|
|
1757
|
+
if at.kind == 'object':
|
|
1758
|
+
for name, schema in cast(dict[str, Any], argument).items():
|
|
1759
|
+
if name in at.value:
|
|
1760
|
+
self.apply(at.value[name], schema, at, at.child(name), f'{at.where}/{_escape(name)}')
|
|
1761
|
+
|
|
1762
|
+
def pattern_properties(self, at: _At, argument: Any) -> None:
|
|
1763
|
+
if at.kind == 'object':
|
|
1764
|
+
for pattern, schema in cast(dict[str, Any], argument).items():
|
|
1765
|
+
for name, item in cast(dict[str, Any], at.value).items():
|
|
1766
|
+
if self.index.pattern(pattern).search(name):
|
|
1767
|
+
self.apply(item, schema, at, at.child(name), f'{at.where}/{_escape(pattern)}')
|
|
1768
|
+
|
|
1769
|
+
def additional_properties(self, at: _At, argument: Any) -> None:
|
|
1770
|
+
if at.kind != 'object':
|
|
1771
|
+
return
|
|
1772
|
+
properties = cast(dict[str, Any], at.schema.get('properties', {}))
|
|
1773
|
+
patterns = [
|
|
1774
|
+
self.index.pattern(pattern) for pattern in cast(dict[str, Any], at.schema.get('patternProperties', {}))
|
|
1775
|
+
]
|
|
1776
|
+
for name, item in cast(dict[str, Any], at.value).items():
|
|
1777
|
+
if name in properties or any(pattern.search(name) for pattern in patterns):
|
|
1778
|
+
continue
|
|
1779
|
+
if argument is False:
|
|
1780
|
+
at.error('Property not allowed.', at.child(name))
|
|
1781
|
+
else:
|
|
1782
|
+
self.apply(item, argument, at, at.child(name))
|
|
1783
|
+
|
|
1784
|
+
def property_names(self, at: _At, argument: Any) -> None:
|
|
1785
|
+
if at.kind == 'object':
|
|
1786
|
+
for name in at.value:
|
|
1787
|
+
self.apply(name, argument, at, at.child(name))
|
|
1788
|
+
|
|
1789
|
+
def required(self, at: _At, argument: Any) -> None:
|
|
1790
|
+
if at.kind == 'object':
|
|
1791
|
+
for name in cast(list[str], argument):
|
|
1792
|
+
if name not in at.value:
|
|
1793
|
+
at.error('Missing property.', at.child(name))
|
|
1794
|
+
|
|
1795
|
+
def min_properties(self, at: _At, argument: Any) -> None:
|
|
1796
|
+
if at.kind == 'object' and len(at.value) < argument:
|
|
1797
|
+
at.error(f'Fewer than {to_json(argument)} properties.')
|
|
1798
|
+
|
|
1799
|
+
def max_properties(self, at: _At, argument: Any) -> None:
|
|
1800
|
+
if at.kind == 'object' and len(at.value) > argument:
|
|
1801
|
+
at.error(f'More than {to_json(argument)} properties.')
|
|
1802
|
+
|
|
1803
|
+
def dependent_required(self, at: _At, argument: Any) -> None:
|
|
1804
|
+
if at.kind == 'object':
|
|
1805
|
+
for name, names in cast(dict[str, list[str]], argument).items():
|
|
1806
|
+
for required in names if name in at.value else []:
|
|
1807
|
+
if required not in at.value:
|
|
1808
|
+
message = f'Missing property, which {to_json(name)} requires.'
|
|
1809
|
+
at.error(message, at.child(required), f'{at.where}/{_escape(name)}')
|
|
1810
|
+
|
|
1811
|
+
def dependent_schemas(self, at: _At, argument: Any) -> None:
|
|
1812
|
+
if at.kind == 'object':
|
|
1813
|
+
for name, schema in cast(dict[str, Any], argument).items():
|
|
1814
|
+
if name in at.value:
|
|
1815
|
+
self.apply(at.value, schema, at, where=f'{at.where}/{_escape(name)}')
|
|
1816
|
+
|
|
1817
|
+
# Combinations
|
|
1818
|
+
|
|
1819
|
+
def all_of(self, at: _At, argument: Any) -> None:
|
|
1820
|
+
for i, schema in enumerate(cast(list[Any], argument)):
|
|
1821
|
+
self.apply(at.value, schema, at, where=f'{at.where}/{i}')
|
|
1822
|
+
|
|
1823
|
+
def any_of(self, at: _At, argument: Any) -> None:
|
|
1824
|
+
failures: list[list[Error]] = []
|
|
1825
|
+
for i, schema in enumerate(cast(list[Any], argument)):
|
|
1826
|
+
failure = self.errors(at.value, schema, at, where=f'{at.where}/{i}')
|
|
1827
|
+
if not failure:
|
|
1828
|
+
return
|
|
1829
|
+
failures.append(failure)
|
|
1830
|
+
at.errors.extend(_mismatch(at, failures))
|
|
1831
|
+
|
|
1832
|
+
def one_of(self, at: _At, argument: Any) -> None:
|
|
1833
|
+
failures: list[list[Error]] = []
|
|
1834
|
+
valid: list[str] = []
|
|
1835
|
+
for i, schema in enumerate(cast(list[Any], argument)):
|
|
1836
|
+
failure = self.errors(at.value, schema, at, where=f'{at.where}/{i}')
|
|
1837
|
+
if failure:
|
|
1838
|
+
failures.append(failure)
|
|
1839
|
+
else:
|
|
1840
|
+
valid.append(str(i))
|
|
1841
|
+
if len(valid) > 1:
|
|
1842
|
+
at.error(f'Matches more than one schema of oneOf: {", ".join(valid)}.')
|
|
1843
|
+
elif not valid:
|
|
1844
|
+
at.errors.extend(_mismatch(at, failures))
|
|
1845
|
+
|
|
1846
|
+
def not_(self, at: _At, argument: Any) -> None:
|
|
1847
|
+
if not self.errors(at.value, argument, at):
|
|
1848
|
+
at.error('Matches the schema of not.')
|
|
1849
|
+
|
|
1850
|
+
def if_(self, at: _At, argument: Any) -> None:
|
|
1851
|
+
keyword = 'else' if self.errors(at.value, argument, at) else 'then'
|
|
1852
|
+
if keyword in at.schema:
|
|
1853
|
+
self.apply(at.value, at.schema[keyword], at, where=f'{at.where.rpartition("/")[0]}/{keyword}')
|
|
1854
|
+
|
|
1855
|
+
# References
|
|
1856
|
+
|
|
1857
|
+
def ref(self, at: _At, argument: Any) -> None:
|
|
1858
|
+
target, base = self.index.resolve(at.base, argument, at.where)
|
|
1859
|
+
self.evaluate(at.value, target, base, at.path, at.where, at.errors)
|
|
1860
|
+
|
|
1861
|
+
def unsupported(self, at: _At, argument: Any) -> None:
|
|
1862
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support {at.where.rpartition("/")[2]} in {at.where}')
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
type _Check = Callable[[_Validator, _At, Any], None]
|
|
1866
|
+
|
|
1867
|
+
# The keywords of the validation, and the ones of 2020-12 that it does not support, which a schema refers to through
|
|
1868
|
+
# a $ref of a JSON Pointer too
|
|
1869
|
+
_KEYWORDS: dict[str, _Check] = {
|
|
1870
|
+
'type': _Validator.type,
|
|
1871
|
+
'enum': _Validator.enum,
|
|
1872
|
+
'const': _Validator.const,
|
|
1873
|
+
'minimum': _Validator.minimum,
|
|
1874
|
+
'maximum': _Validator.maximum,
|
|
1875
|
+
'exclusiveMinimum': _Validator.exclusive_minimum,
|
|
1876
|
+
'exclusiveMaximum': _Validator.exclusive_maximum,
|
|
1877
|
+
'multipleOf': _Validator.multiple_of,
|
|
1878
|
+
'minLength': _Validator.min_length,
|
|
1879
|
+
'maxLength': _Validator.max_length,
|
|
1880
|
+
'pattern': _Validator.pattern,
|
|
1881
|
+
'format': _Validator.format,
|
|
1882
|
+
'items': _Validator.items,
|
|
1883
|
+
'prefixItems': _Validator.prefix_items,
|
|
1884
|
+
'minItems': _Validator.min_items,
|
|
1885
|
+
'maxItems': _Validator.max_items,
|
|
1886
|
+
'uniqueItems': _Validator.unique_items,
|
|
1887
|
+
'contains': _Validator.contains,
|
|
1888
|
+
'properties': _Validator.properties,
|
|
1889
|
+
'patternProperties': _Validator.pattern_properties,
|
|
1890
|
+
'additionalProperties': _Validator.additional_properties,
|
|
1891
|
+
'propertyNames': _Validator.property_names,
|
|
1892
|
+
'required': _Validator.required,
|
|
1893
|
+
'minProperties': _Validator.min_properties,
|
|
1894
|
+
'maxProperties': _Validator.max_properties,
|
|
1895
|
+
'dependentRequired': _Validator.dependent_required,
|
|
1896
|
+
'dependentSchemas': _Validator.dependent_schemas,
|
|
1897
|
+
'allOf': _Validator.all_of,
|
|
1898
|
+
'anyOf': _Validator.any_of,
|
|
1899
|
+
'oneOf': _Validator.one_of,
|
|
1900
|
+
'not': _Validator.not_,
|
|
1901
|
+
'if': _Validator.if_,
|
|
1902
|
+
'$ref': _Validator.ref,
|
|
1903
|
+
'$dynamicRef': _Validator.unsupported,
|
|
1904
|
+
'unevaluatedItems': _Validator.unsupported,
|
|
1905
|
+
'unevaluatedProperties': _Validator.unsupported,
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
# The types of the numbers, and the keywords of 2020-12 that the validation does not support
|
|
1909
|
+
_NUMBERS = ('integer', 'number')
|
|
1910
|
+
_UNSUPPORTED = ('$dynamicRef', 'unevaluatedItems', 'unevaluatedProperties')
|
|
1911
|
+
|
|
1912
|
+
# The keywords of a schema, of an object of schemas and of an array of schemas
|
|
1913
|
+
_SCHEMA = (
|
|
1914
|
+
'additionalProperties',
|
|
1915
|
+
'contains',
|
|
1916
|
+
'contentSchema',
|
|
1917
|
+
'else',
|
|
1918
|
+
'if',
|
|
1919
|
+
'items',
|
|
1920
|
+
'not',
|
|
1921
|
+
'propertyNames',
|
|
1922
|
+
'then',
|
|
1923
|
+
)
|
|
1924
|
+
_SCHEMAS = ('$defs', 'dependentSchemas', 'patternProperties', 'properties')
|
|
1925
|
+
_SCHEMA_LISTS = ('allOf', 'anyOf', 'oneOf', 'prefixItems')
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _mismatch(at: _At, failures: list[list[Error]]) -> list[Error]:
|
|
1929
|
+
# The errors of the schemas of anyOf or oneOf of the type of the value, or one of the types that they expect
|
|
1930
|
+
def expected(failure: list[Error]) -> list[str] | None:
|
|
1931
|
+
for error in failure:
|
|
1932
|
+
if error._expected is not None and error.path == at.path: # pyright: ignore[reportPrivateUsage]
|
|
1933
|
+
return error._expected # pyright: ignore[reportPrivateUsage]
|
|
1934
|
+
return None
|
|
1935
|
+
|
|
1936
|
+
errors = [error for failure in failures if expected(failure) is None for error in failure]
|
|
1937
|
+
if errors:
|
|
1938
|
+
return errors
|
|
1939
|
+
types = list(dict.fromkeys(t for failure in failures for t in expected(failure) or []))
|
|
1940
|
+
error = Error(at.path, at.where, f'Expected {" or ".join(types)} - got {_type_name(at.value)}.')
|
|
1941
|
+
error._expected = types # pyright: ignore[reportPrivateUsage]
|
|
1942
|
+
return [error]
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
def _json_errors(value: Any, path: str, errors: list[Error]) -> None:
|
|
1946
|
+
# The values that JSON does not have, which no schema validates
|
|
1947
|
+
if isinstance(value, dict):
|
|
1948
|
+
for name, item in cast(dict[Any, Any], value).items():
|
|
1949
|
+
if not isinstance(name, str):
|
|
1950
|
+
errors.append(Error(path, '', f'Not a JSON value: the name {name!r}.'))
|
|
1951
|
+
else:
|
|
1952
|
+
_json_errors(item, f'{path}/{_escape(name)}', errors)
|
|
1953
|
+
elif isinstance(value, (list, builtins.tuple)):
|
|
1954
|
+
for i, item in enumerate(cast(list[Any], value)):
|
|
1955
|
+
_json_errors(item, f'{path}/{i}', errors)
|
|
1956
|
+
elif isinstance(value, float) and not math.isfinite(value):
|
|
1957
|
+
errors.append(Error(path, '', f'Not a JSON value: {value!r}.'))
|
|
1958
|
+
elif not (value is None or isinstance(value, (bool, builtins.int, float, str))):
|
|
1959
|
+
errors.append(Error(path, '', f'Not a JSON value: {type(value).__name__}.'))
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def _instance_type(value: Any) -> str:
|
|
1963
|
+
if isinstance(value, float):
|
|
1964
|
+
return 'integer' if value.is_integer() else 'number'
|
|
1965
|
+
return _type_name(value)
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
def _type_name(value: Any) -> str:
|
|
1969
|
+
return _json_type(value) or ('object' if isinstance(value, dict) else 'array')
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def _canonical(value: Any) -> Any:
|
|
1973
|
+
# A value that is equal to another one as JSON has it: 1 and 1.0 are, True and 1 are not
|
|
1974
|
+
if isinstance(value, bool) or value is None:
|
|
1975
|
+
return ('literal', value)
|
|
1976
|
+
if isinstance(value, (builtins.int, float)):
|
|
1977
|
+
return ('number', value)
|
|
1978
|
+
if isinstance(value, dict):
|
|
1979
|
+
return ('object', frozenset((name, _canonical(item)) for name, item in cast(dict[str, Any], value).items()))
|
|
1980
|
+
if isinstance(value, (list, builtins.tuple)):
|
|
1981
|
+
return ('array', builtins.tuple(_canonical(item) for item in cast(list[Any], value)))
|
|
1982
|
+
return ('string', value)
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def _fraction(number: float) -> fractions.Fraction:
|
|
1986
|
+
# The number as JSON writes it, so that 0.0075 is a multiple of 0.0001
|
|
1987
|
+
return fractions.Fraction(number) if isinstance(number, builtins.int) else fractions.Fraction(repr(number))
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
def _escape(name: Any) -> str:
|
|
1991
|
+
return str(name).replace('~', '~0').replace('/', '~1')
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def _uri(base: str, ref: str) -> tuple[str, str]:
|
|
1995
|
+
# The URI of a reference without its fragment, and the fragment, decoded
|
|
1996
|
+
url = Pyjo.URL.new(ref).to_abs(Pyjo.URL.new(base))
|
|
1997
|
+
fragment = url.fragment or ''
|
|
1998
|
+
url.fragment = None
|
|
1999
|
+
return url.to_str(), fragment
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
# The classes of ECMAScript, ASCII but the white space, as ranges of code points
|
|
2003
|
+
_ECMA_CLASSES: dict[str, list[builtins.tuple[builtins.int, builtins.int]]] = {
|
|
2004
|
+
'd': [(0x30, 0x39)],
|
|
2005
|
+
'w': [(0x30, 0x39), (0x41, 0x5A), (0x5F, 0x5F), (0x61, 0x7A)],
|
|
2006
|
+
's': [
|
|
2007
|
+
(0x09, 0x0D),
|
|
2008
|
+
(0x20, 0x20),
|
|
2009
|
+
(0xA0, 0xA0),
|
|
2010
|
+
(0x1680, 0x1680),
|
|
2011
|
+
(0x2000, 0x200A),
|
|
2012
|
+
(0x2028, 0x2029),
|
|
2013
|
+
(0x202F, 0x202F),
|
|
2014
|
+
(0x205F, 0x205F),
|
|
2015
|
+
(0x3000, 0x3000),
|
|
2016
|
+
(0xFEFF, 0xFEFF),
|
|
2017
|
+
],
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
def _ranges(ranges: list[builtins.tuple[builtins.int, builtins.int]], negate: bool) -> str:
|
|
2022
|
+
if negate:
|
|
2023
|
+
ranges = _complement(ranges)
|
|
2024
|
+
return ''.join(f'\\U{low:08x}' if low == high else f'\\U{low:08x}-\\U{high:08x}' for low, high in ranges)
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
# The groups of ECMAScript, with the modifiers of ES2025
|
|
2028
|
+
_RE_ECMA_GROUP = re.compile(r'\(\?(?:[:=!]|<[=!]|[ims]*(?:-[ims]*)?:)')
|
|
2029
|
+
|
|
2030
|
+
# The general categories of Unicode, by their aliases, which \p of ECMAScript takes
|
|
2031
|
+
_CATEGORIES = {
|
|
2032
|
+
'C': 'C Other',
|
|
2033
|
+
'Cc': 'Cc Control cntrl',
|
|
2034
|
+
'Cf': 'Cf Format',
|
|
2035
|
+
'Cn': 'Cn Unassigned',
|
|
2036
|
+
'Co': 'Co Private_Use',
|
|
2037
|
+
'Cs': 'Cs Surrogate',
|
|
2038
|
+
'L': 'L Letter',
|
|
2039
|
+
'LC': 'LC Cased_Letter',
|
|
2040
|
+
'Ll': 'Ll Lowercase_Letter',
|
|
2041
|
+
'Lm': 'Lm Modifier_Letter',
|
|
2042
|
+
'Lo': 'Lo Other_Letter',
|
|
2043
|
+
'Lt': 'Lt Titlecase_Letter',
|
|
2044
|
+
'Lu': 'Lu Uppercase_Letter',
|
|
2045
|
+
'M': 'M Mark Combining_Mark',
|
|
2046
|
+
'Mc': 'Mc Spacing_Mark',
|
|
2047
|
+
'Me': 'Me Enclosing_Mark',
|
|
2048
|
+
'Mn': 'Mn Nonspacing_Mark',
|
|
2049
|
+
'N': 'N Number',
|
|
2050
|
+
'Nd': 'Nd Decimal_Number digit',
|
|
2051
|
+
'Nl': 'Nl Letter_Number',
|
|
2052
|
+
'No': 'No Other_Number',
|
|
2053
|
+
'P': 'P Punctuation punct',
|
|
2054
|
+
'Pc': 'Pc Connector_Punctuation',
|
|
2055
|
+
'Pd': 'Pd Dash_Punctuation',
|
|
2056
|
+
'Pe': 'Pe Close_Punctuation',
|
|
2057
|
+
'Pf': 'Pf Final_Punctuation',
|
|
2058
|
+
'Pi': 'Pi Initial_Punctuation',
|
|
2059
|
+
'Po': 'Po Other_Punctuation',
|
|
2060
|
+
'Ps': 'Ps Open_Punctuation',
|
|
2061
|
+
'S': 'S Symbol',
|
|
2062
|
+
'Sc': 'Sc Currency_Symbol',
|
|
2063
|
+
'Sk': 'Sk Modifier_Symbol',
|
|
2064
|
+
'Sm': 'Sm Math_Symbol',
|
|
2065
|
+
'So': 'So Other_Symbol',
|
|
2066
|
+
'Z': 'Z Separator',
|
|
2067
|
+
'Zl': 'Zl Line_Separator',
|
|
2068
|
+
'Zp': 'Zp Paragraph_Separator',
|
|
2069
|
+
'Zs': 'Zs Space_Separator',
|
|
2070
|
+
}
|
|
2071
|
+
_CATEGORY_ALIASES = {alias: category for category, aliases in _CATEGORIES.items() for alias in aliases.split()}
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
@functools.cache
|
|
2075
|
+
def _category_ranges() -> dict[str, list[builtins.tuple[builtins.int, builtins.int]]]:
|
|
2076
|
+
# The ranges of the code points of each general category of unicodedata, once a pattern has \p
|
|
2077
|
+
ranges: dict[str, list[builtins.tuple[builtins.int, builtins.int]]] = {}
|
|
2078
|
+
start, current = 0, unicodedata.category('\0')
|
|
2079
|
+
for code in range(1, sys.maxunicode + 2):
|
|
2080
|
+
category = unicodedata.category(chr(code)) if code <= sys.maxunicode else ''
|
|
2081
|
+
if category != current:
|
|
2082
|
+
ranges.setdefault(current, []).append((start, code - 1))
|
|
2083
|
+
start, current = code, category
|
|
2084
|
+
return ranges
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
def _property(source: str, name: str) -> list[builtins.tuple[builtins.int, builtins.int]]:
|
|
2088
|
+
# The code points of a property of \p, a general category or ASCII, Any and Assigned
|
|
2089
|
+
name = name.removeprefix('General_Category=').removeprefix('gc=')
|
|
2090
|
+
if name in ('ASCII', 'Any'):
|
|
2091
|
+
return [(0, 0x7F if name == 'ASCII' else sys.maxunicode)]
|
|
2092
|
+
category = 'Cn' if name == 'Assigned' else _CATEGORY_ALIASES.get(name)
|
|
2093
|
+
if category is None:
|
|
2094
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support the pattern {source}: the property {name}')
|
|
2095
|
+
ranges = sorted(
|
|
2096
|
+
r
|
|
2097
|
+
for c, rs in _category_ranges().items()
|
|
2098
|
+
if c == category or (len(category) == 1 and c[0] == category) or (category == 'LC' and c in ('Ll', 'Lt', 'Lu'))
|
|
2099
|
+
for r in rs
|
|
2100
|
+
)
|
|
2101
|
+
if name == 'Assigned':
|
|
2102
|
+
return _complement(ranges)
|
|
2103
|
+
return ranges
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _complement(
|
|
2107
|
+
ranges: list[builtins.tuple[builtins.int, builtins.int]],
|
|
2108
|
+
) -> list[builtins.tuple[builtins.int, builtins.int]]:
|
|
2109
|
+
complement: list[builtins.tuple[builtins.int, builtins.int]] = []
|
|
2110
|
+
start = 0
|
|
2111
|
+
for low, high in ranges:
|
|
2112
|
+
if low > start:
|
|
2113
|
+
complement.append((start, low - 1))
|
|
2114
|
+
start = high + 1
|
|
2115
|
+
return [*complement, (start, sys.maxunicode)] if start <= sys.maxunicode else complement
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _regex(source: str) -> re.Pattern[str]:
|
|
2119
|
+
# A regular expression of ECMAScript as one of Python: \d, \w and \b are ASCII, \s has the white space of
|
|
2120
|
+
# ECMAScript, . matches no line terminator, $ only the end, and the named groups have the syntax of Python
|
|
2121
|
+
out: list[str] = []
|
|
2122
|
+
i, in_class = 0, False
|
|
2123
|
+
while i < len(source):
|
|
2124
|
+
c = source[i]
|
|
2125
|
+
if c == '\\' and i + 1 < len(source):
|
|
2126
|
+
e = source[i + 1]
|
|
2127
|
+
i += 2
|
|
2128
|
+
if e in 'dDsSwW' or (e in 'pP' and source.startswith('{', i) and '}' in source[i:]):
|
|
2129
|
+
if e in 'pP':
|
|
2130
|
+
end = source.index('}', i)
|
|
2131
|
+
ranges = _ranges(_property(source, source[i + 1 : end]), e == 'P')
|
|
2132
|
+
i = end + 1
|
|
2133
|
+
else:
|
|
2134
|
+
ranges = _ranges(_ECMA_CLASSES[e.lower()], e.isupper())
|
|
2135
|
+
out.append(ranges if in_class else f'[{ranges}]')
|
|
2136
|
+
elif e in 'bB' and not in_class:
|
|
2137
|
+
out.append(f'(?a:\\{e})')
|
|
2138
|
+
elif e == 'k' and source.startswith('<', i) and '>' in source[i:]:
|
|
2139
|
+
end = source.index('>', i)
|
|
2140
|
+
out.append(f'(?P={source[i + 1 : end]})')
|
|
2141
|
+
i = end + 1
|
|
2142
|
+
elif e == 'c' and i < len(source) and source[i].isascii() and source[i].isalpha():
|
|
2143
|
+
out.append(f'\\x{ord(source[i]) % 32:02x}')
|
|
2144
|
+
i += 1
|
|
2145
|
+
elif e == 'u' and source.startswith('{', i) and '}' in source[i:]:
|
|
2146
|
+
end = source.index('}', i)
|
|
2147
|
+
out.append(f'\\U{builtins.int(source[i + 1 : end], 16):08x}')
|
|
2148
|
+
i = end + 1
|
|
2149
|
+
elif e.isascii() and e.isalpha() and e not in 'bBcdDfknpPrsStuvwWx':
|
|
2150
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support the pattern {source}: \\{e} of Python')
|
|
2151
|
+
else:
|
|
2152
|
+
out.append(c + e)
|
|
2153
|
+
continue
|
|
2154
|
+
if in_class:
|
|
2155
|
+
if c == ']':
|
|
2156
|
+
in_class = False
|
|
2157
|
+
out.append(f'\\{c}' if c in '[&~|' else c)
|
|
2158
|
+
elif source.startswith('[]', i):
|
|
2159
|
+
out.append('(?!)')
|
|
2160
|
+
i += 1
|
|
2161
|
+
elif source.startswith('[^]', i):
|
|
2162
|
+
out.append('[\\s\\S]')
|
|
2163
|
+
i += 2
|
|
2164
|
+
elif c == '[':
|
|
2165
|
+
in_class = True
|
|
2166
|
+
out.append('[^' if source.startswith('[^', i) else '[')
|
|
2167
|
+
i += 1 if source.startswith('[^', i) else 0
|
|
2168
|
+
elif c == '.':
|
|
2169
|
+
out.append('[^\\n\\r\\u2028\\u2029]')
|
|
2170
|
+
elif c == '$':
|
|
2171
|
+
out.append('\\Z')
|
|
2172
|
+
elif source.startswith('(?<', i) and not source.startswith(('(?<=', '(?<!'), i):
|
|
2173
|
+
out.append('(?P<')
|
|
2174
|
+
i += 2
|
|
2175
|
+
elif source.startswith('(?', i) and not _RE_ECMA_GROUP.match(source, i):
|
|
2176
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support the pattern {source}: a group of Python')
|
|
2177
|
+
else:
|
|
2178
|
+
out.append(c)
|
|
2179
|
+
i += 1
|
|
2180
|
+
try:
|
|
2181
|
+
return re.compile(''.join(out))
|
|
2182
|
+
except re.error as e:
|
|
2183
|
+
raise Pyjo.Exception.new(f'JSON Schema does not support the pattern {source}: {e}') from None
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
# The formats of RFC 3339, of RFC 3986 and the others, which validate a string
|
|
2187
|
+
_RE_DATE = re.compile(r'([0-9]{4})-([0-9]{2})-([0-9]{2})')
|
|
2188
|
+
_RE_TIME = re.compile(r'([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(?:[Zz]|([+-])([0-9]{2}):([0-9]{2}))')
|
|
2189
|
+
_RE_DURATION = re.compile(
|
|
2190
|
+
r'P(?:[0-9]+W|(?:[0-9]+Y(?:[0-9]+M(?:[0-9]+D)?)?|[0-9]+M(?:[0-9]+D)?|[0-9]+D)(?:T(?:{0}))?|T(?:{0}))'.format(
|
|
2191
|
+
r'[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S'
|
|
2192
|
+
)
|
|
2193
|
+
)
|
|
2194
|
+
_RE_EMAIL_LOCAL = re.compile(
|
|
2195
|
+
r'''[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x20\x21\x23-\x5b\x5d-\x7e]|\\[\x20-\x7e])*"'''
|
|
2196
|
+
)
|
|
2197
|
+
_RE_HOSTNAME_LABEL = re.compile(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?')
|
|
2198
|
+
_RE_IPV4 = re.compile(
|
|
2199
|
+
r'(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
|
|
2200
|
+
)
|
|
2201
|
+
_RE_JSON_POINTER = re.compile(r'(?:/(?:[^~/]|~[01])*)*')
|
|
2202
|
+
_RE_RELATIVE_JSON_POINTER = re.compile(r'(?:0|[1-9][0-9]*)(?:#|(?:/(?:[^~/]|~[01])*)*)')
|
|
2203
|
+
_RE_UUID = re.compile(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')
|
|
2204
|
+
|
|
2205
|
+
_URI_PCHAR = r"(?:[A-Za-z0-9\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})"
|
|
2206
|
+
_URI_SEGMENT_NC = r"(?:[A-Za-z0-9\-._~!$&'()*+,;=@]|%[0-9A-Fa-f]{2})"
|
|
2207
|
+
_URI_AUTHORITY = (
|
|
2208
|
+
r"//(?:(?:[A-Za-z0-9\-._~!$&'()*+,;=:]|%[0-9A-Fa-f]{2})*@)?"
|
|
2209
|
+
r"(?:\[(?P<ip>[^\]]*)\]|(?:[A-Za-z0-9\-._~!$&'()*+,;=]|%[0-9A-Fa-f]{2})*)(?::[0-9]*)?"
|
|
2210
|
+
)
|
|
2211
|
+
_URI_ABSOLUTE = rf'/(?:{_URI_PCHAR}+(?:/{_URI_PCHAR}*)*)?'
|
|
2212
|
+
_URI_END = rf'(?:\?(?:{_URI_PCHAR}|[/?])*)?(?:#(?:{_URI_PCHAR}|[/?])*)?'
|
|
2213
|
+
_URI = (
|
|
2214
|
+
rf'[A-Za-z][A-Za-z0-9+\-.]*:(?:{_URI_AUTHORITY}(?:/{_URI_PCHAR}*)*|{_URI_ABSOLUTE}'
|
|
2215
|
+
rf'|{_URI_PCHAR}+(?:/{_URI_PCHAR}*)*)?{_URI_END}'
|
|
2216
|
+
)
|
|
2217
|
+
_RE_URI = re.compile(_URI)
|
|
2218
|
+
_RE_URI_REFERENCE = re.compile(
|
|
2219
|
+
rf'{_URI}|(?:{_URI_AUTHORITY.replace("(?P<ip>", "(?P<relative_ip>")}(?:/{_URI_PCHAR}*)*|{_URI_ABSOLUTE}'
|
|
2220
|
+
rf'|{_URI_SEGMENT_NC}+(?:/{_URI_PCHAR}*)*)?{_URI_END}'
|
|
2221
|
+
)
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
def _date(year: str, month: str, day: str) -> bool:
|
|
2225
|
+
try:
|
|
2226
|
+
datetime.date(builtins.int(year), builtins.int(month), builtins.int(day))
|
|
2227
|
+
except ValueError:
|
|
2228
|
+
return False
|
|
2229
|
+
return True
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _time(value: str) -> bool:
|
|
2233
|
+
match = _RE_TIME.fullmatch(value)
|
|
2234
|
+
if match is None:
|
|
2235
|
+
return False
|
|
2236
|
+
hour, minute, second, sign, offset_hour, offset_minute = match.groups('0')
|
|
2237
|
+
if builtins.int(hour) > 23 or builtins.int(minute) > 59 or builtins.int(second) > 60:
|
|
2238
|
+
return False
|
|
2239
|
+
if builtins.int(offset_hour) > 23 or builtins.int(offset_minute) > 59:
|
|
2240
|
+
return False
|
|
2241
|
+
if second == '60':
|
|
2242
|
+
# A leap second is the last second of a day of UTC
|
|
2243
|
+
offset = (builtins.int(offset_hour) * 60 + builtins.int(offset_minute)) * (-1 if sign == '-' else 1)
|
|
2244
|
+
return (builtins.int(hour) * 60 + builtins.int(minute) - offset) % 1440 == 23 * 60 + 59
|
|
2245
|
+
return True
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
def _format_date(value: str) -> bool:
|
|
2249
|
+
match = _RE_DATE.fullmatch(value)
|
|
2250
|
+
return match is not None and _date(*match.groups())
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
def _format_date_time(value: str) -> bool:
|
|
2254
|
+
match = _RE_DATE.match(value)
|
|
2255
|
+
return match is not None and value[10:11] in ('T', 't') and _date(*match.groups()) and _time(value[11:])
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def _format_email(value: str) -> bool:
|
|
2259
|
+
local, at, domain = value.rpartition('@')
|
|
2260
|
+
if not at or _RE_EMAIL_LOCAL.fullmatch(local) is None:
|
|
2261
|
+
return False
|
|
2262
|
+
if domain[:6].lower() == '[ipv6:' and domain.endswith(']'):
|
|
2263
|
+
return _format_ipv6(domain[6:-1])
|
|
2264
|
+
if domain.startswith('[') and domain.endswith(']'):
|
|
2265
|
+
return _RE_IPV4.fullmatch(domain[1:-1]) is not None
|
|
2266
|
+
return _format_hostname(domain)
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def _format_hostname(value: str) -> bool:
|
|
2270
|
+
labels = value.split('.')
|
|
2271
|
+
return len(value) <= 253 and all(_RE_HOSTNAME_LABEL.fullmatch(label) and _punycode(label) for label in labels)
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _punycode(label: str) -> bool:
|
|
2275
|
+
# An A-label of IDNA is Punycode
|
|
2276
|
+
if label[:4].lower() != 'xn--':
|
|
2277
|
+
return True
|
|
2278
|
+
try:
|
|
2279
|
+
punycode_decode(label[4:])
|
|
2280
|
+
except UnicodeError:
|
|
2281
|
+
return False
|
|
2282
|
+
return True
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
def _format_ipv6(value: str) -> bool:
|
|
2286
|
+
if not value.isascii() or '%' in value:
|
|
2287
|
+
return False
|
|
2288
|
+
try:
|
|
2289
|
+
ipaddress.IPv6Address(value)
|
|
2290
|
+
except ValueError:
|
|
2291
|
+
return False
|
|
2292
|
+
return True
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def _format_regex(value: str) -> bool:
|
|
2296
|
+
try:
|
|
2297
|
+
_regex(value)
|
|
2298
|
+
except Pyjo.Exception.object:
|
|
2299
|
+
return False
|
|
2300
|
+
return True
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
def _format_uri(regex: re.Pattern[str]) -> Callable[[str], bool]:
|
|
2304
|
+
def check(value: str) -> bool:
|
|
2305
|
+
match = regex.fullmatch(value)
|
|
2306
|
+
if match is None:
|
|
2307
|
+
return False
|
|
2308
|
+
ip = match.groupdict().get('ip') or match.groupdict().get('relative_ip')
|
|
2309
|
+
return (
|
|
2310
|
+
ip is None
|
|
2311
|
+
or _format_ipv6(ip)
|
|
2312
|
+
or re.fullmatch(r"[vV][0-9A-Fa-f]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+", ip) is not None
|
|
2313
|
+
)
|
|
2314
|
+
|
|
2315
|
+
return check
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
_FORMAT_CHECKS: dict[str, Callable[[str], bool]] = {
|
|
2319
|
+
'date': _format_date,
|
|
2320
|
+
'date-time': _format_date_time,
|
|
2321
|
+
'duration': lambda value: _RE_DURATION.fullmatch(value) is not None,
|
|
2322
|
+
'email': _format_email,
|
|
2323
|
+
'hostname': _format_hostname,
|
|
2324
|
+
'ipv4': lambda value: _RE_IPV4.fullmatch(value) is not None,
|
|
2325
|
+
'ipv6': _format_ipv6,
|
|
2326
|
+
'json-pointer': lambda value: _RE_JSON_POINTER.fullmatch(value) is not None,
|
|
2327
|
+
'regex': _format_regex,
|
|
2328
|
+
'relative-json-pointer': lambda value: _RE_RELATIVE_JSON_POINTER.fullmatch(value) is not None,
|
|
2329
|
+
'time': _time,
|
|
2330
|
+
'uri': _format_uri(_RE_URI),
|
|
2331
|
+
'uri-reference': _format_uri(_RE_URI_REFERENCE),
|
|
2332
|
+
'uuid': lambda value: _RE_UUID.fullmatch(value) is not None,
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
|
|
2336
|
+
def _literal(values: list[Any], enumeration: bool = False) -> dict[str, Any]:
|
|
2337
|
+
keywords: dict[str, Any] = {}
|
|
2338
|
+
types = {_json_type(value) for value in values}
|
|
2339
|
+
if len(types) == 1 and None not in types:
|
|
2340
|
+
keywords['type'] = types.pop()
|
|
2341
|
+
if len(values) == 1 and not enumeration:
|
|
2342
|
+
keywords['const'] = _json_value(values[0])
|
|
2343
|
+
else:
|
|
2344
|
+
keywords['enum'] = [_json_value(value) for value in values]
|
|
2345
|
+
return keywords
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
def _nullable(schema: Pyjo_JSON_Schema) -> dict[str, Any]:
|
|
2349
|
+
keywords = schema.keywords
|
|
2350
|
+
if not schema.is_lazy and isinstance(keywords, dict) and builtins.list(keywords) == ['type']:
|
|
2351
|
+
types = keywords['type']
|
|
2352
|
+
if isinstance(types, str):
|
|
2353
|
+
return {'type': [types, 'null']}
|
|
2354
|
+
return {'type': [*cast(list[str], types), 'null']} if 'null' not in types else {'type': types}
|
|
2355
|
+
return {'anyOf': [schema, {'type': 'null'}]}
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
def _json_type(value: Any) -> str | None:
|
|
2359
|
+
if isinstance(value, enum.Enum):
|
|
2360
|
+
value = value.value
|
|
2361
|
+
if value is None:
|
|
2362
|
+
return 'null'
|
|
2363
|
+
if isinstance(value, bool):
|
|
2364
|
+
return 'boolean'
|
|
2365
|
+
if isinstance(value, builtins.int):
|
|
2366
|
+
return 'integer'
|
|
2367
|
+
if isinstance(value, float):
|
|
2368
|
+
return 'number'
|
|
2369
|
+
if isinstance(value, str):
|
|
2370
|
+
return 'string'
|
|
2371
|
+
return None
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
def _json_value(value: Any) -> Any:
|
|
2375
|
+
# A default or a literal as JSON has it, or _MISSING for one that it has not
|
|
2376
|
+
if isinstance(value, enum.Enum):
|
|
2377
|
+
value = value.value
|
|
2378
|
+
if value is None or isinstance(value, (bool, builtins.int, float, str)):
|
|
2379
|
+
return value
|
|
2380
|
+
if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
|
|
2381
|
+
return value.isoformat()
|
|
2382
|
+
if isinstance(value, uuid.UUID):
|
|
2383
|
+
return str(value)
|
|
2384
|
+
if isinstance(value, (list, builtins.tuple)):
|
|
2385
|
+
items = [_json_value(item) for item in cast(list[Any], value)]
|
|
2386
|
+
return _MISSING if _MISSING in items else items
|
|
2387
|
+
if isinstance(value, dict):
|
|
2388
|
+
items = {name: _json_value(item) for name, item in cast(dict[Any, Any], value).items()}
|
|
2389
|
+
if _MISSING in items.values() or not all(isinstance(name, str) for name in items):
|
|
2390
|
+
return _MISSING
|
|
2391
|
+
return items
|
|
2392
|
+
return _MISSING
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
def _name(tp: Any) -> str:
|
|
2396
|
+
if isinstance(tp, type):
|
|
2397
|
+
return tp.__name__ if tp.__module__ == 'builtins' else f'{tp.__module__}.{tp.__qualname__}'
|
|
2398
|
+
return repr(tp)
|
|
2399
|
+
|
|
2400
|
+
|
|
2401
|
+
def _hints(obj: Any, where: str) -> dict[str, Any]:
|
|
2402
|
+
try:
|
|
2403
|
+
return typing.get_type_hints(obj, include_extras=True)
|
|
2404
|
+
except NameError as e:
|
|
2405
|
+
raise Pyjo.Exception.new(f'JSON Schema{where}: {e}') from e
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def _signature(function: Callable[..., Any]) -> tuple[inspect.Signature, dict[str, Any]]:
|
|
2409
|
+
target: Any = function
|
|
2410
|
+
# The annotations of a class are those of its method __init__, and of another callable object of __call__
|
|
2411
|
+
if isinstance(target, type):
|
|
2412
|
+
target = target.__init__
|
|
2413
|
+
elif not (inspect.isfunction(target) or inspect.ismethod(target)):
|
|
2414
|
+
target = target.__call__
|
|
2415
|
+
return inspect.signature(function), _hints(
|
|
2416
|
+
target, f' of the function {getattr(function, "__qualname__", function)}'
|
|
2417
|
+
)
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
def _description(tp: Any) -> str | None:
|
|
2421
|
+
if not isinstance(tp, type):
|
|
2422
|
+
return None
|
|
2423
|
+
name, doc = tp.__name__, tp.__dict__.get('__doc__')
|
|
2424
|
+
# A docstring of its own, not the one that dataclasses makes of the signature, a descriptor on Python 3.15
|
|
2425
|
+
if not isinstance(doc, str) or not doc:
|
|
2426
|
+
return None
|
|
2427
|
+
if dataclasses.is_dataclass(tp) and re.fullmatch(re.escape(name) + r'\(.*\)', doc, re.S):
|
|
2428
|
+
return None
|
|
2429
|
+
return _docstringto_json(inspect.cleandoc(doc))[0]
|
|
2430
|
+
|
|
2431
|
+
|
|
2432
|
+
def _docstring(function: Callable[..., Any]) -> tuple[str | None, dict[str, str], str | None]:
|
|
2433
|
+
return _docstringto_json(inspect.getdoc(function) or '')
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
def _docstringto_json(doc: str) -> tuple[str | None, dict[str, str], str | None]:
|
|
2437
|
+
# The first paragraph, and the descriptions of the parameters and of the return value, in the fields of
|
|
2438
|
+
# reStructuredText or in the sections of the Google Python Style Guide, each with the lines that continue it
|
|
2439
|
+
lines = doc.splitlines()
|
|
2440
|
+
paragraph: list[str] = []
|
|
2441
|
+
for line in lines:
|
|
2442
|
+
if not line.strip() or _RE_FIELD.match(line) or _RE_ARGS.fullmatch(line) or _RE_SECTION.fullmatch(line):
|
|
2443
|
+
break
|
|
2444
|
+
paragraph.append(line.strip())
|
|
2445
|
+
|
|
2446
|
+
params: dict[str, list[str]] = {}
|
|
2447
|
+
returns: list[str] = []
|
|
2448
|
+
current: list[str] | None = None
|
|
2449
|
+
section: str | None = None
|
|
2450
|
+
indent = item = 0
|
|
2451
|
+
for line in lines:
|
|
2452
|
+
text = line.strip()
|
|
2453
|
+
depth = len(line) - len(line.lstrip())
|
|
2454
|
+
if not text:
|
|
2455
|
+
continue
|
|
2456
|
+
if section is not None and depth <= indent:
|
|
2457
|
+
section = current = None
|
|
2458
|
+
if section == 'args':
|
|
2459
|
+
match = _RE_ITEM.fullmatch(text)
|
|
2460
|
+
if match and (current is None or depth <= item):
|
|
2461
|
+
item, current = depth, [match.group(2)]
|
|
2462
|
+
params[match.group(1)] = current
|
|
2463
|
+
elif current is not None:
|
|
2464
|
+
current.append(text)
|
|
2465
|
+
elif section == 'returns':
|
|
2466
|
+
match = _RE_RETURN_TYPE.fullmatch(text)
|
|
2467
|
+
returns.append(match.group(1) if match and not returns else text)
|
|
2468
|
+
elif _RE_ARGS.fullmatch(text):
|
|
2469
|
+
section, indent, current = 'args', depth, None
|
|
2470
|
+
elif _RE_RETURNS.fullmatch(text):
|
|
2471
|
+
section, indent, current = 'returns', depth, None
|
|
2472
|
+
elif _RE_SECTION.fullmatch(text):
|
|
2473
|
+
section, indent, current = 'other', depth, None
|
|
2474
|
+
elif match := _RE_FIELD.match(text):
|
|
2475
|
+
kind, name, rest = match.groups()
|
|
2476
|
+
if kind in ('return', 'returns'):
|
|
2477
|
+
current = returns
|
|
2478
|
+
returns.append(rest)
|
|
2479
|
+
elif kind in ('type', 'rtype', 'raise', 'raises') or not name.split():
|
|
2480
|
+
current = None
|
|
2481
|
+
else:
|
|
2482
|
+
current = [rest]
|
|
2483
|
+
params[name.split()[-1]] = current
|
|
2484
|
+
elif current is not None and depth > 0:
|
|
2485
|
+
current.append(text)
|
|
2486
|
+
else:
|
|
2487
|
+
current = None
|
|
2488
|
+
|
|
2489
|
+
descriptions = {name: ' '.join(text).strip() for name, text in params.items()}
|
|
2490
|
+
return (
|
|
2491
|
+
' '.join(paragraph) or None,
|
|
2492
|
+
{name: text for name, text in descriptions.items() if text},
|
|
2493
|
+
' '.join(returns).strip() or None,
|
|
2494
|
+
)
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
new = Pyjo_JSON_Schema.new
|
|
2498
|
+
object = Pyjo_JSON_Schema
|
|
2499
|
+
Schema = Pyjo_JSON_Schema
|
|
2500
|
+
|
|
2501
|
+
of = Pyjo_JSON_Schema.of
|
|
2502
|
+
parameters = Pyjo_JSON_Schema.parameters
|
|
2503
|
+
returns = Pyjo_JSON_Schema.returns
|
|
2504
|
+
|
|
2505
|
+
# The builder of schemas, the class itself, as z of zod
|
|
2506
|
+
z = Pyjo_JSON_Schema
|