diff --git a/adr/013-markers-on-annotated-fields.md b/adr/013-markers-on-annotated-fields.md index 16e0487..dbd4eab 100644 --- a/adr/013-markers-on-annotated-fields.md +++ b/adr/013-markers-on-annotated-fields.md @@ -36,8 +36,8 @@ marker chain (`Secret`, `Alias`, ...) with the field name as the key. ```python class Login(TypedDict): user_name: Annotated[str, Key(alias=["user-name", "userName"])] - password: Annotated[str, Key(secret=True)] - token: Annotated[str, Key(exclusive="auth")] + password: Annotated[str, Key(secret=True)] + token: Annotated[str, Key(exclusive="auth")] ``` The same spelling works on a dataclass. Plain dict schemas keep using the markers diff --git a/adr/014-annotation-driven-argument-decorator.md b/adr/014-annotation-driven-argument-decorator.md index f1f6b0f..3a98837 100644 --- a/adr/014-annotation-driven-argument-decorator.md +++ b/adr/014-annotation-driven-argument-decorator.md @@ -24,12 +24,11 @@ drop-in. ```python @probatio -async def fetch(user_id: Annotated[int, Range(min=1)], name: str) -> Response: - ... +async def fetch(user_id: Annotated[int, Range(min=1)], name: str) -> Response: ... + @probatio({"name": Length(min=2)}, returns=User) -def make(name: str, age: int) -> User: - ... +def make(name: str, age: int) -> User: ... ``` - **Inference is the default.** Each annotated parameter becomes its inferred diff --git a/docs/src/content/docs/guides/combinators.md b/docs/src/content/docs/guides/combinators.md index edfa318..f1c0b1e 100644 --- a/docs/src/content/docs/guides/combinators.md +++ b/docs/src/content/docs/guides/combinators.md @@ -34,7 +34,7 @@ from probatio import Schema, Any schema = Schema(Any(int, str)) -schema(5) # 5 +schema(5) # 5 schema("a") # 'a' ``` @@ -81,9 +81,11 @@ validate a tagged union by its tag: ```python from probatio import Schema, Union + def by_type(value, alternatives): return [a for a in alternatives if a["type"] == value.get("type")] + schema = Schema( Union( {"type": "point", "x": int, "y": int}, @@ -171,7 +173,7 @@ schema = Schema( ) schema({"level": "high"}) # {'level': 'high'} -schema({"level": "7"}) # {'level': 7} +schema({"level": "7"}) # {'level': 7} ``` ## Passing options through diff --git a/docs/src/content/docs/guides/compiled-schemas.md b/docs/src/content/docs/guides/compiled-schemas.md index 72e6fec..5093575 100644 --- a/docs/src/content/docs/guides/compiled-schemas.md +++ b/docs/src/content/docs/guides/compiled-schemas.md @@ -41,7 +41,7 @@ from probatio import Schema, All, Coerce, Range PORT = Schema(All(Coerce(int), Range(min=1, max=65535))) PORT("443") # 443 -PORT(8080) # 8080 +PORT(8080) # 8080 ``` ## Asking for it explicitly @@ -69,11 +69,13 @@ function with no intermediate dict. from dataclasses import dataclass from probatio import DataclassSchema + @dataclass class Point: x: int y: int + POINT = DataclassSchema(Point).compile() POINT({"x": 1, "y": 2}) # Point(x=1, y=2) ``` @@ -92,8 +94,8 @@ behavior invisibly. from probatio import CompilePolicy, set_compile_policy set_compile_policy(CompilePolicy.OFF) # never compile unless a schema opts in -set_compile_policy(CompilePolicy.ON) # compile every eligible schema on first use -set_compile_policy(CompilePolicy.AUTO) # the default: compile a schema once it is hot +set_compile_policy(CompilePolicy.ON) # compile every eligible schema on first use +set_compile_policy(CompilePolicy.AUTO) # the default: compile a schema once it is hot ``` A per-schema `compile` flag always wins over the policy, in either direction. The diff --git a/docs/src/content/docs/guides/custom-error-messages.md b/docs/src/content/docs/guides/custom-error-messages.md index 4ce06e4..6cb56af 100644 --- a/docs/src/content/docs/guides/custom-error-messages.md +++ b/docs/src/content/docs/guides/custom-error-messages.md @@ -65,6 +65,7 @@ for a consumer instead of only prose: ```python from probatio import Schema, Invalid, MultipleInvalid + def quiet_hour(value): if not isinstance(value, int) or not 0 <= value <= 23: raise Invalid( @@ -76,16 +77,17 @@ def quiet_hour(value): ) return value + schema = Schema({"start": quiet_hour}) try: schema({"start": 25}) except MultipleInvalid as err: error = err.errors[0] - print(error) # expected an hour between 0 and 23 at 'start' - print(error.code) # quiet_hour + print(error) # expected an hour between 0 and 23 at 'start' + print(error.code) # quiet_hour print(error.translation_key) # quiet_hour_out_of_range - print(error.placeholders) # {'min': 0, 'max': 23} + print(error.placeholders) # {'min': 0, 'max': 23} ``` The message is the fallback for anyone who just prints the error; the @@ -157,12 +159,14 @@ DUTCH = { "length_min": "lengte moet minimaal {min} zijn", } + def render_dutch(error): template = DUTCH.get(error.translation_key) if template is None: return error.error_message return template.format(**error.placeholders) + schema = Schema({"port": int, "host": str}) try: @@ -196,6 +200,7 @@ DUTCH = { "did_you_mean": ", bedoelde je {candidates}?", } + def render_dutch(error): text = DUTCH.get(error.translation_key) if text is None: @@ -207,6 +212,7 @@ def render_dutch(error): text += DUTCH["did_you_mean"].format(candidates=joined) return text + schema = Schema({"name": str, "email": str}) try: diff --git a/docs/src/content/docs/guides/custom-validators.md b/docs/src/content/docs/guides/custom-validators.md index 16a8cdf..5760e8e 100644 --- a/docs/src/content/docs/guides/custom-validators.md +++ b/docs/src/content/docs/guides/custom-validators.md @@ -16,9 +16,11 @@ it returns becomes the validated result. ```python from probatio import Schema + def double(value): return value * 2 + schema = Schema(double) schema(21) # 42 @@ -34,11 +36,13 @@ caller sees, and Probatio attaches the path to the offending value for you. ```python from probatio import Schema, Invalid, Required + def even(value): if value % 2 != 0: raise Invalid("must be even") return value + schema = Schema({Required("count"): even}) schema({"count": 4}) # {'count': 4} @@ -50,11 +54,13 @@ key that broke: ```python from probatio import Schema, Invalid, Required, MultipleInvalid + def even(value): if value % 2 != 0: raise Invalid("must be even") return value + schema = Schema({Required("count"): even}) try: @@ -73,12 +79,14 @@ is on purpose: a lot of standard-library and third-party functions already raise ```python from probatio import Schema + def port(value): number = int(value) # raises ValueError on "nope" if not 0 < number <= 65535: raise ValueError("out of range") return number + schema = Schema(port) schema("443") # 443 @@ -90,12 +98,14 @@ The catch keeps the `ValueError` reason, appending it after `not a valid value: ```python from probatio import Schema, MultipleInvalid + def port(value): number = int(value) if not 0 < number <= 65535: raise ValueError("out of range") return number + schema = Schema(port) try: @@ -122,10 +132,12 @@ rejects it when it is falsy. ```python from probatio import Schema, truth + @truth def positive(value): return value > 0 + schema = Schema(positive) schema(5) # 5 ``` @@ -138,10 +150,12 @@ message. ```python from probatio import Schema, MultipleInvalid, truth + @truth def positive(value): return value > 0 + schema = Schema(positive) try: @@ -160,9 +174,11 @@ hands back what you return, and the original input stays as it was (see ```python from probatio import Schema + def to_slug(value): return value.strip().lower().replace(" ", "-") + schema = Schema(to_slug) schema(" Hello World ") # 'hello-world' @@ -202,9 +218,11 @@ specifically: ```python from probatio import Schema, Msg, Match, Invalid, MultipleInvalid + class BadName(Invalid): """The name does not look right.""" + schema = Schema(Msg(Match(r"^[a-z]+$"), "lowercase letters only", cls=BadName)) try: @@ -222,11 +240,13 @@ validator feeds the next (see [combinators](/guides/combinators/)). ```python from probatio import Schema, All, Strip, Lower, Invalid, Required + def not_empty(value): if not value: raise Invalid("must not be empty") return value + schema = Schema({Required("name"): All(Strip, Lower, not_empty)}) schema({"name": " Frenck "}) # {'name': 'frenck'} @@ -238,11 +258,13 @@ field of only spaces collapses to an empty string and is rejected: ```python from probatio import Schema, All, Strip, Lower, Invalid, Required, MultipleInvalid + def not_empty(value): if not value: raise Invalid("must not be empty") return value + schema = Schema({Required("name"): All(Strip, Lower, not_empty)}) try: @@ -260,13 +282,16 @@ every built-in factory works, `Range(min=...)` and `Length(max=...)` included. ```python from probatio import Schema, Invalid, MultipleInvalid + def at_least(minimum): def check(value): if value < minimum: raise Invalid(f"must be at least {minimum}") return value + return check + schema = Schema(at_least(18)) schema(21) # 21 @@ -282,6 +307,7 @@ validator carries several settings or wants a readable `repr`: ```python from probatio import Schema, Invalid + class AtLeast: def __init__(self, minimum): self.minimum = minimum @@ -291,6 +317,7 @@ class AtLeast: raise Invalid(f"must be at least {self.minimum}") return value + Schema(AtLeast(18))(21) # 21 ``` @@ -314,7 +341,7 @@ class Color(enum.Enum): schema = Schema(Color) -schema("red") # Color.RED +schema("red") # Color.RED schema(Color.BLUE) # Color.BLUE ``` @@ -391,10 +418,12 @@ input. ```python from probatio import probatio, MultipleInvalid + @probatio def area(width: int, height: int) -> int: return width * height + area(3, 4) # 12 try: @@ -409,10 +438,12 @@ Layer extra rules with a `constraints` map, and validate the result against the ```python from probatio import probatio, Length + @probatio({"name": Length(min=2)}, returns=True) def greet(name: str) -> str: return "hi " + name + greet("ada") # 'hi ada' ``` diff --git a/docs/src/content/docs/guides/dataclasses.md b/docs/src/content/docs/guides/dataclasses.md index 1cdcdf4..0d09d04 100644 --- a/docs/src/content/docs/guides/dataclasses.md +++ b/docs/src/content/docs/guides/dataclasses.md @@ -211,9 +211,15 @@ from probatio import DataclassSchema, Key, Length @dataclass class Account: name: str - password: Annotated[str, Key(secret=True), Length(min=8)] # redacted, length-checked - user_name: Annotated[str, Key(alias=["user-name", "userName"])] = "" # accept aliases - is_admin: Annotated[bool, Key(forbidden=True)] = False # reject if the caller sends it + password: Annotated[ + str, Key(secret=True), Length(min=8) + ] # redacted, length-checked + user_name: Annotated[str, Key(alias=["user-name", "userName"])] = ( + "" # accept aliases + ) + is_admin: Annotated[bool, Key(forbidden=True)] = ( + False # reject if the caller sends it + ) schema = DataclassSchema(Account) @@ -428,7 +434,9 @@ class Event: when: Annotated[datetime, Coerce(datetime.fromisoformat)] -DataclassSchema(Event)({"when": "2020-01-01T12:00"}) # Event(when=datetime(2020, 1, 1, 12, 0)) +DataclassSchema(Event)( + {"when": "2020-01-01T12:00"} +) # Event(when=datetime(2020, 1, 1, 12, 0)) ``` The `Coerce` runs first and the `datetime` type confirms the result, so the field diff --git a/docs/src/content/docs/guides/dict-schemas-and-markers.md b/docs/src/content/docs/guides/dict-schemas-and-markers.md index bf750d2..90f5d6b 100644 --- a/docs/src/content/docs/guides/dict-schemas-and-markers.md +++ b/docs/src/content/docs/guides/dict-schemas-and-markers.md @@ -116,7 +116,7 @@ try: schema({"nmae": "app"}) except Invalid as err: error = err.errors[0] - print(error) # not a valid option, did you mean 'name'? at 'nmae' + print(error) # not a valid option, did you mean 'name'? at 'nmae' print(error.candidates) # ['name'] ``` @@ -126,7 +126,9 @@ keys untouched), and `REMOVE_EXTRA` (drop them from the result): ```python from probatio import Schema, ALLOW_EXTRA, REMOVE_EXTRA -Schema({"name": str}, extra=ALLOW_EXTRA)({"name": "app", "x": 1}) # {'name': 'app', 'x': 1} +Schema({"name": str}, extra=ALLOW_EXTRA)( + {"name": "app", "x": 1} +) # {'name': 'app', 'x': 1} Schema({"name": str}, extra=REMOVE_EXTRA)({"name": "app", "x": 1}) # {'name': 'app'} ``` @@ -198,7 +200,7 @@ from probatio import Schema, Alias schema = Schema({Alias("user_name", "user-name", "userName"): str}) schema({"user-name": "ada"}) # {'user_name': 'ada'} -schema({"userName": "ada"}) # {'user_name': 'ada'} +schema({"userName": "ada"}) # {'user_name': 'ada'} schema({"user_name": "ada"}) # {'user_name': 'ada'} ``` @@ -213,7 +215,7 @@ from probatio import Schema, Alias schema = Schema({Alias("name", "alias", accept_canonical=False): str}) schema({"alias": "ada"}) # {'name': 'ada'} -schema({"name": "ada"}) # {} (the canonical name is not an input name here) +schema({"name": "ada"}) # {} (the canonical name is not an input name here) ``` Note what the second call does: the canonical key is still recognized, so it is @@ -321,7 +323,9 @@ schema = Schema( try: schema({"lat": 52.1}) except Invalid as err: - print(err) # some but not all values in the same group of inclusion 'coords' at '' + print( + err + ) # some but not all values in the same group of inclusion 'coords' at '' ``` Since no single key is at fault, a group error reports a synthetic path segment diff --git a/docs/src/content/docs/guides/error-handling.md b/docs/src/content/docs/guides/error-handling.md index 817923b..43ef075 100644 --- a/docs/src/content/docs/guides/error-handling.md +++ b/docs/src/content/docs/guides/error-handling.md @@ -42,8 +42,8 @@ schema = Schema({"server": {"ports": [int]}}) try: schema({"server": {"ports": [80, "nope"]}}) except Invalid as err: - print(err) # expected int at 'server.ports[1]' - print(err.path) # ['server', 'ports', 1] + print(err) # expected int at 'server.ports[1]' + print(err.path) # ['server', 'ports', 1] ``` `error.path` is the machine-readable form; follow it into the original data to @@ -65,7 +65,7 @@ try: except MultipleInvalid as err: print(len(err.errors)) # 2 for sub in err.errors: - print(sub.path) # ['a'] then ['b'] + print(sub.path) # ['a'] then ['b'] ``` For convenience, a `MultipleInvalid` proxies its first error, so `error.msg` and @@ -112,7 +112,7 @@ try: except MultipleInvalid as err: first = err.errors[0] print(isinstance(first, RangeInvalid)) # True - print(first.error_message) # value must be at most 10 + print(first.error_message) # value must be at most 10 ``` `error_message` is the bare message without the path; `msg` is the same text. @@ -138,7 +138,7 @@ try: schema({"port": "nope"}) except Invalid as err: first = err.errors[0] - print(first.code) # type + print(first.code) # type print(first.as_dict()["path"]) # ['port'] ``` diff --git a/docs/src/content/docs/guides/json-schema.md b/docs/src/content/docs/guides/json-schema.md index 4ab143f..211dd0e 100644 --- a/docs/src/content/docs/guides/json-schema.md +++ b/docs/src/content/docs/guides/json-schema.md @@ -40,7 +40,10 @@ from probatio import from_json_schema document = { "type": "object", - "properties": {"name": {"type": "string"}, "age": {"type": "integer", "minimum": 0}}, + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "minimum": 0}, + }, "required": ["name"], } schema = from_json_schema(document) @@ -85,11 +88,13 @@ tune that: from probatio import Schema, to_json_schema from probatio.codecs import UNSUPPORTED + def as_password(node): if node is str.strip: return {"type": "string", "writeOnly": True} return UNSUPPORTED + to_json_schema(Schema({"token": str.strip}), custom_serializer=as_password) # {'type': 'object', 'properties': {'token': {'type': 'string', 'writeOnly': True}}, 'additionalProperties': False} ``` diff --git a/docs/src/content/docs/guides/lazy-building.md b/docs/src/content/docs/guides/lazy-building.md index 66ddf0e..cade55a 100644 --- a/docs/src/content/docs/guides/lazy-building.md +++ b/docs/src/content/docs/guides/lazy-building.md @@ -41,7 +41,7 @@ architectural choice, not a deployment toggle. ```python from probatio import BuildPolicy, set_build_policy -set_build_policy(BuildPolicy.LAZY) # defer every eligible schema to first use +set_build_policy(BuildPolicy.LAZY) # defer every eligible schema to first use set_build_policy(BuildPolicy.EAGER) # the default: compile at construction ``` diff --git a/docs/src/content/docs/guides/loading-and-dumping.md b/docs/src/content/docs/guides/loading-and-dumping.md index 482930f..641acb9 100644 --- a/docs/src/content/docs/guides/loading-and-dumping.md +++ b/docs/src/content/docs/guides/loading-and-dumping.md @@ -100,6 +100,7 @@ import json from datetime import date from decimal import Decimal + def to_jsonable(value): if isinstance(value, (set, frozenset)): return sorted(value) @@ -109,6 +110,7 @@ def to_jsonable(value): return value.isoformat() raise TypeError(type(value).__name__) + json.dumps({"when": date(2020, 1, 1)}, default=to_jsonable) # '{"when": "2020-01-01"}' ``` @@ -121,6 +123,7 @@ hook only covers what it does not know: import orjson from decimal import Decimal + def to_jsonable(value): if isinstance(value, (set, frozenset)): return sorted(value) @@ -128,6 +131,7 @@ def to_jsonable(value): return float(value) raise TypeError(type(value).__name__) + orjson.dumps({"scale": Decimal("1.5")}, default=to_jsonable) # b'{"scale":1.5}' ``` @@ -158,10 +162,12 @@ from probatio.humanize import humanize_error schema = Schema({Required("server"): {Required("port"): Range(min=1, max=65535)}}) data, positions = load_yaml_with_positions("server:\n port: 70000\n") # your loader + def locator(path): node = positions_at(positions, path) # your lookup into the located tree return Location(node.line, node.column) if node else None + try: schema(data) except MultipleInvalid as err: diff --git a/docs/src/content/docs/guides/openapi.md b/docs/src/content/docs/guides/openapi.md index e25efea..e640eb8 100644 --- a/docs/src/content/docs/guides/openapi.md +++ b/docs/src/content/docs/guides/openapi.md @@ -45,12 +45,15 @@ from probatio import from_openapi document = { "type": "object", - "properties": {"name": {"type": "string"}, "age": {"type": "integer", "nullable": True}}, + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "nullable": True}, + }, "required": ["name"], } schema = from_openapi(document) schema({"name": "Ada", "age": None}) # {'name': 'Ada', 'age': None} -schema({"name": "Ada", "age": 37}) # {'name': 'Ada', 'age': 37} +schema({"name": "Ada", "age": 37}) # {'name': 'Ada', 'age': 37} ``` ## The nullable keyword diff --git a/docs/src/content/docs/guides/probatio-decorator.md b/docs/src/content/docs/guides/probatio-decorator.md index e866b1b..6fe68d1 100644 --- a/docs/src/content/docs/guides/probatio-decorator.md +++ b/docs/src/content/docs/guides/probatio-decorator.md @@ -210,8 +210,8 @@ def widen(value: Annotated[int, Coerce(int)]) -> int: return value -widen("5") # 5, validated and coerced -widen.__wrapped__("5") # '5', straight through, no validation +widen("5") # 5, validated and coerced +widen.__wrapped__("5") # '5', straight through, no validation ``` That escape hatch frames when not to use the decorator at all. Every decorated diff --git a/docs/src/content/docs/guides/recursive-schemas.md b/docs/src/content/docs/guides/recursive-schemas.md index e104191..e7945dc 100644 --- a/docs/src/content/docs/guides/recursive-schemas.md +++ b/docs/src/content/docs/guides/recursive-schemas.md @@ -49,7 +49,9 @@ data = { ], } -comment(data) # {'text': 'top', 'replies': [{'text': 'first', 'replies': []}, {'text': 'second', 'replies': [{'text': 'nested', 'replies': []}]}]} +comment( + data +) # {'text': 'top', 'replies': [{'text': 'first', 'replies': []}, {'text': 'second', 'replies': [{'text': 'nested', 'replies': []}]}]} ``` Recursion follows the data. A finite structure validates fine, because each diff --git a/docs/src/content/docs/guides/sequence-schemas.md b/docs/src/content/docs/guides/sequence-schemas.md index bfcef8f..c6c80e4 100644 --- a/docs/src/content/docs/guides/sequence-schemas.md +++ b/docs/src/content/docs/guides/sequence-schemas.md @@ -22,7 +22,7 @@ from probatio import Schema schema = Schema([int, str]) schema([1, "a", 2]) # [1, 'a', 2] -schema([]) # [] +schema([]) # [] ``` Each element tries the alternatives in order and the first match wins, like @@ -88,7 +88,7 @@ alternatives, and the result is a new container of that type. from probatio import Schema Schema((int,))((1, 2, 3)) # (1, 2, 3) -Schema({int})({1, 2}) # {1, 2} +Schema({int})({1, 2}) # {1, 2} ``` The container types do not mix: @@ -119,7 +119,7 @@ schema = Schema({"ports": [int]}) try: schema({"ports": [80, "nope"]}) except Invalid as err: - print(err) # expected int at 'ports[1]' + print(err) # expected int at 'ports[1]' print(err.path) # ['ports', 1] ``` @@ -201,7 +201,7 @@ schema({"servers": [{"host": "a", "ports": [80, 443]}]}) try: schema({"servers": [{"host": "a", "ports": [80, "x"]}]}) except Invalid as err: - print(err) # expected int at 'servers[0].ports[1]' + print(err) # expected int at 'servers[0].ports[1]' print(err.path) # ['servers', 0, 'ports', 1] ``` diff --git a/docs/src/content/docs/guides/troubleshooting.md b/docs/src/content/docs/guides/troubleshooting.md index 2a89a4e..f8117fc 100644 --- a/docs/src/content/docs/guides/troubleshooting.md +++ b/docs/src/content/docs/guides/troubleshooting.md @@ -27,8 +27,8 @@ schema = Schema({"server": {"port": int}}) try: schema({"server": {"port": "nope"}}) except MultipleInvalid as err: - print(err) # expected int at 'server.port' - print(err.errors[0].path) # ['server', 'port'] + print(err) # expected int at 'server.port' + print(err.errors[0].path) # ['server', 'port'] print(err.errors[0].error_message) # expected int ``` @@ -67,9 +67,9 @@ schema = Schema(int) try: schema("nope") -except Invalid as err: # catches MultipleInvalid too - print(type(err).__name__) # MultipleInvalid - print(err.errors[0].msg) # expected int +except Invalid as err: # catches MultipleInvalid too + print(type(err).__name__) # MultipleInvalid + print(err.errors[0].msg) # expected int ``` To branch on a specific kind of failure (like `RangeInvalid`), inspect @@ -109,11 +109,13 @@ Raise `Invalid` with your own message to control it exactly, with no prefix: ```python from probatio import Schema, Invalid + def even(value): if value % 2: raise Invalid("must be even") return value + try: Schema(even)(3) except Invalid as err: diff --git a/docs/src/content/docs/guides/typeddict.md b/docs/src/content/docs/guides/typeddict.md index e42301a..e57641a 100644 --- a/docs/src/content/docs/guides/typeddict.md +++ b/docs/src/content/docs/guides/typeddict.md @@ -79,7 +79,7 @@ class Server(TypedDict): schema = TypedDictSchema(Server) schema({"name": "nas", "port": 22}) # {'name': 'nas', 'port': 22} -schema({"name": "nas"}) # {'name': 'nas'} +schema({"name": "nas"}) # {'name': 'nas'} ``` A `total=False` class flips the default, so nothing is required: diff --git a/docs/src/content/docs/guides/validation-model.md b/docs/src/content/docs/guides/validation-model.md index fc4be80..34ceb89 100644 --- a/docs/src/content/docs/guides/validation-model.md +++ b/docs/src/content/docs/guides/validation-model.md @@ -15,9 +15,9 @@ ordinary Python object, and its shape _is_ the rule: ```python from probatio import Schema -Schema(int)(42) # 42 -Schema("on")("on") # 'on' -Schema([int])([1, 2, 3]) # [1, 2, 3] +Schema(int)(42) # 42 +Schema("on")("on") # 'on' +Schema([int])([1, 2, 3]) # [1, 2, 3] Schema({"a": int})({"a": 1}) # {'a': 1} ``` @@ -73,7 +73,7 @@ schema = Schema({"port": Coerce(int)}) data = {"port": "443"} schema(data) # {'port': 443} -data # {'port': '443'} (unchanged) +data # {'port': '443'} (unchanged) ``` ## Failure is an exception, not a return value diff --git a/docs/src/content/docs/guides/validators.md b/docs/src/content/docs/guides/validators.md index 6deb557..efdb799 100644 --- a/docs/src/content/docs/guides/validators.md +++ b/docs/src/content/docs/guides/validators.md @@ -43,20 +43,20 @@ and `Equal` pin a value to a constant. `In` and `NotIn` test membership, ```python from probatio import Schema, Coerce, Boolean, Literal, Equal -Schema(Coerce(int))("42") # 42 -Schema(Boolean())("on") # True -Schema(Boolean())("off") # False -Schema(Literal("on"))("on") # 'on' -Schema(Equal(3))(3) # 3 +Schema(Coerce(int))("42") # 42 +Schema(Boolean())("on") # True +Schema(Boolean())("off") # False +Schema(Literal("on"))("on") # 'on' +Schema(Equal(3))(3) # 3 ``` ```python from probatio import Schema, In, NotIn, Contains, Match Schema(In(["red", "green", "blue"]))("green") # 'green' -Schema(NotIn(["root", "admin"]))("frenck") # 'frenck' -Schema(Contains(2))([1, 2, 3]) # [1, 2, 3] -Schema(Match(r"^[a-z]+$"))("probatio") # 'probatio' +Schema(NotIn(["root", "admin"]))("frenck") # 'frenck' +Schema(Contains(2))([1, 2, 3]) # [1, 2, 3] +Schema(Match(r"^[a-z]+$"))("probatio") # 'probatio' ``` `Coerce` raises when the value cannot be converted: @@ -82,10 +82,10 @@ digits) and scale (decimal places). ```python from probatio import Schema, Range, Clamp, Number -Schema(Range(min=1, max=10))(5) # 5 +Schema(Range(min=1, max=10))(5) # 5 Schema(Range(min=0, max=1, max_included=False))(0.5) # 0.5 -Schema(Clamp(min=0, max=100))(150) # 100 -Schema(Number(precision=4, scale=2))("12.34") # '12.34' +Schema(Clamp(min=0, max=100))(150) # 100 +Schema(Number(precision=4, scale=2))("12.34") # '12.34' ``` `Positive`, `Negative`, and `NonNegative` are sign conveniences over `Range`. @@ -98,15 +98,21 @@ with `Coerce` for that (`All(Coerce(int), NonNegative())`). ```python from probatio import ( - Schema, Positive, MultipleOf, Percentage, FromPercentage, Latitude, Longitude, + Schema, + Positive, + MultipleOf, + Percentage, + FromPercentage, + Latitude, + Longitude, ) -Schema(Positive())(5) # 5 -Schema(MultipleOf(15))(45) # 45 -Schema(Percentage())("80%") # '80%' -Schema(FromPercentage())("80%") # 80.0 -Schema(Latitude())(52.37) # 52.37 -Schema(Longitude())(4.9) # 4.9 +Schema(Positive())(5) # 5 +Schema(MultipleOf(15))(45) # 45 +Schema(Percentage())("80%") # '80%' +Schema(FromPercentage())("80%") # 80.0 +Schema(Latitude())(52.37) # 52.37 +Schema(Longitude())(4.9) # 4.9 ``` A small family of arithmetic mutators rescales a number, the readable stand-in for @@ -120,11 +126,11 @@ more than one step. ```python from probatio import Schema, All, Multiply, Divide, Offset, Round -Schema(Divide(1000))(5000) # 5.0 (milliunits to units) -Schema(Multiply(0.1))(50) # 5.0 (a gain) -Schema(Offset(50))(100) # 150 (a shift, stays an int) -Schema(Round(2))(5.126) # 5.13 -Schema(All(Offset(-273.15), Round(2)))(300) # 26.85 (Kelvin to Celsius, chained) +Schema(Divide(1000))(5000) # 5.0 (milliunits to units) +Schema(Multiply(0.1))(50) # 5.0 (a gain) +Schema(Offset(50))(100) # 150 (a shift, stays an int) +Schema(Round(2))(5.126) # 5.13 +Schema(All(Offset(-273.15), Round(2)))(300) # 26.85 (Kelvin to Celsius, chained) ``` `Remap` linearly maps a number from one range onto another, the Arduino `map()`. It @@ -135,7 +141,7 @@ in `Clamp` to bound the result, and add `Round` to tidy it. ```python from probatio import Schema, All, Remap, Clamp, Round -Schema(All(Remap(0, 255, 0, 100), Round(1)))(128) # 50.2 (a byte as a percentage) +Schema(All(Remap(0, 255, 0, 100), Round(1)))(128) # 50.2 (a byte as a percentage) Schema(All(Remap(-100, -50, 0, 100), Clamp(0, 100)))(-70) # 60.0 (RSSI dBm to percent) ``` @@ -145,10 +151,10 @@ offset`, optionally rounded, when a single field does several steps at once. ```python from probatio import Schema, Scale -Schema(Scale(divisor=1000))(5000) # 5.0 (milliunits to units) -Schema(Scale(10))(5) # 50 (integer gain, stays an int) -Schema(Scale(100, divisor=255, round=1))(128) # 50.2 (a byte as a percentage) -Schema(Scale(offset=-273.15, round=2))(300) # 26.85 (Kelvin to Celsius) +Schema(Scale(divisor=1000))(5000) # 5.0 (milliunits to units) +Schema(Scale(10))(5) # 50 (integer gain, stays an int) +Schema(Scale(100, divisor=255, round=1))(128) # 50.2 (a byte as a percentage) +Schema(Scale(offset=-273.15, round=2))(300) # 26.85 (Kelvin to Celsius) ``` A few finishers round out the number work. `Round` takes the nearest value; `RoundUp` @@ -160,11 +166,11 @@ validates divisibility rather than transforming). ```python from probatio import Schema, RoundUp, RoundDown, Snap, Abs, Modulo -Schema(RoundUp())(4.1) # 5 -Schema(RoundDown())(4.9) # 4 -Schema(Snap(0.5))(1.2) # 1.0 (nearest half) -Schema(Abs())(-3) # 3 -Schema(Modulo(360))(370) # 10 (wrap a heading) +Schema(RoundUp())(4.1) # 5 +Schema(RoundDown())(4.9) # 4 +Schema(Snap(0.5))(1.2) # 1.0 (nearest half) +Schema(Abs())(-3) # 3 +Schema(Modulo(360))(370) # 10 (wrap a heading) ``` ## Collections and structure @@ -184,13 +190,13 @@ value (string, list, mapping) to not be empty. ```python from probatio import Schema, Length, Unique, ExactSequence, Unordered, Maybe, EnsureList -Schema(Length(min=1, max=3))([1, 2]) # [1, 2] -Schema(Unique())([1, 2, 3]) # [1, 2, 3] +Schema(Length(min=1, max=3))([1, 2]) # [1, 2] +Schema(Unique())([1, 2, 3]) # [1, 2, 3] Schema(ExactSequence([str, int]))(["a", 1]) # ['a', 1] -Schema(Unordered([str, int]))([1, "a"]) # [1, 'a'] -Schema(Maybe(int))(None) # None -Schema(Maybe(int))(5) # 5 -Schema(EnsureList())("one") # ['one'] +Schema(Unordered([str, int]))([1, "a"]) # [1, 'a'] +Schema(Maybe(int))(None) # None +Schema(Maybe(int))(5) # 5 +Schema(EnsureList())("one") # ['one'] ``` `Maybe` composes with a coercer for the "optional, coerce if present" field: @@ -200,8 +206,8 @@ no null check to hand-write: ```python from probatio import Schema, Maybe, Coerce -Schema(Maybe(Coerce(float)))(None) # None -Schema(Maybe(Coerce(float)))("0.5") # 0.5 +Schema(Maybe(Coerce(float)))(None) # None +Schema(Maybe(Coerce(float)))("0.5") # 0.5 ``` `Sorted` requires a collection to already be in order (it does not reorder): @@ -222,12 +228,12 @@ the holes. ```python from probatio import Schema, Split, Join, Sort, Dedupe, First, Last, Without -Schema(Split(","))("a, b ,c") # ['a', 'b', 'c'] -Schema(Join(","))([1, 2, 3]) # '1,2,3' -Schema(Sort())([3, 1, 2]) # [1, 2, 3] -Schema(Dedupe())([1, 2, 1, 3]) # [1, 2, 3] -Schema(First())([1, 2, 3]) # 1 -Schema(Last())([1, 2, 3]) # 3 +Schema(Split(","))("a, b ,c") # ['a', 'b', 'c'] +Schema(Join(","))([1, 2, 3]) # '1,2,3' +Schema(Sort())([3, 1, 2]) # [1, 2, 3] +Schema(Dedupe())([1, 2, 1, 3]) # [1, 2, 3] +Schema(First())([1, 2, 3]) # 1 +Schema(Last())([1, 2, 3]) # 3 Schema(Without(None, 0))([1, None, 0, 2]) # [1, 2] ``` @@ -245,10 +251,12 @@ sorted(result) # [1, 2, 3] ```python from probatio import Schema, Object + class Point: def __init__(self, x, y): self.x, self.y = x, y + result = Schema(Object({"x": int, "y": int}))(Point(1, 2)) result.x # 1 result.y # 2 @@ -268,11 +276,11 @@ They avoid backtracking regular expressions, so a crafted input cannot hang them ```python from probatio import Schema, Lower, Upper, Capitalize, Title, Strip, Replace -Schema(Lower)("HELLO") # 'hello' -Schema(Upper)("hello") # 'HELLO' -Schema(Capitalize)("hello world") # 'Hello world' -Schema(Title)("hello world") # 'Hello World' -Schema(Strip)(" hi ") # 'hi' +Schema(Lower)("HELLO") # 'hello' +Schema(Upper)("hello") # 'HELLO' +Schema(Capitalize)("hello world") # 'Hello world' +Schema(Title)("hello world") # 'Hello World' +Schema(Strip)(" hi ") # 'hi' Schema(Replace("-", "_"))("a-b-c") # 'a_b_c' ``` @@ -285,18 +293,18 @@ non-string rather than coercing it. ```python from probatio import Schema, CollapseWhitespace, RemovePrefix, RemoveSuffix, Truncate -Schema(CollapseWhitespace())(" a b c ") # 'a b c' +Schema(CollapseWhitespace())(" a b c ") # 'a b c' Schema(RemovePrefix("sensor."))("sensor.temp") # 'temp' -Schema(RemoveSuffix("_raw"))("value_raw") # 'value' -Schema(Truncate(5))("hello world") # 'hello' +Schema(RemoveSuffix("_raw"))("value_raw") # 'value' +Schema(Truncate(5))("hello world") # 'hello' ``` ```python from probatio import Schema, Email, Url, FqdnUrl -Schema(Email())("me@example.com") # 'me@example.com' +Schema(Email())("me@example.com") # 'me@example.com' Schema(Url())("https://example.com/path") # 'https://example.com/path' -Schema(FqdnUrl())("https://example.com") # 'https://example.com' +Schema(FqdnUrl())("https://example.com") # 'https://example.com' ``` `Slug` validates a slug (lowercase alphanumerics with hyphen or underscore @@ -318,9 +326,11 @@ More string checks: the character classes `Alpha`, `Alphanumeric`, `ASCII`, ```python from probatio import Schema, Alphanumeric, StartsWith, HexColor -Schema(Alphanumeric())("abc123") # 'abc123' +Schema(Alphanumeric())("abc123") # 'abc123' Schema(StartsWith("https://"))("https://example.com") # 'https://example.com' -Schema(HexColor())("#FF8800") # '#FF8800' (validated, unchanged; use Lower to fold case) +Schema(HexColor())( + "#FF8800" +) # '#FF8800' (validated, unchanged; use Lower to fold case) ``` :::tip @@ -342,10 +352,10 @@ return the value unchanged. ```python from probatio import Schema, CreditCard, IBAN, E164 -Schema(CreditCard())("4242 4242 4242 4242") # '4242424242424242' -Schema(IBAN())("de89 3704 0044 0532 0130 00") # 'DE89370400440532013000' -Schema(E164())("+1 (415) 555-2671") # '+14155552671' -Schema(E164(normalize=False))("+14155552671") # '+14155552671' (unchanged) +Schema(CreditCard())("4242 4242 4242 4242") # '4242424242424242' +Schema(IBAN())("de89 3704 0044 0532 0130 00") # 'DE89370400440532013000' +Schema(E164())("+1 (415) 555-2671") # '+14155552671' +Schema(E164(normalize=False))("+14155552671") # '+14155552671' (unchanged) ``` These check shape, not existence: `CreditCard` confirms the Luhn checksum, not that @@ -363,8 +373,8 @@ anything else. from probatio import Schema, Datetime, Date Schema(Datetime())("2026-06-25T10:30:00.000000Z") # '2026-06-25T10:30:00.000000Z' -Schema(Date())("2026-06-25") # '2026-06-25' -Schema(Date(format="%d/%m/%Y"))("25/06/2026") # '25/06/2026' +Schema(Date())("2026-06-25") # '2026-06-25' +Schema(Date(format="%d/%m/%Y"))("25/06/2026") # '25/06/2026' ``` `Time` is the time-of-day sibling, defaulting to `%H:%M:%S`. `Duration` validates a @@ -386,17 +396,26 @@ offset into a `datetime.timezone`. import zoneinfo from probatio import ( - Schema, Time, Duration, AsTimedelta, TimeZone, TimeZoneInfo, AsTimezone, Coerce, + Schema, + Time, + Duration, + AsTimedelta, + TimeZone, + TimeZoneInfo, + AsTimezone, + Coerce, ) -Schema(Time())("14:30:00") # '14:30:00' -Schema(Duration())("1:30:00") # '1:30:00' -Schema(AsTimedelta())("1:30:00") # datetime.timedelta(seconds=5400) -Schema(AsTimedelta())("P1DT2H30M") # datetime.timedelta(days=1, seconds=9000) +Schema(Time())("14:30:00") # '14:30:00' +Schema(Duration())("1:30:00") # '1:30:00' +Schema(AsTimedelta())("1:30:00") # datetime.timedelta(seconds=5400) +Schema(AsTimedelta())("P1DT2H30M") # datetime.timedelta(days=1, seconds=9000) Schema(TimeZoneInfo())("Europe/Amsterdam") # 'Europe/Amsterdam' -Schema(Coerce(zoneinfo.ZoneInfo))("Europe/Amsterdam") # zoneinfo.ZoneInfo(key='Europe/Amsterdam') -Schema(TimeZone())("+01:00") # '+01:00' -Schema(AsTimezone())("+01:00") # datetime.timezone(datetime.timedelta(seconds=3600)) +Schema(Coerce(zoneinfo.ZoneInfo))( + "Europe/Amsterdam" +) # zoneinfo.ZoneInfo(key='Europe/Amsterdam') +Schema(TimeZone())("+01:00") # '+01:00' +Schema(AsTimezone())("+01:00") # datetime.timezone(datetime.timedelta(seconds=3600)) ``` `AsDatetime`, `AsDate`, and `AsTime` are the object-returning siblings of @@ -412,8 +431,8 @@ rejects a `datetime`, since it carries a time a pure date would drop). ```python from probatio import Schema, AsDatetime, AsDate, AsTime -Schema(AsDate())("2026-06-25") # datetime.date(2026, 6, 25) -Schema(AsTime())("14:30:00") # datetime.time(14, 30) +Schema(AsDate())("2026-06-25") # datetime.date(2026, 6, 25) +Schema(AsTime())("14:30:00") # datetime.time(14, 30) Schema(AsDatetime())("2026-06-25T10:30:00+02:00") # datetime.datetime(2026, 6, 25, 10, 30, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200))) Schema(AsDatetime(format="%d/%m/%Y %H:%M"))("25/06/2026 10:30") @@ -493,8 +512,8 @@ Schema(NormalizeMacAddress(separator="-"))("aabbccddeeff") # 'aa-bb-cc-dd-ee-ff from probatio import Schema, IPNetwork, Hostname, Fqdn Schema(IPNetwork())("192.0.2.5/24") # '192.0.2.5/24' -Schema(Hostname())("localhost") # 'localhost' -Schema(Fqdn())("host.example.com") # 'host.example.com' +Schema(Hostname())("localhost") # 'localhost' +Schema(Fqdn())("host.example.com") # 'host.example.com' ``` ## Encoding @@ -507,8 +526,8 @@ returns the decoded value. ```python from probatio import Schema, JSONString, FromJSONString -Schema(JSONString())('{"a": 1, "b": [2, 3]}') # '{"a": 1, "b": [2, 3]}' -Schema(FromJSONString())('{"a": 1, "b": [2, 3]}') # {'a': 1, 'b': [2, 3]} +Schema(JSONString())('{"a": 1, "b": [2, 3]}') # '{"a": 1, "b": [2, 3]}' +Schema(FromJSONString())('{"a": 1, "b": [2, 3]}') # {'a': 1, 'b': [2, 3]} Schema(FromJSONString({"port": int}))('{"port": 8080}') # {'port': 8080} ``` @@ -519,7 +538,7 @@ unchanged; decode it yourself with `Coerce` if you want the bytes). from probatio import Schema, Base64, Hex Schema(Base64())("aGVsbG8=") # 'aGVsbG8=' -Schema(Hex())("deadbeef") # 'deadbeef' +Schema(Hex())("deadbeef") # 'deadbeef' ``` `HexInt` is the parsing sibling of `Hex`: it reads a hexadecimal string (a @@ -528,7 +547,7 @@ leading `0x` is optional) and returns the `int`. ```python from probatio import Schema, HexInt -Schema(HexInt())("ff") # 255 +Schema(HexInt())("ff") # 255 Schema(HexInt())("0x1A") # 26 ``` @@ -548,9 +567,9 @@ with tempfile.TemporaryDirectory() as path: file_path = os.path.join(path, "config.yaml") open(file_path, "w").close() - Schema(IsDir())(path) # an existing directory - Schema(IsFile())(file_path) # an existing file - Schema(PathExists())(path) # any existing path + Schema(IsDir())(path) # an existing directory + Schema(IsFile())(file_path) # an existing file + Schema(PathExists())(path) # any existing path ``` ## Truthiness @@ -561,7 +580,7 @@ unchanged. ```python from probatio import Schema, IsTrue, IsFalse -Schema(IsTrue())(1) # 1 +Schema(IsTrue())(1) # 1 Schema(IsFalse())(0) # 0 ``` @@ -595,7 +614,7 @@ schema = Schema( ), ) schema({"tls": True, "cert": "c", "key": "k"}) # unchanged -schema({}) # unchanged, no trigger +schema({}) # unchanged, no trigger ``` `Check` runs an arbitrary predicate over the value with a paired message. A falsy @@ -606,7 +625,10 @@ that message, so a cross-field rule never leaks a raw exception. from probatio import Schema, All, Check schema = Schema( - All({"start": int, "end": int}, Check(lambda d: d["start"] < d["end"], "start must be before end")), + All( + {"start": int, "end": int}, + Check(lambda d: d["start"] < d["end"], "start must be before end"), + ), ) schema({"start": 1, "end": 2}) # unchanged ``` @@ -626,10 +648,10 @@ more naturally as a standalone check than as a marker on each key. ```python from probatio import Schema, AtLeastOne, AtMostOne, ExactlyOne, AllOrNone -Schema(AtLeastOne("host", "url"))({"host": "nas"}) # unchanged +Schema(AtLeastOne("host", "url"))({"host": "nas"}) # unchanged Schema(AtMostOne("include", "exclude"))({"include": 1}) # unchanged Schema(ExactlyOne("token", "password"))({"token": "t"}) # unchanged -Schema(AllOrNone("lat", "lon"))({"lat": 1, "lon": 2}) # unchanged +Schema(AllOrNone("lat", "lon"))({"lat": 1, "lon": 2}) # unchanged ``` A non-mapping is rejected by default, so the rule stands on its own without an @@ -675,11 +697,11 @@ choose, which is the simplest way to make an error read in your own words. ```python from probatio import Schema, DefaultTo, EmptyToNone, SetTo -Schema(DefaultTo("fallback"))(None) # 'fallback' +Schema(DefaultTo("fallback"))(None) # 'fallback' Schema(DefaultTo("fallback"))("value") # 'value' -Schema(EmptyToNone())("") # None -Schema(EmptyToNone())(0) # 0 -Schema(SetTo(42))("anything") # 42 +Schema(EmptyToNone())("") # None +Schema(EmptyToNone())(0) # 0 +Schema(SetTo(42))("anything") # 42 ``` `Map` translates a value through a table you supply, like a device status code to a @@ -690,8 +712,8 @@ the table is rejected, unless you pass a `default`. ```python from probatio import Schema, Map -Schema(Map({0: "off", 1: "on", 2: "auto"}))(1) # 'on' -Schema(Map({0: "off"}, default="unknown"))(9) # 'unknown' +Schema(Map({0: "off", 1: "on", 2: "auto"}))(1) # 'on' +Schema(Map({0: "off"}, default="unknown"))(9) # 'unknown' ``` A fixed `default` folds every miss to one value. To rewrite only the keys you list @@ -704,7 +726,7 @@ validator. from probatio import Schema, Map, PASSTHROUGH sentinels = Map({"N.v.t.": None, "n/a": None}, default=PASSTHROUGH) -Schema(sentinels)("N.v.t.") # None +Schema(sentinels)("N.v.t.") # None Schema(sentinels)("Personenauto") # 'Personenauto' (unmapped, left as-is) ``` diff --git a/docs/src/content/docs/recipes/cookbook.md b/docs/src/content/docs/recipes/cookbook.md index c14fab4..2f80178 100644 --- a/docs/src/content/docs/recipes/cookbook.md +++ b/docs/src/content/docs/recipes/cookbook.md @@ -28,7 +28,7 @@ schema = Schema( ) schema({"type": "point", "x": 1, "y": 2}) # {'type': 'point', 'x': 1, 'y': 2} -schema({"type": "label", "text": "hi"}) # {'type': 'label', 'text': 'hi'} +schema({"type": "label", "text": "hi"}) # {'type': 'label', 'text': 'hi'} ``` A point with a non-int coordinate fails against the point branch, not the whole @@ -39,9 +39,7 @@ union: ```python from probatio import Schema, TaggedUnion -schema = Schema( - TaggedUnion("type", {"point": {"type": "point", "x": int, "y": int}}) -) +schema = Schema(TaggedUnion("type", {"point": {"type": "point", "x": int, "y": int}})) schema({"type": "point", "x": "nope", "y": 2}) # expected int at 'x' ``` @@ -151,13 +149,15 @@ from enum import StrEnum from probatio import Schema, All, Map, Maybe, PASSTHROUGH + class VehicleType(StrEnum): CAR = "Personenauto" + vehicle_type = Schema( All(Map({"N.v.t.": None}, default=PASSTHROUGH), Maybe(VehicleType)) ) -vehicle_type("N.v.t.") # None +vehicle_type("N.v.t.") # None vehicle_type("Personenauto") # ``` @@ -176,9 +176,11 @@ from typing import Annotated from probatio import DataclassSchema, Key, Map, PASSTHROUGH + class VehicleType(StrEnum): CAR = "Personenauto" + @dataclass class Vehicle: vehicle_type: Annotated[ @@ -187,9 +189,12 @@ class Vehicle: Map({"N.v.t.": None}, default=PASSTHROUGH), ] = None + schema = DataclassSchema(Vehicle) -schema({"voertuigsoort": "N.v.t."}) # Vehicle(vehicle_type=None) -schema({"voertuigsoort": "Personenauto"}) # Vehicle(vehicle_type=) +schema({"voertuigsoort": "N.v.t."}) # Vehicle(vehicle_type=None) +schema( + {"voertuigsoort": "Personenauto"} +) # Vehicle(vehicle_type=) ``` An asserting constraint keeps the opposite order. An `In([...])` in the metadata runs @@ -414,7 +419,9 @@ schema = Schema({Required(Any("email", "phone")): str}) try: schema({}) except Invalid as err: - print(err) # at least one of ['email', 'phone'] is required at '[Any('email', 'phone', msg=None)]' + print( + err + ) # at least one of ['email', 'phone'] is required at '[Any('email', 'phone', msg=None)]' ``` That default group label is honest but ugly: the path segment renders the diff --git a/docs/src/content/docs/recipes/llm-tools.md b/docs/src/content/docs/recipes/llm-tools.md index efa5206..3a68538 100644 --- a/docs/src/content/docs/recipes/llm-tools.md +++ b/docs/src/content/docs/recipes/llm-tools.md @@ -97,7 +97,10 @@ from probatio import from_json_schema document = { "type": "object", - "properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1}}, + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1}, + }, "required": ["query"], } validate_args = from_json_schema(document) diff --git a/docs/src/content/docs/reference/errors.md b/docs/src/content/docs/reference/errors.md index e0be64b..2f28441 100644 --- a/docs/src/content/docs/reference/errors.md +++ b/docs/src/content/docs/reference/errors.md @@ -67,8 +67,8 @@ try: schema({"server": {"ports": [80, "nope"]}}) except MultipleInvalid as err: first = err.errors[0] - print(first.path) # ['server', 'ports', 1] - print(first.code) # type + print(first.path) # ['server', 'ports', 1] + print(first.code) # type print(first.as_dict()["context"]) # {'expected': 'int'} ``` @@ -187,5 +187,5 @@ try: except MultipleInvalid as err: first = err.errors[0] print(isinstance(first, RangeInvalid)) # True - print(first.code) # range + print(first.code) # range ``` diff --git a/docs/src/content/docs/reference/index.md b/docs/src/content/docs/reference/index.md index eb0886f..eaf946b 100644 --- a/docs/src/content/docs/reference/index.md +++ b/docs/src/content/docs/reference/index.md @@ -33,7 +33,9 @@ schema. Keyword arguments pass through to `Schema`. ```python from probatio import Schema, Required -Schema.infer({"name": "app", "port": 80}) # == Schema({Required("name"): str, Required("port"): int}) +Schema.infer( + {"name": "app", "port": 80} +) # == Schema({Required("name"): str, Required("port"): int}) ``` A schema is built from plain Python: a type (`int`), a literal (`"on"`), a @@ -93,8 +95,8 @@ The extra-key policy is set with the `extra` argument to `Schema`: ```python from probatio import All, Any, SomeOf, Range, Schema -Schema(All(str, str.strip))(" hi ") # 'hi' -Schema(Any(int, str))("a") # 'a' +Schema(All(str, str.strip))(" hi ") # 'hi' +Schema(Any(int, str))("a") # 'a' Schema(SomeOf(min_valid=2, validators=[Range(1, 5), int, 3]))(3) # 3 ``` @@ -103,9 +105,11 @@ A `Union` discriminant picks the branch instead of trying every alternative: ```python from probatio import Schema, Union + def by_type(value, alternatives): return [a for a in alternatives if a["type"] == value.get("type")] + schema = Schema( Union({"type": "a", "v": int}, {"type": "b", "v": str}, discriminant=by_type) ) @@ -175,12 +179,14 @@ mean ...?`) and records them on the error's `candidates`. ```python from probatio import Object, Schema, Unordered -Schema(Unordered([str, int]))([1, "a"]) # [1, 'a'] +Schema(Unordered([str, int]))([1, "a"]) # [1, 'a'] + class Point: def __init__(self, x, y): self.x, self.y = x, y + result = Schema(Object({"x": int, "y": int}))(Point(1, 2)) # validates the attributes result.x # 1 ``` @@ -367,10 +373,12 @@ from typing import Annotated from probatio import probatio, Range + @probatio(returns=True) def multiply(arg1: int, arg2: int) -> Annotated[int, Range(min=0)]: return arg1 * arg2 + multiply(3, 4) # 12 ``` diff --git a/docs/src/content/docs/reference/performance.md b/docs/src/content/docs/reference/performance.md index 7e6150b..353126b 100644 --- a/docs/src/content/docs/reference/performance.md +++ b/docs/src/content/docs/reference/performance.md @@ -23,7 +23,7 @@ from probatio import Schema, All, Coerce, Range PORT = Schema(All(Coerce(int), Range(min=1, max=65535))) PORT("443") # 443 -PORT(8080) # 8080 +PORT(8080) # 8080 ``` The expensive work (walking the definition, resolving markers, wiring up the diff --git a/docs/src/content/docs/reference/typing.md b/docs/src/content/docs/reference/typing.md index cde01bd..ba6eac4 100644 --- a/docs/src/content/docs/reference/typing.md +++ b/docs/src/content/docs/reference/typing.md @@ -64,18 +64,22 @@ into a typed object: from dataclasses import dataclass from probatio import Schema, Required + @dataclass class Config: name: str port: int + schema = Schema({Required("name"): str, Required("port"): int}) + def load(raw: object) -> Config: """Validate, then build a typed object the checker understands.""" data = schema(raw) # runtime-checked, typed Any return Config(name=data["name"], port=data["port"]) + load({"name": "app", "port": 8080}) # Config(name='app', port=8080) ``` @@ -95,11 +99,13 @@ dataclass type, so the checker infers the result. `DataclassSchema(Config)` is a from dataclasses import dataclass from probatio import DataclassSchema + @dataclass class Config: name: str port: int = 8080 + schema = DataclassSchema(Config) config = schema({"name": "app"}) # config is typed as Config config.port # 8080, and the checker knows .port is an int @@ -122,10 +128,12 @@ and `result["key"]` access keeps working, because it really is a dict. from typing import TypedDict from probatio import TypedDictSchema + class Config(TypedDict): name: str port: int + schema = TypedDictSchema(Config) config = schema({"name": "app", "port": 8080}) # typed as Config config["port"] # 8080, and the checker knows it is an int @@ -156,10 +164,12 @@ so the alias is `Any`. Use it for intent and readability, not for narrowing: ```python from probatio import Schema, Schemable + def make_validator(definition: Schemable) -> Schema: """Build a Schema from any schemable definition.""" return Schema(definition) + make_validator({"port": int}) # a compiled Schema ``` diff --git a/pyproject.toml b/pyproject.toml index 0383320..97b1a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ test = [ "hypothesis==6.164.0", ] typing = ["mypy==2.3.0", "ty==0.0.65"] -lint = ["ruff==0.15.22", "codespell==2.4.3", "zizmor==1.28.0"] +lint = ["ruff==0.16.0", "codespell==2.4.3", "zizmor==1.28.0"] # Continuous performance testing (bench/) measured by CodSpeed. Includes the test # group for voluptuous, which the benchmarks compare against. codspeed = [{ include-group = "test" }, "pytest-codspeed==5.0.3"] @@ -148,6 +148,7 @@ select = ["ALL"] ignore = [ "ANN401", # Opinionated warning on disallowing dynamically typed expressions "A005", # The `codecs` subpackage is always used qualified (probatio.codecs) + "CPY001", # This project does not use file-level copyright headers "E501", # Line length is handled by the formatter "TRY301", # Abstract raise to inner function, not useful for this codebase "D203", # Conflicts with other rules @@ -196,6 +197,7 @@ ignore = [ "D", "PLR0912", "PLR0913", + "PLR0917", "PLC0415", "FURB188", "EXE001", diff --git a/src/probatio/_engine.py b/src/probatio/_engine.py index 8915239..daa698a 100644 --- a/src/probatio/_engine.py +++ b/src/probatio/_engine.py @@ -365,7 +365,7 @@ def _match_validator( self._unmatched(key, value, key_error, out, errors) - def _apply( # noqa: PLR0913 + def _apply( # noqa: PLR0913, PLR0917 self, key: Any, value: Any, diff --git a/src/probatio/codecs/jsonschema.py b/src/probatio/codecs/jsonschema.py index dd6474c..8531518 100644 --- a/src/probatio/codecs/jsonschema.py +++ b/src/probatio/codecs/jsonschema.py @@ -408,7 +408,7 @@ def _convert_mapping( return result -def _emit_named_key( # noqa: PLR0913 +def _emit_named_key( # noqa: PLR0913, PLR0917 name: str, decorated: dict[str, Any], marker: Marker | None, diff --git a/uv.lock b/uv.lock index 0c2dc7a..c7c39c3 100644 --- a/uv.lock +++ b/uv.lock @@ -1004,7 +1004,7 @@ dev = [ { name = "openapi-schema-validator", specifier = "==0.9.0" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-cov", specifier = "==7.1.0" }, - { name = "ruff", specifier = "==0.15.22" }, + { name = "ruff", specifier = "==0.16.0" }, { name = "rust-just", specifier = "==1.57.0" }, { name = "syrupy", specifier = "==5.5.3" }, { name = "ty", specifier = "==0.0.65" }, @@ -1015,7 +1015,7 @@ dev = [ ] lint = [ { name = "codespell", specifier = "==2.4.3" }, - { name = "ruff", specifier = "==0.15.22" }, + { name = "ruff", specifier = "==0.16.0" }, { name = "zizmor", specifier = "==1.28.0" }, ] test = [ @@ -1392,27 +1392,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]]