tigrbl 0.0.1.dev1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tigrbl/README.md +94 -0
- tigrbl/__init__.py +139 -14
- tigrbl/api/__init__.py +6 -0
- tigrbl/api/_api.py +97 -0
- tigrbl/api/api_spec.py +30 -0
- tigrbl/api/mro_collect.py +43 -0
- tigrbl/api/shortcuts.py +56 -0
- tigrbl/api/tigrbl_api.py +291 -0
- tigrbl/app/__init__.py +0 -0
- tigrbl/app/_app.py +86 -0
- tigrbl/app/_model_registry.py +41 -0
- tigrbl/app/app_spec.py +42 -0
- tigrbl/app/mro_collect.py +67 -0
- tigrbl/app/shortcuts.py +65 -0
- tigrbl/app/tigrbl_app.py +319 -0
- tigrbl/bindings/__init__.py +73 -0
- tigrbl/bindings/api/__init__.py +12 -0
- tigrbl/bindings/api/common.py +109 -0
- tigrbl/bindings/api/include.py +256 -0
- tigrbl/bindings/api/resource_proxy.py +149 -0
- tigrbl/bindings/api/rpc.py +111 -0
- tigrbl/bindings/columns.py +49 -0
- tigrbl/bindings/handlers/__init__.py +11 -0
- tigrbl/bindings/handlers/builder.py +119 -0
- tigrbl/bindings/handlers/ctx.py +74 -0
- tigrbl/bindings/handlers/identifiers.py +228 -0
- tigrbl/bindings/handlers/namespaces.py +51 -0
- tigrbl/bindings/handlers/steps.py +276 -0
- tigrbl/bindings/hooks.py +311 -0
- tigrbl/bindings/model.py +194 -0
- tigrbl/bindings/model_helpers.py +139 -0
- tigrbl/bindings/model_registry.py +77 -0
- tigrbl/bindings/rest/__init__.py +7 -0
- tigrbl/bindings/rest/attach.py +34 -0
- tigrbl/bindings/rest/collection.py +286 -0
- tigrbl/bindings/rest/common.py +120 -0
- tigrbl/bindings/rest/fastapi.py +76 -0
- tigrbl/bindings/rest/helpers.py +119 -0
- tigrbl/bindings/rest/io.py +317 -0
- tigrbl/bindings/rest/io_headers.py +49 -0
- tigrbl/bindings/rest/member.py +386 -0
- tigrbl/bindings/rest/router.py +296 -0
- tigrbl/bindings/rest/routing.py +153 -0
- tigrbl/bindings/rpc.py +364 -0
- tigrbl/bindings/schemas/__init__.py +11 -0
- tigrbl/bindings/schemas/builder.py +348 -0
- tigrbl/bindings/schemas/defaults.py +260 -0
- tigrbl/bindings/schemas/utils.py +193 -0
- tigrbl/column/README.md +62 -0
- tigrbl/column/__init__.py +72 -0
- tigrbl/column/_column.py +96 -0
- tigrbl/column/column_spec.py +40 -0
- tigrbl/column/field_spec.py +31 -0
- tigrbl/column/infer/__init__.py +25 -0
- tigrbl/column/infer/core.py +92 -0
- tigrbl/column/infer/jsonhints.py +44 -0
- tigrbl/column/infer/planning.py +133 -0
- tigrbl/column/infer/types.py +102 -0
- tigrbl/column/infer/utils.py +59 -0
- tigrbl/column/io_spec.py +136 -0
- tigrbl/column/mro_collect.py +59 -0
- tigrbl/column/shortcuts.py +89 -0
- tigrbl/column/storage_spec.py +65 -0
- tigrbl/config/__init__.py +19 -0
- tigrbl/config/constants.py +224 -0
- tigrbl/config/defaults.py +29 -0
- tigrbl/config/resolver.py +295 -0
- tigrbl/core/__init__.py +47 -0
- tigrbl/core/crud/__init__.py +36 -0
- tigrbl/core/crud/bulk.py +168 -0
- tigrbl/core/crud/helpers/__init__.py +76 -0
- tigrbl/core/crud/helpers/db.py +92 -0
- tigrbl/core/crud/helpers/enum.py +86 -0
- tigrbl/core/crud/helpers/filters.py +162 -0
- tigrbl/core/crud/helpers/model.py +123 -0
- tigrbl/core/crud/helpers/normalize.py +99 -0
- tigrbl/core/crud/ops.py +235 -0
- tigrbl/ddl/__init__.py +344 -0
- tigrbl/decorators.py +17 -0
- tigrbl/deps/__init__.py +20 -0
- tigrbl/deps/fastapi.py +45 -0
- tigrbl/deps/favicon.svg +4 -0
- tigrbl/deps/jinja.py +27 -0
- tigrbl/deps/pydantic.py +10 -0
- tigrbl/deps/sqlalchemy.py +94 -0
- tigrbl/deps/starlette.py +36 -0
- tigrbl/engine/__init__.py +45 -0
- tigrbl/engine/_engine.py +144 -0
- tigrbl/engine/bind.py +33 -0
- tigrbl/engine/builders.py +236 -0
- tigrbl/engine/capabilities.py +29 -0
- tigrbl/engine/collect.py +111 -0
- tigrbl/engine/decorators.py +110 -0
- tigrbl/engine/docs/PLUGINS.md +49 -0
- tigrbl/engine/engine_spec.py +355 -0
- tigrbl/engine/plugins.py +52 -0
- tigrbl/engine/registry.py +36 -0
- tigrbl/engine/resolver.py +224 -0
- tigrbl/engine/shortcuts.py +216 -0
- tigrbl/hook/__init__.py +21 -0
- tigrbl/hook/_hook.py +22 -0
- tigrbl/hook/decorators.py +28 -0
- tigrbl/hook/hook_spec.py +24 -0
- tigrbl/hook/mro_collect.py +98 -0
- tigrbl/hook/shortcuts.py +44 -0
- tigrbl/hook/types.py +76 -0
- tigrbl/op/__init__.py +50 -0
- tigrbl/op/_op.py +31 -0
- tigrbl/op/canonical.py +31 -0
- tigrbl/op/collect.py +11 -0
- tigrbl/op/decorators.py +238 -0
- tigrbl/op/model_registry.py +301 -0
- tigrbl/op/mro_collect.py +99 -0
- tigrbl/op/resolver.py +216 -0
- tigrbl/op/types.py +136 -0
- tigrbl/orm/__init__.py +1 -0
- tigrbl/orm/mixins/_RowBound.py +83 -0
- tigrbl/orm/mixins/__init__.py +95 -0
- tigrbl/orm/mixins/bootstrappable.py +113 -0
- tigrbl/orm/mixins/bound.py +47 -0
- tigrbl/orm/mixins/edges.py +40 -0
- tigrbl/orm/mixins/fields.py +165 -0
- tigrbl/orm/mixins/hierarchy.py +54 -0
- tigrbl/orm/mixins/key_digest.py +44 -0
- tigrbl/orm/mixins/lifecycle.py +115 -0
- tigrbl/orm/mixins/locks.py +51 -0
- tigrbl/orm/mixins/markers.py +16 -0
- tigrbl/orm/mixins/operations.py +57 -0
- tigrbl/orm/mixins/ownable.py +337 -0
- tigrbl/orm/mixins/principals.py +98 -0
- tigrbl/orm/mixins/tenant_bound.py +301 -0
- tigrbl/orm/mixins/upsertable.py +118 -0
- tigrbl/orm/mixins/utils.py +49 -0
- tigrbl/orm/tables/__init__.py +72 -0
- tigrbl/orm/tables/_base.py +8 -0
- tigrbl/orm/tables/audit.py +56 -0
- tigrbl/orm/tables/client.py +25 -0
- tigrbl/orm/tables/group.py +29 -0
- tigrbl/orm/tables/org.py +30 -0
- tigrbl/orm/tables/rbac.py +76 -0
- tigrbl/orm/tables/status.py +106 -0
- tigrbl/orm/tables/tenant.py +22 -0
- tigrbl/orm/tables/user.py +39 -0
- tigrbl/response/README.md +34 -0
- tigrbl/response/__init__.py +33 -0
- tigrbl/response/bind.py +12 -0
- tigrbl/response/decorators.py +37 -0
- tigrbl/response/resolver.py +83 -0
- tigrbl/response/shortcuts.py +171 -0
- tigrbl/response/types.py +49 -0
- tigrbl/rest/__init__.py +27 -0
- tigrbl/runtime/README.md +129 -0
- tigrbl/runtime/__init__.py +20 -0
- tigrbl/runtime/atoms/__init__.py +102 -0
- tigrbl/runtime/atoms/emit/__init__.py +42 -0
- tigrbl/runtime/atoms/emit/paired_post.py +158 -0
- tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
- tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
- tigrbl/runtime/atoms/out/__init__.py +38 -0
- tigrbl/runtime/atoms/out/masking.py +135 -0
- tigrbl/runtime/atoms/refresh/__init__.py +38 -0
- tigrbl/runtime/atoms/refresh/demand.py +130 -0
- tigrbl/runtime/atoms/resolve/__init__.py +40 -0
- tigrbl/runtime/atoms/resolve/assemble.py +167 -0
- tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
- tigrbl/runtime/atoms/response/__init__.py +19 -0
- tigrbl/runtime/atoms/response/headers_from_payload.py +57 -0
- tigrbl/runtime/atoms/response/negotiate.py +30 -0
- tigrbl/runtime/atoms/response/negotiation.py +43 -0
- tigrbl/runtime/atoms/response/render.py +36 -0
- tigrbl/runtime/atoms/response/renderer.py +116 -0
- tigrbl/runtime/atoms/response/template.py +44 -0
- tigrbl/runtime/atoms/response/templates.py +88 -0
- tigrbl/runtime/atoms/schema/__init__.py +40 -0
- tigrbl/runtime/atoms/schema/collect_in.py +21 -0
- tigrbl/runtime/atoms/schema/collect_out.py +21 -0
- tigrbl/runtime/atoms/storage/__init__.py +38 -0
- tigrbl/runtime/atoms/storage/to_stored.py +167 -0
- tigrbl/runtime/atoms/wire/__init__.py +45 -0
- tigrbl/runtime/atoms/wire/build_in.py +166 -0
- tigrbl/runtime/atoms/wire/build_out.py +87 -0
- tigrbl/runtime/atoms/wire/dump.py +206 -0
- tigrbl/runtime/atoms/wire/validate_in.py +227 -0
- tigrbl/runtime/context.py +206 -0
- tigrbl/runtime/errors/__init__.py +61 -0
- tigrbl/runtime/errors/converters.py +214 -0
- tigrbl/runtime/errors/exceptions.py +124 -0
- tigrbl/runtime/errors/mappings.py +71 -0
- tigrbl/runtime/errors/utils.py +150 -0
- tigrbl/runtime/events.py +209 -0
- tigrbl/runtime/executor/__init__.py +6 -0
- tigrbl/runtime/executor/guards.py +132 -0
- tigrbl/runtime/executor/helpers.py +88 -0
- tigrbl/runtime/executor/invoke.py +150 -0
- tigrbl/runtime/executor/types.py +84 -0
- tigrbl/runtime/kernel.py +644 -0
- tigrbl/runtime/labels.py +353 -0
- tigrbl/runtime/opview.py +89 -0
- tigrbl/runtime/ordering.py +256 -0
- tigrbl/runtime/system.py +279 -0
- tigrbl/runtime/trace.py +330 -0
- tigrbl/schema/__init__.py +38 -0
- tigrbl/schema/_schema.py +27 -0
- tigrbl/schema/builder/__init__.py +17 -0
- tigrbl/schema/builder/build_schema.py +209 -0
- tigrbl/schema/builder/cache.py +24 -0
- tigrbl/schema/builder/compat.py +16 -0
- tigrbl/schema/builder/extras.py +85 -0
- tigrbl/schema/builder/helpers.py +51 -0
- tigrbl/schema/builder/list_params.py +117 -0
- tigrbl/schema/builder/strip_parent_fields.py +70 -0
- tigrbl/schema/collect.py +79 -0
- tigrbl/schema/decorators.py +68 -0
- tigrbl/schema/get_schema.py +86 -0
- tigrbl/schema/schema_spec.py +20 -0
- tigrbl/schema/shortcuts.py +42 -0
- tigrbl/schema/types.py +34 -0
- tigrbl/schema/utils.py +143 -0
- tigrbl/session/README.md +14 -0
- tigrbl/session/__init__.py +28 -0
- tigrbl/session/abc.py +76 -0
- tigrbl/session/base.py +151 -0
- tigrbl/session/decorators.py +43 -0
- tigrbl/session/default.py +118 -0
- tigrbl/session/shortcuts.py +50 -0
- tigrbl/session/spec.py +112 -0
- tigrbl/shortcuts.py +22 -0
- tigrbl/specs.py +44 -0
- tigrbl/system/__init__.py +13 -0
- tigrbl/system/diagnostics/__init__.py +24 -0
- tigrbl/system/diagnostics/compat.py +31 -0
- tigrbl/system/diagnostics/healthz.py +41 -0
- tigrbl/system/diagnostics/hookz.py +51 -0
- tigrbl/system/diagnostics/kernelz.py +20 -0
- tigrbl/system/diagnostics/methodz.py +43 -0
- tigrbl/system/diagnostics/router.py +73 -0
- tigrbl/system/diagnostics/utils.py +43 -0
- tigrbl/system/uvicorn.py +60 -0
- tigrbl/table/__init__.py +9 -0
- tigrbl/table/_base.py +260 -0
- tigrbl/table/_table.py +54 -0
- tigrbl/table/mro_collect.py +69 -0
- tigrbl/table/shortcuts.py +57 -0
- tigrbl/table/table_spec.py +28 -0
- tigrbl/transport/__init__.py +74 -0
- tigrbl/transport/jsonrpc/__init__.py +19 -0
- tigrbl/transport/jsonrpc/dispatcher.py +352 -0
- tigrbl/transport/jsonrpc/helpers.py +115 -0
- tigrbl/transport/jsonrpc/models.py +41 -0
- tigrbl/transport/rest/__init__.py +25 -0
- tigrbl/transport/rest/aggregator.py +132 -0
- tigrbl/types/__init__.py +170 -0
- tigrbl/types/allow_anon_provider.py +19 -0
- tigrbl/types/authn_abc.py +30 -0
- tigrbl/types/nested_path_provider.py +22 -0
- tigrbl/types/op.py +35 -0
- tigrbl/types/op_config_provider.py +17 -0
- tigrbl/types/op_verb_alias_provider.py +33 -0
- tigrbl/types/request_extras_provider.py +22 -0
- tigrbl/types/response_extras_provider.py +22 -0
- tigrbl/types/table_config_provider.py +13 -0
- tigrbl/types/uuid.py +55 -0
- tigrbl-0.3.0.dist-info/METADATA +516 -0
- tigrbl-0.3.0.dist-info/RECORD +266 -0
- {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dist-info}/WHEEL +1 -1
- tigrbl-0.3.0.dist-info/licenses/LICENSE +201 -0
- tigrbl/ExampleAgent.py +0 -1
- tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
- tigrbl-0.0.1.dev1.dist-info/RECORD +0 -5
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# tigrbl/v3/runtime/atoms/wire/build_in.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Mapping, MutableMapping, Optional
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from ... import events as _ev
|
|
8
|
+
|
|
9
|
+
# Runs in PRE_HANDLER just before validation.
|
|
10
|
+
ANCHOR = _ev.IN_VALIDATE # "in:validate"
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("uvicorn")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(obj: Optional[object], ctx: Any) -> None:
|
|
16
|
+
"""
|
|
17
|
+
wire:build_in@in:validate
|
|
18
|
+
|
|
19
|
+
Purpose
|
|
20
|
+
-------
|
|
21
|
+
Normalize the inbound request payload into canonical field names based on
|
|
22
|
+
the schema collected by schema:collect_in. This prepares a dict that
|
|
23
|
+
downstream atoms (resolve:assemble, validate_in, etc.) can use.
|
|
24
|
+
|
|
25
|
+
What it does
|
|
26
|
+
------------
|
|
27
|
+
- Reads ctx.temp["schema_in"] (built by schema:collect_in).
|
|
28
|
+
- Extracts a dict-like payload from ctx (ctx.in_data/payload/data/body or Pydantic model).
|
|
29
|
+
- Maps known *aliases* (alias_in) → canonical field names.
|
|
30
|
+
- Keeps only fields enabled for inbound IO; unknown keys captured to diagnostics.
|
|
31
|
+
- Distinguishes **ABSENT** (missing) from present(None) by *not* inserting missing keys.
|
|
32
|
+
- Stores results in ctx.temp["in_values"] and supporting diagnostics.
|
|
33
|
+
|
|
34
|
+
Notes
|
|
35
|
+
-----
|
|
36
|
+
- This atom does *not* perform validation—that belongs to wire:validate_in.
|
|
37
|
+
- For bulk inputs, adapters may pre-split and invoke the executor per-item.
|
|
38
|
+
"""
|
|
39
|
+
schema_in = _schema_in(ctx)
|
|
40
|
+
if not schema_in:
|
|
41
|
+
logger.debug("No schema_in available; skipping wire:build_in")
|
|
42
|
+
return # nothing to do
|
|
43
|
+
|
|
44
|
+
logger.debug("Running wire:build_in")
|
|
45
|
+
temp = _ensure_temp(ctx)
|
|
46
|
+
|
|
47
|
+
payload = _coerce_payload(ctx)
|
|
48
|
+
if not isinstance(payload, Mapping):
|
|
49
|
+
logger.debug("Payload is not a mapping; skipping normalization")
|
|
50
|
+
# Non-mapping payloads are ignored here; adapters can pre-normalize.
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
by_field: Mapping[str, Mapping[str, Any]] = schema_in.get("by_field", {}) # type: ignore[assignment]
|
|
54
|
+
# Build alias→field and ingress whitelist (field and alias forms)
|
|
55
|
+
alias_to_field: Dict[str, str] = {}
|
|
56
|
+
ingress_keys: set[str] = set()
|
|
57
|
+
|
|
58
|
+
for fname, entry in by_field.items():
|
|
59
|
+
alias = _safe_str(entry.get("alias_in"))
|
|
60
|
+
ingress_keys.add(fname)
|
|
61
|
+
if alias:
|
|
62
|
+
alias_to_field[alias] = fname
|
|
63
|
+
ingress_keys.add(alias)
|
|
64
|
+
|
|
65
|
+
# Normalize
|
|
66
|
+
in_values: Dict[str, Any] = {}
|
|
67
|
+
present_fields: set[str] = set()
|
|
68
|
+
unknown_keys: Dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
# First pass: direct field-name matches win
|
|
71
|
+
for key, val in payload.items():
|
|
72
|
+
if key in by_field:
|
|
73
|
+
in_values[key] = val
|
|
74
|
+
present_fields.add(key)
|
|
75
|
+
else:
|
|
76
|
+
# Track unknowns for now; we may reclassify as alias below
|
|
77
|
+
unknown_keys[key] = val
|
|
78
|
+
|
|
79
|
+
# Second pass: alias matches for anything not already set
|
|
80
|
+
for key, val in list(unknown_keys.items()):
|
|
81
|
+
target = alias_to_field.get(key)
|
|
82
|
+
if target and target not in in_values:
|
|
83
|
+
logger.debug("Resolved alias %s -> %s", key, target)
|
|
84
|
+
in_values[target] = val
|
|
85
|
+
present_fields.add(target)
|
|
86
|
+
unknown_keys.pop(key, None)
|
|
87
|
+
|
|
88
|
+
# Keep minimal diagnostics
|
|
89
|
+
temp["in_values"] = in_values
|
|
90
|
+
temp["in_present"] = tuple(sorted(present_fields))
|
|
91
|
+
if unknown_keys:
|
|
92
|
+
temp["in_unknown"] = tuple(sorted(unknown_keys.keys()))
|
|
93
|
+
logger.debug("Unknown inbound keys: %s", list(unknown_keys.keys()))
|
|
94
|
+
# optionally stash raw unknowns for tooling (avoid huge payloads)
|
|
95
|
+
if len(unknown_keys) <= 16: # small guard
|
|
96
|
+
temp["in_unknown_samples"] = {
|
|
97
|
+
k: unknown_keys[k] for k in list(unknown_keys)[:16]
|
|
98
|
+
}
|
|
99
|
+
logger.debug("Normalized inbound values: %s", in_values)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
# Internals
|
|
104
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ensure_temp(ctx: Any) -> MutableMapping[str, Any]:
|
|
108
|
+
tmp = getattr(ctx, "temp", None)
|
|
109
|
+
if not isinstance(tmp, dict):
|
|
110
|
+
tmp = {}
|
|
111
|
+
setattr(ctx, "temp", tmp)
|
|
112
|
+
return tmp
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _schema_in(ctx: Any) -> Mapping[str, Any]:
|
|
116
|
+
tmp = getattr(ctx, "temp", {})
|
|
117
|
+
sch = getattr(tmp, "get", lambda *_a, **_k: None)("schema_in") # type: ignore
|
|
118
|
+
if isinstance(sch, Mapping):
|
|
119
|
+
return sch
|
|
120
|
+
# allow adapters to stuff schema_in directly on ctx
|
|
121
|
+
sch2 = getattr(ctx, "schema_in", None)
|
|
122
|
+
return sch2 if isinstance(sch2, Mapping) else {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _coerce_payload(ctx: Any) -> Mapping[str, Any] | Any:
|
|
126
|
+
"""
|
|
127
|
+
Try to obtain a dict-like payload from common places on the context.
|
|
128
|
+
Accepts Pydantic v1/v2 models and simple dataclasses.
|
|
129
|
+
"""
|
|
130
|
+
# Preferred explicit staging from router/adapters
|
|
131
|
+
for name in ("in_data", "payload", "data", "body"):
|
|
132
|
+
val = getattr(ctx, name, None)
|
|
133
|
+
if val is None:
|
|
134
|
+
continue
|
|
135
|
+
# Mapping already?
|
|
136
|
+
if isinstance(val, Mapping):
|
|
137
|
+
return val
|
|
138
|
+
# Pydantic v2
|
|
139
|
+
if hasattr(val, "model_dump") and callable(getattr(val, "model_dump")):
|
|
140
|
+
try:
|
|
141
|
+
return dict(val.model_dump())
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
# Pydantic v1
|
|
145
|
+
if hasattr(val, "dict") and callable(getattr(val, "dict")):
|
|
146
|
+
try:
|
|
147
|
+
return dict(val.dict())
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
# Dataclass?
|
|
151
|
+
try:
|
|
152
|
+
import dataclasses as _dc # local import; safe if missing
|
|
153
|
+
|
|
154
|
+
if _dc.is_dataclass(val):
|
|
155
|
+
return _dc.asdict(val)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
return val # give back as-is; validator can complain later
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _safe_str(v: Any) -> Optional[str]:
|
|
163
|
+
return v if isinstance(v, str) and v else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["ANCHOR", "run"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Mapping, Optional
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from ... import events as _ev
|
|
7
|
+
from ...opview import opview_from_ctx, ensure_schema_out, _ensure_temp
|
|
8
|
+
|
|
9
|
+
# POST_HANDLER, runs before readtime aliases and dump.
|
|
10
|
+
ANCHOR = _ev.OUT_BUILD # "out:build"
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("uvicorn")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(obj: Optional[object], ctx: Any) -> None:
|
|
16
|
+
"""Build canonical outbound values keyed by field name."""
|
|
17
|
+
logger.debug("Running wire:build_out")
|
|
18
|
+
ov = opview_from_ctx(ctx)
|
|
19
|
+
schema_out = ensure_schema_out(ctx, ov)
|
|
20
|
+
by_field = schema_out["by_field"]
|
|
21
|
+
expose = schema_out["expose"]
|
|
22
|
+
|
|
23
|
+
temp = _ensure_temp(ctx)
|
|
24
|
+
out_values: Dict[str, Any] = {}
|
|
25
|
+
produced_virtuals: list[str] = []
|
|
26
|
+
missing: list[str] = []
|
|
27
|
+
|
|
28
|
+
for field in expose:
|
|
29
|
+
desc = by_field.get(field, {})
|
|
30
|
+
if desc.get("virtual"):
|
|
31
|
+
producer = ov.virtual_producers.get(field)
|
|
32
|
+
if callable(producer):
|
|
33
|
+
try:
|
|
34
|
+
out_values[field] = producer(obj, ctx)
|
|
35
|
+
produced_virtuals.append(field)
|
|
36
|
+
logger.debug("Produced virtual field %s", field)
|
|
37
|
+
except Exception:
|
|
38
|
+
missing.append(field)
|
|
39
|
+
logger.debug("Virtual producer failed for field %s", field)
|
|
40
|
+
else:
|
|
41
|
+
missing.append(field)
|
|
42
|
+
logger.debug("No producer for virtual field %s", field)
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
value = _read_current_value(obj, ctx, field)
|
|
46
|
+
if value is None:
|
|
47
|
+
missing.append(field)
|
|
48
|
+
logger.debug("No value available for field %s", field)
|
|
49
|
+
out_values[field] = value
|
|
50
|
+
|
|
51
|
+
temp["out_values"] = out_values
|
|
52
|
+
if produced_virtuals:
|
|
53
|
+
temp["out_virtual_produced"] = tuple(produced_virtuals)
|
|
54
|
+
if missing:
|
|
55
|
+
temp["out_missing"] = tuple(missing)
|
|
56
|
+
logger.debug(
|
|
57
|
+
"Built outbound values: %s (virtuals=%s, missing=%s)",
|
|
58
|
+
out_values,
|
|
59
|
+
produced_virtuals,
|
|
60
|
+
missing,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
# Internals
|
|
66
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_current_value(obj: Optional[object], ctx: Any, field: str) -> Optional[Any]:
|
|
70
|
+
if obj is not None and hasattr(obj, field):
|
|
71
|
+
try:
|
|
72
|
+
return getattr(obj, field)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
for name in ("row", "values", "current_values"):
|
|
76
|
+
src = getattr(ctx, name, None)
|
|
77
|
+
if isinstance(src, Mapping) and field in src:
|
|
78
|
+
return src.get(field)
|
|
79
|
+
hv = getattr(getattr(ctx, "temp", {}), "get", lambda *a, **k: None)(
|
|
80
|
+
"hydrated_values"
|
|
81
|
+
) # type: ignore
|
|
82
|
+
if isinstance(hv, Mapping):
|
|
83
|
+
return hv.get(field)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["ANCHOR", "run"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# tigrbl/v3/runtime/atoms/wire/dump.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import decimal as _dc
|
|
7
|
+
import uuid as _uuid
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Dict, Mapping, MutableMapping, Optional
|
|
10
|
+
|
|
11
|
+
from ... import events as _ev
|
|
12
|
+
|
|
13
|
+
# Runs at the very end of model shaping; out:masking follows at the same anchor.
|
|
14
|
+
ANCHOR = _ev.OUT_DUMP # "out:dump"
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("uvicorn")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(obj: Optional[object], ctx: Any) -> None:
|
|
20
|
+
"""
|
|
21
|
+
wire:dump@out:dump
|
|
22
|
+
|
|
23
|
+
Purpose
|
|
24
|
+
-------
|
|
25
|
+
Build the final wire payload (dict or list[dict]) from:
|
|
26
|
+
- ctx.temp["out_values"] (field -> value) produced by wire:build_out
|
|
27
|
+
- ctx.temp["schema_out"] (for alias mapping)
|
|
28
|
+
- ctx.temp["response_extras"] (alias extras from emit atoms)
|
|
29
|
+
|
|
30
|
+
What it does
|
|
31
|
+
------------
|
|
32
|
+
- Maps canonical field names → wire keys using alias_out (when provided).
|
|
33
|
+
- Optionally omits null values (cfg.exclude_none / cfg.omit_nulls).
|
|
34
|
+
- Applies minimal JSON-friendly scalar conversions (date/time/uuid/decimal/bytes).
|
|
35
|
+
- Merges response_extras (without overwriting unless configured) for single-object payloads.
|
|
36
|
+
- Stores the result in ctx.temp["response_payload"] for downstream masking/transport.
|
|
37
|
+
|
|
38
|
+
Notes
|
|
39
|
+
-----
|
|
40
|
+
- This step does NOT perform masking/redaction; that is handled by out:masking.
|
|
41
|
+
- For collection/list responses, extras are not merged (they are usually per-item).
|
|
42
|
+
"""
|
|
43
|
+
logger.debug("Running wire:dump")
|
|
44
|
+
temp = _ensure_temp(ctx)
|
|
45
|
+
out_values = temp.get("out_values")
|
|
46
|
+
|
|
47
|
+
if not out_values:
|
|
48
|
+
logger.debug("No out_values available; skipping dump")
|
|
49
|
+
return # nothing to dump
|
|
50
|
+
|
|
51
|
+
schema_out = _schema_out(ctx)
|
|
52
|
+
aliases: Mapping[str, str] = (
|
|
53
|
+
schema_out.get("aliases", {}) if isinstance(schema_out, Mapping) else {}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
omit_nulls = _omit_nulls(ctx)
|
|
57
|
+
allow_overwrite = _allow_extras_overwrite(ctx)
|
|
58
|
+
|
|
59
|
+
# Single object
|
|
60
|
+
if isinstance(out_values, Mapping):
|
|
61
|
+
logger.debug("Dumping single-object payload")
|
|
62
|
+
payload = _dump_one(out_values, aliases, omit_nulls)
|
|
63
|
+
# Merge extras (single-object only)
|
|
64
|
+
extras = temp.get("response_extras")
|
|
65
|
+
if isinstance(extras, Mapping) and extras:
|
|
66
|
+
conflicts = []
|
|
67
|
+
for k, v in extras.items():
|
|
68
|
+
if (k in payload) and not allow_overwrite:
|
|
69
|
+
conflicts.append(k)
|
|
70
|
+
logger.debug("Conflict on extra key %s", k)
|
|
71
|
+
continue
|
|
72
|
+
payload[k] = _dump_scalar(v)
|
|
73
|
+
if conflicts:
|
|
74
|
+
temp["dump_conflicts"] = tuple(sorted(set(conflicts)))
|
|
75
|
+
temp["response_payload"] = payload
|
|
76
|
+
logger.debug("Response payload built: %s", payload)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# List/tuple of objects (already expanded by executor)
|
|
80
|
+
if isinstance(out_values, (list, tuple)) and all(
|
|
81
|
+
isinstance(x, Mapping) for x in out_values
|
|
82
|
+
):
|
|
83
|
+
logger.debug("Dumping list payload with %d items", len(out_values))
|
|
84
|
+
payload_list = [
|
|
85
|
+
_dump_one(item, aliases, omit_nulls)
|
|
86
|
+
for item in out_values # type: ignore[arg-type]
|
|
87
|
+
]
|
|
88
|
+
temp["response_payload"] = payload_list
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Unknown shape — stash as-is to avoid surprises (transport may serialize).
|
|
92
|
+
temp["response_payload"] = out_values
|
|
93
|
+
logger.debug("Stored opaque response payload: %s", type(out_values).__name__)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
# Internals
|
|
99
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ensure_temp(ctx: Any) -> MutableMapping[str, Any]:
|
|
103
|
+
tmp = getattr(ctx, "temp", None)
|
|
104
|
+
if not isinstance(tmp, dict):
|
|
105
|
+
tmp = {}
|
|
106
|
+
setattr(ctx, "temp", tmp)
|
|
107
|
+
return tmp
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _schema_out(ctx: Any) -> Mapping[str, Any]:
|
|
111
|
+
tmp = getattr(ctx, "temp", {})
|
|
112
|
+
sch = getattr(tmp, "get", lambda *_a, **_k: None)("schema_out") # type: ignore
|
|
113
|
+
if isinstance(sch, Mapping):
|
|
114
|
+
return sch
|
|
115
|
+
sch2 = getattr(ctx, "schema_out", None)
|
|
116
|
+
return sch2 if isinstance(sch2, Mapping) else {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _omit_nulls(ctx: Any) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Config flags to drop null-valued keys:
|
|
122
|
+
- cfg.exclude_none (preferred)
|
|
123
|
+
- cfg.omit_nulls
|
|
124
|
+
Default False.
|
|
125
|
+
"""
|
|
126
|
+
cfg = getattr(ctx, "cfg", None)
|
|
127
|
+
for name in ("exclude_none", "omit_nulls"):
|
|
128
|
+
val = getattr(cfg, name, None)
|
|
129
|
+
if isinstance(val, bool):
|
|
130
|
+
return val
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _allow_extras_overwrite(ctx: Any) -> bool:
|
|
135
|
+
"""
|
|
136
|
+
If True, extras can overwrite existing keys; default False.
|
|
137
|
+
cfg.extras_overwrite or cfg.response_extras_overwrite.
|
|
138
|
+
"""
|
|
139
|
+
cfg = getattr(ctx, "cfg", None)
|
|
140
|
+
for name in ("response_extras_overwrite", "extras_overwrite"):
|
|
141
|
+
val = getattr(cfg, name, None)
|
|
142
|
+
if isinstance(val, bool):
|
|
143
|
+
return val
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _dump_one(
|
|
148
|
+
values: Mapping[str, Any], aliases: Mapping[str, str], omit_nulls: bool
|
|
149
|
+
) -> Dict[str, Any]:
|
|
150
|
+
"""
|
|
151
|
+
Convert a single out_values mapping to a wire payload dict with alias mapping and scalar dumps.
|
|
152
|
+
"""
|
|
153
|
+
out: Dict[str, Any] = {}
|
|
154
|
+
used_aliases: list[str] = []
|
|
155
|
+
omitted: list[str] = []
|
|
156
|
+
|
|
157
|
+
for field, val in values.items():
|
|
158
|
+
if omit_nulls and val is None:
|
|
159
|
+
omitted.append(field)
|
|
160
|
+
continue
|
|
161
|
+
key = aliases.get(field) or field
|
|
162
|
+
if key != field:
|
|
163
|
+
used_aliases.append(key)
|
|
164
|
+
out[key] = _dump_scalar(val)
|
|
165
|
+
|
|
166
|
+
if used_aliases:
|
|
167
|
+
# diagnostics hook
|
|
168
|
+
# note: this goes on the payload's temp, not user-visible
|
|
169
|
+
pass
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _dump_scalar(v: Any) -> Any:
|
|
174
|
+
"""
|
|
175
|
+
Minimal JSON-friendly conversion for common scalars.
|
|
176
|
+
Leave complex types as-is; transport may have its own encoder.
|
|
177
|
+
"""
|
|
178
|
+
if v is None:
|
|
179
|
+
return None
|
|
180
|
+
if isinstance(v, (_dt.datetime, _dt.date, _dt.time)):
|
|
181
|
+
# ISO 8601
|
|
182
|
+
try:
|
|
183
|
+
return v.isoformat()
|
|
184
|
+
except Exception:
|
|
185
|
+
return str(v)
|
|
186
|
+
if isinstance(v, _uuid.UUID):
|
|
187
|
+
return str(v)
|
|
188
|
+
if isinstance(v, _dc.Decimal):
|
|
189
|
+
# Preserve precision via string
|
|
190
|
+
return str(v)
|
|
191
|
+
if isinstance(v, (bytes, bytearray, memoryview)):
|
|
192
|
+
try:
|
|
193
|
+
return base64.b64encode(bytes(v)).decode("ascii")
|
|
194
|
+
except Exception:
|
|
195
|
+
return None
|
|
196
|
+
# Plain containers → recurse shallowly
|
|
197
|
+
if isinstance(v, Mapping):
|
|
198
|
+
return {k: _dump_scalar(v[k]) for k in v}
|
|
199
|
+
if isinstance(v, list):
|
|
200
|
+
return [_dump_scalar(x) for x in v]
|
|
201
|
+
if isinstance(v, tuple):
|
|
202
|
+
return tuple(_dump_scalar(x) for x in v)
|
|
203
|
+
return v
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["ANCHOR", "run"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# tigrbl/v3/runtime/atoms/wire/validate_in.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
import decimal as _dc
|
|
6
|
+
import uuid as _uuid
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException, status as _status
|
|
11
|
+
|
|
12
|
+
from ... import events as _ev
|
|
13
|
+
from ...opview import opview_from_ctx, ensure_schema_in, _ensure_temp
|
|
14
|
+
|
|
15
|
+
# PRE_HANDLER, runs after wire:build_in
|
|
16
|
+
ANCHOR = _ev.IN_VALIDATE # "in:validate"
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("uvicorn")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(obj: Optional[object], ctx: Any) -> None:
|
|
22
|
+
"""
|
|
23
|
+
wire:validate_in@in:validate
|
|
24
|
+
|
|
25
|
+
Validates the normalized inbound payload (ctx.temp["in_values"]) using the
|
|
26
|
+
schema collected by schema:collect_in plus tolerant signals from ColumnSpec.
|
|
27
|
+
|
|
28
|
+
What it checks
|
|
29
|
+
--------------
|
|
30
|
+
- Required fields are present (ABSENT → error; present(None) is a separate check)
|
|
31
|
+
- Nullability (value is None while column is non-nullable → error)
|
|
32
|
+
- Lightweight type conformance (optional coercion if enabled)
|
|
33
|
+
- String max_length (when available)
|
|
34
|
+
- Author validators (validate_in / in_validator / validator_in on ColumnSpec/FieldSpec/IOSpec)
|
|
35
|
+
|
|
36
|
+
Effects
|
|
37
|
+
-------
|
|
38
|
+
- Mutates ctx.temp["in_values"] with any successful coercions/validator returns
|
|
39
|
+
- Writes diagnostics to:
|
|
40
|
+
ctx.temp["in_errors"] : list of {field, code, message}
|
|
41
|
+
ctx.temp["in_invalid"] : bool
|
|
42
|
+
ctx.temp["in_coerced"] : tuple of coerced field names (if any)
|
|
43
|
+
- Raises HTTPException(422) if any validation errors are found
|
|
44
|
+
"""
|
|
45
|
+
logger.debug("Running wire:validate_in")
|
|
46
|
+
temp = _ensure_temp(ctx)
|
|
47
|
+
ov = opview_from_ctx(ctx)
|
|
48
|
+
schema_in = ensure_schema_in(ctx, ov)
|
|
49
|
+
|
|
50
|
+
in_values: Dict[str, Any] = dict(temp.get("in_values") or {})
|
|
51
|
+
by_field: Mapping[str, Mapping[str, Any]] = schema_in.get("by_field", {}) # type: ignore[assignment]
|
|
52
|
+
required: Tuple[str, ...] = tuple(schema_in.get("required", ())) # type: ignore[assignment]
|
|
53
|
+
|
|
54
|
+
errors: list[Dict[str, Any]] = []
|
|
55
|
+
coerced: list[str] = []
|
|
56
|
+
|
|
57
|
+
# 1) Required presence (ABSENT → error)
|
|
58
|
+
for name in required:
|
|
59
|
+
if name not in in_values:
|
|
60
|
+
logger.debug("Required field %s missing", name)
|
|
61
|
+
errors.append(
|
|
62
|
+
_err(name, "required", "Field is required but was not provided.")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# 2) Per-field validation
|
|
66
|
+
for name, value in list(in_values.items()):
|
|
67
|
+
entry = by_field.get(name) or {}
|
|
68
|
+
|
|
69
|
+
# Nullability
|
|
70
|
+
nullable = entry.get("nullable", None)
|
|
71
|
+
if value is None and nullable is False:
|
|
72
|
+
logger.debug("Field %s is null but not nullable", name)
|
|
73
|
+
errors.append(
|
|
74
|
+
_err(name, "null_not_allowed", "Null is not allowed for this field.")
|
|
75
|
+
)
|
|
76
|
+
continue # skip further checks for this field
|
|
77
|
+
|
|
78
|
+
# Type (optionally coerce)
|
|
79
|
+
target_type = entry.get("py_type")
|
|
80
|
+
if value is not None and isinstance(target_type, type):
|
|
81
|
+
allow_coerce = bool(entry.get("coerce", True))
|
|
82
|
+
ok, new_val, msg = _coerce_if_needed(value, target_type, allow=allow_coerce)
|
|
83
|
+
if not ok:
|
|
84
|
+
logger.debug("Type mismatch for field %s", name)
|
|
85
|
+
errors.append(
|
|
86
|
+
_err(
|
|
87
|
+
name,
|
|
88
|
+
"type_mismatch",
|
|
89
|
+
msg or f"Expected {_type_name(target_type)}.",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
if new_val is not value:
|
|
94
|
+
in_values[name] = new_val
|
|
95
|
+
coerced.append(name)
|
|
96
|
+
|
|
97
|
+
# Max length (strings)
|
|
98
|
+
max_len = entry.get("max_length", None)
|
|
99
|
+
if (
|
|
100
|
+
isinstance(max_len, int)
|
|
101
|
+
and max_len > 0
|
|
102
|
+
and isinstance(in_values.get(name), str)
|
|
103
|
+
):
|
|
104
|
+
if len(in_values[name]) > max_len:
|
|
105
|
+
logger.debug("Field %s exceeds max_length %d", name, max_len)
|
|
106
|
+
errors.append(
|
|
107
|
+
_err(name, "max_length", f"String exceeds max_length={max_len}.")
|
|
108
|
+
)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Author-supplied validator(s)
|
|
112
|
+
vfn = entry.get("validator")
|
|
113
|
+
if callable(vfn) and in_values.get(name) is not None:
|
|
114
|
+
try:
|
|
115
|
+
out = vfn(in_values[name], ctx)
|
|
116
|
+
if out is not None:
|
|
117
|
+
in_values[name] = out
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.debug("Validator failed for field %s: %s", name, e)
|
|
120
|
+
errors.append(
|
|
121
|
+
_err(name, "validator_failed", f"{type(e).__name__}: {e}")
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# 3) Unknown keys policy (handled after build_in captured samples)
|
|
126
|
+
unknown = tuple(temp.get("in_unknown") or ())
|
|
127
|
+
if unknown and _reject_unknown(ctx):
|
|
128
|
+
logger.debug("Rejecting unknown fields: %s", unknown)
|
|
129
|
+
for k in unknown:
|
|
130
|
+
errors.append(_err(k, "unknown_field", "Unknown input key."))
|
|
131
|
+
|
|
132
|
+
# Persist results + diagnostics
|
|
133
|
+
temp["in_values"] = in_values
|
|
134
|
+
if coerced:
|
|
135
|
+
temp["in_coerced"] = tuple(coerced)
|
|
136
|
+
|
|
137
|
+
if errors:
|
|
138
|
+
temp["in_errors"] = errors
|
|
139
|
+
temp["in_invalid"] = True
|
|
140
|
+
logger.debug("Validation errors found: %s", errors)
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=_status.HTTP_422_UNPROCESSABLE_ENTITY, detail=errors
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
temp["in_invalid"] = False
|
|
146
|
+
logger.debug("Inbound payload validated successfully")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
# Internals
|
|
151
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _reject_unknown(ctx: Any) -> bool:
|
|
155
|
+
"""
|
|
156
|
+
Check cfg for a 'reject_unknown_fields' (bool); default False.
|
|
157
|
+
"""
|
|
158
|
+
cfg = getattr(ctx, "cfg", None)
|
|
159
|
+
val = getattr(cfg, "reject_unknown_fields", None)
|
|
160
|
+
return bool(val) if isinstance(val, bool) else False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _err(field: str, code: str, msg: str) -> Dict[str, Any]:
|
|
164
|
+
return {"field": field, "code": code, "message": msg}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _type_name(t: type) -> str:
|
|
168
|
+
return getattr(t, "__name__", str(t))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── coercion helpers ──────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _coerce_if_needed(
|
|
175
|
+
value: Any, target: type, *, allow: bool
|
|
176
|
+
) -> Tuple[bool, Any, Optional[str]]:
|
|
177
|
+
"""Return (ok, new_value, error_message). Only coerces when allow=True."""
|
|
178
|
+
if isinstance(value, target):
|
|
179
|
+
return True, value, None
|
|
180
|
+
if not allow:
|
|
181
|
+
return False, value, f"Expected {_type_name(target)}."
|
|
182
|
+
try:
|
|
183
|
+
coerced = _coerce(value, target)
|
|
184
|
+
return True, coerced, None
|
|
185
|
+
except Exception:
|
|
186
|
+
return False, value, f"Could not convert to {_type_name(target)}."
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _coerce(value: Any, target: type) -> Any:
|
|
190
|
+
if target is str:
|
|
191
|
+
return str(value)
|
|
192
|
+
if target is int:
|
|
193
|
+
if isinstance(value, bool):
|
|
194
|
+
return int(value)
|
|
195
|
+
return int(str(value).strip())
|
|
196
|
+
if target is float:
|
|
197
|
+
return float(str(value).strip())
|
|
198
|
+
if target is bool:
|
|
199
|
+
if isinstance(value, bool):
|
|
200
|
+
return value
|
|
201
|
+
s = str(value).strip().lower()
|
|
202
|
+
if s in {"true", "1", "yes", "y", "on"}:
|
|
203
|
+
return True
|
|
204
|
+
if s in {"false", "0", "no", "n", "off"}:
|
|
205
|
+
return False
|
|
206
|
+
raise ValueError("not a boolean")
|
|
207
|
+
if target is _dc.Decimal:
|
|
208
|
+
return _dc.Decimal(str(value).strip())
|
|
209
|
+
if target is _uuid.UUID:
|
|
210
|
+
return _uuid.UUID(str(value))
|
|
211
|
+
if target is _dt.datetime:
|
|
212
|
+
# allow both date-time and date-only (promote to midnight)
|
|
213
|
+
s = str(value).strip()
|
|
214
|
+
try:
|
|
215
|
+
return _dt.datetime.fromisoformat(s)
|
|
216
|
+
except Exception:
|
|
217
|
+
d = _dt.date.fromisoformat(s)
|
|
218
|
+
return _dt.datetime.combine(d, _dt.time())
|
|
219
|
+
if target is _dt.date:
|
|
220
|
+
return _dt.date.fromisoformat(str(value).strip())
|
|
221
|
+
if target is _dt.time:
|
|
222
|
+
return _dt.time.fromisoformat(str(value).strip())
|
|
223
|
+
# Fallback: try direct construction
|
|
224
|
+
return target(value)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["ANCHOR", "run"]
|