Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions adaptive_cards_python/adaptive_card/Action.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
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

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):
Expand Down Expand Up @@ -73,7 +74,7 @@ class ActionBase(Item):
)


AssociatedInputs = Literal["Auto", "None"]
AssociatedInputs = CaseInsensitiveLiteral[Literal["Auto", "None"]]


def get_json_schema_file() -> Path:
Expand Down
2 changes: 1 addition & 1 deletion adaptive_cards_python/adaptive_card/AdaptiveCard.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
38 changes: 18 additions & 20 deletions adaptive_cards_python/adaptive_card/config.py
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions adaptive_cards_python/case_insensitive_literal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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
164 changes: 164 additions & 0 deletions tests/test_case_insensitive_literal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
from __future__ import annotations
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"]

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
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)
8 changes: 4 additions & 4 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"version": "1.5",
"body": [],
}
WEBHOOK_URL = "<MY_MSTEAMS_WORKFLOW_WEBHOOK_URL>"
WEBHOOK_URL = "<REDACTED_WEBHOOK_URL>"


def test_valid_card():
Expand Down Expand Up @@ -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",
)
)

Expand Down Expand Up @@ -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": [
Expand Down
Loading