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..c4669f9 100644 --- a/adaptive_cards_python/adaptive_card/config.py +++ b/adaptive_cards_python/adaptive_card/config.py @@ -1,25 +1,38 @@ 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[ - "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"] +] +FontType = CaseInsensitiveLiteral[Literal["default", "monospace"]] +FontWeight = CaseInsensitiveLiteral[Literal["default", "lighter", "bolder"]] +HorizontalAlignment = CaseInsensitiveLiteral[Literal["left", "center", "right"]] +ImageFillMode = CaseInsensitiveLiteral[ + Literal["cover", "repeatHorizontally", "repeatVertically", "repeat"] ] -BlockElementHeight = Literal["auto", "stretch"] -FallbackOption = Literal["drop"] -ContainerStyle = Literal[ - "default", "emphasis", "good", "attention", "warning", "accent" +ImageSize = CaseInsensitiveLiteral[ + Literal["auto", "stretch", "small", "medium", "large"] ] -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"] +ImageStyle = CaseInsensitiveLiteral[Literal["default", "person"]] +TextBlockStyle = CaseInsensitiveLiteral[Literal["default", "heading"]] +VerticalAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] +VerticalContentAlignment = CaseInsensitiveLiteral[Literal["top", "center", "bottom"]] Colors = ( - Literal["default", "dark", "light", "accent", "good", "warning", "attention"] | str + ( + 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 new file mode 100644 index 0000000..3444320 --- /dev/null +++ b/adaptive_cards_python/case_insensitive_literal.py @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..6887be3 --- /dev/null +++ b/tests/test_case_insensitive_literal.py @@ -0,0 +1,192 @@ +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 188eca9..faa7ce7 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://github.com/axteams-one/ddm-contracts/blob/main/contracts/idd/acap_list.yaml", + url="https://example.com/contract.yaml", ) ) AdaptiveCard(type="AdaptiveCard", version="1.5", body=body, actions=actions_list) -def contruct_from_dict(): +def test_construct_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 **ADACO**"}, + {"type": "TextBlock", "text": "Submitted by **REDACTED**"}, { "type": "TextBlock", - "text": "Approval pending from **DDM**" + "text": "Approval pending from **REDACTED**" } ], "actions": [