Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #789 +/- ##
==========================================
+ Coverage 89.75% 89.79% +0.04%
==========================================
Files 71 71
Lines 8218 8251 +33
Branches 1861 1863 +2
==========================================
+ Hits 7376 7409 +33
Misses 477 477
Partials 365 365
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Fixes incorrect token_count computation for Pydantic discriminated unions (notably within lists), preventing crashes like ValueError: Cannot Union types that consume different numbers of tokens (issue #785).
Changes:
- Adds a
token_count()fast-path forAnnotated[..., Field(discriminator=...)]so discriminated unions consume a single token. - Adds a regression test covering
list[DiscriminatedUnion]inside a PydanticBaseModel.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
cyclopts/_convert.py |
Updates token_count() to treat discriminated unions as single-token values. |
tests/test_pydantic.py |
Adds regression coverage for discriminated-union lists in Pydantic models. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Check before get_parameters strips the Annotated metadata. | ||
| from cyclopts.argument.utils import get_annotated_discriminator | ||
|
|
||
| if get_annotated_discriminator(resolve_optional(type_)) is not None: |
There was a problem hiding this comment.
get_annotated_discriminator(resolve_optional(type_)) is called without first verifying the hint is Annotated[...]. Since get_annotated_discriminator() currently iterates over get_args(annotation)[1:] for any parameterized type, it can return a non-None value for non-Annotated hints whose type parameters happen to have a discriminator attribute, causing token_count() to incorrectly return (1, False). Consider guarding with is_annotated(...) / get_origin(...) is Annotated, or tightening get_annotated_discriminator() to return None unless the origin is Annotated.
| if get_annotated_discriminator(resolve_optional(type_)) is not None: | |
| resolved_optional_type = resolve_optional(type_) | |
| if ( | |
| get_origin(resolved_optional_type) is typing.Annotated | |
| and get_annotated_discriminator(resolved_optional_type) is not None | |
| ): |
| # Discriminated unions (e.g. Annotated[Cat | Dog, pydantic.Field(discriminator="type")]) | ||
| # consume a single JSON string token regardless of member field counts. | ||
| # Check before get_parameters strips the Annotated metadata. | ||
| from cyclopts.argument.utils import get_annotated_discriminator | ||
|
|
There was a problem hiding this comment.
This introduces a mutual dependency between cyclopts/_convert.py and cyclopts/argument/utils.py (argument.utils already imports convert_enum_flag from _convert). Even though the import here is inside token_count(), the circular module relationship is fragile and can become an import-time failure if future changes call token_count() during module initialization. Consider moving get_annotated_discriminator to a lower-level module (e.g., cyclopts.annotations) or duplicating a minimal discriminator check locally to avoid the cycle.
|
Yes it fixes it, thank you! The reported missing argument is however wrong, when both Home and Cat or Dog have the same attribute name: from dataclasses import dataclass
from typing import Annotated, Literal
import cyclopts
import pydantic
class AnimalBase(pydantic.BaseModel):
name: str
class Cat(AnimalBase):
dead_birds: int
dead_rats: int
type: Literal["cat"] = "cat"
class Dog(AnimalBase):
type: Literal["dog"] = "dog"
Animal = Annotated[Cat | Dog, pydantic.Field(discriminator="type")]
class Home(pydantic.BaseModel):
animals: list[Animal]
name: str
app = cyclopts.App()
@app.default
def main(*, home: Home):
pass
app(["--home.animals", '{"type": "dog"}', '--home.name', "ISS"])Prints Do you want me to open another issue for this? |
Addresses #785. @AntoninRousset can you please give this a try? thanks!