Skip to content

Python semantic layer: Injected marker and the @measure decorator - #247

Open
jat255 wants to merge 8 commits into
mainfrom
jat255/wwmt-measure-basics
Open

Python semantic layer: Injected marker and the @measure decorator#247
jat255 wants to merge 8 commits into
mainfrom
jat255/wwmt-measure-basics

Conversation

@jat255

@jat255 jat255 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

First of four stacked PRs building the Python semantic layer (M3). This one adds the measure record and the rule that tells the two kinds of measure argument apart.

These are implemented in pkg-py/src/commons/_measures.py and corresponding tests.

Two types of measures:

  • The model supplies parameters annotated as Annotated[T, Field(description=...)]
  • commons supplies parameters annotated Injected[T] and they never reach the model.

A parameter that is neither raises TypeError at decoration time. This came from decision D9, and it is stricter than the R package, where a forgotten @param silently hides an argument from the model.

Some API notes

@measure returns the function unchanged and attaches the record as an attribute, so measures and the helpers they call stay ordinary callables.

The schema is not built through chatlas, because Tool.from_func() rejects any model whose fields do not cover every function parameter, which won't work on injected parameters. Measures are never registered with the provider in any case, since the provider sees only call_measure.

Argument validation uses pydantic's model_validate on an extra="forbid" model. That covers unknown arguments, required arguments, enum vocabularies, and coercion in one call, so there is no need for a hand-written validator. Its error text differs from the R package's, which is accepted because the text goes back to the model as a tool error rather than to a user.

One caveat worth noting: An Annotated Field carrying default= or default_factory= is a decoration error, and the default belongs in the signature instead.

For example, the following will not work:

@measure
def discount_rate(
    order_id: Annotated[str, Field(description="The order to price.")],
    rate: Annotated[float, Field(description="Discount fraction.", default=0.1)],
) -> float:
    ...

validate_args omits unset arguments so that Python applies the default at call time. Accepting a Field default would make the schema optional while the signature still required the argument, and the call would then fail mid-conversation.

Not in this PR: semantic_layer(), injection resolution, and the public exports. Those are the PRs above it in the stack.

for my own future reference, the thumbs up below is an indication that I've approved this PR for myself, while the remaining PRs in the stack are awaiting my manual reivew

Four signature mistakes are rejected at decoration rather than left to fail later, because each one produces a measure that can never run. A parameter that is neither described nor injected, a positional-only parameter (the caller passes arguments by keyword), an async def or async generator (it would hand a coroutine to the model), and a parameter marked both described and injected, which would otherwise vanish from the schema in silence.

One error deserves its own mention. Annotating Injected[Engine] and importing Engine under if TYPE_CHECKING: is the way this feature is meant to be used, and get_type_hints() would fail it with a bare NameError: name 'Engine' is not defined under a stack of typing internals. It now raises naming the measure, the unresolved name, and the two ways out.

@jat255 jat255 changed the title jat255/wwmt measure basics Python semantic layer: Injected marker and the @measure decorator Sep 2, 2026
@jat255 jat255 added the py Affects the Python implementation label Sep 2, 2026
@jat255
jat255 force-pushed the jat255/wwmt-measure-basics branch from 064a319 to 52af05b Compare September 2, 2026 03:14
@jat255
jat255 marked this pull request as ready for review September 2, 2026 04:00
@jat255
jat255 force-pushed the jat255/wwmt-measure-basics branch 2 times, most recently from 064a319 to 1f56886 Compare September 2, 2026 22:25
@jat255 jat255 added this to the py-M3: semantic layer milestone Sep 2, 2026
…otated_attribute

Replaced deprecated pydantic API with the supported path for building FieldInfo
from annotations with defaults. This also merges all field constraints (e.g.
Field(gt=0)) rather than silently dropping them. Added test to verify constraint
merging.
FieldInfo.from_annotated_attribute overwrites a Field's default= with
PydanticUndefined whenever the signature omits its own default, silently
making the parameter required in the schema while Python's call convention
still requires it. Raise TypeError at decoration time instead, naming the
parameter and telling the author to move the default into the signature.
Route direct as_measure() dereferences through a shared _as_measure()
helper so pyrefly sees Measure instead of Measure | None.
The fail-closed check in 75914f5 only inspected the FieldInfo that
_described_field() returns, the one carrying the description. An
annotation can carry more than one Field(...), and a default declared
in a separate one slipped through, producing a required schema field
whose default Python never applies. Scan all FieldInfo metadata on the
annotation instead of just the described one.
…guments; name unresolvable annotations

get_type_hints() resolves every annotation, so a measure whose Injected[T]
names a type imported only under `if TYPE_CHECKING:` (the case the feature
exists for) failed with a bare NameError naming nothing, fifteen frames deep
in typing internals. Catch it and re-raise a TypeError naming the measure,
the unresolved name, and the two ways out: import it unconditionally, or use
Injected[Any].

Also: reject a positional-only parameter, which builds a schema but can
never actually be called since callers pass arguments by keyword; reject
async def, since nothing here can call a measure yet and shipping a
coroutine silently is worse than rejecting it fail-closed; and reject a
parameter marked both Injected and Field(description=...), which previously
vanished from the schema silently, contradicting what the module docstring
already claims the design prevents.
inspect.iscoroutinefunction() does not recognize an async def containing
yield; that makes it an async generator function, a different kind, and it
slipped through the async rejection to return an async generator instead of
a result. Check inspect.isasyncgenfunction() too, with the same message.
@jat255
jat255 force-pushed the jat255/wwmt-measure-basics branch from 1f56886 to 48a4d74 Compare September 3, 2026 00:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

py Affects the Python implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant