From 271606cd89954f2fc2e57830885f7f02804fb073 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 22:20:36 +0000 Subject: [PATCH 1/9] add case insensitive literal type annotation --- .../case_insensitive_literal.py | 25 ++++++++++++++++++ tests/test_case_insensitive_literal.py | 26 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 adaptive_cards_python/case_insensitive_literal.py create mode 100644 tests/test_case_insensitive_literal.py diff --git a/adaptive_cards_python/case_insensitive_literal.py b/adaptive_cards_python/case_insensitive_literal.py new file mode 100644 index 0000000..9b3850a --- /dev/null +++ b/adaptive_cards_python/case_insensitive_literal.py @@ -0,0 +1,25 @@ +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, ...]) -> Any: + mapping = {v.lower(): v for v in literal_values} + def validator(val: Any) -> str: + 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]): + def __class_getitem__(cls, literal_type: type[T]) -> type[T]: + values = get_args(literal_type) + if not values: + raise TypeError("CaseInsensitiveLiteral expects a Literal[...] type as input") + annotated = 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 new file mode 100644 index 0000000..39db459 --- /dev/null +++ b/tests/test_case_insensitive_literal.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from pydantic import BaseModel, ValidationError +from typing import Literal, Annotated +from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral +import pytest + +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 + with pytest.raises(AssertionError): + assert getattr(ann, "__origin__", None).__name__ == "Annotated" + assert getattr(ann, "__origin__", None).__name__ == "Literal" + assert set(ann.__args__) == {"Some", "Config"} From f502c76eaad8b7abd8f8d5d3f4864dcc59b25d18 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:04:24 +0000 Subject: [PATCH 2/9] Add docsstring for case insensitive literal --- .../case_insensitive_literal.py | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/adaptive_cards_python/case_insensitive_literal.py b/adaptive_cards_python/case_insensitive_literal.py index 9b3850a..bdf4295 100644 --- a/adaptive_cards_python/case_insensitive_literal.py +++ b/adaptive_cards_python/case_insensitive_literal.py @@ -3,9 +3,24 @@ T = TypeVar("T", bound=LiteralString) -def case_insensitive_literal_validator(literal_values: tuple[str, ...]) -> Any: - mapping = {v.lower(): v for v in literal_values} +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() @@ -15,11 +30,24 @@ def validator(val: Any) -> str: 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 = Annotated[literal_type, case_insensitive_literal_validator(values)] + annotated: type[T] = Annotated[literal_type, case_insensitive_literal_validator(values)] return cast(type[T], annotated) -CaseInsensitiveLiteral = CaseInsensitiveLiteralClass +CaseInsensitiveLiteral: type = CaseInsensitiveLiteralClass From 65c2b2d42e9c8e5343490ce7ff95dec0e8160af6 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:06:32 +0000 Subject: [PATCH 3/9] Improve and expand tests for CaseInsensitiveLiteral and its validator - Add direct tests for the inner validator function from case_insensitive_literal_validator, including type and value error cases. - Assert that the returned validator is a PlainValidator instance. - Ensure all case-insensitive variants are tested for canonical value mapping. - Add/clarify docstrings and comments for test clarity. - Retain and verify Pydantic model integration and error handling. --- tests/test_case_insensitive_literal.py | 148 ++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 5 deletions(-) diff --git a/tests/test_case_insensitive_literal.py b/tests/test_case_insensitive_literal.py index 39db459..4f2b154 100644 --- a/tests/test_case_insensitive_literal.py +++ b/tests/test_case_insensitive_literal.py @@ -1,7 +1,8 @@ from __future__ import annotations -from pydantic import BaseModel, ValidationError -from typing import Literal, Annotated -from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral +from pydantic import BaseModel, ValidationError, TypeAdapter +from pydantic.functional_validators import PlainValidator +from typing import Literal, Union +from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral, case_insensitive_literal_validator import pytest SomeConfig = Literal["Some", "Config"] @@ -20,7 +21,144 @@ def test_accepts_any_case(): def test_literal_type(): ann = TestClass.model_fields["test"].annotation # At runtime, Pydantic exposes the base Literal, not Annotated - with pytest.raises(AssertionError): - assert getattr(ann, "__origin__", None).__name__ == "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 + import pytest + 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) \ No newline at end of file From 867e6864e7e14278f9c9244e7e0cd295d7d47d58 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:06:55 +0000 Subject: [PATCH 4/9] Redact dumb stuff from tests --- tests/test_validation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index 188eca9..f5eb412 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,7 +113,7 @@ def test_construct_card(): actions_list.append( actions.OpenUrl( title="View Contract", - url="https://github.com/axteams-one/ddm-contracts/blob/main/contracts/idd/acap_list.yaml", + url="https://example.com/contract.yaml", ) ) @@ -169,10 +169,10 @@ def test_construct_from_json_string(): }, "body": [ {"type": "TextBlock", "text": "Migrations Ready"}, - {"type": "TextBlock", "text": "Submitted by **ADACO**"}, + {"type": "TextBlock", "text": "Submitted by **REDACTED**"}, { "type": "TextBlock", - "text": "Approval pending from **DDM**" + "text": "Approval pending from **REDACTED**" } ], "actions": [ From c98d7ae95009d4b48fd38fc0e24527a3e7a33271 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:10:07 +0000 Subject: [PATCH 5/9] Resolve pylance report type variable error --- adaptive_cards_python/case_insensitive_literal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adaptive_cards_python/case_insensitive_literal.py b/adaptive_cards_python/case_insensitive_literal.py index bdf4295..337cb09 100644 --- a/adaptive_cards_python/case_insensitive_literal.py +++ b/adaptive_cards_python/case_insensitive_literal.py @@ -50,4 +50,4 @@ def __class_getitem__(cls, literal_type: type[T]) -> type[T]: annotated: type[T] = Annotated[literal_type, case_insensitive_literal_validator(values)] return cast(type[T], annotated) -CaseInsensitiveLiteral: type = CaseInsensitiveLiteralClass +CaseInsensitiveLiteral = CaseInsensitiveLiteralClass From 760d87e9ef50d7bc25164a5791417ec823aaabcb Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:12:09 +0000 Subject: [PATCH 6/9] Use case insensitive literal for literal options Only place we still use Literal is for tagged discriminators in pydantic. This is because pydantic requires enum or literal for the discriminator type. For these, we don't want case insensitive behavior. --- adaptive_cards_python/adaptive_card/Action.py | 7 ++-- .../adaptive_card/AdaptiveCard.py | 2 +- adaptive_cards_python/adaptive_card/config.py | 38 +++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/adaptive_cards_python/adaptive_card/Action.py b/adaptive_cards_python/adaptive_card/Action.py index e28d466..51af8e0 100644 --- a/adaptive_cards_python/adaptive_card/Action.py +++ b/adaptive_cards_python/adaptive_card/Action.py @@ -3,6 +3,7 @@ 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 @@ -10,9 +11,9 @@ import orjson -ActionMode = Literal["primary", "secondary"] +ActionMode = CaseInsensitiveLiteral[Literal["primary", "secondary"]] -ActionStyle = Literal["default", "positive", "destructive"] +ActionStyle = CaseInsensitiveLiteral[Literal["default", "positive", "destructive"]] class ActionBase(Item): @@ -73,7 +74,7 @@ class ActionBase(Item): ) -AssociatedInputs = Literal["Auto", "None"] +AssociatedInputs = CaseInsensitiveLiteral[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 599c2b8..0f85705 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, Literal, Self +from typing import Any, Self, Literal 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 2ca2a1e..aee7d2e 100644 --- a/adaptive_cards_python/adaptive_card/config.py +++ b/adaptive_cards_python/adaptive_card/config.py @@ -1,25 +1,23 @@ from __future__ import annotations # Required to defer type hint evaluation! from typing import Literal +from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral - -Spacing = Literal[ +Spacing = CaseInsensitiveLiteral[Literal[ "default", "none", "small", "medium", "large", "extraLarge", "padding" -] -BlockElementHeight = Literal["auto", "stretch"] -FallbackOption = Literal["drop"] -ContainerStyle = Literal[ +]] +BlockElementHeight = CaseInsensitiveLiteral[Literal["auto", "stretch"]] +FallbackOption = CaseInsensitiveLiteral[Literal["drop"]] +ContainerStyle = CaseInsensitiveLiteral[Literal[ "default", "emphasis", "good", "attention", "warning", "accent" -] -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 = ( - Literal["default", "dark", "light", "accent", "good", "warning", "attention"] | str -) +]] +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"]] +ImageSize = CaseInsensitiveLiteral[Literal["auto", "stretch", "small", "medium", "large"]] +ImageStyle = CaseInsensitiveLiteral[Literal["default", "person"]] +TextBlockStyle = CaseInsensitiveLiteral[Literal["default", "heading"]] +VerticalAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] +VerticalContentAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] +Colors = CaseInsensitiveLiteral[Literal["default", "dark", "light", "accent", "good", "warning", "attention"]] | str From 9d565b0513d99a3cf9a946e23ed68c239ba4c7db Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:24:19 +0000 Subject: [PATCH 7/9] Apply copilot review comments --- adaptive_cards_python/adaptive_card/config.py | 2 +- tests/test_case_insensitive_literal.py | 3 +-- tests/test_validation.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/adaptive_cards_python/adaptive_card/config.py b/adaptive_cards_python/adaptive_card/config.py index aee7d2e..5b3ddb4 100644 --- a/adaptive_cards_python/adaptive_card/config.py +++ b/adaptive_cards_python/adaptive_card/config.py @@ -20,4 +20,4 @@ TextBlockStyle = CaseInsensitiveLiteral[Literal["default", "heading"]] VerticalAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] VerticalContentAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] -Colors = CaseInsensitiveLiteral[Literal["default", "dark", "light", "accent", "good", "warning", "attention"]] | str +Colors = (CaseInsensitiveLiteral[Literal["default", "dark", "light", "accent", "good", "warning", "attention"]]) | str diff --git a/tests/test_case_insensitive_literal.py b/tests/test_case_insensitive_literal.py index 4f2b154..75dbae2 100644 --- a/tests/test_case_insensitive_literal.py +++ b/tests/test_case_insensitive_literal.py @@ -2,8 +2,8 @@ from pydantic import BaseModel, ValidationError, TypeAdapter from pydantic.functional_validators import PlainValidator from typing import Literal, Union -from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral, case_insensitive_literal_validator import pytest +from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral, case_insensitive_literal_validator SomeConfig = Literal["Some", "Config"] @@ -116,7 +116,6 @@ def test_case_insensitive_literal_validator(): assert inner_validator("bar") == "bar" assert inner_validator("bAr") == "bar" # Invalid value raises ValueError - import pytest with pytest.raises(ValueError): inner_validator("baz") with pytest.raises(TypeError): diff --git a/tests/test_validation.py b/tests/test_validation.py index f5eb412..3caf1bc 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -120,7 +120,7 @@ def test_construct_card(): AdaptiveCard(type="AdaptiveCard", version="1.5", body=body, actions=actions_list) -def contruct_from_dict(): +def construct_from_dict(): AdaptiveCard.model_validate( { "type": "AdaptiveCard", From 07b1c54f2e9a31b83f11c9f728dcf4bc252c6e95 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:26:32 +0000 Subject: [PATCH 8/9] more copilot review --- tests/test_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index 3caf1bc..faa7ce7 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -120,7 +120,7 @@ def test_construct_card(): AdaptiveCard(type="AdaptiveCard", version="1.5", body=body, actions=actions_list) -def construct_from_dict(): +def test_construct_from_dict(): AdaptiveCard.model_validate( { "type": "AdaptiveCard", From bb94ab185aa7e09f8453cf95a90eddf8bb72ca08 Mon Sep 17 00:00:00 2001 From: Emir Karamehmetoglu Date: Mon, 19 May 2025 23:29:37 +0000 Subject: [PATCH 9/9] Fix formatting issues --- adaptive_cards_python/adaptive_card/config.py | 35 ++++++++---- .../case_insensitive_literal.py | 22 +++++-- tests/test_case_insensitive_literal.py | 57 ++++++++++++++----- 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/adaptive_cards_python/adaptive_card/config.py b/adaptive_cards_python/adaptive_card/config.py index 5b3ddb4..c4669f9 100644 --- a/adaptive_cards_python/adaptive_card/config.py +++ b/adaptive_cards_python/adaptive_card/config.py @@ -2,22 +2,37 @@ from typing import Literal from adaptive_cards_python.case_insensitive_literal import CaseInsensitiveLiteral -Spacing = CaseInsensitiveLiteral[Literal[ - "default", "none", "small", "medium", "large", "extraLarge", "padding" -]] +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"]] +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"]] -ImageSize = CaseInsensitiveLiteral[Literal["auto", "stretch", "small", "medium", "large"]] +ImageFillMode = CaseInsensitiveLiteral[ + Literal["cover", "repeatHorizontally", "repeatVertically", "repeat"] +] +ImageSize = CaseInsensitiveLiteral[ + Literal["auto", "stretch", "small", "medium", "large"] +] ImageStyle = CaseInsensitiveLiteral[Literal["default", "person"]] TextBlockStyle = CaseInsensitiveLiteral[Literal["default", "heading"]] VerticalAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] VerticalContentAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] -Colors = (CaseInsensitiveLiteral[Literal["default", "dark", "light", "accent", "good", "warning", "attention"]]) | str +Colors = ( + ( + CaseInsensitiveLiteral[ + 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 index 337cb09..3444320 100644 --- a/adaptive_cards_python/case_insensitive_literal.py +++ b/adaptive_cards_python/case_insensitive_literal.py @@ -3,7 +3,10 @@ T = TypeVar("T", bound=LiteralString) -def case_insensitive_literal_validator(literal_values: tuple[str, ...]) -> PlainValidator: + +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. @@ -15,6 +18,7 @@ def case_insensitive_literal_validator(literal_values: tuple[str, ...]) -> Plain 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). @@ -26,14 +30,19 @@ def validator(val: Any) -> str: lowered = val.lower() if lowered in mapping: return mapping[lowered] - raise ValueError(f"Value '{val}' is not a valid literal. Allowed: {literal_values}") + 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. @@ -46,8 +55,13 @@ def __class_getitem__(cls, literal_type: type[T]) -> type[T]: """ 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)] + 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 index 75dbae2..6887be3 100644 --- a/tests/test_case_insensitive_literal.py +++ b/tests/test_case_insensitive_literal.py @@ -3,13 +3,18 @@ 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 +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" @@ -18,28 +23,30 @@ def test_accepts_any_case(): 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" assert Model2(value="cAse").value == "case" assert Model2(value="CASE").value == "case" @@ -47,28 +54,35 @@ class Model2(BaseModel): # 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"]] + 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" 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 @@ -90,7 +104,9 @@ def test_case_insensitive_literal_in_nested_model(): # 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" + 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): @@ -124,40 +140,53 @@ def test_case_insensitive_literal_validator(): # 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") + +@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 ), -]) +@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) \ No newline at end of file + assert isinstance(obj, expected)