pyvocabulary 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyvocabulary
3
+ Version: 0.1.0
4
+ Summary: The shared core of composable, JSON-definable building blocks: named registries, JSON Schemas from signatures and JSON-path errors.
5
+ Keywords: registry,vocabulary,json-schema,dsl,composition
6
+ Author: aaltatan
7
+ Author-email: aaltatan <a.altatan@gmail.com>
8
+ License-Expression: GPL-3.0-only
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: pyargprocessors>=0.1.0
19
+ Requires-Python: >=3.12
20
+ Project-URL: Homepage, https://github.com/aaltatan/pyvocabulary
21
+ Project-URL: Repository, https://github.com/aaltatan/pyvocabulary
22
+ Project-URL: Issues, https://github.com/aaltatan/pyvocabulary/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pyvocabulary
26
+
27
+ The shared core of composable, JSON-definable building blocks, used by
28
+ [pyspecification](https://github.com/aaltatan/pyspecification),
29
+ [pyflowstep](https://github.com/aaltatan/pyflowstep) and
30
+ [pyruleset](https://github.com/aaltatan/pyruleset).
31
+
32
+ ```python
33
+ >>> from pyvocabulary import Vocabulary, call_json_schema, located
34
+ >>> steps = Vocabulary[str]("step")
35
+ >>> steps.add("click", "click()")
36
+ 'click()'
37
+ >>> steps["fill"]
38
+ Traceback (most recent call last):
39
+ ...
40
+ pyvocabulary.exceptions.NameDoesNotExistError: Step 'fill' does not exist. Available steps: click
41
+ ```
42
+
43
+ | Piece | What it does |
44
+ | --- | --- |
45
+ | `Vocabulary[F]` | Named entries, hidden entries, lookups with "Available ..." errors; packages inject their own error classes |
46
+ | `validate_name` | ASCII identifier check for names used in JSON |
47
+ | `bind_problem` | Classifies `Signature.bind` errors so each package raises its own `ArgumentError` subclass |
48
+ | `json_schema_of`, `call_json_schema` | JSON Schema of a type annotation, and of a `{"name", "args", "kwargs"}` call from a signature |
49
+ | `located`, `index_path`, `key_path` | Adds `at $.path` notes to errors, prefixing paths across nested compilers |
50
+
51
+ Python 3.12+. Licensed GPL-3.0-only.
@@ -0,0 +1,27 @@
1
+ # pyvocabulary
2
+
3
+ The shared core of composable, JSON-definable building blocks, used by
4
+ [pyspecification](https://github.com/aaltatan/pyspecification),
5
+ [pyflowstep](https://github.com/aaltatan/pyflowstep) and
6
+ [pyruleset](https://github.com/aaltatan/pyruleset).
7
+
8
+ ```python
9
+ >>> from pyvocabulary import Vocabulary, call_json_schema, located
10
+ >>> steps = Vocabulary[str]("step")
11
+ >>> steps.add("click", "click()")
12
+ 'click()'
13
+ >>> steps["fill"]
14
+ Traceback (most recent call last):
15
+ ...
16
+ pyvocabulary.exceptions.NameDoesNotExistError: Step 'fill' does not exist. Available steps: click
17
+ ```
18
+
19
+ | Piece | What it does |
20
+ | --- | --- |
21
+ | `Vocabulary[F]` | Named entries, hidden entries, lookups with "Available ..." errors; packages inject their own error classes |
22
+ | `validate_name` | ASCII identifier check for names used in JSON |
23
+ | `bind_problem` | Classifies `Signature.bind` errors so each package raises its own `ArgumentError` subclass |
24
+ | `json_schema_of`, `call_json_schema` | JSON Schema of a type annotation, and of a `{"name", "args", "kwargs"}` call from a signature |
25
+ | `located`, `index_path`, `key_path` | Adds `at $.path` notes to errors, prefixing paths across nested compilers |
26
+
27
+ Python 3.12+. Licensed GPL-3.0-only.
@@ -0,0 +1,107 @@
1
+ [project]
2
+ name = "pyvocabulary"
3
+ version = "0.1.0"
4
+ description = "The shared core of composable, JSON-definable building blocks: named registries, JSON Schemas from signatures and JSON-path errors."
5
+ readme = "README.md"
6
+ license = "GPL-3.0-only"
7
+ requires-python = ">=3.12"
8
+ dependencies = ["pyargprocessors>=0.1.0"]
9
+ keywords = [
10
+ "registry",
11
+ "vocabulary",
12
+ "json-schema",
13
+ "dsl",
14
+ "composition",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Typing :: Typed",
26
+ ]
27
+
28
+ [[project.authors]]
29
+ name = "aaltatan"
30
+ email = "a.altatan@gmail.com"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/aaltatan/pyvocabulary"
34
+ Repository = "https://github.com/aaltatan/pyvocabulary"
35
+ Issues = "https://github.com/aaltatan/pyvocabulary/issues"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.11.32,<0.12.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=9.1.1",
44
+ "pytest-cov>=6.0",
45
+ ]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = [
49
+ "tests",
50
+ "src",
51
+ ]
52
+ pythonpath = ["."]
53
+ addopts = [
54
+ "--doctest-modules",
55
+ "--strict-markers",
56
+ ]
57
+ doctest_optionflags = [
58
+ "ELLIPSIS",
59
+ "NORMALIZE_WHITESPACE",
60
+ ]
61
+
62
+ [tool.coverage.run]
63
+ source = ["pyvocabulary"]
64
+ branch = true
65
+
66
+ [tool.coverage.report]
67
+ show_missing = true
68
+ exclude_also = [
69
+ "@overload",
70
+ "if TYPE_CHECKING:",
71
+ ]
72
+
73
+ [tool.ruff]
74
+ line-length = 100
75
+ target-version = "py312"
76
+
77
+ [tool.ruff.lint]
78
+ select = ["ALL"]
79
+ ignore = [
80
+ "COM812",
81
+ "CPY001",
82
+ "D100",
83
+ "D101",
84
+ "D102",
85
+ "D103",
86
+ "D104",
87
+ "D105",
88
+ "D107",
89
+ "D203",
90
+ "D213",
91
+ "ANN401",
92
+ "PLR0911",
93
+ ]
94
+
95
+ [tool.ruff.lint.per-file-ignores]
96
+ "tests/**" = [
97
+ "S101",
98
+ "PLR2004",
99
+ "ANN",
100
+ "ARG",
101
+ "INP001",
102
+ "E501",
103
+ ]
104
+
105
+ [tool.pyright]
106
+ venvPath = "."
107
+ venv = ".venv"
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "pyvocabulary"
3
+ version = "0.1.0"
4
+ description = "The shared core of composable, JSON-definable building blocks: named registries, JSON Schemas from signatures and JSON-path errors."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "aaltatan", email = "a.altatan@gmail.com" }
8
+ ]
9
+ license = "GPL-3.0-only"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "pyargprocessors>=0.1.0",
13
+ ]
14
+ keywords = ["registry", "vocabulary", "json-schema", "dsl", "composition"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/aaltatan/pyvocabulary"
29
+ Repository = "https://github.com/aaltatan/pyvocabulary"
30
+ Issues = "https://github.com/aaltatan/pyvocabulary/issues"
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.11.32,<0.12.0"]
34
+ build-backend = "uv_build"
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=9.1.1",
39
+ "pytest-cov>=6.0",
40
+ ]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests", "src"]
44
+ pythonpath = ["."]
45
+ addopts = ["--doctest-modules", "--strict-markers"]
46
+ doctest_optionflags = ["ELLIPSIS", "NORMALIZE_WHITESPACE"]
47
+
48
+ [tool.coverage.run]
49
+ source = ["pyvocabulary"]
50
+ branch = true
51
+
52
+ [tool.coverage.report]
53
+ show_missing = true
54
+ exclude_also = ["@overload", "if TYPE_CHECKING:"]
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py312"
59
+
60
+ [tool.ruff.lint]
61
+ select = ["ALL"]
62
+ ignore = [
63
+ "COM812", # conflicts with the formatter
64
+ "CPY001", # no per-file copyright headers
65
+ "D100", "D101", "D102", "D103", "D104", "D105", "D107", "D203", "D213",
66
+ "ANN401", # JSON values and registry entries genuinely are `Any`
67
+ "PLR0911", # guard-clause style dispatch returns early on purpose
68
+ ]
69
+
70
+ [tool.ruff.lint.per-file-ignores]
71
+ "tests/**" = ["S101", "PLR2004", "ANN", "ARG", "INP001", "E501"]
72
+
73
+ [tool.pyright]
74
+ venvPath = "."
75
+ venv = ".venv"
@@ -0,0 +1,35 @@
1
+ """pyvocabulary: the shared core of composable, JSON-definable building blocks.
2
+
3
+ Used by pyspecification, pyflowstep and pyruleset for the parts every such
4
+ package needs: a registry of named entries, JSON Schemas generated from
5
+ signatures, errors that point at a JSON path, and early argument checks.
6
+ """
7
+
8
+ from .arguments import BindProblem, bind_problem
9
+ from .exceptions import (
10
+ InvalidNameError,
11
+ NameAlreadyRegisteredError,
12
+ NameDoesNotExistError,
13
+ PyvocabularyError,
14
+ )
15
+ from .json_schema import JSON_SCHEMA_DIALECT, call_json_schema, json_schema_of
16
+ from .names import validate_name
17
+ from .paths import index_path, key_path, located
18
+ from .vocabulary import Vocabulary
19
+
20
+ __all__ = [
21
+ "JSON_SCHEMA_DIALECT",
22
+ "BindProblem",
23
+ "InvalidNameError",
24
+ "NameAlreadyRegisteredError",
25
+ "NameDoesNotExistError",
26
+ "PyvocabularyError",
27
+ "Vocabulary",
28
+ "bind_problem",
29
+ "call_json_schema",
30
+ "index_path",
31
+ "json_schema_of",
32
+ "key_path",
33
+ "located",
34
+ "validate_name",
35
+ ]
@@ -0,0 +1,43 @@
1
+ """Classify the `TypeError` raised by `inspect.Signature.bind`.
2
+
3
+ Building blocks bind their arguments when they are built, so a wrong argument
4
+ fails early. Each package maps the problem to its own exception class.
5
+ """
6
+
7
+ import re
8
+ from typing import Literal
9
+
10
+ type BindProblem = Literal[
11
+ "positional_only",
12
+ "missing",
13
+ "too_many",
14
+ "unexpected_keyword",
15
+ "multiple_values",
16
+ ]
17
+
18
+ _PATTERNS: tuple[tuple[str, BindProblem], ...] = (
19
+ (r"positional.only.*passed as (a )?keyword", "positional_only"),
20
+ (r"missing a required", "missing"),
21
+ (r"too many positional arguments", "too_many"),
22
+ (r"got an unexpected keyword argument", "unexpected_keyword"),
23
+ (r"multiple values for argument", "multiple_values"),
24
+ )
25
+
26
+
27
+ def bind_problem(error: TypeError, /) -> BindProblem | None:
28
+ """Return what went wrong in a failed `Signature.bind`, or `None` if unknown.
29
+
30
+ Example:
31
+ ```python
32
+ >>> bind_problem(TypeError("too many positional arguments"))
33
+ 'too_many'
34
+ >>> bind_problem(TypeError("missing a required argument: 'value'"))
35
+ 'missing'
36
+ >>> bind_problem(TypeError("something else")) is None
37
+ True
38
+
39
+ ```
40
+
41
+ """
42
+ message = str(error)
43
+ return next((problem for pattern, problem in _PATTERNS if re.search(pattern, message)), None)
@@ -0,0 +1,48 @@
1
+ """Exceptions raised by pyvocabulary.
2
+
3
+ Packages built on pyvocabulary usually inject their own error classes into a
4
+ `Vocabulary` (e.g. `RuleDoesNotExistError`), so these defaults are what callers
5
+ see only when nothing is injected.
6
+ """
7
+
8
+ from collections.abc import Iterable
9
+
10
+
11
+ class PyvocabularyError(Exception):
12
+ """Base class for every exception raised by pyvocabulary."""
13
+
14
+
15
+ class NameDoesNotExistError(PyvocabularyError, LookupError):
16
+ """Raised when a name is not in a vocabulary.
17
+
18
+ Example:
19
+ ```python
20
+ >>> str(NameDoesNotExistError("stepz", ["click", "fill"], kind="step"))
21
+ "Step 'stepz' does not exist. Available steps: click, fill"
22
+
23
+ ```
24
+
25
+ """
26
+
27
+ def __init__(self, name: str, available: Iterable[str], /, *, kind: str = "name") -> None:
28
+ names = ", ".join(available) or "(none)"
29
+ super().__init__(f"{kind.capitalize()} '{name}' does not exist. Available {kind}s: {names}")
30
+
31
+
32
+ class NameAlreadyRegisteredError(PyvocabularyError):
33
+ """Raised when a name is registered twice in the same vocabulary."""
34
+
35
+ def __init__(self, name: str, /, *, kind: str = "name") -> None:
36
+ super().__init__(f"{kind.capitalize()} '{name}' is already registered.")
37
+
38
+
39
+ class InvalidNameError(PyvocabularyError, ValueError):
40
+ """Raised when a name is not a valid ASCII Python identifier."""
41
+
42
+ def __init__(self, name: object, /, *, kind: str = "name") -> None:
43
+ label = "name" if kind == "name" else f"{kind} name"
44
+ super().__init__(
45
+ f"Invalid {label}: {name!r}, "
46
+ "you should follow the python variable naming convention: "
47
+ "name must start with a letter and can only contain letters, numbers and underscores"
48
+ )
@@ -0,0 +1,242 @@
1
+ """Generate JSON Schemas from Python type annotations and building-block signatures.
2
+
3
+ A building block (a rule, a step, a formula variable) is called from JSON as
4
+ `{"name": ..., "args": [...], "kwargs": {...}}`. Its signature already says what
5
+ those may contain, so the schema is derived from it instead of written by hand.
6
+ """
7
+
8
+ from collections.abc import Callable, Mapping
9
+ from datetime import date, datetime, time
10
+ from decimal import Decimal
11
+ from enum import Enum
12
+ from inspect import Parameter, get_annotations, signature
13
+ from types import NoneType, UnionType
14
+ from typing import (
15
+ Annotated,
16
+ Any,
17
+ Literal,
18
+ NotRequired,
19
+ Required,
20
+ TypeAliasType,
21
+ Union,
22
+ get_args,
23
+ get_origin,
24
+ is_typeddict,
25
+ )
26
+ from uuid import UUID
27
+
28
+ from pyargprocessors import Process, annotated_metadata, input_annotation, resolved_annotations
29
+
30
+ JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
31
+
32
+ _SCALAR_SCHEMAS: dict[Any, dict[str, Any]] = {
33
+ str: {"type": "string"},
34
+ int: {"type": "integer"},
35
+ float: {"type": "number"},
36
+ Decimal: {"type": "number"},
37
+ bool: {"type": "boolean"},
38
+ None: {"type": "null"},
39
+ NoneType: {"type": "null"},
40
+ datetime: {"type": "string", "format": "date-time"},
41
+ date: {"type": "string", "format": "date"},
42
+ time: {"type": "string", "format": "time"},
43
+ UUID: {"type": "string", "format": "uuid"},
44
+ }
45
+
46
+ _ARRAY_TYPES = (list, tuple, set, frozenset)
47
+ _WRAPPER_ORIGINS = (Annotated, Required, NotRequired)
48
+ _POSITIONAL_KINDS = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
49
+ _KEYWORD_KINDS = (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
50
+
51
+
52
+ def json_schema_of(annotation: Any) -> dict[str, Any]:
53
+ """Return the JSON Schema of a Python type annotation.
54
+
55
+ Unknown or unannotated types map to `{}`, which accepts any value. A type
56
+ marked with `Process(fn)` is described by what `fn` accepts, because that is
57
+ what the JSON has to send.
58
+
59
+ Example:
60
+ ```python
61
+ >>> json_schema_of(int)
62
+ {'type': 'integer'}
63
+ >>> json_schema_of(list[str])
64
+ {'type': 'array', 'items': {'type': 'string'}}
65
+ >>> json_schema_of(Literal["active", "pending"])
66
+ {'enum': ['active', 'pending'], 'type': 'string'}
67
+ >>> json_schema_of(int | None)
68
+ {'anyOf': [{'type': 'integer'}, {'type': 'null'}]}
69
+
70
+ ```
71
+
72
+ """
73
+ origin = get_origin(annotation)
74
+
75
+ if is_typeddict(annotation):
76
+ return _typeddict_schema(annotation)
77
+
78
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
79
+ return _enum_schema([member.value for member in annotation])
80
+
81
+ if isinstance(annotation, TypeAliasType):
82
+ return json_schema_of(annotation.__value__)
83
+
84
+ if origin in _WRAPPER_ORIGINS:
85
+ return json_schema_of(_sent_annotation(annotation))
86
+
87
+ if origin is Literal:
88
+ return _enum_schema(list(get_args(annotation)))
89
+
90
+ if origin in (Union, UnionType):
91
+ return {"anyOf": [json_schema_of(arg) for arg in get_args(annotation)]}
92
+
93
+ if (origin or annotation) in _ARRAY_TYPES:
94
+ return _array_schema(get_args(annotation))
95
+
96
+ if (origin or annotation) is dict:
97
+ return _dict_schema(get_args(annotation))
98
+
99
+ return dict(_SCALAR_SCHEMAS.get(annotation, {}))
100
+
101
+
102
+ def call_json_schema(
103
+ name: str,
104
+ factory: Callable[..., Any],
105
+ /,
106
+ *,
107
+ extra_properties: Mapping[str, dict[str, Any]] | None = None,
108
+ required: tuple[str, ...] = ("name",),
109
+ ) -> dict[str, Any]:
110
+ """Return the JSON Schema of one call: `{"name": ..., "args": [...], "kwargs": {...}}`.
111
+
112
+ `factory` is a building-block factory whose signature excludes the subject.
113
+ Positional parameters are described under `args` (via `prefixItems`),
114
+ keyword-capable ones under `kwargs`, and the docstring becomes the description.
115
+
116
+ Args:
117
+ name: The registered name, fixed with `const`.
118
+ factory: The factory whose signature describes the arguments.
119
+ extra_properties: More keys a package adds to its call shape,
120
+ e.g. `{"inverse": {"type": "boolean"}}` for rules.
121
+ required: The keys that must be present.
122
+
123
+ Example:
124
+ ```python
125
+ >>> def age__between(min_age: int, max_age: int = 120) -> bool:
126
+ ... '''Whether the age is within a range.'''
127
+ ... return True
128
+ >>> schema = call_json_schema("age__between", age__between)
129
+ >>> schema["properties"]["name"]
130
+ {'const': 'age__between'}
131
+ >>> schema["properties"]["args"]["prefixItems"]
132
+ [{'type': 'integer'}, {'type': 'integer', 'default': 120}]
133
+ >>> schema["description"], schema["required"]
134
+ ('Whether the age is within a range.', ['name'])
135
+
136
+ ```
137
+
138
+ """
139
+ parameters = list(signature(factory).parameters.values())
140
+ annotations = resolved_annotations(factory)
141
+
142
+ def parameter_schema(parameter: Parameter) -> dict[str, Any]:
143
+ schema = json_schema_of(annotations.get(parameter.name, Any))
144
+ if parameter.default is not Parameter.empty and _is_json_value(parameter.default):
145
+ schema["default"] = parameter.default
146
+ return schema
147
+
148
+ schema: dict[str, Any] = {
149
+ "type": "object",
150
+ "properties": {
151
+ "name": {"const": name},
152
+ "args": _args_schema(parameters, parameter_schema),
153
+ "kwargs": _kwargs_schema(parameters, parameter_schema),
154
+ **(extra_properties or {}),
155
+ },
156
+ "required": list(required),
157
+ "additionalProperties": False,
158
+ }
159
+
160
+ if factory.__doc__:
161
+ schema["description"] = factory.__doc__.strip()
162
+
163
+ return schema
164
+
165
+
166
+ # -----------------------
167
+ # helpers
168
+ # -----------------------
169
+
170
+
171
+ def _args_schema(
172
+ parameters: list[Parameter],
173
+ parameter_schema: Callable[[Parameter], dict[str, Any]],
174
+ ) -> dict[str, Any]:
175
+ positional = [parameter for parameter in parameters if parameter.kind in _POSITIONAL_KINDS]
176
+ var_positional = next(
177
+ (parameter for parameter in parameters if parameter.kind is Parameter.VAR_POSITIONAL),
178
+ None,
179
+ )
180
+ return {
181
+ "type": "array",
182
+ "prefixItems": [parameter_schema(parameter) for parameter in positional],
183
+ "items": parameter_schema(var_positional) if var_positional else False,
184
+ }
185
+
186
+
187
+ def _kwargs_schema(
188
+ parameters: list[Parameter],
189
+ parameter_schema: Callable[[Parameter], dict[str, Any]],
190
+ ) -> dict[str, Any]:
191
+ keyword = [parameter for parameter in parameters if parameter.kind in _KEYWORD_KINDS]
192
+ var_keyword = next(
193
+ (parameter for parameter in parameters if parameter.kind is Parameter.VAR_KEYWORD),
194
+ None,
195
+ )
196
+ return {
197
+ "type": "object",
198
+ "properties": {parameter.name: parameter_schema(parameter) for parameter in keyword},
199
+ "required": [
200
+ parameter.name
201
+ for parameter in keyword
202
+ if parameter.kind is Parameter.KEYWORD_ONLY and parameter.default is Parameter.empty
203
+ ],
204
+ "additionalProperties": parameter_schema(var_keyword) if var_keyword else False,
205
+ }
206
+
207
+
208
+ def _enum_schema(values: list[Any]) -> dict[str, Any]:
209
+ schema: dict[str, Any] = {"enum": values}
210
+ if values and all(isinstance(value, str) for value in values):
211
+ schema["type"] = "string"
212
+ return schema
213
+
214
+
215
+ def _array_schema(args: tuple[Any, ...]) -> dict[str, Any]:
216
+ return {"type": "array", "items": json_schema_of(args[0]) if args else {}}
217
+
218
+
219
+ def _dict_schema(args: tuple[Any, ...]) -> dict[str, Any]:
220
+ return {"type": "object", "additionalProperties": json_schema_of(args[1]) if args else {}}
221
+
222
+
223
+ def _typeddict_schema(annotation: Any) -> dict[str, Any]:
224
+ annotations = get_annotations(annotation)
225
+ return {
226
+ "type": "object",
227
+ "properties": {name: json_schema_of(typ) for name, typ in annotations.items()},
228
+ "required": [name for name in annotations if name in annotation.__required_keys__],
229
+ }
230
+
231
+
232
+ def _sent_annotation(annotation: Any) -> Any:
233
+ """Return the type the JSON must send for an `Annotated`/`Required`/`NotRequired` type."""
234
+ processor = next(
235
+ (item for item in annotated_metadata(annotation) if isinstance(item, Process)), None
236
+ )
237
+ sent = input_annotation(processor) if processor else None
238
+ return get_args(annotation)[0] if sent is None else sent
239
+
240
+
241
+ def _is_json_value(value: Any) -> bool:
242
+ return value is None or isinstance(value, str | int | float | bool | list | dict)
@@ -0,0 +1,51 @@
1
+ """Validate the names of building blocks.
2
+
3
+ A name ends up in JSON definitions, error messages and reprs, so it is kept to
4
+ an ASCII Python identifier: `age__between`, `add_milk`.
5
+ """
6
+
7
+ import re
8
+ from collections.abc import Callable
9
+
10
+ from .exceptions import InvalidNameError
11
+
12
+ type InvalidNameErrorFactory = Callable[[object], Exception]
13
+
14
+ # `fullmatch`, not `match` with `$`: `$` also matches before a trailing newline
15
+ _IDENTIFIER = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
16
+
17
+
18
+ def validate_name(
19
+ name: object,
20
+ /,
21
+ *,
22
+ kind: str = "name",
23
+ error: InvalidNameErrorFactory | None = None,
24
+ ) -> str:
25
+ """Return `name` unchanged if it is a valid ASCII Python identifier.
26
+
27
+ Args:
28
+ name: The candidate name.
29
+ kind: What is being named, used in the default error message.
30
+ error: Builds the exception to raise for an invalid name. Defaults to
31
+ `InvalidNameError`, which is also a `ValueError`.
32
+
33
+ Raises:
34
+ InvalidNameError: If `name` is invalid and no `error` factory is given.
35
+
36
+ Example:
37
+ ```python
38
+ >>> validate_name("add_milk")
39
+ 'add_milk'
40
+ >>> validate_name("add-milk", kind="step")
41
+ Traceback (most recent call last):
42
+ ...
43
+ pyvocabulary.exceptions.InvalidNameError: Invalid step name: 'add-milk', ...
44
+
45
+ ```
46
+
47
+ """
48
+ if isinstance(name, str) and _IDENTIFIER.fullmatch(name):
49
+ return name
50
+
51
+ raise (error or (lambda value: InvalidNameError(value, kind=kind)))(name)
@@ -0,0 +1,74 @@
1
+ """Point errors at the part of a JSON definition that caused them.
2
+
3
+ Compilers walk nested definitions; an error deep inside should say where it
4
+ happened, e.g. `at $.expressions[1]`. The location is added as an exception
5
+ note, so the original message and type stay unchanged.
6
+ """
7
+
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+
11
+
12
+ def index_path(path: str, index: int, /) -> str:
13
+ """Return the JSON path of an item in a list.
14
+
15
+ Example:
16
+ ```python
17
+ >>> index_path("$.branches", 2)
18
+ '$.branches[2]'
19
+
20
+ ```
21
+
22
+ """
23
+ return f"{path}[{index}]"
24
+
25
+
26
+ def key_path(path: str, key: str, /) -> str:
27
+ """Return the JSON path of a key in an object.
28
+
29
+ Example:
30
+ ```python
31
+ >>> key_path(index_path("$.branches", 0), "when")
32
+ '$.branches[0].when'
33
+
34
+ ```
35
+
36
+ """
37
+ return f"{path}.{key}"
38
+
39
+
40
+ @contextmanager
41
+ def located(path: str, /, *errors: type[Exception]) -> Iterator[None]:
42
+ """Add an `at <path>` note to errors of the given types raised inside the block.
43
+
44
+ Paths nest: when the error already carries a location from an inner block,
45
+ or from another compiler that reports paths relative to its own root (`$`),
46
+ the outer path is prefixed to it. So a rule compiled inside a decision
47
+ reports its full path in the outer document.
48
+
49
+ Example:
50
+ ```python
51
+ >>> try:
52
+ ... with located("$.branches[1].when", KeyError):
53
+ ... with located("$.expressions[0]", KeyError):
54
+ ... raise KeyError("itemz__ge")
55
+ ... except KeyError as error:
56
+ ... error.__notes__
57
+ ['at $.branches[1].when.expressions[0]']
58
+
59
+ ```
60
+
61
+ """
62
+ try:
63
+ yield
64
+ except errors as error:
65
+ notes = getattr(error, "__notes__", [])
66
+ index = next((i for i, note in enumerate(notes) if note.startswith(_PREFIX)), None)
67
+ if index is None:
68
+ error.add_note(f"at {path}")
69
+ else:
70
+ notes[index] = f"at {path}{notes[index].removeprefix(_PREFIX)}"
71
+ raise
72
+
73
+
74
+ _PREFIX = "at $"
File without changes
@@ -0,0 +1,133 @@
1
+ """A vocabulary: the named building blocks a JSON-definable language understands.
2
+
3
+ A rules registry, a steps registry and a formulas registry are all the same
4
+ thing underneath: names mapped to entries, some of them hidden from lookups.
5
+ `Vocabulary` is that shared part. Packages keep their own decorators on top.
6
+ """
7
+
8
+ from collections.abc import Callable, Iterable, Iterator, Mapping
9
+
10
+ from .exceptions import NameAlreadyRegisteredError, NameDoesNotExistError
11
+ from .names import InvalidNameErrorFactory, validate_name
12
+
13
+ type DoesNotExistErrorFactory = Callable[[str, Iterable[str]], Exception]
14
+ type AlreadyRegisteredErrorFactory = Callable[[str], Exception]
15
+
16
+
17
+ class Vocabulary[F](Mapping[str, F]):
18
+ """A read-only mapping of visible names to entries, filled with `add`.
19
+
20
+ Hidden entries are kept, so a name cannot be reused, but they are left out of
21
+ lookups, iteration and length. That keeps them out of compilers and JSON
22
+ Schemas while the objects stay usable from Python.
23
+
24
+ Args:
25
+ kind: What the entries are ("rule", "step"), used in default error messages.
26
+ does_not_exist: Builds the error for an unknown name from the name and
27
+ the visible names. Defaults to `NameDoesNotExistError`.
28
+ already_registered: Builds the error for a duplicate name. Defaults to
29
+ `NameAlreadyRegisteredError`.
30
+ invalid_name: Builds the error for a name that is not an identifier.
31
+ Defaults to `InvalidNameError`.
32
+
33
+ Example:
34
+ ```python
35
+ >>> steps = Vocabulary[str]("step")
36
+ >>> steps.add("click", "click()")
37
+ 'click()'
38
+ >>> steps.add("debug", "debug()", hidden=True)
39
+ 'debug()'
40
+ >>> list(steps), "debug" in steps, len(steps)
41
+ (['click'], False, 1)
42
+ >>> steps["fill"]
43
+ Traceback (most recent call last):
44
+ ...
45
+ pyvocabulary.exceptions.NameDoesNotExistError: Step 'fill' does not exist. ...
46
+
47
+ ```
48
+
49
+ """
50
+
51
+ __slots__ = (
52
+ "_already_registered",
53
+ "_does_not_exist",
54
+ "_entries",
55
+ "_hidden",
56
+ "_invalid_name",
57
+ "_kind",
58
+ )
59
+
60
+ def __init__(
61
+ self,
62
+ kind: str = "name",
63
+ /,
64
+ *,
65
+ does_not_exist: DoesNotExistErrorFactory | None = None,
66
+ already_registered: AlreadyRegisteredErrorFactory | None = None,
67
+ invalid_name: InvalidNameErrorFactory | None = None,
68
+ ) -> None:
69
+ self._kind = kind
70
+ self._entries: dict[str, F] = {}
71
+ self._hidden: set[str] = set()
72
+ self._does_not_exist = does_not_exist or (
73
+ lambda name, available: NameDoesNotExistError(name, available, kind=kind)
74
+ )
75
+ self._already_registered = already_registered or (
76
+ lambda name: NameAlreadyRegisteredError(name, kind=kind)
77
+ )
78
+ self._invalid_name = invalid_name
79
+
80
+ def add(self, name: str, entry: F, /, *, hidden: bool = False) -> F:
81
+ """Register `entry` under `name` and return the entry.
82
+
83
+ Raises:
84
+ InvalidNameError: If `name` is not a valid identifier (or the injected error).
85
+ NameAlreadyRegisteredError: If `name` is taken, hidden or not (or the injected error).
86
+
87
+ """
88
+ validate_name(name, kind=self._kind, error=self._invalid_name)
89
+
90
+ if name in self._entries:
91
+ raise self._already_registered(name)
92
+
93
+ self._entries[name] = entry
94
+ if hidden:
95
+ self._hidden.add(name)
96
+
97
+ return entry
98
+
99
+ def is_registered(self, name: str, /) -> bool:
100
+ """Whether `name` is taken, including hidden entries.
101
+
102
+ Example:
103
+ ```python
104
+ >>> vocabulary = Vocabulary[int]()
105
+ >>> _ = vocabulary.add("secret", 1, hidden=True)
106
+ >>> vocabulary.is_registered("secret"), "secret" in vocabulary
107
+ (True, False)
108
+
109
+ ```
110
+
111
+ """
112
+ return name in self._entries
113
+
114
+ def __getitem__(self, name: str, /) -> F:
115
+ if name not in self:
116
+ raise self._does_not_exist(name, list(self))
117
+ return self._entries[name]
118
+
119
+ def get(self, name: str, default: F | None = None, /) -> F | None: # type: ignore[override]
120
+ # `Mapping.get` only catches `KeyError`; injected errors may be any `LookupError`.
121
+ return self._entries[name] if name in self else default
122
+
123
+ def __contains__(self, name: object, /) -> bool:
124
+ return name in self._entries and name not in self._hidden
125
+
126
+ def __iter__(self) -> Iterator[str]:
127
+ return (name for name in self._entries if name not in self._hidden)
128
+
129
+ def __len__(self) -> int:
130
+ return len(self._entries) - len(self._hidden)
131
+
132
+ def __repr__(self) -> str:
133
+ return f"Vocabulary[{self._kind}]({', '.join(self)})"