From a42292f3ad35069137889506bd453fec0afc8a68 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Sat, 31 May 2025 12:15:16 +0200 Subject: [PATCH] Revert "Implement case insensitive literals for Pydantic models" --- adaptive_cards_python/adaptive_card/Action.py | 7 +- .../adaptive_card/AdaptiveCard.py | 2 +- adaptive_cards_python/adaptive_card/config.py | 49 ++--- .../case_insensitive_literal.py | 67 ------ tests/test_case_insensitive_literal.py | 192 ------------------ tests/test_validation.py | 10 +- 6 files changed, 27 insertions(+), 300 deletions(-) delete mode 100644 adaptive_cards_python/case_insensitive_literal.py delete mode 100644 tests/test_case_insensitive_literal.py diff --git a/adaptive_cards_python/adaptive_card/Action.py b/adaptive_cards_python/adaptive_card/Action.py index 51af8e0..e28d466 100644 --- a/adaptive_cards_python/adaptive_card/Action.py +++ b/adaptive_cards_python/adaptive_card/Action.py @@ -3,7 +3,6 @@ from typing import Annotated, Any, Literal from pathlib import Path from pydantic import Field, ConfigDict -from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral from .Extendable import Item, ConfiguredBaseModel from .config import FallbackOption @@ -11,9 +10,9 @@ import orjson -ActionMode = CaseInsensitiveLiteral[Literal["primary", "secondary"]] +ActionMode = Literal["primary", "secondary"] -ActionStyle = CaseInsensitiveLiteral[Literal["default", "positive", "destructive"]] +ActionStyle = Literal["default", "positive", "destructive"] class ActionBase(Item): @@ -74,7 +73,7 @@ class ActionBase(Item): ) -AssociatedInputs = CaseInsensitiveLiteral[Literal["Auto", "None"]] +AssociatedInputs = Literal["Auto", "None"] def get_json_schema_file() -> Path: diff --git a/adaptive_cards_python/adaptive_card/AdaptiveCard.py b/adaptive_cards_python/adaptive_card/AdaptiveCard.py index 0f85705..599c2b8 100644 --- a/adaptive_cards_python/adaptive_card/AdaptiveCard.py +++ b/adaptive_cards_python/adaptive_card/AdaptiveCard.py @@ -1,6 +1,6 @@ from __future__ import annotations # Required to defer type hint evaluation! -from typing import Any, Self, Literal +from typing import Any, Literal, Self from pathlib import Path import json diff --git a/adaptive_cards_python/adaptive_card/config.py b/adaptive_cards_python/adaptive_card/config.py index c4669f9..2ca2a1e 100644 --- a/adaptive_cards_python/adaptive_card/config.py +++ b/adaptive_cards_python/adaptive_card/config.py @@ -1,38 +1,25 @@ from __future__ import annotations # Required to defer type hint evaluation! from typing import Literal -from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral -Spacing = CaseInsensitiveLiteral[ - Literal["default", "none", "small", "medium", "large", "extraLarge", "padding"] -] -BlockElementHeight = CaseInsensitiveLiteral[Literal["auto", "stretch"]] -FallbackOption = CaseInsensitiveLiteral[Literal["drop"]] -ContainerStyle = CaseInsensitiveLiteral[ - Literal["default", "emphasis", "good", "attention", "warning", "accent"] -] -FontSize = CaseInsensitiveLiteral[ - Literal["default", "small", "medium", "large", "extraLarge"] -] -FontType = CaseInsensitiveLiteral[Literal["default", "monospace"]] -FontWeight = CaseInsensitiveLiteral[Literal["default", "lighter", "bolder"]] -HorizontalAlignment = CaseInsensitiveLiteral[Literal["left", "center", "right"]] -ImageFillMode = CaseInsensitiveLiteral[ - Literal["cover", "repeatHorizontally", "repeatVertically", "repeat"] + +Spacing = Literal[ + "default", "none", "small", "medium", "large", "extraLarge", "padding" ] -ImageSize = CaseInsensitiveLiteral[ - Literal["auto", "stretch", "small", "medium", "large"] +BlockElementHeight = Literal["auto", "stretch"] +FallbackOption = Literal["drop"] +ContainerStyle = Literal[ + "default", "emphasis", "good", "attention", "warning", "accent" ] -ImageStyle = CaseInsensitiveLiteral[Literal["default", "person"]] -TextBlockStyle = CaseInsensitiveLiteral[Literal["default", "heading"]] -VerticalAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] -VerticalContentAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] +FontSize = Literal["default", "small", "medium", "large", "extraLarge"] +FontType = Literal["default", "monospace"] +FontWeight = Literal["default", "lighter", "bolder"] +HorizontalAlignment = Literal["left", "center", "right"] +ImageFillMode = Literal["cover", "repeatHorizontally", "repeatVertically", "repeat"] +ImageSize = Literal["auto", "stretch", "small", "medium", "large"] +ImageStyle = Literal["default", "person"] +TextBlockStyle = Literal["default", "heading"] +VerticalAlignment = Literal["top", "center", "bottom"] +VerticalContentAlignment = Literal["top", "center", "bottom"] Colors = ( - ( - CaseInsensitiveLiteral[ - Literal[ - "default", "dark", "light", "accent", "good", "warning", "attention" - ] - ] - ) - | str + Literal["default", "dark", "light", "accent", "good", "warning", "attention"] | str ) diff --git a/adaptive_cards_python/case_insensitive_literal.py b/adaptive_cards_python/case_insensitive_literal.py deleted file mode 100644 index 3444320..0000000 --- a/adaptive_cards_python/case_insensitive_literal.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Any, TypeVar, Annotated, cast, get_args, Generic, LiteralString -from pydantic.functional_validators import PlainValidator - -T = TypeVar("T", bound=LiteralString) - - -def case_insensitive_literal_validator( - literal_values: tuple[str, ...], -) -> PlainValidator: - """ - Returns a PlainValidator that validates a string against a set of allowed literal values (case-insensitive). - The returned value is always the canonical value from the original tuple. - - Args: - literal_values (tuple[str, ...]): The allowed literal string values. - - Returns: - PlainValidator: A Pydantic PlainValidator for use with Annotated. - """ - mapping: dict[str, str] = {v.lower(): v for v in literal_values} - - def validator(val: Any) -> str: - """ - Validate that val is a string matching one of the allowed literals (case-insensitive). - Returns the canonical value from literal_values. - Raises TypeError if not a string, ValueError if not allowed. - """ - if not isinstance(val, str): - raise TypeError("Value must be a string") - lowered = val.lower() - if lowered in mapping: - return mapping[lowered] - raise ValueError( - f"Value '{val}' is not a valid literal. Allowed: {literal_values}" - ) - - return PlainValidator(validator) - - -class CaseInsensitiveLiteralClass(Generic[T]): - """ - Generic class for case-insensitive literal validation with Pydantic v2. - Use as CaseInsensitiveLiteral[Literal[...]] to create an Annotated type with a case-insensitive validator. - """ - - def __class_getitem__(cls, literal_type: type[T]) -> type[T]: - """ - Returns an Annotated type with a case-insensitive validator for the given Literal type. - Args: - literal_type (type[T]): A Literal[...] type. - Returns: - type[T]: Annotated[Literal, validator] - Raises: - TypeError: If not given a Literal type. - """ - values = get_args(literal_type) - if not values: - raise TypeError( - "CaseInsensitiveLiteral expects a Literal[...] type as input" - ) - annotated: type[T] = Annotated[ - literal_type, case_insensitive_literal_validator(values) - ] - return cast(type[T], annotated) - - -CaseInsensitiveLiteral = CaseInsensitiveLiteralClass diff --git a/tests/test_case_insensitive_literal.py b/tests/test_case_insensitive_literal.py deleted file mode 100644 index 6887be3..0000000 --- a/tests/test_case_insensitive_literal.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations -from pydantic import BaseModel, ValidationError, TypeAdapter -from pydantic.functional_validators import PlainValidator -from typing import Literal, Union -import pytest -from adaptive_cards_python.case_insensitive_literal import ( - CaseInsensitiveLiteral, - case_insensitive_literal_validator, -) - -SomeConfig = Literal["Some", "Config"] - - -class TestClass(BaseModel): - test: CaseInsensitiveLiteral[SomeConfig] - - -def test_accepts_any_case(): - for val in ["Some", "some", "SOME", "sOmE"]: - assert TestClass(test=val).test == "Some" - for val in ["Config", "config", "CONFIG", "cOnFiG"]: - assert TestClass(test=val).test == "Config" - with pytest.raises(ValidationError): - TestClass(test="other") - - -def test_literal_type(): - ann = TestClass.model_fields["test"].annotation - # At runtime, Pydantic exposes the base Literal, not Annotated - assert getattr(ann, "__origin__", None).__name__ == "Literal" - assert set(ann.__args__) == {"Some", "Config"} - - -def test_case_insensitive_literal_as_is(): - """Test CaseInsensitiveLiteral with various input cases.""" - - class Model1(BaseModel): - value: CaseInsensitiveLiteral[Literal["Case"]] - - for val in ["Case", "case", "CASE", "CaSe"]: - assert Model1(value=val).value == "Case" - - # Test a mapping that should all be equivalent - class Model2(BaseModel): - value: CaseInsensitiveLiteral[Literal["Case", "CASe", "case"]] - - # Document the limitation: the canonical value is always the last occurrence - assert Model2(value="Case").value == "case" - assert Model2(value="CASe").value == "case" - assert Model2(value="case").value == "case" - assert Model2(value="cAse").value == "case" - assert Model2(value="CASE").value == "case" - - # Multiworded case-insensitive literal - class Model3(BaseModel): - value: CaseInsensitiveLiteral[Literal["caseCase"]] - - for val in ["caseCase", "CASECASE", "CaseCase", "casecase"]: - assert Model3(value=val).value == "caseCase" - - class Model4(BaseModel): - value: CaseInsensitiveLiteral[ - Literal["caseCase", "CASECASE", "Casecase", "CASEcase"] - ] - - assert Model4(value="caseCase").value == "CASEcase" - assert Model4(value="CASECASE").value == "CASEcase" - assert Model4(value="Casecase").value == "CASEcase" - assert Model4(value="CASEcase").value == "CASEcase" - assert Model4(value="casecase").value == "CASEcase" - assert Model4(value="CaSeCaSe").value == "CASEcase" - - -# 2. Used in a nested model - - -class TestNestedModelValue(BaseModel): - value: CaseInsensitiveLiteral[Literal["Nested", "NESTED", "nested"]] - - -class NestedModel(BaseModel): - nested: TestNestedModelValue - value: CaseInsensitiveLiteral[Literal["Nested", "NESTED", "nested"]] - - -def test_case_insensitive_literal_in_nested_model(): - """Test use of CaseInsensitiveLiteral in a Nested Pydantic Model, including roundtripping.""" - # Direct instantiation - model = NestedModel(value="Nested", nested={"value": "NESTED"}) - assert model.value == "nested" - assert model.nested.value == "nested" - - # Roundtrip: model -> dict -> model_validate - data = model.model_dump() - model2 = NestedModel.model_validate(data) - assert model2.value == "nested" - assert model2.nested.value == "nested" - - # Roundtrip: model -> json -> model_validate_json - json_str = model.model_dump_json() - model3 = NestedModel.model_validate_json(json_str) - assert model3.value == "nested" - assert model3.nested.value == "nested" - - # All other case-insensitive matches will map to the last occurrence - assert NestedModel(value="NeStEd", nested={"value": "nEsTeD"}).value == "nested" - assert ( - NestedModel(value="NeStEd", nested={"value": "nEsTeD"}).nested.value == "nested" - ) - - # Document the limitation: the canonical value is always the last occurrence - with pytest.raises(ValidationError): - NestedModel(value="notnested", nested={"value": "nested"}) - with pytest.raises(ValidationError): - NestedModel(value="nested", nested={"value": "notnested"}) - - -def test_case_insensitive_literal_validator(): - """Test the validator function via a Pydantic model and also directly.""" - - # Directly using the validator function - validator_obj = case_insensitive_literal_validator(("foo", "bar")) - - assert isinstance(validator_obj, PlainValidator) - # Access the inner function for direct testing - inner_validator = validator_obj.func - # Accepts any case, returns canonical value - assert inner_validator("FOO") == "foo" - assert inner_validator("foo") == "foo" - assert inner_validator("FoO") == "foo" - assert inner_validator("BAR") == "bar" - assert inner_validator("bar") == "bar" - assert inner_validator("bAr") == "bar" - # Invalid value raises ValueError - with pytest.raises(ValueError): - inner_validator("baz") - with pytest.raises(TypeError): - inner_validator(123) - - # Also test via a Pydantic model - class Model(BaseModel): - value: CaseInsensitiveLiteral[Literal["foo", "bar"]] - - assert Model(value="FOO").value == "foo" - assert Model(value="Bar").value == "bar" - with pytest.raises(ValidationError): - Model(value="baz") - - -# 4. Known failure: use as a discriminator in a tagged union -class A(BaseModel): - type: CaseInsensitiveLiteral[Literal["A"]] - value: int - - -class B(BaseModel): - type: CaseInsensitiveLiteral[Literal["B"]] - value: int - - -class ALit(BaseModel): - type: Literal["A"] - value: int - - -class BLit(BaseModel): - type: Literal["B"] - value: int - - -TaggedUnion = TypeAdapter(Union[ALit, BLit]) - - -@pytest.mark.xfail( - reason="CaseInsensitiveLiteral cannot be used as a discriminator in Pydantic V2" -) -def test_case_insensitive_literal_discriminator_fails(): - with pytest.raises(Exception): - TypeAdapter(Union[A, B]) - - -# 5. Literal-based union works -@pytest.mark.parametrize( - "data,expected", - [ - ({"type": "A", "value": 1}, ALit), - ({"type": "B", "value": 2}, BLit), - ], -) -def test_literal_discriminator_works(data, expected): - obj = TaggedUnion.validate_python(data) - assert isinstance(obj, expected) diff --git a/tests/test_validation.py b/tests/test_validation.py index faa7ce7..188eca9 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -27,7 +27,7 @@ "version": "1.5", "body": [], } -WEBHOOK_URL = "" +WEBHOOK_URL = "" def test_valid_card(): @@ -113,14 +113,14 @@ def test_construct_card(): actions_list.append( actions.OpenUrl( title="View Contract", - url="https://example.com/contract.yaml", + url="https://github.com/axteams-one/ddm-contracts/blob/main/contracts/idd/acap_list.yaml", ) ) AdaptiveCard(type="AdaptiveCard", version="1.5", body=body, actions=actions_list) -def test_construct_from_dict(): +def contruct_from_dict(): AdaptiveCard.model_validate( { "type": "AdaptiveCard", @@ -169,10 +169,10 @@ def test_construct_from_json_string(): }, "body": [ {"type": "TextBlock", "text": "Migrations Ready"}, - {"type": "TextBlock", "text": "Submitted by **REDACTED**"}, + {"type": "TextBlock", "text": "Submitted by **ADACO**"}, { "type": "TextBlock", - "text": "Approval pending from **REDACTED**" + "text": "Approval pending from **DDM**" } ], "actions": [