Skip to content

ci: Add ci #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 4, 2025
Merged
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
33 changes: 33 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: PyPI Release

on:
release:
types: [published]
workflow_dispatch:

jobs:

publish-release:
name: upload release to PyPI
runs-on: ubuntu-latest
permissions:
id-token: write
environment: release
steps:
- name: Check out repository
uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Build package
run: uv build

- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
81 changes: 81 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Tests And Linting

on:
pull_request:
push:
branches:
- main

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install Pre-Commit
run: python -m pip install pre-commit && pre-commit install

- name: Load cached Pre-Commit Dependencies
id: cached-pre-commit-dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit/
key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}

- name: Execute Pre-Commit
run: pre-commit run --show-diff-on-failure --color=always --all-files

mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.9"
allow-prereleases: true

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Run mypy
run: uv run mypy

test:
runs-on: ubuntu-latest
name: Test (Py ${{ matrix.python-version }}, Dj ${{ matrix.django-version }})
strategy:
fail-fast: true
matrix:
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
django-version: [ "4.1", "5.1" ]
exclude:
- python-version: 3.9
django-version: 5.1

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Test
run: >
uv run
--python=${{ matrix.python-version }}
--with="django~=${{ matrix.django-version }}"
python -m pytest
29 changes: 29 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
default_language_version:
python: "3.13"
repos:
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v4.0.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-ast
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: debug-statements
- id: end-of-file-fixer
- id: mixed-line-ending
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.11.0"
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
- repo: https://github.com/crate-ci/typos
rev: v1.30.3
hooks:
- id: typos
22 changes: 10 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Book(models.Model):
name = models.CharField()
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
genres = models.ManyToManyField(Genre, related_name="books")


@get("/{author_id:int}")
async def handler(author_id: int) -> Author:
Expand Down Expand Up @@ -80,11 +80,11 @@ from app.models import Author
sync_to_thread=True,
dto=DjangoModelDTO[
Annotated[
Author,
Author,
# exclude primary key and relationship fields
DTOConfig(exclude={"id", "books"})
]
],
]
],
return_dto=DjangoModelDTO[Author],
)
async def handler(data: Author) -> Author:
Expand Down Expand Up @@ -138,29 +138,29 @@ Relationships will be represented as individual components, referenced in the sc

## Lazy loading

> [!IMPORTANT]
> [!IMPORTANT]
> Since lazy-loading is not supported in an async context, you must ensure to always
> load everything consumed by the DTO. Not doing so will result in a
> load everything consumed by the DTO. Not doing so will result in a
> [`SynchronousOnlyOperation`](https://docs.djangoproject.com/en/5.2/ref/exceptions/#django.core.exceptions.SynchronousOnlyOperation)
> exception being raised by Django

This can be mitigated by:

1. Setting `include` or `exclude` rules to only include necessary fields ([docs](https://docs.litestar.dev/latest/usage/dto/1-abstract-dto.html#excluding-fields))
2. Configuring nested relationships with an appropriate `max_nexted_depth`
2. Configuring nested relationships with an appropriate `max_nexted_depth`
([docs](https://docs.litestar.dev/latest/usage/dto/1-abstract-dto.html#nested-fields))
3. Using [`select_related`](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#select-related)
3. Using [`select_related`](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#select-related)
and [`prefetch_related`](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#prefetch-related)
to ensure relationships are fully loaded



## Contributing

All [Litestar Organization][litestar-org] projects are open for contributions of any
All [Litestar Organization][litestar-org] projects are open for contributions of any
size and form.

If you have any questions, reach out to us on [Discord][discord] or our org-wide
If you have any questions, reach out to us on [Discord][discord] or our org-wide
[GitHub discussions][litestar-discussions] page.

<!-- markdownlint-disable -->
Expand All @@ -175,5 +175,3 @@ If you have any questions, reach out to us on [Discord][discord] or our org-wide
[litestar-org]: https://github.com/litestar-org
[discord]: https://discord.gg/litestar
[litestar-discussions]: https://github.com/orgs/litestar-org/discussions


6 changes: 3 additions & 3 deletions litestar_django/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from litestar_django.dto import DjangoModelDTO, DjangoDTOConfig

__all__ = (
DjangoModelDTO,
DjangoDTOConfig,
DjangoModelPlugin,
"DjangoModelDTO",
"DjangoDTOConfig",
"DjangoModelPlugin",
)
45 changes: 31 additions & 14 deletions litestar_django/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
import datetime
import decimal
import uuid
from typing import TypeVar, Generic, Type, Any, Generator, List, Optional, Callable
from typing import (
TypeVar,
Generic,
Type,
Any,
Generator,
List,
Optional,
Callable,
Mapping,
)

from django.core import validators # type: ignore[import-untyped]
from django.db import models # type: ignore[import-untyped]
Expand Down Expand Up @@ -50,7 +60,7 @@
}

try:
from enumfields import EnumField
from enumfields import EnumField # type: ignore[import-untyped]
except ImportError:
EnumField = None

Expand All @@ -73,7 +83,7 @@ class DjangoDTOConfig(DTOConfig):

class DjangoModelDTO(AbstractDTO[T], Generic[T]):
attribute_accessor = _get_model_attribute
custom_field_types: dict[type[AnyField], Any] | None = None
custom_field_types: Optional[dict[type[AnyField], Any]] = None

@classmethod
def get_field_type(cls, field: Field, type_map: dict[type[AnyField], Any]) -> Any:
Expand All @@ -98,19 +108,26 @@ def get_field_constraints(cls, field: AnyField) -> KwargDefinition:
# add choices as enum. if field is an enum type, we hand this off to
# Litestar for native enum support
if field.choices and not (EnumField and isinstance(field, EnumField)):
constraints["enum"] = [c[0] for c in field.choices]
choices = field.choices
if isinstance(choices, Mapping):
constraints["enum"] = list(choices.keys())
else:
constraints["enum"] = [c[0] for c in choices]

for validator in field.validators:
# fast path for known supported validators
if isinstance(validator, validators.MinValueValidator):
constraints["gt"] = validator.limit_value
elif isinstance(validator, validators.MinLengthValidator):
constraints["min_length"] = validator.limit_value
elif isinstance(validator, validators.MaxValueValidator):
constraints["lt"] = validator.limit_value
elif isinstance(validator, validators.MaxLengthValidator):
constraints["max_length"] = validator.limit_value
elif isinstance(validator, validators.RegexValidator):
# nullable fields do not support these constraints and for enum the
# constraint is defined implicitly by its values
if not field.null and "enum" not in constraints:
if isinstance(validator, validators.MinValueValidator):
constraints["gt"] = validator.limit_value
elif isinstance(validator, validators.MinLengthValidator):
constraints["min_length"] = validator.limit_value
elif isinstance(validator, validators.MaxValueValidator):
constraints["lt"] = validator.limit_value
elif isinstance(validator, validators.MaxLengthValidator):
constraints["max_length"] = validator.limit_value
if isinstance(validator, validators.RegexValidator):
if validator.inverse_match:
if (
isinstance(cls.config, DjangoDTOConfig)
Expand Down Expand Up @@ -151,7 +168,7 @@ def create_constraints_for_validator(
@classmethod
def get_field_default(
cls, field: AnyField
) -> tuple[Any, Callable[..., Any] | None]:
) -> tuple[Any, Optional[Callable[..., Any]]]:
if isinstance(field, ForeignObjectRel):
if isinstance(field, ManyToOneRel):
return Empty, list
Expand Down
12 changes: 9 additions & 3 deletions litestar_django/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
from django.contrib.contenttypes.fields import GenericForeignKey # type: ignore[import-untyped]
from django.db.models import Field, ForeignObjectRel # type: ignore[import-untyped]
from __future__ import annotations

AnyField = Field | ForeignObjectRel | GenericForeignKey
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing_extensions import TypeAlias
from django.contrib.contenttypes.fields import GenericForeignKey # type: ignore[import-untyped]
from django.db.models import Field, ForeignObjectRel # type: ignore[import-untyped]

AnyField: TypeAlias = "Field | ForeignObjectRel | GenericForeignKey"
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ dependencies = [
dev = [
"django-enumfields2>=3.0.2",
"mypy>=1.15.0",
"pre-commit>=4.2.0",
"pytest>=8.3.5",
"pytest-django>=4.11.1",
"ruff>=0.11.7",
"typing-extensions>=4.13.2",
]

[tool.uv.sources]
Expand All @@ -32,3 +34,8 @@ litestar = { git = "https://github.com/litestar-org/litestar", branch = "main" }
[tool.mypy]
packages = ["litestar_django"]
strict = true
python_version = "3.9"


[tool.ruff]
target-version = "py39"
3 changes: 0 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import os

import pytest
import django



os.environ["DJANGO_SETTINGS_MODULE"] = "tests.some_app.settings"

django.setup()

# @pytest.fixture(scope="session", autouse=True)
# def django_setup(monkeypatch) -> None:

5 changes: 3 additions & 2 deletions tests/manage.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""

import os
import sys


def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testapp.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
Expand All @@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv)


if __name__ == '__main__':
if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions tests/some_app/app/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@


class AppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tests.some_app.app'
default_auto_field = "django.db.models.BigAutoField"
name = "tests.some_app.app"
Loading