jsonschema-extras 0.2.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.
Files changed (32) hide show
  1. jsonschema_extras/VERSION +1 -0
  2. jsonschema_extras/__init__.py +96 -0
  3. jsonschema_extras/_common.py +31 -0
  4. jsonschema_extras/_util/__init__.py +21 -0
  5. jsonschema_extras/_util/tests/__init__.py +0 -0
  6. jsonschema_extras/_util/tests/test_uri.py +210 -0
  7. jsonschema_extras/_util/uri.py +125 -0
  8. jsonschema_extras/formats/__init__.py +30 -0
  9. jsonschema_extras/formats/_common.py +74 -0
  10. jsonschema_extras/formats/numbers_range.py +57 -0
  11. jsonschema_extras/formats/slice_string.py +69 -0
  12. jsonschema_extras/formats/tests/__init__.py +0 -0
  13. jsonschema_extras/formats/tests/test_numbers_range.py +22 -0
  14. jsonschema_extras/formats/tests/test_slice_string.py +39 -0
  15. jsonschema_extras/registries/__init__.py +25 -0
  16. jsonschema_extras/registries/_common.py +209 -0
  17. jsonschema_extras/registries/filesystem.py +330 -0
  18. jsonschema_extras/registries/retrieval/__init__.py +184 -0
  19. jsonschema_extras/registries/retrieval/_common.py +14 -0
  20. jsonschema_extras/registries/retrieval/json.py +18 -0
  21. jsonschema_extras/registries/tests/__init__.py +0 -0
  22. jsonschema_extras/registries/tests/test_filesystem.py +131 -0
  23. jsonschema_extras/schemas/common/range.json +15 -0
  24. jsonschema_extras/schemas/common/range_integer.json +12 -0
  25. jsonschema_extras/schemas/common/slice_object.json +29 -0
  26. jsonschema_extras/schemas/common/slice_string.json +21 -0
  27. jsonschema_extras/typing.py +4 -0
  28. jsonschema_extras-0.2.0.dist-info/METADATA +52 -0
  29. jsonschema_extras-0.2.0.dist-info/RECORD +32 -0
  30. jsonschema_extras-0.2.0.dist-info/WHEEL +5 -0
  31. jsonschema_extras-0.2.0.dist-info/licenses/LICENSE.txt +21 -0
  32. jsonschema_extras-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -0,0 +1,96 @@
1
+ """Utilities for working with the JSON Schema Python library
2
+ `jsonschema <https://pypi.org/project/jsonschema/>`__.
3
+
4
+ Features:
5
+
6
+ - :doc:`Schemas bundled with the library </schemas>`
7
+
8
+ - :doc:`Formats bundled with the library </formats>`
9
+
10
+ - :doc:`Utilities for accessing schemas on a filesystem </filesystem>`
11
+
12
+ - :doc:`Other generally useful utilities </misc>`
13
+ """
14
+
15
+ from collections.abc import Iterator
16
+ from contextlib import contextmanager
17
+ import importlib.resources
18
+ from importlib.resources.abc import Traversable
19
+ from typing import Any, Final
20
+
21
+ from referencing.typing import Retrieve
22
+
23
+ from .registries import build_schemas_from_filesystem_retriever
24
+ from .registries.retrieval import CacheFn, CacheSpecDefault
25
+ from .registries.retrieval.json import LOADS_FN_JSON_DEFAULT
26
+
27
+
28
+ __all__ = (
29
+ 'bundled_schemas_files',
30
+ 'BUNDLED_SCHEMAS_URI_BASE',
31
+ 'bundled_schemas_retriever',
32
+ )
33
+
34
+
35
+ def bundled_schemas_files() -> Traversable:
36
+ """Returns a :class:`~importlib.resources.abc.Traversable` object
37
+ containing resources for bundled schemas.
38
+
39
+ Built upon :func:`importlib.resources.files`.
40
+ """
41
+ return (importlib.resources.files('jsonschema_extras') / 'schemas')
42
+
43
+
44
+ #: Base URI for schemas bundled with this library.
45
+ #:
46
+ #: See Also:
47
+ #: :func:`bundled_schemas_retriever`
48
+ BUNDLED_SCHEMAS_URI_BASE: Final[str] = 'file:/jsonschema_extras/schemas'
49
+
50
+ _BUNDLED_SCHEMAS_ENCODING: Final = 'utf-8'
51
+
52
+
53
+ @contextmanager
54
+ def bundled_schemas_retriever(
55
+ *, open_buffering: int = -1, cache: CacheFn[Any] | CacheSpecDefault | None = None,
56
+ ) -> Iterator[Retrieve]:
57
+ """**Context manager** producing a retrieval callable for this library's
58
+ bundled schemas.
59
+
60
+ Warning:
61
+ The produced retriever is only valid within duration of an explicit
62
+ lifecycle, hence the context manager.
63
+
64
+ Args:
65
+ cache (CacheFn | CacheSpecDefault, optional):
66
+ Caching decorator for :class:`~referencing.typing.Retrieve`,
67
+ or ``'default'`` to use the default caching implementation
68
+ (see description of :mod:`jsonschema_extras.registries.retrieval`
69
+ for details). Defaults to ``None``, meaning no caching.
70
+ open_buffering (int, optional):
71
+ Optional integer used to set the buffering policy.
72
+ See the Python built-in function :func:`open`.
73
+
74
+ Yields:
75
+ A retrieval callable for this library's bundled schemas.
76
+ The retriever is only valid until the context is exited.
77
+
78
+ Raises:
79
+ ValueError: On invalid `uri_base`.
80
+
81
+ Note:
82
+ The implementation uses :mod:`importlib.resources` which conceptually
83
+ cannot provide a persistent path to a directory which could be accessed
84
+ by common code at any time. The lifecycle has to be explicit
85
+ due to using package resources.
86
+ """
87
+ with importlib.resources.as_file(bundled_schemas_files()) as bundled_schemas_path:
88
+ yield build_schemas_from_filesystem_retriever(
89
+ BUNDLED_SCHEMAS_URI_BASE,
90
+ bundled_schemas_path,
91
+ open_kwargs=dict(
92
+ buffering=open_buffering, encoding=_BUNDLED_SCHEMAS_ENCODING,
93
+ ),
94
+ cache=cache,
95
+ loads=LOADS_FN_JSON_DEFAULT,
96
+ )
@@ -0,0 +1,31 @@
1
+ from collections.abc import Collection, Mapping
2
+ from typing import Any, TypeAlias
3
+
4
+
5
+ __all__ = ()
6
+
7
+
8
+ type Kwargs = Mapping[str, Any]
9
+
10
+
11
+ # NOTE: returns kwargs without changes
12
+ def validate_kwargs(
13
+ kwargs: Kwargs, *, allowed: Collection[str] = (), required: Collection[str] = (),
14
+ ) -> Kwargs:
15
+ # NOTE: using lists instead of sets to preserve order
16
+ allowed = list(allowed)
17
+ allowed += [s for s in required if s not in allowed]
18
+ unexpected = [s for s in kwargs if s not in allowed]
19
+ if len(unexpected) > 0:
20
+ raise TypeError(
21
+ 'got unexpected keyword argument(s): {0}'.format(', '.join(unexpected))
22
+ )
23
+ missing = [s for s in required if s not in kwargs]
24
+ if len(missing) > 0:
25
+ raise TypeError(
26
+ 'missing required keyword argument(s): {0}'.format(', '.join(missing))
27
+ )
28
+ return kwargs
29
+
30
+
31
+ EncodingId: TypeAlias = str
@@ -0,0 +1,21 @@
1
+ from collections.abc import Collection, Hashable, Mapping
2
+ from typing import TypeVar
3
+
4
+
5
+ _K = TypeVar('_K', bound=Hashable)
6
+ _V = TypeVar('_V')
7
+
8
+
9
+ COLLECTION_PRIMITIVE_TYPES_DEFAULT: Collection[type] = (
10
+ str, bytes, bytearray, memoryview,
11
+ )
12
+
13
+ SEQUENCE_PRIMITIVE_TYPES_DEFAULT: Collection[type] = (
14
+ *COLLECTION_PRIMITIVE_TYPES_DEFAULT,
15
+ )
16
+
17
+
18
+ def coerce_to_dict(mapping: Mapping[_K, _V]) -> dict[_K, _V]:
19
+ if isinstance(mapping, dict):
20
+ return mapping
21
+ return dict(mapping.items())
File without changes
@@ -0,0 +1,210 @@
1
+ from pathlib import PurePosixPath
2
+ from urllib.parse import urlsplit, urlunsplit
3
+
4
+ import pytest
5
+
6
+ from jsonschema_extras._util.uri import (
7
+ RPURIBValidateURIValueErrorCode,
8
+ RPURIBValidateURIValueError,
9
+ is_uri_absolute,
10
+ rpurib_validate_uri_base_split,
11
+ rpurib_validate_uri_split,
12
+ RPURIBNotRelativeValueError,
13
+ relative_path_from_uri_by_base_splits_validated,
14
+ translate_uri_base_splits_validated,
15
+ )
16
+
17
+
18
+ class TestIsUriAbsolute:
19
+
20
+ @pytest.mark.parametrize(
21
+ ('uri', 'is_absolute'),
22
+ [
23
+ ('https://example.com/path?q=1#frag', True),
24
+ ('//example.com/path', True),
25
+ ('mailto:someone@example.com', True),
26
+ ('http://', True),
27
+ ('//user:pass@example.com', True),
28
+ ('/absolute/path', False),
29
+ ('relative/path', False),
30
+ ('../parent', False),
31
+ ('#fragment', False),
32
+ ('?query=val', False),
33
+ ('', False),
34
+ ],
35
+ )
36
+ @staticmethod
37
+ def test_absolute_returns_true(uri, is_absolute):
38
+ assert is_uri_absolute(uri) == is_absolute
39
+
40
+ @pytest.mark.parametrize(
41
+ ('uri', 'is_absolute'), [('https://a.b', True), ('/x', False)],
42
+ )
43
+ @staticmethod
44
+ def test_accepts_pre_parsed_split_result(uri, is_absolute):
45
+ assert is_uri_absolute(urlsplit(uri)) == is_absolute
46
+
47
+
48
+ class TestRPURIBValidateURIBaseSplit:
49
+
50
+ _VALID_BASE = urlsplit('https://example.com/base')
51
+
52
+ @classmethod
53
+ def test_returns_input_for_valid_absolute_base(cls):
54
+ result = rpurib_validate_uri_base_split(cls._VALID_BASE)
55
+ assert result == cls._VALID_BASE
56
+
57
+ @pytest.mark.parametrize(
58
+ ('uri_base_split', 'code_exp'),
59
+ [
60
+ (
61
+ _VALID_BASE._replace(scheme='', netloc=''),
62
+ RPURIBValidateURIValueErrorCode.IS_RELATIVE,
63
+ ),
64
+ (
65
+ _VALID_BASE._replace(query='a=1'),
66
+ RPURIBValidateURIValueErrorCode.HAS_QUERY,
67
+ ),
68
+ (
69
+ _VALID_BASE._replace(fragment='section-1'),
70
+ RPURIBValidateURIValueErrorCode.HAS_FRAGMENT,
71
+ ),
72
+ ],
73
+ )
74
+ @staticmethod
75
+ def test_rpurib_validate_uri_base_split_rejects_invalid_base_uris(
76
+ uri_base_split, code_exp,
77
+ ):
78
+ with pytest.raises(RPURIBValidateURIValueError) as exc_info:
79
+ rpurib_validate_uri_base_split(uri_base_split)
80
+ assert exc_info.value.code == code_exp
81
+
82
+
83
+ class TestRPURIBValidateURISplit:
84
+
85
+ _VALID_URI = urlsplit('https://example.com/path')
86
+
87
+ @classmethod
88
+ def test_returns_input_for_valid_absolute_uri(cls):
89
+ result = rpurib_validate_uri_split(cls._VALID_URI)
90
+ assert result == cls._VALID_URI
91
+
92
+ @pytest.mark.parametrize(
93
+ ('uri_split', 'code_exp'),
94
+ [
95
+ (
96
+ _VALID_URI._replace(scheme='', netloc=''),
97
+ RPURIBValidateURIValueErrorCode.IS_RELATIVE,
98
+ ),
99
+ (
100
+ _VALID_URI._replace(query='a=1'),
101
+ RPURIBValidateURIValueErrorCode.HAS_QUERY,
102
+ ),
103
+ (
104
+ _VALID_URI._replace(fragment='section-1'),
105
+ RPURIBValidateURIValueErrorCode.HAS_FRAGMENT,
106
+ ),
107
+ ],
108
+ )
109
+ @staticmethod
110
+ def test_rpurib_validate_uri_split_rejects_invalid_uris(
111
+ uri_split, code_exp,
112
+ ):
113
+ with pytest.raises(RPURIBValidateURIValueError) as exc_info:
114
+ rpurib_validate_uri_split(uri_split)
115
+ assert exc_info.value.code == code_exp
116
+
117
+
118
+ class TestRelativePathFromURIByBaseSplitsValidated:
119
+
120
+ @pytest.mark.parametrize(
121
+ ('uri', 'base', 'expected'),
122
+ [
123
+ ('https://ex.com/a/b/c', 'https://ex.com/a/b/', PurePosixPath('c')),
124
+ ('https://ex.com/a/b/c/d', 'https://ex.com/a/b/', PurePosixPath('c/d')),
125
+ ('https://ex.com/a/b', 'https://ex.com/a/b', PurePosixPath('.')),
126
+ ('https://ex.com/a/b/', 'https://ex.com/a/b', PurePosixPath('.')),
127
+ ('https://ex.com/', 'https://ex.com/', PurePosixPath('.')),
128
+ ('https://ex.com/x', 'https://ex.com/', PurePosixPath('x')),
129
+ ],
130
+ )
131
+ @staticmethod
132
+ def test_relative_path_success(uri, base, expected):
133
+ assert (
134
+ relative_path_from_uri_by_base_splits_validated(
135
+ urlsplit(uri), urlsplit(base),
136
+ )
137
+ == expected
138
+ )
139
+
140
+ @staticmethod
141
+ def test_raises_not_relative():
142
+ with pytest.raises(RPURIBNotRelativeValueError):
143
+ relative_path_from_uri_by_base_splits_validated(
144
+ urlsplit('https://ex.com/a/b/'), urlsplit('https://ex.com/a/c/d'),
145
+ )
146
+
147
+
148
+ def test_uri_not_under_old_base_propagates_error():
149
+ with pytest.raises(RPURIBNotRelativeValueError):
150
+ translate_uri_base_splits_validated(
151
+ urlsplit('https://old.example.com/other/file'),
152
+ urlsplit('https://old.example.com/base/'),
153
+ urlsplit('https://new.example.com/base/'),
154
+ )
155
+
156
+
157
+ @pytest.mark.parametrize(
158
+ ('uri', 'old_base', 'new_base', 'expected'),
159
+ [
160
+ (
161
+ 'https://old.example.com/base/file.txt',
162
+ 'https://old.example.com/base/',
163
+ 'https://new.example.com/newbase/',
164
+ 'https://new.example.com/newbase/file.txt',
165
+ ),
166
+ (
167
+ 'https://old.example.com/base/sub/deep/file.txt',
168
+ 'https://old.example.com/base/',
169
+ 'https://new.example.com/newbase/',
170
+ 'https://new.example.com/newbase/sub/deep/file.txt',
171
+ ),
172
+ (
173
+ 'https://old.example.com/base/file',
174
+ 'https://old.example.com/base/',
175
+ 'http://new.example.com/base/',
176
+ 'http://new.example.com/base/file',
177
+ ),
178
+ (
179
+ 'https://old.example.com/base/file',
180
+ 'https://old.example.com/base/',
181
+ 'https://new-domain.com/base/',
182
+ 'https://new-domain.com/base/file',
183
+ ),
184
+ (
185
+ 'https://old.example.com/base/file',
186
+ 'https://old.example.com/base/',
187
+ 'https://user:pass@new.example.com/base/',
188
+ 'https://user:pass@new.example.com/base/file',
189
+ ),
190
+ (
191
+ 'https://old.example.com/base/file',
192
+ 'https://old.example.com/base/',
193
+ 'https://new.example.com:8080/base/',
194
+ 'https://new.example.com:8080/base/file',
195
+ ),
196
+ (
197
+ 'https://old.example.com/base/file',
198
+ 'https://old.example.com/base/',
199
+ 'https://new.example.com/a/b/c/base/',
200
+ 'https://new.example.com/a/b/c/base/file',
201
+ ),
202
+ ],
203
+ )
204
+ def test_translation_uri_base_splits_validated(uri, old_base, new_base, expected):
205
+ assert (
206
+ urlunsplit(translate_uri_base_splits_validated(
207
+ urlsplit(uri), urlsplit(old_base), urlsplit(new_base),
208
+ ))
209
+ == expected
210
+ )
@@ -0,0 +1,125 @@
1
+ from enum import StrEnum
2
+ from pathlib import PurePosixPath
3
+ from urllib.parse import SplitResult, unquote, urlsplit
4
+
5
+
6
+ def is_uri_absolute(uri: str | SplitResult) -> bool:
7
+ # here scheme is a default value:
8
+ if not isinstance(uri, SplitResult):
9
+ uri_split = urlsplit(uri, scheme='')
10
+ else:
11
+ uri_split = uri
12
+ return (
13
+ bool(uri_split.scheme)
14
+ or bool(uri_split.netloc)
15
+ or (uri_split.username is not None)
16
+ or (uri_split.password is not None)
17
+ )
18
+
19
+
20
+ class RPURIBValidateURIValueErrorCode(StrEnum):
21
+ IS_RELATIVE = 'is_relative'
22
+ HAS_QUERY = 'has_query'
23
+ HAS_FRAGMENT = 'has_fragment'
24
+
25
+
26
+ class RPURIBValidateURIValueError(ValueError):
27
+
28
+ def __init__(self, message: str, code: RPURIBValidateURIValueErrorCode):
29
+ super().__init__(message, code)
30
+ self.code = code
31
+
32
+
33
+ def rpurib_validate_uri_base_split(uri_base_split: SplitResult) -> SplitResult:
34
+ if not is_uri_absolute(uri_base_split):
35
+ raise RPURIBValidateURIValueError(
36
+ 'base URI should be absolute',
37
+ RPURIBValidateURIValueErrorCode.IS_RELATIVE,
38
+ )
39
+ if unquote(uri_base_split.query):
40
+ raise RPURIBValidateURIValueError(
41
+ 'base URI should not have a query',
42
+ RPURIBValidateURIValueErrorCode.HAS_QUERY,
43
+ )
44
+ if uri_base_split.fragment:
45
+ raise RPURIBValidateURIValueError(
46
+ 'base URI should not have a fragment',
47
+ RPURIBValidateURIValueErrorCode.HAS_FRAGMENT,
48
+ )
49
+ return uri_base_split
50
+
51
+
52
+ def rpurib_split_and_validate_uri_base(uri_base: str) -> SplitResult:
53
+ # here scheme is a default value:
54
+ uri_base_split = urlsplit(uri_base, scheme='')
55
+ return rpurib_validate_uri_base_split(uri_base_split)
56
+
57
+
58
+ def rpurib_validate_uri_split(uri_split: SplitResult) -> SplitResult:
59
+ if not is_uri_absolute(uri_split):
60
+ raise RPURIBValidateURIValueError(
61
+ 'URI should be absolute',
62
+ RPURIBValidateURIValueErrorCode.IS_RELATIVE,
63
+ )
64
+ if uri_split.fragment:
65
+ raise RPURIBValidateURIValueError(
66
+ 'URI should not have a fragment',
67
+ RPURIBValidateURIValueErrorCode.HAS_FRAGMENT,
68
+ )
69
+ if unquote(uri_split.query):
70
+ raise RPURIBValidateURIValueError(
71
+ 'URI should not have a query',
72
+ RPURIBValidateURIValueErrorCode.HAS_QUERY,
73
+ )
74
+ return uri_split
75
+
76
+
77
+ def rpurib_split_and_validate_uri(uri: str) -> SplitResult:
78
+ # here scheme is a default value:
79
+ uri_split = urlsplit(uri, scheme='')
80
+ return rpurib_validate_uri_split(uri_split)
81
+
82
+
83
+ class RPURIBNotRelativeValueError(ValueError):
84
+ pass
85
+
86
+
87
+ def relative_path_from_uri_by_base_splits_validated(
88
+ uri_split: SplitResult, uri_base_split: SplitResult,
89
+ ) -> PurePosixPath:
90
+ uri_path = PurePosixPath(unquote(uri_split.path))
91
+ uri_base_path = PurePosixPath(unquote(uri_base_split.path))
92
+ try:
93
+ return uri_path.relative_to(uri_base_path, walk_up=False)
94
+ except ValueError as err:
95
+ raise RPURIBNotRelativeValueError(str(err)) from err
96
+
97
+
98
+ def _relative_path_from_uri_by_base(
99
+ uri: str | SplitResult, uri_base: str | SplitResult,
100
+ ) -> PurePosixPath:
101
+ # XXX: ValueError s are propagated
102
+ if not isinstance(uri_base, SplitResult):
103
+ uri_base_split = rpurib_split_and_validate_uri_base(uri_base)
104
+ else:
105
+ uri_base_split = uri_base
106
+ if not isinstance(uri, SplitResult):
107
+ uri_split = rpurib_split_and_validate_uri(uri)
108
+ else:
109
+ uri_split = uri
110
+ return relative_path_from_uri_by_base_splits_validated(uri_split, uri_base_split)
111
+
112
+
113
+ def translate_uri_base_splits_validated(
114
+ uri_split: SplitResult,
115
+ uri_base_old_split: SplitResult,
116
+ uri_base_new_split: SplitResult,
117
+ ) -> SplitResult:
118
+ path_rel = _relative_path_from_uri_by_base(uri_split, uri_base_old_split)
119
+ uri_base_new_split = rpurib_validate_uri_base_split(uri_base_new_split)
120
+ path_new = PurePosixPath(uri_base_new_split.path) / path_rel
121
+ return uri_split._replace(
122
+ scheme=uri_base_new_split.scheme,
123
+ netloc=uri_base_new_split.netloc,
124
+ path=str(path_new),
125
+ )
@@ -0,0 +1,30 @@
1
+ """Utilities for working with format checkers, and some bundled
2
+ format checkers.
3
+
4
+ To use some of the bundled format checkers:
5
+
6
+ 1. instantiate a :class:`jsonschema.FormatChecker`
7
+ (or use one which you already instantiate);
8
+ 2. add desired format checking functions to the format checker
9
+ using :func:`register_funcs_in_checker` or :func:`register_func_in_checker`
10
+ with bundled instances of :class:`FormatCheckingFuncInfo`;
11
+ 3. pass the format checker to :func:`jsonschema.validate`
12
+ or :class:`jsonschema.protocols.Validator` to enable validation of formats.
13
+ """
14
+
15
+ from ._common import * # noqa: F401,F403
16
+ from .numbers_range import * # noqa: F401,F403
17
+ from .slice_string import * # noqa: F401,F403
18
+
19
+
20
+ __all__ = (
21
+ 'FormatCheckFn',
22
+ 'FormatCheckingFuncInfo',
23
+ 'register_func_in_checker',
24
+ 'register_funcs_in_checker',
25
+ 'is_numbers_range',
26
+ 'is_numbers_range_info',
27
+ 'SLICE_STRING_PATTERN',
28
+ 'is_slice_string',
29
+ 'is_slice_string_info',
30
+ )
@@ -0,0 +1,74 @@
1
+ from collections.abc import Callable, Iterable, Sequence
2
+ from typing import TYPE_CHECKING, NamedTuple
3
+
4
+
5
+ if TYPE_CHECKING:
6
+ from jsonschema import FormatChecker
7
+
8
+
9
+ #: Format checking function.
10
+ #:
11
+ #: Note:
12
+ #: Any exceptions the function raises on an invalid data value are part
13
+ #: of the function's contract for :class:`~jsonschema.FormatChecker`.
14
+ #:
15
+ #: Args:
16
+ #: object: a data value to validate
17
+ #:
18
+ #: Returns:
19
+ #: bool: whether the object is valid for the format
20
+ type FormatCheckFn = Callable[[object], bool]
21
+
22
+
23
+ class FormatCheckingFuncInfo(NamedTuple):
24
+ """Data needed to register a format checking function
25
+ in a :class:`~jsonschema.FormatChecker`.
26
+
27
+ Attributes:
28
+ format: Exact name of the format for JSON Schema.
29
+ func:
30
+ Function that checks if a JSON data value satisfies a format.
31
+ raises:
32
+ Type(s) of exceptions raised by `func` on an invalid value.
33
+ Exceptions of other types are immediately propagated.
34
+ See :meth:`~jsonschema.FormatChecker.checks` for details.
35
+ """
36
+ format: str
37
+ func: FormatCheckFn
38
+ raises: type[Exception] | tuple[type[Exception], ...] = ()
39
+
40
+
41
+ def register_func_in_checker(
42
+ checker: 'FormatChecker', func_info: FormatCheckingFuncInfo,
43
+ ) -> FormatCheckFn:
44
+ """Register a format checking function
45
+ in a :class:`~jsonschema.FormatChecker`.
46
+
47
+ Utility function working with :class:`FormatCheckingFuncInfo`.
48
+
49
+ Args:
50
+ checker (jsonschema.FormatChecker): A ``format`` property checker.
51
+ func_info (FormatCheckingFuncInfo):
52
+ Data needed to register a format checking function.
53
+
54
+ Returns:
55
+ ``func_info.func``
56
+ """
57
+ return checker.checks(func_info.format, func_info.raises)(func_info.func)
58
+
59
+
60
+ def register_funcs_in_checker(
61
+ checker: 'FormatChecker', funcs_info: Iterable[FormatCheckingFuncInfo],
62
+ ) -> Sequence[FormatCheckFn]:
63
+ """Register multiple format checking functions
64
+ in a :class:`~jsonschema.FormatChecker`.
65
+
66
+ Args:
67
+ checker (jsonschema.FormatChecker): A ``format`` property checker.
68
+ funcs_info (Iterable[FormatCheckingFuncInfo]):
69
+ Instances of data needed to register formatt checking functions.
70
+
71
+ Returns:
72
+ sequence of ``func_info.func`` objects
73
+ """
74
+ return [register_func_in_checker(checker, func_info) for func_info in funcs_info]
@@ -0,0 +1,57 @@
1
+ from collections.abc import Sequence
2
+ from typing import cast
3
+
4
+ from jsonschema_extras._util import SEQUENCE_PRIMITIVE_TYPES_DEFAULT
5
+ from ._common import FormatCheckingFuncInfo
6
+
7
+
8
+ def is_numbers_range(instance: object) -> bool:
9
+ """Tests if an object is a numbers range (a sequence of 2 numbers
10
+ in non-descending order).
11
+
12
+ A numbers range is defined as a sequence containing exactly 2 comparable
13
+ numeric elements where the first element is less than or equal
14
+ to the second element, forming a valid range [min, max].
15
+
16
+ Returns:
17
+ bool: Whether `instance` is a numbers range: ascending sequence
18
+ of 2 numbers.
19
+
20
+ Examples:
21
+
22
+ Valid ranges::
23
+
24
+ >>> is_numbers_range([1, 5])
25
+ True
26
+ >>> is_numbers_range([3.14, 3.14])
27
+ True
28
+ >>> is_numbers_range((0, 10))
29
+ True
30
+
31
+ Invalid ranges::
32
+
33
+ >>> is_numbers_range([5, 1])
34
+ False
35
+ >>> is_numbers_range([1, 2, 3])
36
+ False
37
+ >>> is_numbers_range([1])
38
+ False
39
+ >>> is_numbers_range(5)
40
+ False
41
+ """
42
+ if not (
43
+ isinstance(instance, Sequence)
44
+ and (not isinstance(instance, tuple(SEQUENCE_PRIMITIVE_TYPES_DEFAULT)))
45
+ and (len(instance) == 2)
46
+ ):
47
+ return False
48
+ try:
49
+ return cast(bool, (instance[0] <= instance[1]))
50
+ except TypeError:
51
+ return False
52
+
53
+
54
+ #: Format ``numbers-range`` checked by :func:`is_numbers_range`
55
+ is_numbers_range_info = FormatCheckingFuncInfo(
56
+ 'numbers-range', is_numbers_range, (TypeError,),
57
+ )