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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v2
- name: Set up Python
run: uv python install
run: uv python install 3.14
- run: uv sync
- run: uv run plain-code check . --skip-ty
# Annotation coverage plus `ty check` over every fully typed path --
Expand Down
4 changes: 3 additions & 1 deletion example/app/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ class NotesTool(MCPTool):
`self.user` as the demo user the bearer token maps to.
"""

mcp: NotesMCP
# The documented narrowing pattern; ty flags any narrowed mutable
# attribute, but the dispatcher only ever sets a NotesMCP here.
mcp: NotesMCP # ty: ignore[invalid-mutable-override]

@property
def user(self) -> User:
Expand Down
4 changes: 2 additions & 2 deletions plain-admin/plain/admin/cards/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class Sizes(Enum):
text: str = ""
link: str = ""
metric: int | float | Decimal | None = None
filters: list[str] | Enum | None = None
filters: list[str] | type[Enum] | None = None

# These will be accessible at render time
view: AdminView
Expand Down Expand Up @@ -97,7 +97,7 @@ def get_link(self) -> str:
def get_current_filter(self) -> str:
return self.request.query_params.get(f"{self.get_slug()}.filter", "")

def get_filters(self) -> list[str] | Enum | None:
def get_filters(self) -> list[str] | type[Enum] | None:
if isinstance(self.filters, list):
# Avoid mutating the class attribute
return self.filters.copy()
Expand Down
14 changes: 9 additions & 5 deletions plain-admin/plain/admin/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .types import Img

if TYPE_CHECKING:
from app.users.models import User
from plain.http import Response
from plain.postgres import Model

Expand All @@ -26,7 +27,10 @@

class AdminView(AuthView, TemplateView):
admin_required = True
user: Model # Always set — admin_required guarantees authentication
# Always a user once `check_auth` passes -- admin_required rejects anonymous
# requests before any handler runs. Narrowing a writable attribute is
# unsound to the checker, which is the cost of not re-checking everywhere.
user: User # ty: ignore[invalid-mutable-override]

# True for framework-provided views (index, search, settings, etc.)
# Available for use in ADMIN_HAS_PERMISSION to make per-view decisions.
Expand Down Expand Up @@ -61,7 +65,7 @@ def has_permission(cls, user: Model) -> bool:
extra_links: ClassVar[dict[str, str]] = {}
field_templates: ClassVar[dict[str, str]] = {}

parent_view_class: AdminView | None = None
parent_view_class: type[AdminView] | None = None

# Set dynamically by AdminViewset.get_views()
viewset: type[AdminViewset] | None = None
Expand All @@ -84,7 +88,7 @@ def get_delete_url(self, obj: Any) -> str:
return ""

template_name = "admin/page.html"
cards: tuple[Card, ...] = ()
cards: tuple[type[Card], ...] = ()

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
Expand Down Expand Up @@ -160,7 +164,7 @@ def get_path(cls) -> str:
return cls.path

@classmethod
def get_parent_view_classes(cls) -> list[AdminView]:
def get_parent_view_classes(cls) -> list[type[AdminView]]:
parents = []
parent = cls.parent_view_class
while parent:
Expand Down Expand Up @@ -194,7 +198,7 @@ def get_links(self) -> dict[str, str]:
def get_extra_links(self) -> dict[str, str]:
return self.extra_links.copy()

def get_cards(self) -> tuple[Card, ...]:
def get_cards(self) -> tuple[type[Card], ...]:
return self.cards

def get_field_value(self, obj: Any, field: str) -> Any:
Expand Down
11 changes: 0 additions & 11 deletions plain-admin/plain/admin/views/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ class AdminModelListView(AdminListView):
queryset_order: tuple[FieldRef, ...] = ()
search_fields: tuple[FieldRef, ...] = ("id",)

# Filter *names* shown in the UI, not fields. Can also be a dict mapping a
# filter name to a Q object, which filters the queryset automatically.
filters: tuple[str, ...] | dict[str, Q] = ()

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
# Normalize the declared field references now, so a reference to
Expand Down Expand Up @@ -99,13 +95,6 @@ def get_initial_queryset(self) -> postgres.QuerySet:
"""Override this to customize the base queryset (e.g., add annotations)."""
return self.model.query.all()

def get_filter_names(self) -> tuple[str, ...]:
"""Return filter names. Supports both tuple[str, ...] and dict[str, Q] formats."""
filters = self.filters
if isinstance(filters, dict):
return tuple(filters.keys())
return super().get_filter_names()

def get_search_fields(self) -> tuple[str, ...]:
"""`search_fields` as lookup paths, checked against the view's model."""
return field_lookup_paths(
Expand Down
22 changes: 13 additions & 9 deletions plain-admin/plain/admin/views/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from plain.htmx.views import HTMXView
from plain.http import RedirectResponse, Response
from plain.postgres import QuerySet
from plain.postgres import Q, QuerySet
from plain.templates.views import (
CreateView,
DeleteView,
Expand Down Expand Up @@ -62,9 +62,10 @@ class AdminListView(HTMXView, AdminView, ListView):
search_fields: tuple[FieldRef, ...] = ()
actions: tuple[str, ...] = ()
# Filter *names* shown in the UI, not fields -- `filter_queryset` maps a
# name to whatever query it means.
filters: tuple[str, ...] = ()
page_size: int = 20
# name to whatever query it means. `AdminModelListView` also takes a dict
# mapping a filter name to a Q object, which filters the queryset itself.
filters: tuple[str, ...] | dict[str, Q] = ()
page_size = 20
allow_global_search = False

@cached_property
Expand All @@ -75,13 +76,16 @@ def filter(self) -> str:
def get_objects(self) -> list[Any] | QuerySet[Any]:
return self.process_objects()

def get_page_size(self) -> int:
def get_page_size(self) -> int | None:
default = self.page_size
if default is None:
return None
try:
page_size = int(self.request.query_params.get("page_size", self.page_size))
page_size = int(self.request.query_params.get("page_size", default))
except ValueError:
return self.page_size
return default
if page_size < 1:
return self.page_size
return default
return page_size

def get_template_context(self) -> dict[str, Any]:
Expand Down Expand Up @@ -245,7 +249,7 @@ def get_actions(self) -> tuple[str, ...]:
return self.actions

def get_filter_names(self) -> tuple[str, ...]:
return self.filters
return tuple(self.filters)

def get_search_fields(self) -> tuple[str, ...]:
"""`search_fields` as lookup paths, whichever way each was declared.
Expand Down
4 changes: 3 additions & 1 deletion plain-admin/tests/public/test_search_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,10 @@ class PropertySearch(AdminModelListView):
model = PinnedNavItem
title = "Property search"

# A read-only property where the base declares an attribute: unsound to
# the checker, and exactly the runtime path under test.
@property
def search_fields(self) -> tuple[FieldRef, ...]:
def search_fields(self) -> tuple[FieldRef, ...]: # ty: ignore[invalid-property-type-override]
return (PinnedNavItem.view_slug,)


Expand Down
2 changes: 1 addition & 1 deletion plain-code/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ dependencies = [
"plain>=0.155.0,<1.0.0",
# Bounded so every checkout and every app lints with the same rule set —
# ruff adds rules (and import-sort tweaks) in minor releases.
"ruff>=0.16.2,<0.17",
"ruff>=0.17.1,<0.18",
"ty>=0.0.82,<1.0.0",
"httpx>=0.27",
"tomlkit>=0.11.0",
Expand Down
2 changes: 1 addition & 1 deletion plain-dev/plain/dev/poncho/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def __init__(self, printer: Printer | None = None) -> None:
self._processes = {}

self._terminating = False
self._terminate_start = datetime.datetime.min # noqa: DTZ901 — poncho's clock is consistently naive
self._terminate_start = datetime.datetime.min
self._killed = False

def add_process(
Expand Down
2 changes: 1 addition & 1 deletion plain-dev/plain/dev/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class Supervisor:
state_filename: str
log_dir: Path
# Foreground command that re-runs this supervisor, e.g. ["dev", "services"].
background_command: list[str]
background_command: tuple[str, ...]
# Human label for "already running" warnings, e.g. "Services".
display_name: str

Expand Down
4 changes: 2 additions & 2 deletions plain-jobs/plain/jobs/locks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Lock implementations for job enqueueing."""

import hashlib
from collections.abc import Iterator
from collections.abc import Generator
from contextlib import contextmanager
from typing import TYPE_CHECKING

Expand All @@ -10,7 +10,7 @@


@contextmanager
def postgres_advisory_lock(job: Job, concurrency_key: str) -> Iterator[None]:
def postgres_advisory_lock(job: Job, concurrency_key: str) -> Generator[None]:
"""
PostgreSQL advisory lock context manager.

Expand Down
4 changes: 2 additions & 2 deletions plain-jobs/plain/jobs/otel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime
import importlib.metadata
from collections.abc import Callable, Iterable, Iterator
from collections.abc import Callable, Generator, Iterable
from contextlib import contextmanager
from dataclasses import dataclass
from functools import wraps
Expand Down Expand Up @@ -70,7 +70,7 @@ def record_span_error(span: trace.Span, exc: BaseException) -> str:


@contextmanager
def error_consumer_span(*, name: str, exc: BaseException) -> Iterator[None]:
def error_consumer_span(*, name: str, exc: BaseException) -> Generator[None]:
"""Open a one-off CONSUMER span solely to carry a failure that has no
other entry span — a DB error before the span that would normally own
the work is ever reached. Stamps the canonical failure signal so the
Expand Down
1 change: 0 additions & 1 deletion plain-jobs/tests/public/test_parameters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# ruff: noqa: DTZ001 — naive datetimes are deliberate here: Schedule does
# tz-independent calendar math, and these exercise exactly that.
import datetime

Expand Down
1 change: 0 additions & 1 deletion plain-jobs/tests/public/test_scheduling.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# ruff: noqa: DTZ001 — naive datetimes are deliberate here: Schedule does
# tz-independent calendar math, and these exercise exactly that.
import datetime

Expand Down
4 changes: 2 additions & 2 deletions plain-loginlink/plain/loginlink/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ def get_success_url(self, form: LoginLinkForm) -> str:
if next_url := form.cleaned_data.get("next"):
# Keep the next URL in the query string so the sent
# view can redirect to it if reloaded and logged in already.
return f"{self.success_url}?next={next_url}"
return f"{super().get_success_url(form)}?next={next_url}"
else:
return self.success_url
return super().get_success_url(form)


class LoginLinkSentView(AuthView, TemplateView):
Expand Down
13 changes: 6 additions & 7 deletions plain-oauth/plain/oauth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class OAuthCallbackView(TemplateView):
"""

template_name = "oauth/error.html"
oauth_error: OAuthError | None = None

def get(self) -> Response:
provider = self.url_kwargs["provider"]
Expand All @@ -44,17 +45,15 @@ def get(self) -> Response:
return self.render(status_code=400)

def get_template_names(self) -> list[str]:
names = []
if (
oauth_error := getattr(self, "oauth_error", None)
) and oauth_error.template_name:
names.append(oauth_error.template_name)
names.append(self.template_name)
names: list[str] = []
if self.oauth_error and self.oauth_error.template_name:
names.append(self.oauth_error.template_name)
names.extend(super().get_template_names())
return names

def get_template_context(self) -> dict:
context = super().get_template_context()
context["oauth_error"] = getattr(self, "oauth_error", None)
context["oauth_error"] = self.oauth_error
return context


Expand Down
4 changes: 3 additions & 1 deletion plain-passwords/plain/passwords/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ class PasswordLoginView(AuthView, FormView[PasswordLoginForm]):
def get(self) -> Response:
# Redirect if the user is already logged in
if self.user:
return RedirectResponse(self.success_url, status_code=302)
return RedirectResponse(
str(self.success_url) if self.success_url else "/", status_code=302
)

return super().get()

Expand Down
4 changes: 2 additions & 2 deletions plain-postgres/plain/postgres/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import copy
import warnings
from collections.abc import Collection, Iterable, Iterator, Sequence
from collections.abc import Collection, Generator, Iterable, Iterator, Sequence
from contextlib import contextmanager
from itertools import chain
from typing import TYPE_CHECKING, Any, ClassVar, Self, dataclass_transform
Expand Down Expand Up @@ -564,7 +564,7 @@ def _integrity_error_to_validation_error(
return ValidationError(error.update_error_dict({}))

@contextmanager
def _mapped_write(self) -> Iterator[None]:
def _mapped_write(self) -> Generator[None]:
"""Run a table write inside the rollback guard, mapping a
declared-constraint IntegrityError to the ValidationError a pre-check
would have raised -- so the same violation surfaces identically whether
Expand Down
4 changes: 2 additions & 2 deletions plain-postgres/plain/postgres/cli/decorators.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import functools
from collections.abc import Callable, Iterator
from collections.abc import Callable, Generator
from contextlib import contextmanager
from typing import Any

Expand All @@ -11,7 +11,7 @@


@contextmanager
def cli_schema_lock() -> Iterator[Callable[[], None]]:
def cli_schema_lock() -> Generator[Callable[[], None]]:
"""`schema_lock()` for CLI commands: a timeout or lost lock surfaces as a
clean one-line error instead of a traceback burying it. Yields the lock's
verify callable for multi-phase commands to check between phases."""
Expand Down
6 changes: 3 additions & 3 deletions plain-postgres/plain/postgres/convergence/analysis.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import contextvars
import json
import re
from collections.abc import Iterator
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass, field
from enum import StrEnum
Expand Down Expand Up @@ -1790,7 +1790,7 @@ class _ProbeSession:


@contextmanager
def _probe_session(cursor: CursorWrapper, model: type[Model]) -> Iterator[None]:
def _probe_session(cursor: CursorWrapper, model: type[Model]) -> Generator[None]:
"""Reuse one probe table across every round-trip in a model's analysis.

The temp table is created lazily by the first probe inside this scope and
Expand Down Expand Up @@ -1833,7 +1833,7 @@ def _create_probe_table(cursor: CursorWrapper, model: type[Model]) -> None:


@contextmanager
def _probe_table(cursor: CursorWrapper, model: type[Model]) -> Iterator[None]:
def _probe_table(cursor: CursorWrapper, model: type[Model]) -> Generator[None]:
"""Provide an empty temp table mirroring the model's real table for one
round-trip, isolating the probe's DDL so it can't leak into the analyze
transaction.
Expand Down
4 changes: 2 additions & 2 deletions plain-postgres/plain/postgres/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,8 @@ def year_lookup_bounds_for_datetime_field(
value + 1, 1, 1
) - datetime.timedelta(microseconds=1)
else:
first = datetime.datetime(value, 1, 1) # noqa: DTZ001 — made aware just below
second = datetime.datetime(value, 12, 31, 23, 59, 59, 999999) # noqa: DTZ001 — made aware just below
first = datetime.datetime(value, 1, 1)
second = datetime.datetime(value, 12, 31, 23, 59, 59, 999999)

# Make sure that datetimes are aware in the current timezone
tz = timezone.get_current_timezone()
Expand Down
Loading
Loading