Skip to content
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
21 changes: 6 additions & 15 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,26 @@ This directory contains GitHub Actions workflows for automated code quality chec

**Jobs**:
- **Lint & Format Check**: Verifies code formatting and linting rules using `make lint`
- **Type Check**: Runs type checking with `make pyright` (non-blocking, continues on error)
- **Tests & Coverage**: Runs the test suite and generates coverage reports using `make coverage`
- **Type Check**: Runs type checking with `make typecheck` (zuban); advisory (non-blocking) until zuban is green on the async refactor
- **Tests & Coverage**: Runs the test suite (against a Redis service) on a Python **3.13 + 3.14** matrix and generates a coverage report using `make coverage-xml`

**Requirements**: Tests will only run if linting passes.

### `code-quality.yml` - Comprehensive Code Quality Check
**Triggers**: Manual dispatch, Weekly on Sundays

**Features**:
- Complete code quality assessment
- Detailed test coverage reporting
- HTML coverage report generation and artifact upload
- All checks with detailed output grouping

## Makefile Targets Used

The workflows leverage the following Makefile targets:

- `make install` - Install dependencies using uv
- `make lint` - Check formatting and linting (ruff)
- `make pyright` - Run type checking (basedpyright)
- `make typecheck` - Run type checking (zuban)
- `make test` - Run Django tests
- `make coverage` - Run tests with coverage reporting
- `make coverage-html` - Generate HTML coverage report

## Setup Requirements

1. **Python 3.13**: Workflows use Python 3.13 as specified in the Makefile
2. **uv Package Manager**: Uses uv 0.7.3 for fast dependency management
1. **Python 3.13 / 3.14**: lint and type-check run on 3.14; tests run on a 3.13 + 3.14 matrix (the matrix version is threaded into `make install` via `PYTHON_VERSION`)
2. **uv Package Manager**: Uses uv 0.10.10 (matching `REQUIRED_UV_VERSION` in the Makefile)

## Coverage Reports

Expand All @@ -51,6 +42,6 @@ You can run the same checks locally using:
```bash
make install # Install dependencies
make lint # Check formatting/linting
make pyright # Type checking
make typecheck # Type checking
make coverage # Tests with coverage
```
29 changes: 17 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.7.3"
version: "0.10.10"
enable-cache: true

- name: Set up Python
run: uv python install 3.13
run: uv python install 3.14

- name: Install dependencies
run: make install
Expand All @@ -33,32 +33,37 @@ jobs:
type-check:
runs-on: ubuntu-latest
name: "Type Check"
continue-on-error: true # Allow this to fail without failing the whole CI
continue-on-error: true # Advisory until zuban is green on the async refactor

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.7.3"
version: "0.10.10"
enable-cache: true

- name: Set up Python
run: uv python install 3.13
run: uv python install 3.14

- name: Install dependencies
run: make install

- name: Run type checking
run: make pyright
run: make typecheck

test:
runs-on: ubuntu-latest
name: "Tests & Coverage"
name: "Tests & Coverage (py${{ matrix.python-version }})"
needs: [lint-and-format] # Only run if linting passes


strategy:
fail-fast: false
matrix:
python-version: ["3.13", "3.14"]

services:
redis:
image: redis:7-alpine
Expand All @@ -77,14 +82,14 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.7.3"
version: "0.10.10"
enable-cache: true

- name: Set up Python
run: uv python install 3.13
run: uv python install ${{ matrix.python-version }}

- name: Install dependencies
run: make install
run: make install PYTHON_VERSION=${{ matrix.python-version }}

- name: Install Redis CLI
run: sudo apt-get update && sudo apt-get install -y redis-tools
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Inline type information (PEP 561)**: djhtmx now ships a `py.typed` marker, so type checkers consume the package's own annotations instead of inferring types from source. This also makes them honor the package's re-export rules: names merely re-imported into a module (e.g. `Iterable` in `djhtmx.sse`) and not listed in `__all__` are no longer offered as importable symbols, so editor auto-import stops suggesting `from djhtmx.sse import Iterable` and similar indirect imports. Import public names from their documented modules.

- **Python 3.14 support**: djhtmx is now tested on a Python 3.13 + 3.14 matrix. On 3.14 the dependency floors rise to the first releases shipping 3.14 wheels (`pydantic>=2.13`, `orjson>=3.11`, `lxml>=6`); 3.13 installs are unaffected.

### Changed

- **Minimum Django is now 5.2**: djhtmx requires `django>=5.2` (was `>=4.1`). Django releases before 5.2 are end-of-life upstream and were neither tested nor supported; 5.2 is the LTS line djhtmx is developed against and the first to support Python 3.14.

## [1.3.13] - 2026-06-17

### Added
Expand Down
13 changes: 9 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,15 @@ lint:
.PHONY: lint


PYRIGHT_FILES ?= src/$(PROJECT_NAME)
pyright:
@$(RUN) basedpyright $(PYRIGHT_FILES)
.PHONY: pyright
ZUBAN_EXTRA_ARGS ?=
# RAYON_NUM_THREADS=1: zuban 0.8.2's parallel name resolution is racy and can
# produce nondeterministic results.

ZUBAN_MODE ?= mypy

.PHONY: typecheck
typecheck:
@RAYON_NUM_THREADS=1 $(RUN) zuban check --mode $(ZUBAN_MODE) $(ZUBAN_EXTRA_ARGS)


SERVER_CMD ?= granian --reload --reload-paths fision --reload-paths ../djhtmx --port 8000 --access-log --workers 1 --workers-kill-timeout 1s --interface asginl fision.asgi:application
Expand Down
67 changes: 48 additions & 19 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,27 @@ classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 4.1",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Software Development :: Libraries :: Application Frameworks",
"Topic :: Software Development :: Libraries :: Python Modules",
]

dependencies = [
"django>=4.1",
"pydantic>=2,<3",
"django>=5.2",
"pydantic>=2,<3; python_version < '3.14'",
"pydantic>=2.13,<3; python_version >= '3.14'",
"xotl.tools>=3.1.1",
"orjson>=3.10.7",
"orjson>=3.10.7; python_version < '3.14'",
"orjson>=3.11.0; python_version >= '3.14'",
"channels>=4.1.0",
"uuid6>=2024.7.10",
"redis[hiredis]>=7.4.0",
Expand All @@ -62,14 +62,23 @@ cli = [
"django-click>=2.3.0,<3",
]
test = [
"lxml>=5.3.0",
"lxml>=5.3.0; python_version < '3.14'",
"lxml>=6.0; python_version >= '3.14'",
"cssselect>=1.2.0",
"coverage[toml]>=7.6.9",
]
typing = [
"basedpyright==1.28.1",
"django-types>=0.22.0",
# zuban reads mypy-format config and runs its pyright-style "default" mode.
"zuban==0.8.2",
# Keep django-types (plugin-free, pyright-style) so it shadows zuban's bundled
# django-stubs, which assume the settings-aware mypy_django_plugin that zuban
# does not fully implement. 0.24.0 also types CheckConstraint(condition=).
"django-types==0.24.0",
"types-lxml>=2024.11.8",

# Include the optional sentry-sdk, and logfire so that we can typecheck
"sentry_sdk>=2.62.0,<2.63",
"logfire[django]>=3.8.0",
]
lint = [
"ruff==0.11.*",
Expand All @@ -91,6 +100,13 @@ dev = [
{ include-group = "devtools" },
]

[tool.uv]
# The library supports Django >= 5.2 (see [project.dependencies]); older Django
# is EOL upstream. The lock / CI is a dev artifact not seen by consumers, so it
# is pinned to the 5.2 LTS line (the version the downstream app ships) rather
# than allowed to drift onto Django 6.x.
constraint-dependencies = ["django>=5.2,<6"]

[project.urls]
Homepage = "https://github.com/edelvalle/djhtmx"
Documentation = "https://github.com/edelvalle/djhtmx#readme"
Expand Down Expand Up @@ -188,17 +204,30 @@ select = [
[tool.ruff.format]
preview = true

[tool.basedpyright]
typeCheckingMode="standard"
reportIncompatibleMethodOverride = false
reportIncompatibleVariableOverride = false
strictParameterNoneValue = false
reportMatchNotExhaustive = "error"
[tool.mypy]
# This is zuban's configuration: zuban reads the standard mypy configuration.
# The Makefile runs `zuban check --mode mypy` (see ZUBAN_MODE) on top of it,
# replacing the former basedpyright setup.
mypy_path = "src"
files = ["src/djhtmx"]
disallow_untyped_decorators = false
warn_unused_ignores = true
exclude = [
"**/static",
"**/migrations",
"**/__pycache__",
'/migrations/',
'/static/',
'/__pycache__/',
]
disable_error_code = [
# Prefer inference over forced annotations. `var-annotated` fires when the
# checker wants an explicit annotation for a binding it could infer from later
# use (e.g. `xs = []` populated below); we let it infer instead.
"var-annotated",
# Informational note, not an error: the checker reminds that untyped function
# bodies are skipped. We intentionally don't enable check_untyped_defs, so the
# reminder is just noise.
"annotation-unchecked",
]


[tool.djlint]
# Here we ignore
Expand Down
2 changes: 1 addition & 1 deletion src/djhtmx/command_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def _run_command(self, commands: CommandQueue) -> Generator[ProcessedCommand]:
# Component dropped its SSE subscription between
# enqueue and dispatch; nothing to do.
return
emitted_commands: list[Command] = []
emitted_commands = []
for envelope in envelopes:
try:
yielded = handler(envelope)
Expand Down
8 changes: 6 additions & 2 deletions src/djhtmx/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ class HtmxComponent(BaseModel):
arbitrary_types_allowed=True,
)

if TYPE_CHECKING:

def __init__(self, /, **data: Any) -> None: ...

def __init_subclass__(cls, public=None):
FQN[cls] = f"{cls.__module__}.{cls.__name__}"

Expand Down Expand Up @@ -134,7 +138,7 @@ def __init_subclass__(cls, public=None):
FQN[cls],
)

assert isinstance(cls._template_name, ModelPrivateAttr)
assert isinstance(cls._template_name, ModelPrivateAttr) # type: ignore
if isinstance(cls._template_name.default, str) and (
basename(cls._template_name.default)
not in (f"{klass.__name__}.html" for klass in cls.__mro__)
Expand Down Expand Up @@ -241,7 +245,7 @@ def __check_consistent_event_handler(cls, *, strict: bool = False):
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser

user: Annotated[AbstractBaseUser | None, Field(exclude=True)]
user: Annotated[AbstractBaseUser | None, Field(exclude=True)] # type: ignore

hx_name: str
lazy: bool = False
Expand Down
2 changes: 1 addition & 1 deletion src/djhtmx/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class ComponentsAdded(BaseModel):


Event = ComponentsRemoved | ComponentsAdded
EventAdapter = TypeAdapter(Event)
EventAdapter: TypeAdapter[Event] = TypeAdapter(Event)


class Consumer(AsyncJsonWebsocketConsumer):
Expand Down
12 changes: 6 additions & 6 deletions src/djhtmx/introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,16 +202,16 @@ def from_modelclass(cls, model: type[M], allow_none: bool = False):
return cls(model, allow_none=allow_none)


def _Model(
model: type[models.Model],
def _Model[M: models.Model](
model: type[M],
model_config: ModelConfig | None = None,
allow_none: bool = False,
):
assert issubclass_safe(model, models.Model)
model_config = model_config or _DEFAULT_MODEL_CONFIG

# Determine the base type
base_type = model if not model_config.lazy else _LazyModelProxy[model]
base_type = model if not model_config.lazy else _LazyModelProxy[M]
# If allow_none, make it optional
annotated_type = base_type | None if allow_none else base_type

Expand All @@ -226,7 +226,7 @@ def _Model(


def _QuerySet(qs: type[models.QuerySet]):
[model] = [m for m in apps.get_models() if isinstance(m.objects.all(), qs)]
[model] = [m for m in apps.get_models() if isinstance(m.objects.all(), qs)] # type: ignore
return Annotated[
qs,
PlainValidator(lambda v: (v if isinstance(v, qs) else model.objects.filter(pk__in=v))),
Expand Down Expand Up @@ -387,7 +387,7 @@ def parse_request_data(data: MultiValueDict[str, Any] | dict[str, Any]):
def _extract_data(data: MultiValueDict[str, Any]):
for key in set(data):
if key.endswith("[]"):
value = data.getlist(key)
value: Any = data.getlist(key)
key = key.removesuffix("[]")
else:
value = data.get(key)
Expand Down Expand Up @@ -442,7 +442,7 @@ def _substitute_typevars(annotation, typevar_map: dict[TypeVar, type]):
new_args = tuple(_substitute_typevars(a, typevar_map) for a in args)
if new_args == args:
return annotation
return origin[new_args] if len(new_args) > 1 else origin[new_args[0]]
return origin[new_args] if len(new_args) > 1 else origin[new_args[0]] # type: ignore


def _extract_event_types(annotation) -> set[type]:
Expand Down
4 changes: 2 additions & 2 deletions src/djhtmx/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def middleware(

if iscoroutinefunction(get_response):
# Async version
async def middleware(request: HttpRequest) -> HttpResponse: # type: ignore
async def middleware(request: HttpRequest) -> HttpResponse:
response = await get_response(request)
if repo := getattr(request, "htmx_repo", None):
await sync_to_async(repo.session.flush)()
Expand All @@ -25,7 +25,7 @@ async def middleware(request: HttpRequest) -> HttpResponse: # type: ignore

else:
# Sync version
def middleware(request: HttpRequest) -> HttpResponse:
def middleware(request: HttpRequest) -> HttpResponse: # type: ignore
response = get_response(request)
if repo := getattr(request, "htmx_repo", None):
repo.session.flush()
Expand Down
Loading
Loading