Repository: litestar-org/litestar

Issue:
build: extend build timeout for platform compat CI. 
<!--
By submitting this pull request, you agree to:
- follow [Litestar's Code of Conduct](https://github.com/litestar-org/.github/blob/main/CODE_OF_CONDUCT.md)
- follow [Litestar's contribution guidelines](https://github.com/litestar-org/.github/blob/main/CONTRIBUTING.md)
- follow the [PSFs's Code of Conduct](https://www.python.org/psf/conduct/)
-->

### Pull Request Checklist

- [ ] New code has 100% test coverage
- [ ] (If applicable) The prose documentation has been updated to reflect the changes introduced by this PR
- [ ] (If applicable) The reference documentation has been updated to reflect the changes introduced by this PR
- [ ] Pre-Commit Checks were ran and passed
- [ ] Tests were ran and passed

### Description
<!--
Please describe your pull request for new release changelog purposes
-->

I've experienced multiple consecutive timeouts on the windows compat CI step.

This PR makes "timeout" an input for the test workflow, and extends it for the compat tests.

### Close Issue(s)
<!--
Please add in issue numbers this pull request will close, if applicable
Examples: Fixes #4321 or Closes #1234
-->

-



Discussion:
Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=litestar-org_litestar&pullRequest=2456)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=litestar-org_litestar&pullRequest=2456&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=litestar-org_litestar&pullRequest=2456&metric=coverage&view=list) No Coverage information  
[![No Duplication information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo-16px.png 'No Duplication information')](https://sonarcloud.io/component_measures?id=litestar-org_litestar&pullRequest=2456&metric=duplicated_lines_density&view=list) No Duplication information


Documentation preview will be available shortly at https://litestar-org.github.io/litestar-docs-preview/2456

Resolving patch:
diff --git a/litestar/_openapi/schema_generation/schema.py b/litestar/_openapi/schema_generation/schema.py
--- a/litestar/_openapi/schema_generation/schema.py
+++ b/litestar/_openapi/schema_generation/schema.py
@@ -42,6 +42,11 @@
     create_numerical_constrained_field_schema,
     create_string_constrained_field_schema,
 )
+from litestar._openapi.schema_generation.utils import (
+    _should_create_enum_schema,
+    _should_create_literal_schema,
+    _type_or_first_not_none_inner_type,
+)
 from litestar.datastructures.upload_file import UploadFile
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.openapi.spec import Reference
@@ -159,16 +164,19 @@ def _get_type_schema_name(field_definition: FieldDefinition) -> str:
     return name
 
 
-def create_enum_schema(annotation: EnumMeta) -> Schema:
+def create_enum_schema(annotation: EnumMeta, include_null: bool = False) -> Schema:
     """Create a schema instance for an enum.
 
     Args:
         annotation: An enum.
+        include_null: Whether to include null as a possible value.
 
     Returns:
         A schema instance.
     """
-    enum_values: list[str | int] = [v.value for v in annotation]  # type: ignore
+    enum_values: list[str | int | None] = [v.value for v in annotation]  # type: ignore
+    if include_null:
+        enum_values.append(None)
     openapi_type = OpenAPIType.STRING if isinstance(enum_values[0], str) else OpenAPIType.INTEGER
     return Schema(type=openapi_type, enum=enum_values)
 
@@ -189,16 +197,19 @@ def _iter_flat_literal_args(annotation: Any) -> Iterable[Any]:
             yield arg.value if isinstance(arg, Enum) else arg
 
 
-def create_literal_schema(annotation: Any) -> Schema:
+def create_literal_schema(annotation: Any, include_null: bool = False) -> Schema:
     """Create a schema instance for a Literal.
 
     Args:
         annotation: An Literal annotation.
+        include_null: Whether to include null as a possible value.
 
     Returns:
         A schema instance.
     """
-    args = tuple(_iter_flat_literal_args(annotation))
+    args = list(_iter_flat_literal_args(annotation))
+    if include_null:
+        args.append(None)
     schema = copy(TYPE_MAP[type(args[0])])
     if len(args) > 1:
         schema.enum = args
@@ -217,13 +228,7 @@ def create_schema_for_annotation(annotation: Any) -> Schema:
         A schema instance or None.
     """
 
-    if annotation in TYPE_MAP:
-        return copy(TYPE_MAP[annotation])
-
-    if isinstance(annotation, EnumMeta):
-        return create_enum_schema(annotation)
-
-    return Schema()
+    return copy(TYPE_MAP[annotation]) if annotation in TYPE_MAP else Schema()
 
 
 class SchemaCreator:
@@ -292,17 +297,26 @@ def for_field_definition(self, field_definition: FieldDefinition) -> Schema | Re
 
         result: Schema | Reference
 
-        # NOTE: The check for whether the field_definition.annotation is a Pagination type
-        # has to come before the `is_dataclass_check` since the Pagination classes are dataclasses,
-        # but we want to handle them differently from how dataclasses are normally handled.
-
         if plugin_for_annotation := self.get_plugin_for(field_definition):
             result = self.for_plugin(field_definition, plugin_for_annotation)
+        elif _should_create_enum_schema(field_definition):
+            annotation = _type_or_first_not_none_inner_type(field_definition)
+            result = create_enum_schema(annotation, include_null=field_definition.is_optional)
+        elif _should_create_literal_schema(field_definition):
+            annotation = (
+                make_non_optional_union(field_definition.annotation)
+                if field_definition.is_optional
+                else field_definition.annotation
+            )
+            result = create_literal_schema(annotation, include_null=field_definition.is_optional)
         elif field_definition.is_optional:
             result = self.for_optional_field(field_definition)
         elif field_definition.is_union:
             result = self.for_union_field(field_definition)
         elif field_definition.origin in (CursorPagination, OffsetPagination, ClassicPagination):
+            # NOTE: The check for whether the field_definition.annotation is a Pagination type
+            # has to come before the `is_dataclass_check` since the Pagination classes are dataclasses,
+            # but we want to handle them differently from how dataclasses are normally handled.
             result = self.for_builtin_generics(field_definition)
         elif field_definition.is_type_var:
             result = self.for_typevar()
@@ -394,9 +408,6 @@ def for_object_type(self, field_definition: FieldDefinition) -> Schema:
                 items=Schema(one_of=items) if len(items) > 1 else items[0],
             )
 
-        if field_definition.is_literal:
-            return create_literal_schema(field_definition.annotation)
-
         raise ImproperlyConfiguredException(
             f"Parameter '{field_definition.name}' with type '{field_definition.annotation}' could not be mapped to an Open API type. "
             f"This can occur if a user-defined generic type is resolved as a parameter. If '{field_definition.name}' should "
diff --git a/litestar/_openapi/schema_generation/utils.py b/litestar/_openapi/schema_generation/utils.py
new file mode 100644
--- /dev/null
+++ b/litestar/_openapi/schema_generation/utils.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+    from litestar.typing import FieldDefinition
+
+__all__ = (
+    "_type_or_first_not_none_inner_type",
+    "_should_create_enum_schema",
+    "_should_create_literal_schema",
+)
+
+
+def _type_or_first_not_none_inner_type(field_definition: FieldDefinition) -> Any:
+    """Get the first inner type that is not None.
+
+    This is a narrow focussed utility to be used when we know that a field definition either represents
+    a single type, or a single type in a union with `None`, and we want the single type.
+
+    Args:
+        field_definition: A field definition instance.
+
+    Returns:
+        A field definition instance.
+    """
+    if not field_definition.is_optional:
+        return field_definition.annotation
+    inner = next((t for t in field_definition.inner_types if not t.is_none_type), None)
+    if inner is None:
+        raise ValueError("Field definition has no inner type that is not None")
+    return inner.annotation
+
+
+def _should_create_enum_schema(field_definition: FieldDefinition) -> bool:
+    """Predicate to determine if we should create an enum schema for the field def, or not.
+
+    This returns true if the field definition is an enum, or if the field definition is a union
+    of an enum and ``None``.
+
+    When an annotation is ``SomeEnum | None`` we should create a schema for the enum that includes ``null``
+    in the enum values.
+
+    Args:
+        field_definition: A field definition instance.
+
+    Returns:
+        A boolean
+    """
+    return field_definition.is_subclass_of(Enum) or (
+        field_definition.is_optional
+        and len(field_definition.args) == 2
+        and field_definition.has_inner_subclass_of(Enum)
+    )
+
+
+def _should_create_literal_schema(field_definition: FieldDefinition) -> bool:
+    """Predicate to determine if we should create a literal schema for the field def, or not.
+
+    This returns ``True`` if the field definition is an literal, or if the field definition is a union
+    of a literal and None.
+
+    When an annotation is `Literal["anything"] | None` we should create a schema for the literal that includes `null`
+    in the enum values.
+
+    Args:
+        field_definition: A field definition instance.
+
+    Returns:
+        A boolean
+    """
+    return (
+        field_definition.is_literal
+        or field_definition.is_optional
+        and all(inner.is_literal for inner in field_definition.inner_types if not inner.is_none_type)
+    )
diff --git a/litestar/typing.py b/litestar/typing.py
--- a/litestar/typing.py
+++ b/litestar/typing.py
@@ -7,7 +7,7 @@
 from typing import Any, AnyStr, Callable, Collection, ForwardRef, Literal, Mapping, Protocol, Sequence, TypeVar, cast
 
 from msgspec import UnsetType
-from typing_extensions import Annotated, NotRequired, Required, Self, get_args, get_origin, get_type_hints, is_typeddict
+from typing_extensions import NotRequired, Required, Self, get_args, get_origin, get_type_hints, is_typeddict
 
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.openapi.spec import Example
@@ -336,12 +336,12 @@ def is_required(self) -> bool:
     @property
     def is_annotated(self) -> bool:
         """Check if the field type is Annotated."""
-        return Annotated in self.type_wrappers  # type: ignore[comparison-overlap]
+        return bool(self.metadata)
 
     @property
     def is_literal(self) -> bool:
         """Check if the field type is Literal."""
-        return get_origin(self.annotation) is Literal
+        return self.origin is Literal
 
     @property
     def is_forward_ref(self) -> bool:
@@ -373,6 +373,11 @@ def is_optional(self) -> bool:
         """Whether the annotation is Optional or not."""
         return bool(self.is_union and NoneType in self.args)
 
+    @property
+    def is_none_type(self) -> bool:
+        """Whether the annotation is NoneType or not."""
+        return self.annotation is NoneType
+
     @property
     def is_collection(self) -> bool:
         """Whether the annotation is a collection type or not."""


Test patch:
diff --git a/tests/unit/test_openapi/test_schema.py b/tests/unit/test_openapi/test_schema.py
--- a/tests/unit/test_openapi/test_schema.py
+++ b/tests/unit/test_openapi/test_schema.py
@@ -2,7 +2,20 @@
 from dataclasses import dataclass
 from datetime import date
 from enum import Enum, auto
-from typing import TYPE_CHECKING, Any, Dict, Generic, List, Literal, Optional, Tuple, TypedDict, TypeVar, Union
+from typing import (  # type: ignore[attr-defined]
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    Generic,
+    List,
+    Literal,
+    Optional,
+    Tuple,
+    TypedDict,
+    TypeVar,
+    Union,
+    _GenericAlias,  # pyright: ignore
+)
 
 import annotated_types
 import msgspec
@@ -15,8 +28,8 @@
     KWARG_DEFINITION_ATTRIBUTE_TO_OPENAPI_PROPERTY_MAP,
     SchemaCreator,
     _get_type_schema_name,
-    create_schema_for_annotation,
 )
+from litestar._openapi.schema_generation.utils import _type_or_first_not_none_inner_type
 from litestar.app import DEFAULT_OPENAPI_CONFIG
 from litestar.di import Provide
 from litestar.enums import ParamType
@@ -27,6 +40,7 @@
 from litestar.pagination import ClassicPagination, CursorPagination, OffsetPagination
 from litestar.params import BodyKwarg, Parameter, ParameterKwarg
 from litestar.testing import create_test_client
+from litestar.types.builtin_types import NoneType
 from litestar.typing import FieldDefinition
 from litestar.utils.helpers import get_name
 from tests.models import DataclassPerson, DataclassPet
@@ -112,12 +126,8 @@ class Opts(str, Enum):
         opt1 = "opt1"
         opt2 = "opt2"
 
-    @dataclass()
-    class M:
-        opt: Opts
-
-    schema = create_schema_for_annotation(annotation=M.__annotations__["opt"])
-    assert schema
+    schema = SchemaCreator().for_field_definition(FieldDefinition.from_annotation(Opts))
+    assert isinstance(schema, Schema)
     assert schema.enum == ["opt1", "opt2"]
 
 
@@ -144,7 +154,7 @@ class DataclassWithLiteral:
 
     value = schema.properties["value"]
     assert isinstance(value, Schema)
-    assert value.enum == ("a", "b", "c")
+    assert value.enum == ["a", "b", "c"]
 
     const = schema.properties["const"]
     assert isinstance(const, Schema)
@@ -152,7 +162,7 @@ class DataclassWithLiteral:
 
     composite = schema.properties["composite"]
     assert isinstance(composite, Schema)
-    assert composite.enum == ("a", "b", "c", 1)
+    assert composite.enum == ["a", "b", "c", 1]
 
 
 def test_schema_hashing() -> None:
@@ -283,15 +293,10 @@ class Foo(Enum):
         A = auto()
         B = auto()
 
-    @dataclass
-    class MyDataclass:
-        bar: List[Literal[Foo.A]]
-
-    schemas: Dict[str, Schema] = {}
-    SchemaCreator(schemas=schemas).for_field_definition(
-        FieldDefinition.from_kwarg(name="MyDataclass", annotation=MyDataclass)
-    )
-    assert schemas["MyDataclass"].properties["bar"].items.const == 1  # type: ignore
+    schema = SchemaCreator().for_field_definition(FieldDefinition.from_annotation(List[Literal[Foo.A]]))
+    assert isinstance(schema, Schema)
+    assert isinstance(schema.items, Schema)
+    assert schema.items.const == 1
 
 
 @dataclass
@@ -414,3 +419,39 @@ def test_schema_tuple_with_union() -> None:
         Schema(type=OpenAPIType.INTEGER),
         Schema(one_of=[Schema(type=OpenAPIType.INTEGER), Schema(type=OpenAPIType.STRING)]),
     ]
+
+
+def test_optional_enum() -> None:
+    class Foo(Enum):
+        A = 1
+        B = 2
+
+    schema = SchemaCreator().for_field_definition(FieldDefinition.from_annotation(Optional[Foo]))
+    assert isinstance(schema, Schema)
+    assert schema.type == OpenAPIType.INTEGER
+    assert schema.enum == [1, 2, None]
+
+
+def test_optional_literal() -> None:
+    schema = SchemaCreator().for_field_definition(FieldDefinition.from_annotation(Optional[Literal[1]]))
+    assert isinstance(schema, Schema)
+    assert schema.type == OpenAPIType.INTEGER
+    assert schema.enum == [1, None]
+
+
+@pytest.mark.parametrize(
+    ("in_type", "out_type"),
+    [
+        (FieldDefinition.from_annotation(Optional[int]), int),
+        (FieldDefinition.from_annotation(Union[None, int]), int),
+        (FieldDefinition.from_annotation(int), int),
+        # hack to create a union of NoneType, NoneType to hit a branch for coverage
+        (FieldDefinition.from_annotation(_GenericAlias(Union, (NoneType, NoneType))), ValueError),
+    ],
+)
+def test_type_or_first_not_none_inner_type_utility(in_type: Any, out_type: Any) -> None:
+    if out_type is ValueError:
+        with pytest.raises(out_type):
+            _type_or_first_not_none_inner_type(in_type)
+    else:
+        assert _type_or_first_not_none_inner_type(in_type) == out_type