Skip to main content

pyvocabulary

The shared core of composable, JSON-definable building blocks, used by pyspecification, pyformula, pyflowstep and pyruleset.

Each of those packages lets you register named building blocks (rules, steps, formula variables), call them from JSON as {"name": ..., "args": [...], "kwargs": {...}}, and generate a JSON Schema for that JSON. pyvocabulary is the part they all share:

Piece What it does
Vocabulary[F] Named entries, hidden entries, lookups with "Available ..." errors; packages inject their own error classes
validate_name ASCII identifier check for names used in JSON
bind_problem Classifies Signature.bind errors so each package raises its own ArgumentError subclass
json_schema_of, call_json_schema JSON Schema of a type annotation, and of a {"name", "args", "kwargs"} call from a signature
located, index_path, key_path Adds at $.path notes to errors, prefixing paths across nested compilers

It does not own an expression format or a compiler: those stay in each package.

Installation

pip install pyvocabulary

or with uv:

uv add pyvocabulary

Python 3.12+. The only runtime dependency is pyargprocessors (for Process markers in schemas).

Vocabulary

A Vocabulary[F] is a read-only Mapping[str, F] filled with add. The kind names what the entries are, and is used in error messages.

from pyvocabulary import Vocabulary

steps = Vocabulary[str]("step")

steps.add("click", "click()")  # returns 'click()'
steps.add("fill", "fill()")

print(steps["click"])  # click()
print(list(steps), len(steps), "fill" in steps)  # ['click', 'fill'] 2 True
print(repr(steps))  # Vocabulary[step](click, fill)

add returns the entry, so it fits inside a decorator:

from collections.abc import Callable

rules = Vocabulary[Callable[[int], bool]]("rule")


def rule(function: Callable[[int], bool]) -> Callable[[int], bool]:
    return rules.add(function.__name__, function)


@rule
def is_positive(value: int) -> bool:
    return value > 0


print(rules["is_positive"](3))  # True

Errors

Unknown names list what is available. Names are checked when they are added, and cannot be registered twice:

from pyvocabulary import InvalidNameError, NameAlreadyRegisteredError, NameDoesNotExistError

try:
    steps["submit"]
except NameDoesNotExistError as error:
    print(error)  # Step 'submit' does not exist. Available steps: click, fill

try:
    steps.add("click", "again()")
except NameAlreadyRegisteredError as error:
    print(error)  # Step 'click' is already registered.

try:
    steps.add("double-click", "dblclick()")
except InvalidNameError as error:
    print(error)  # Invalid step name: 'double-click', ...

get returns a default instead of raising:

print(steps.get("submit"))  # None
print(steps.get("submit", "noop()"))  # noop()

Hidden entries

A hidden entry keeps its name taken, but is left out of lookups, iteration and length, so it never reaches a compiler or a JSON Schema. It stays usable from Python through the object you got back from add.

debug = steps.add("debug", "debug()", hidden=True)

print(list(steps), "debug" in steps, steps.is_registered("debug"))  # ['click', 'fill'] False True

try:
    steps.add("debug", "other()")
except NameAlreadyRegisteredError as error:
    print(error)  # Step 'debug' is already registered.

Injecting your own errors

Packages keep their own public exceptions and messages by injecting error factories. does_not_exist gets the name and the visible names, already_registered and invalid_name get the name:

class RuleDoesNotExistError(LookupError):
    def __init__(self, name, available):
        super().__init__(f"No rule {name!r}; try one of: {', '.join(available)}")


class InvalidRuleNameError(ValueError):
    pass


rules = Vocabulary[int](
    "rule",
    does_not_exist=RuleDoesNotExistError,
    invalid_name=lambda name: InvalidRuleNameError(f"Bad rule name: {name!r}"),
)

rules.add("age__ge", 1)

try:
    rules["age__gt"]
except RuleDoesNotExistError as error:
    print(error)  # No rule 'age__gt'; try one of: age__ge

try:
    rules.add("1st", 2)
except InvalidRuleNameError as error:
    print(error)  # Bad rule name: '1st'

get still works with injected errors that are LookupErrors but not KeyErrors:

print(rules.get("age__gt", 0))  # 0

validate_name

Names end up in JSON, error messages and reprs, so they are ASCII Python identifiers. validate_name returns the name unchanged or raises (InvalidNameError is also a ValueError). A trailing newline is not accepted.

from pyvocabulary import InvalidNameError, validate_name

print(validate_name("add_milk"))  # add_milk

try:
    validate_name("add_milk\n")
except InvalidNameError as error:
    print(error)  # Invalid name: 'add_milk\n', ...

try:
    validate_name(42, kind="formula")
except InvalidNameError as error:
    print(error)  # Invalid formula name: 42, ...

try:
    validate_name("é", error=lambda name: KeyError(name))
except KeyError as error:
    print(repr(error))  # KeyError('é')

bind_problem

Building blocks bind their arguments when they are built, so a wrong argument fails early. Signature.bind raises a plain TypeError; bind_problem says which kind it was, so each package can raise its own exception class:

from inspect import signature

from pyvocabulary import bind_problem


def between(low: int, high: int = 10, /, *, inclusive: bool = True) -> None: ...


def problem(*args, **kwargs):
    try:
        signature(between).bind(*args, **kwargs)
    except TypeError as error:
        return bind_problem(error)

    return None


print(problem())  # missing
print(problem(1, 2, 3))  # too_many
print(problem(1, 2, strict=True))  # unexpected_keyword
print(problem(1, high=2))  # positional_only
print(problem(1, 2))  # None

The possible values are the BindProblem literal: "positional_only", "missing", "too_many", "unexpected_keyword", "multiple_values". Unknown messages give None.

A typical mapping in a package:

class ArgumentError(TypeError): ...


class MissingArgumentError(ArgumentError): ...


class TooManyArgumentsError(ArgumentError): ...


ERRORS = {"missing": MissingArgumentError, "too_many": TooManyArgumentsError}


def bind(function, *args, **kwargs):
    try:
        return signature(function).bind(*args, **kwargs)
    except TypeError as error:
        raise ERRORS.get(bind_problem(error), ArgumentError)(str(error)) from error


try:
    bind(between)
except MissingArgumentError as error:
    print(error)  # missing a required argument: 'low'

JSON Schemas

json_schema_of

json_schema_of turns a type annotation into a JSON Schema (draft 2020-12, see JSON_SCHEMA_DIALECT). Unknown or unannotated types give {}, which accepts anything.

from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Literal, NotRequired, TypedDict

from pyvocabulary import json_schema_of

print(json_schema_of(int))  # {'type': 'integer'}
print(json_schema_of(Decimal))  # {'type': 'number'}
print(json_schema_of(date))  # {'type': 'string', 'format': 'date'}
print(json_schema_of(list[str]))  # {'type': 'array', 'items': {'type': 'string'}}

print(json_schema_of(dict[str, float]))
# {'type': 'object', 'additionalProperties': {'type': 'number'}}

print(json_schema_of(int | None))  # {'anyOf': [{'type': 'integer'}, {'type': 'null'}]}

print(json_schema_of(Literal["active", "pending"]))
# {'enum': ['active', 'pending'], 'type': 'string'}


class Color(Enum):
    RED = 1
    GREEN = 2


print(json_schema_of(Color))  # {'enum': [1, 2]}


class Item(TypedDict):
    sku: str
    quantity: NotRequired[int]


print(json_schema_of(Item))
# {'type': 'object',
#  'properties': {'sku': {'type': 'string'}, 'quantity': {'type': 'integer'}},
#  'required': ['sku']}

type Skus = list[str]

print(json_schema_of(Skus))  # {'type': 'array', 'items': {'type': 'string'}}
print(json_schema_of(object))  # {}

Supported: str, int, float, Decimal, bool, None, datetime, date, time, UUID, list/tuple/set/frozenset, dict, unions, Literal, Enum, TypedDict, type aliases, Annotated, Required and NotRequired.

A parameter marked with a pyargprocessors Process(fn) is described by what fn accepts, because that is what the JSON has to send:

from typing import Annotated

from pyargprocessors import Process


def parse_date(value: str) -> date:
    return date.fromisoformat(value)


print(json_schema_of(Annotated[date, Process(parse_date)]))  # {'type': 'string'}

call_json_schema

call_json_schema(name, factory) describes one JSON call of a building block from its factory's signature: positional parameters under args (as prefixItems), keyword-capable ones under kwargs, JSON-compatible defaults as default, and the docstring as description.

from pyvocabulary import call_json_schema


def age__between(min_age: int, max_age: int = 120, *, inclusive: bool = True) -> bool:
    """Whether the age is within a range."""
    return True


schema = call_json_schema("age__between", age__between)

print(schema["type"], schema["required"], schema["additionalProperties"])  # object ['name'] False
print(schema["properties"]["name"])  # {'const': 'age__between'}

print(schema["properties"]["args"])
# {'type': 'array',
#  'prefixItems': [{'type': 'integer'}, {'type': 'integer', 'default': 120}],
#  'items': False}

print(schema["properties"]["kwargs"]["properties"])
# {'min_age': {'type': 'integer'},
#  'max_age': {'type': 'integer', 'default': 120},
#  'inclusive': {'type': 'boolean', 'default': True}}

print(schema["description"])  # Whether the age is within a range.

*args and **kwargs open up items and additionalProperties; required keyword-only parameters land in kwargs.required:

def tag(*labels: str, by: str, **extra: int) -> None: ...


schema = call_json_schema("tag", tag)

print(schema["properties"]["args"]["items"])  # {'type': 'string'}

kwargs = schema["properties"]["kwargs"]

print(kwargs["required"], kwargs["additionalProperties"])  # ['by'] {'type': 'integer'}

Packages add their own keys to the call shape with extra_properties, and change what is required with required:

schema = call_json_schema(
    "age__between",
    age__between,
    extra_properties={"inverse": {"type": "boolean"}},
    required=("name", "args"),
)

print(list(schema["properties"]), schema["required"])
# ['name', 'args', 'kwargs', 'inverse'] ['name', 'args']

A schema for a whole vocabulary is then one oneOf over its visible names:

from pyvocabulary import JSON_SCHEMA_DIALECT

rules = Vocabulary[Callable[..., bool]]("rule")

rules.add("age__between", age__between)
rules.add("internal", lambda: True, hidden=True)

document = {
    "$schema": JSON_SCHEMA_DIALECT,
    "oneOf": [call_json_schema(name, factory) for name, factory in rules.items()],
}

print([call["properties"]["name"]["const"] for call in document["oneOf"]])  # ['age__between']

JSON-path errors

Compilers walk nested JSON definitions. An error deep inside should say where it happened, without changing its type or message. located(path, *errors) adds an at <path> note to errors of the given types raised in its block; index_path and key_path build the paths.

from pyvocabulary import index_path, key_path, located

print(index_path("$.branches", 1))  # $.branches[1]
print(key_path(index_path("$.branches", 1), "when"))  # $.branches[1].when

try:
    with located("$.expressions[0]", LookupError):
        steps["submit"]
except LookupError as error:
    print(type(error).__name__, error.__notes__)  # NameDoesNotExistError ['at $.expressions[0]']

Errors of other types pass through untouched. Paths nest: when an inner block (or another package's compiler, which reports paths from its own root $) already added a location, the outer path is prefixed onto it, so the final note is the absolute path in the outer document:

try:
    with located("$.branches[1].when", LookupError):
        with located("$.expressions[0]", LookupError):
            steps["submit"]
except LookupError as error:
    print(error.__notes__)  # ['at $.branches[1].when.expressions[0]']

Putting it together

A tiny compiler for {"all": [call, ...]} documents, built from every piece above:

from collections.abc import Callable
from inspect import signature
from typing import Any

from pyvocabulary import (
    Vocabulary,
    bind_problem,
    call_json_schema,
    index_path,
    key_path,
    located,
)

type Check = Callable[[int], bool]


class CheckError(Exception): ...


class CheckArgumentError(CheckError, TypeError): ...


checks = Vocabulary[Callable[..., Check]]("check")


def check(factory):
    return checks.add(factory.__name__, factory)


@check
def ge(minimum: int) -> Check:
    """Whether the value is at least `minimum`."""
    return lambda value: value >= minimum


@check
def le(maximum: int) -> Check:
    """Whether the value is at most `maximum`."""
    return lambda value: value <= maximum


def build(call: dict[str, Any]) -> Check:
    factory = checks[call["name"]]
    args, kwargs = call.get("args", []), call.get("kwargs", {})

    try:
        signature(factory).bind(*args, **kwargs)
    except TypeError as error:
        message = f"{call['name']}: {bind_problem(error) or error}"
        raise CheckArgumentError(message) from error

    return factory(*args, **kwargs)


def compile_all(document: dict[str, Any]) -> Check:
    path = key_path("$", "all")
    built = []

    for index, call in enumerate(document["all"]):
        with located(index_path(path, index), LookupError, CheckError):
            built.append(build(call))

    return lambda value: all(item(value) for item in built)


in_range = compile_all({"all": [{"name": "ge", "args": [1]}, {"name": "le", "args": [10]}]})

print(in_range(5), in_range(11))  # True False

try:
    compile_all({"all": [{"name": "ge", "args": [1]}, {"name": "lt", "args": [10]}]})
except LookupError as error:
    print(error, *error.__notes__, sep="\n")
    # Check 'lt' does not exist. Available checks: ge, le
    # at $.all[1]

try:
    compile_all({"all": [{"name": "ge", "args": [1, 2]}]})
except CheckError as error:
    print(error, *error.__notes__, sep="\n")
    # ge: too_many
    # at $.all[0]

print([call_json_schema(name, factory)["description"] for name, factory in checks.items()])
# ['Whether the value is at least `minimum`.', 'Whether the value is at most `maximum`.']

API reference

Name Kind Summary
Vocabulary[F](kind="name", /, *, does_not_exist=None, already_registered=None, invalid_name=None) class Read-only Mapping[str, F] of visible names
Vocabulary.add(name, entry, /, *, hidden=False) -> F method Register and return entry
Vocabulary.is_registered(name, /) -> bool method Whether the name is taken, hidden or not
validate_name(name, /, *, kind="name", error=None) -> str function Return the name if it is an ASCII identifier, else raise
bind_problem(error, /) -> BindProblem | None function Classify a Signature.bind TypeError
BindProblem type Literal["positional_only", "missing", "too_many", "unexpected_keyword", "multiple_values"]
json_schema_of(annotation) -> dict function JSON Schema of a type annotation
call_json_schema(name, factory, /, *, extra_properties=None, required=("name",)) -> dict function JSON Schema of one {"name", "args", "kwargs"} call
JSON_SCHEMA_DIALECT constant "https://json-schema.org/draft/2020-12/schema"
located(path, /, *errors) context manager Add or prefix an at $... note on matching errors
index_path(path, index, /) -> str function "$.a", 2 → "$.a[2]"
key_path(path, key, /) -> str function "$.a", "b" → "$.a.b"
PyvocabularyError exception Base class of the errors below
NameDoesNotExistError exception Unknown name; also a LookupError
NameAlreadyRegisteredError exception Name registered twice
InvalidNameError exception Name is not an ASCII identifier; also a ValueError

Development

uv sync
uv run pytest --cov --cov-branch
uvx ruff check . && uvx ruff format --check .
uvx pyright src

License

GPL-3.0-only.

Metadata

Release files for pyvocabulary 0.1.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyvocabulary 0.1.3
File Size Uploaded Provenance Actions
pyvocabulary-0.1.3.tar.gz 14.6 kB Without provenance Details for pyvocabulary-0.1.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pyvocabulary 0.1.3
File Interpreter ABI Platform Actions
pyvocabulary-0.1.3-py3-none-any.whl Python 3 none any Details for pyvocabulary-0.1.3-py3-none-any.whl

Total release size: 30.6 kB

Release files / pyvocabulary-0.1.3.tar.gz

Download URL pyvocabulary-0.1.3.tar.gz
Size 14.6 kB
Tags Source
SHA-256 checksum
How to use checksums
fabe060a4b9d5065a6da41b2e792d2deba056be9baf33848f8e2f15bc4c9525d
BLAKE2b-256 checksum
How to use checksums
b06c7e76cdd457df0ab22bd7390f85af365b7f2d88043586348158a26a3ee40a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / pyvocabulary-0.1.3-py3-none-any.whl

Download URL pyvocabulary-0.1.3-py3-none-any.whl
Size 16.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cae6247b22eac07d01935ec4c1dd4b4e7f02e0b8dc2ea22dd244fdf86a3e128f
BLAKE2b-256 checksum
How to use checksums
027727ca6c7a0d47bda191f4b9356c0e32b0e5635af0caaa981e788a648318cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for pyvocabulary 0.1.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page