Skip to content

Implement ParamModel: A Parameterized subclass based on type annotations - #1133

Open
philippjfr wants to merge 9 commits into
mainfrom
typed_parameterized
Open

philippjfr wants to merge 9 commits into
mainfrom
typed_parameterized

Conversation

@philippjfr

@philippjfr philippjfr commented Apr 15, 2026

Copy link
Copy Markdown
Member

Based on discussion in #1066 I prototyped ParamModel.

Summary

Adds ParamModel, a new Parameterized subclass that synthesises Parameter instances automatically from PEP 526 type annotations. This gives users a dataclass-like authoring experience without requiring manual Parameter instantiation for common cases.

Motivation

After investigating @dataclass_transform, it became clear that the decorator cannot bind RHS Parameter descriptor types to LHS attribute types in a way that satisfies type checkers, you'd still need to repeat yourself (string_param: str = String()) and even then checkers would see String[str], not str. Additionally, dataclass_transform requires every field specifier to be enumerated upfront, which is impossible at ParameterizedMetaclass definition time because not all Parameter subclasses exist yet.

'ParamField', sidesteps these constraints entirely: the metaclass handles synthesis at class creation time, and a dedicated ParamField() factory covers the cases where users need to supply explicit parameter configuration.

Changes

ParamModel (decorated with @dataclass_transform(field_specifiers=(ParamField,))) overrides __new__ to walk the class __annotations__ dict at class creation time. For each non-private, non-ClassVar annotation it calls _annotation_parameter_factory to map the annotation to an appropriate Parameter subclass and infer constructor kwargs, then calls _build_parameter_from_field to construct the final Parameter object. String annotations are eval'd against the module globals to support from __future__ import annotations.

_annotation_parameter_factory handles the following mappings:

  • boolBoolean
  • intInteger
  • floatNumber
  • strString
  • list[T]List(item_type=T)
  • tuple[...]Tuple(length=N) (fixed-length) or Tuple (variable)
  • dict[...]Dict
  • setClassSelector(class_=set)
  • Literal[...]Selector(objects=[...])
  • T | None / Optional[T] → wraps the inner type with allow_None=True
  • Annotated[T, {...}] → passes the mapping as extra kwargs to the parameter constructor
  • Any / object → bare Parameter

ParamField() is a typed factory (three overloads) that returns a _FieldSpec sentinel. It accepts default, default_factory, a parameter override (class, callable, or instance), and arbitrary kwargs forwarded to the parameter constructor. Type checker overloads ensure that default: FT and default_factory: Callable[[], FT] both propagate FT to the attribute type, matching dataclass semantics.

_build_parameter_from_field merges the inferred factory and kwargs with anything supplied via Field, giving explicit Field values precedence. If a Parameter instance is passed as the parameter argument it is shallow-copied and mutated rather than re-instantiated.

Usage

class MyModel(ParamModel):
    # annotation-only → synthesised automatically
    name: str
    count: int = 0
    ratio: float | None = None
    tags: list[str]
    mode: Literal["fast", "slow"] = "fast"

    # explicit parameter config via Field
    label: str = ParamField(default="untitled", doc="Human-readable label")

    # explicit parameter type override
    path: str = ParamField(parameter=param.String, regex=r"^/")

    # Regular class attribute
    attr: ClassVar[str] = "foo"

@philippjfr

Copy link
Copy Markdown
Member Author

Some suggestions instead of Field:

  • ParamSpec / ParameterSpec
  • ParamConfig / ParameterConfig
  • ParameterHint

@jbednar

jbednar commented Apr 21, 2026

Copy link
Copy Markdown
Member

param.Hints?

@philippjfr

Copy link
Copy Markdown
Member Author

Alternatively param.Param.

@philippjfr

Copy link
Copy Markdown
Member Author

And if we're already riffing on more succinct naming for the Parameter factory, then I'd also suggest ParamModel or ParamClass as names for TypedParameterized. TypedParameterized I no longer love because Parameterized is to some extent also typed.

@philippjfr

Copy link
Copy Markdown
Member Author

Okay, my favorite proposal now is ParamModel and ParamField.

@philippjfr philippjfr changed the title Implement TypedParameterized Implement ParamModel: A Parameterized subclass based on type annotations Apr 30, 2026
@jbednar

jbednar commented Apr 30, 2026

Copy link
Copy Markdown
Member

TypedParameterized I no longer love because Parameterized is to some extent also typed.

I guess it would literally be ParameterizedFromTypeDeclarations?

Okay, my favorite proposal now is ParamModel and ParamField.

I don't personally love Model since it is used to mean so many things that it's lost all meaning for me, but I don't object since Pydantic uses that term.

@codecov

codecov Bot commented Apr 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.38710% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.61%. Comparing base (9d24c4f) to head (b3664f8).

Files with missing lines Patch % Lines
param/typed.py 88.31% 18 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1133      +/-   ##
==========================================
- Coverage   88.61%   88.61%   -0.01%     
==========================================
  Files           9       10       +1     
  Lines        6072     6227     +155     
==========================================
+ Hits         5381     5518     +137     
- Misses        691      709      +18     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…4 gaps

- Annotation-only fields (and Field()-without-default fields) are now
  enforced as required at __init__ time via a _param_model_required
  set accumulated across the MRO, instead of silently resolving to
  each Parameter subclass's placeholder default (e.g. '', 0, []).
  Literal-derived Selector fields remain exempt since Selector already
  derives a sensible default from objects[0].
- Bare (unsubscripted) list/dict/tuple/set annotations now map to
  List/Dict/Tuple/ClassSelector like their subscripted forms, instead
  of falling through to an unvalidated Parameter.
- ParamField(parameter=...) overrides now preserve allow_None inferred
  from Optional[T]/T | None unless explicitly overridden.
- _extract_namespace_annotations no longer swallows genuine annotation
  evaluation errors (e.g. broken forward references) on Python 3.14's
  deferred annotation path; only the annotationlib import itself is
  guarded.

Adds regression tests for all of the above.
- Typing.ipynb: add an 'Annotation-first classes with ParamModel'
  section demonstrating type inference (including Literal, which the
  existing Selector workaround can't narrow), required-field errors,
  and ParamField overrides. Update Limitations/Future
  direction/Practical recommendations to reflect that ParamModel is
  now available rather than purely aspirational.
- getting_started.md: add a short ParamModel/ParamField example
  showing the dataclass-like shorthand and required-field behavior,
  linking to the Typing guide for details.
- user_guide/index.md: mention ParamModel in the Typing guide summary.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants