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.
- pyvocabulary-0.1.0/PKG-INFO +51 -0
- pyvocabulary-0.1.0/README.md +27 -0
- pyvocabulary-0.1.0/pyproject.toml +107 -0
- pyvocabulary-0.1.0/pyproject.toml.orig +75 -0
- pyvocabulary-0.1.0/src/pyvocabulary/__init__.py +35 -0
- pyvocabulary-0.1.0/src/pyvocabulary/arguments.py +43 -0
- pyvocabulary-0.1.0/src/pyvocabulary/exceptions.py +48 -0
- pyvocabulary-0.1.0/src/pyvocabulary/json_schema.py +242 -0
- pyvocabulary-0.1.0/src/pyvocabulary/names.py +51 -0
- pyvocabulary-0.1.0/src/pyvocabulary/paths.py +74 -0
- pyvocabulary-0.1.0/src/pyvocabulary/py.typed +0 -0
- pyvocabulary-0.1.0/src/pyvocabulary/vocabulary.py +133 -0
|
@@ -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)})"
|