From b71e5647965db8f97287f33d6ae77bdb03b65ddb Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:13:14 +0200 Subject: [PATCH 1/2] Update supported Python versions, add support for Pydantic 2.11 Drop support for Python 3.8, add support for Python 3.13 Bump pre-commit dependencies --- .github/workflows/ci.yml | 6 ++-- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- .../fastui/components/display.py | 4 +-- src/python-fastui/fastui/types.py | 32 +++++++++++++++++-- src/python-fastui/pyproject.toml | 6 ++-- src/python-fastui/requirements/pyproject.txt | 7 ++-- 7 files changed, 45 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73f4723f..addc6315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,9 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-13, macos-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] exclude: - # Python 3.8 and 3.9 are not available on macOS 14 + # Python 3.9 is not available on macOS 14 - os: macos-13 python-version: '3.10' - os: macos-13 @@ -78,7 +78,7 @@ jobs: - os: macos-13 python-version: '3.12' - os: macos-latest - python-version: '3.8' + python-version: '3.13' - os: macos-latest python-version: '3.9' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49bbffc5..81bb1822 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v5.0.0 hooks: - id: no-commit-to-branch - id: check-yaml diff --git a/pyproject.toml b/pyproject.toml index d4518502..99dd84a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ line-length = 120 extend-select = ["Q", "RUF100", "UP", "I"] flake8-quotes = {inline-quotes = "single", multiline-quotes = "double"} format.quote-style="single" -target-version = "py38" +target-version = "py39" [tool.pyright] include = ["src/python-fastui/fastui"] diff --git a/src/python-fastui/fastui/components/display.py b/src/python-fastui/fastui/components/display.py index deff7a41..c4e7030d 100644 --- a/src/python-fastui/fastui/components/display.py +++ b/src/python-fastui/fastui/components/display.py @@ -80,7 +80,7 @@ class Details(BaseModel, extra='forbid'): @pydantic.model_validator(mode='after') def _fill_fields(self) -> _te.Self: - fields = {**self.data.model_fields, **self.data.model_computed_fields} + fields = {**type(self.data).model_fields, **type(self.data).model_computed_fields} if self.fields is None: self.fields = [DisplayLookup(field=name, title=field.title) for name, field in fields.items()] @@ -88,7 +88,7 @@ def _fill_fields(self) -> _te.Self: # add pydantic titles to fields that don't have them for field in (c for c in self.fields if c.title is None): if isinstance(field, DisplayLookup): - pydantic_field = self.data.model_fields.get(field.field) + pydantic_field = type(self.data).model_fields.get(field.field) if pydantic_field and pydantic_field.title: field.title = pydantic_field.title elif isinstance(field, Display): diff --git a/src/python-fastui/fastui/types.py b/src/python-fastui/fastui/types.py index 4906ee55..5bd65a71 100644 --- a/src/python-fastui/fastui/types.py +++ b/src/python-fastui/fastui/types.py @@ -2,8 +2,10 @@ import pydantic import typing_extensions as _te +from pydantic.json_schema import GenerateJsonSchema, CoreRef, JsonRef from pydantic_core import core_schema +IS_PYDANTIC_211_OR_GREATER = tuple(int(v) for v in pydantic.VERSION.split('.')[:2]) >= (2, 11) # TODO: replace with https://docs.pydantic.dev/dev/api/types/#pydantic.types.JsonValue, maybe? class JsonDataSchema: @@ -22,7 +24,20 @@ def __get_pydantic_json_schema__( ], ref='JsonData', ) - return handler(json_data_schema) + rv = handler(json_data_schema) + if IS_PYDANTIC_211_OR_GREATER: + # Terrible hack. This is mimicking `GenerateJsonSchema.generate_inner..populate_defs()`, + # which used to be called in some place until 2.11, where the call was removed by + # https://github.com/pydantic/pydantic/pull/11475. + # The approach taken by this `JsonDataSchema` annotation (manually setting references) isn't viable, + # but Pydantic is missing proper semantics on the behavior of core schema references + gen_js_schema = _t.cast(GenerateJsonSchema, handler.generate_json_schema) # type: ignore + defs_ref, ref_json_schema = gen_js_schema.get_cache_defs_ref_schema(CoreRef('JsonData')) + json_ref = JsonRef(ref_json_schema['$ref']) + if rv.get('$ref', None) != json_ref: + gen_js_schema.definitions[defs_ref] = rv + rv = ref_json_schema + return rv JsonData = _te.Annotated[_t.Any, JsonDataSchema()] @@ -38,7 +53,20 @@ def __get_pydantic_json_schema__( core_schema.definition_reference_schema('JsonData'), ref='DataModel', ) - return handler(model_json_schema) + rv = handler(model_json_schema) + if IS_PYDANTIC_211_OR_GREATER: + # Terrible hack. This is mimicking `GenerateJsonSchema.generate_inner..populate_defs()`, + # which used to be called in some place until 2.11, where the call was removed by + # https://github.com/pydantic/pydantic/pull/11475. + # The approach taken by this `PydanticModelSchema` annotation (manually setting references) isn't viable, + # but Pydantic is missing proper semantics on the behavior of core schema references + gen_js_schema = _t.cast(GenerateJsonSchema, handler.generate_json_schema) # type: ignore + defs_ref, ref_json_schema = gen_js_schema.get_cache_defs_ref_schema(CoreRef('DataModel')) + json_ref = JsonRef(ref_json_schema['$ref']) + if rv.get('$ref', None) != json_ref: + gen_js_schema.definitions[defs_ref] = rv + rv = ref_json_schema + return rv DataModel = _te.Annotated[pydantic.BaseModel, PydanticModelSchema()] diff --git a/src/python-fastui/pyproject.toml b/src/python-fastui/pyproject.toml index 90f3fab3..e11e3b5d 100644 --- a/src/python-fastui/pyproject.toml +++ b/src/python-fastui/pyproject.toml @@ -17,18 +17,18 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Framework :: Pydantic :: 2", "Framework :: FastAPI", ] -requires-python = ">=3.8" -dependencies = ["pydantic[email]>=2.5.2"] +requires-python = ">=3.9" +dependencies = ["pydantic[email]>=2.8.0"] dynamic = ["version"] [project.optional-dependencies] diff --git a/src/python-fastui/requirements/pyproject.txt b/src/python-fastui/requirements/pyproject.txt index ad1ed563..3c38c8cb 100644 --- a/src/python-fastui/requirements/pyproject.txt +++ b/src/python-fastui/requirements/pyproject.txt @@ -18,11 +18,11 @@ idna==3.6 # via # anyio # email-validator -pydantic==2.10.6 +pydantic==2.11.7 # via # fastapi # fastui (src/python-fastui/pyproject.toml) -pydantic-core==2.27.2 +pydantic-core==2.33.2 # via pydantic python-multipart==0.0.7 # via fastui (src/python-fastui/pyproject.toml) @@ -35,3 +35,6 @@ typing-extensions==4.12.2 # fastapi # pydantic # pydantic-core + # typing-inspection +typing-inspection==0.4.1 + # via pydantic From 916c3f8daa857008344f84444f16c76cb7e59f01 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:17:17 +0200 Subject: [PATCH 2/2] Auto lint --- demo/sse.py | 2 +- src/python-fastui/fastui/__init__.py | 2 +- src/python-fastui/fastui/auth/github.py | 17 +++++----- src/python-fastui/fastui/auth/shared.py | 10 +++--- src/python-fastui/fastui/class_name.py | 6 ++-- .../fastui/components/__init__.py | 24 ++++++------- .../fastui/components/display.py | 2 +- src/python-fastui/fastui/components/forms.py | 16 ++++----- src/python-fastui/fastui/components/tables.py | 4 +-- src/python-fastui/fastui/events.py | 8 ++--- src/python-fastui/fastui/forms.py | 14 ++++---- src/python-fastui/fastui/json_schema.py | 34 +++++++++---------- src/python-fastui/fastui/types.py | 3 +- src/python-fastui/tests/test_auth_github.py | 18 +++++----- src/python-fastui/tests/test_forms.py | 15 ++++---- 15 files changed, 88 insertions(+), 87 deletions(-) diff --git a/demo/sse.py b/demo/sse.py index bcdb34b2..8b80d9cc 100644 --- a/demo/sse.py +++ b/demo/sse.py @@ -1,6 +1,6 @@ import asyncio +from collections.abc import AsyncIterable from itertools import chain -from typing import AsyncIterable from fastapi import APIRouter from fastui import FastUI diff --git a/src/python-fastui/fastui/__init__.py b/src/python-fastui/fastui/__init__.py index b6fae343..a04fcc85 100644 --- a/src/python-fastui/fastui/__init__.py +++ b/src/python-fastui/fastui/__init__.py @@ -13,7 +13,7 @@ class FastUI(pydantic.RootModel): The root component of a FastUI application. """ - root: _t.List[AnyComponent] + root: list[AnyComponent] @pydantic.field_validator('root', mode='before') def coerce_to_list(cls, v): diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index e32d41dd..030c3961 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -1,7 +1,8 @@ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Tuple, Union, cast +from typing import TYPE_CHECKING, Union, cast from urllib.parse import urlencode from pydantic import BaseModel, SecretStr, TypeAdapter, field_validator @@ -25,10 +26,10 @@ class GitHubExchangeError: class GitHubExchange: access_token: str token_type: str - scope: List[str] + scope: list[str] @field_validator('scope', mode='before') - def check_scope(cls, v: str) -> List[str]: + def check_scope(cls, v: str) -> list[str]: return [s for s in v.split(',') if s] @@ -61,7 +62,7 @@ class GitHubEmail(BaseModel): visibility: Union[str, None] -github_emails_ta = TypeAdapter(List[GitHubEmail]) +github_emails_ta = TypeAdapter(list[GitHubEmail]) class GitHubAuthProvider: @@ -76,7 +77,7 @@ def __init__( github_client_secret: SecretStr, *, redirect_uri: Union[str, None] = None, - scopes: Union[List[str], None] = None, + scopes: Union[list[str], None] = None, state_provider: Union['StateProvider', bool] = True, exchange_cache_age: Union[timedelta, None] = timedelta(seconds=30), ): @@ -202,7 +203,7 @@ async def get_github_user(self, exchange: GitHubExchange) -> GithubUser: user_response.raise_for_status() return GithubUser.model_validate_json(user_response.content) - async def get_github_user_emails(self, exchange: GitHubExchange) -> List[GitHubEmail]: + async def get_github_user_emails(self, exchange: GitHubExchange) -> list[GitHubEmail]: """ See https://docs.github.com/en/rest/users/emails """ @@ -212,7 +213,7 @@ async def get_github_user_emails(self, exchange: GitHubExchange) -> List[GitHubE return github_emails_ta.validate_json(emails_response.content) @staticmethod - def _auth_headers(exchange: GitHubExchange) -> Dict[str, str]: + def _auth_headers(exchange: GitHubExchange) -> dict[str, str]: return { 'Authorization': f'Bearer {exchange.access_token}', 'Accept': 'application/vnd.github+json', @@ -221,7 +222,7 @@ def _auth_headers(exchange: GitHubExchange) -> Dict[str, str]: class ExchangeCache: def __init__(self): - self._data: Dict[str, Tuple[datetime, GitHubExchange]] = {} + self._data: dict[str, tuple[datetime, GitHubExchange]] = {} def get(self, key: str, max_age: timedelta) -> Union[GitHubExchange, None]: self._purge(max_age) diff --git a/src/python-fastui/fastui/auth/shared.py b/src/python-fastui/fastui/auth/shared.py index 37abedc5..3caf740a 100644 --- a/src/python-fastui/fastui/auth/shared.py +++ b/src/python-fastui/fastui/auth/shared.py @@ -1,6 +1,6 @@ import json from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, List, Tuple, Union +from typing import TYPE_CHECKING, Union from .. import AnyComponent, FastUI, events from .. import components as c @@ -17,7 +17,7 @@ class AuthException(ABC, Exception): """ @abstractmethod - def response_data(self) -> Tuple[int, str]: + def response_data(self) -> tuple[int, str]: raise NotImplementedError @@ -26,7 +26,7 @@ def __init__(self, message: str, *, code: str): super().__init__(message) self.code = code - def response_data(self) -> Tuple[int, str]: + def response_data(self) -> tuple[int, str]: return 401, json.dumps({'detail': str(self)}) @@ -41,8 +41,8 @@ def __init__(self, path: str, message: Union[str, None] = None): self.path = path self.message = message - def response_data(self) -> Tuple[int, str]: - components: List[AnyComponent] = [c.FireEvent(event=events.GoToEvent(url=self.path), message=self.message)] + def response_data(self) -> tuple[int, str]: + components: list[AnyComponent] = [c.FireEvent(event=events.GoToEvent(url=self.path), message=self.message)] return 345, FastUI(root=components).model_dump_json(exclude_none=True) diff --git a/src/python-fastui/fastui/class_name.py b/src/python-fastui/fastui/class_name.py index 7dbedc0d..6c886a7b 100644 --- a/src/python-fastui/fastui/class_name.py +++ b/src/python-fastui/fastui/class_name.py @@ -1,10 +1,10 @@ # could be renamed to something general if there's more to add -from typing import Dict, List, Literal, Union +from typing import Annotated, Literal, Union from pydantic import Field -from typing_extensions import Annotated, TypeAliasType +from typing_extensions import TypeAliasType -ClassName = TypeAliasType('ClassName', Union[str, List['ClassName'], Dict[str, Union[bool, None]], None]) +ClassName = TypeAliasType('ClassName', Union[str, list['ClassName'], dict[str, Union[bool, None]], None]) ClassNameField = Annotated[ClassName, Field(serialization_alias='className')] NamedStyle = TypeAliasType('NamedStyle', Union[Literal['primary', 'secondary', 'warning'], None]) diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index 8c2188d6..39037b97 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -107,7 +107,7 @@ class PageTitle(BaseModel, extra='forbid'): class Div(BaseModel, defer_build=True, extra='forbid'): """A generic container component.""" - components: '_t.List[AnyComponent]' + components: 'list[AnyComponent]' """List of components to render inside the div.""" class_name: _class_name.ClassNameField = None @@ -120,7 +120,7 @@ class Div(BaseModel, defer_build=True, extra='forbid'): class Page(BaseModel, defer_build=True, extra='forbid'): """Similar to `container` in many UI frameworks, this acts as a root component for most pages.""" - components: '_t.List[AnyComponent]' + components: 'list[AnyComponent]' """List of components to render on the page.""" class_name: _class_name.ClassNameField = None @@ -245,7 +245,7 @@ class Button(BaseModel, extra='forbid'): class Link(BaseModel, defer_build=True, extra='forbid'): """Link component.""" - components: '_t.List[AnyComponent]' + components: 'list[AnyComponent]' """List of components to render attached to the link.""" on_click: _t.Union[events.AnyEvent, None] = None @@ -270,7 +270,7 @@ class Link(BaseModel, defer_build=True, extra='forbid'): class LinkList(BaseModel, extra='forbid'): """List of Link components.""" - links: _t.List[Link] + links: list[Link] """List of links to render.""" mode: _t.Union[_t.Literal['tabs', 'vertical', 'pagination'], None] = None @@ -292,10 +292,10 @@ class Navbar(BaseModel, extra='forbid'): title_event: _t.Union[events.AnyEvent, None] = None """Optional event to trigger when the title is clicked. Often used to navigate to the home page.""" - start_links: _t.List[Link] = [] + start_links: list[Link] = [] """List of links to render at the start of the navbar.""" - end_links: _t.List[Link] = [] + end_links: list[Link] = [] """List of links to render at the end of the navbar.""" class_name: _class_name.ClassNameField = None @@ -318,7 +318,7 @@ def __get_pydantic_json_schema__( class Footer(BaseModel, extra='forbid'): """Footer component.""" - links: _t.List[Link] + links: list[Link] """List of links to render in the footer.""" extra_text: _t.Union[str, None] = None @@ -337,10 +337,10 @@ class Modal(BaseModel, defer_build=True, extra='forbid'): title: str """The text displayed on the modal trigger button.""" - body: '_t.List[AnyComponent]' + body: 'list[AnyComponent]' """List of components to render in the modal body.""" - footer: '_t.Union[_t.List[AnyComponent], None]' = None + footer: '_t.Union[list[AnyComponent], None]' = None """Optional list of components to render in the modal footer.""" open_trigger: _t.Union[events.PageEvent, None] = None @@ -365,7 +365,7 @@ class ServerLoad(BaseModel, defer_build=True, extra='forbid'): load_trigger: _t.Union[events.PageEvent, None] = None """Optional event to trigger when the component is loaded.""" - components: '_t.Union[_t.List[AnyComponent], None]' = None + components: '_t.Union[list[AnyComponent], None]' = None """Optional list of components to render while the server is loading the new component(s).""" sse: _t.Union[bool, None] = None @@ -457,7 +457,7 @@ class Iframe(BaseModel, extra='forbid'): class Video(BaseModel, extra='forbid'): """Video component that displays a video or multiple videos.""" - sources: _t.List[_p.AnyUrl] + sources: list[_p.AnyUrl] """List of URLs to the video sources.""" autoplay: _t.Union[bool, None] = None @@ -549,7 +549,7 @@ class Toast(BaseModel, defer_build=True, extra='forbid'): title: str """The title of the toast.""" - body: '_t.List[AnyComponent]' + body: 'list[AnyComponent]' """List of components to render in the toast body.""" # TODO: change these before the release (top left, center, end, etc). Can be done with the toast bug fix. diff --git a/src/python-fastui/fastui/components/display.py b/src/python-fastui/fastui/components/display.py index c4e7030d..6b20120a 100644 --- a/src/python-fastui/fastui/components/display.py +++ b/src/python-fastui/fastui/components/display.py @@ -69,7 +69,7 @@ class Details(BaseModel, extra='forbid'): data: pydantic.SerializeAsAny[_types.DataModel] """Data model to display.""" - fields: _t.Union[_t.List[_t.Union[DisplayLookup, Display]], None] = None + fields: _t.Union[list[_t.Union[DisplayLookup, Display]], None] = None """Fields to display.""" class_name: _class_name.ClassNameField = None diff --git a/src/python-fastui/fastui/components/forms.py b/src/python-fastui/fastui/components/forms.py index 17bcb6f6..e12b345e 100644 --- a/src/python-fastui/fastui/components/forms.py +++ b/src/python-fastui/fastui/components/forms.py @@ -21,7 +21,7 @@ class BaseFormField(BaseModel, ABC, defer_build=True): name: str """Name of the field.""" - title: _t.Union[_t.List[str], str] + title: _t.Union[list[str], str] """Title of the field to display. Can be a list of strings for multi-line titles.""" required: bool = False @@ -119,7 +119,7 @@ class FormFieldSelect(BaseFormField): multiple: _t.Union[bool, None] = None """Whether multiple options can be selected.""" - initial: _t.Union[_t.List[str], str, None] = None + initial: _t.Union[list[str], str, None] = None """Initial value for the field.""" vanilla: _t.Union[bool, None] = None @@ -169,7 +169,7 @@ class BaseForm(BaseModel, ABC, defer_build=True, extra='forbid'): submit_url: str """URL to submit the form data to.""" - initial: _t.Union[_t.Dict[str, _types.JsonData], None] = None + initial: _t.Union[dict[str, _types.JsonData], None] = None """Initial values for the form fields, mapping field names to values.""" method: _t.Literal['POST', 'GOTO', 'GET'] = 'POST' @@ -184,10 +184,10 @@ class BaseForm(BaseModel, ABC, defer_build=True, extra='forbid'): submit_trigger: _t.Union[events.PageEvent, None] = None """Event to trigger form submission.""" - loading: '_t.Union[_t.List[AnyComponent], None]' = None + loading: '_t.Union[list[AnyComponent], None]' = None """Components to display while the form is submitting.""" - footer: '_t.Union[_t.List[AnyComponent], None]' = None + footer: '_t.Union[list[AnyComponent], None]' = None """Components to display in the form footer.""" class_name: _class_name.ClassNameField = None @@ -203,7 +203,7 @@ def default_footer(self) -> _te.Self: class Form(BaseForm, defer_build=True): """Form component.""" - form_fields: _t.List[FormField] + form_fields: list[FormField] """List of form fields.""" type: _t.Literal['Form'] = 'Form' @@ -216,14 +216,14 @@ class Form(BaseForm, defer_build=True): class ModelForm(BaseForm, defer_build=True): """Form component generated from a Pydantic model.""" - model: _t.Type[pydantic.BaseModel] = pydantic.Field(exclude=True) + model: type[pydantic.BaseModel] = pydantic.Field(exclude=True) """Pydantic model from which to generate the form.""" type: _t.Literal['ModelForm'] = 'ModelForm' """The type of the component. Always 'ModelForm'.""" @pydantic.computed_field(alias='formFields') - def form_fields(self) -> _t.List[FormField]: + def form_fields(self) -> list[FormField]: from ..json_schema import model_json_schema_to_fields return model_json_schema_to_fields(self.model) diff --git a/src/python-fastui/fastui/components/tables.py b/src/python-fastui/fastui/components/tables.py index 09e709da..0e09bf6e 100644 --- a/src/python-fastui/fastui/components/tables.py +++ b/src/python-fastui/fastui/components/tables.py @@ -18,10 +18,10 @@ class Table(BaseModel, extra='forbid'): data: _t.Sequence[pydantic.SerializeAsAny[_types.DataModel]] """Sequence of data models to display in the table.""" - columns: _t.Union[_t.List[display.DisplayLookup], None] = None + columns: _t.Union[list[display.DisplayLookup], None] = None """List of columns to display in the table. If not provided, columns will be inferred from the data model.""" - data_model: _t.Union[_t.Type[pydantic.BaseModel], None] = pydantic.Field(default=None, exclude=True) + data_model: _t.Union[type[pydantic.BaseModel], None] = pydantic.Field(default=None, exclude=True) """Data model to use for the table. If not provided, the model will be inferred from the first data item.""" no_data_message: _t.Union[str, None] = None diff --git a/src/python-fastui/fastui/events.py b/src/python-fastui/fastui/events.py index cb443444..699ed5a3 100644 --- a/src/python-fastui/fastui/events.py +++ b/src/python-fastui/fastui/events.py @@ -1,11 +1,11 @@ -from typing import Dict, Literal, Union +from typing import Annotated, Literal, Union from pydantic import Field -from typing_extensions import Annotated, TypeAliasType +from typing_extensions import TypeAliasType from .base import BaseModel -ContextType = TypeAliasType('ContextType', Dict[str, Union[str, int]]) +ContextType = TypeAliasType('ContextType', dict[str, Union[str, int]]) class PageEvent(BaseModel, defer_build=True): @@ -20,7 +20,7 @@ class PageEvent(BaseModel, defer_build=True): class GoToEvent(BaseModel): # can be a path or a full URL url: Union[str, None] = None - query: Union[Dict[str, Union[str, float, None]], None] = None + query: Union[dict[str, Union[str, float, None]], None] = None target: Union[Literal['_blank'], None] = None type: Literal['go-to'] = 'go-to' diff --git a/src/python-fastui/fastui/forms.py b/src/python-fastui/fastui/forms.py index 759bf7c5..1e064715 100644 --- a/src/python-fastui/fastui/forms.py +++ b/src/python-fastui/fastui/forms.py @@ -29,11 +29,11 @@ class FastUIForm(_t.Generic[FormModel]): TODO mypy, pyright and pycharm don't understand the model type if this is used, is there a way to get it to work? """ - def __class_getitem__(cls, model: _t.Type[FormModel]) -> fastapi_params.Depends: + def __class_getitem__(cls, model: type[FormModel]) -> fastapi_params.Depends: return fastui_form(model) -def fastui_form(model: _t.Type[FormModel]) -> fastapi_params.Depends: +def fastui_form(model: type[FormModel]) -> fastapi_params.Depends: async def run_fastui_form(request: fastapi.Request): async with request.form() as form_data: model_data = unflatten(form_data) @@ -64,7 +64,7 @@ def validate_single(self, input_value: _t.Any) -> ds.UploadFile: else: raise pydantic_core.PydanticCustomError('not_file', 'Input is not a file') - def validate_multiple(self, input_value: _t.Any) -> _t.List[ds.UploadFile]: + def validate_multiple(self, input_value: _t.Any) -> list[ds.UploadFile]: if isinstance(input_value, list): return [self.validate_single(v) for v in input_value] else: @@ -117,7 +117,7 @@ def _validate_file(self, file: ds.UploadFile) -> None: {'filename': file.filename, 'content_type': file.content_type, 'accept': self.accept}, ) - def __get_pydantic_core_schema__(self, source_type: _t.Type[_t.Any], *_args) -> core_schema.CoreSchema: + def __get_pydantic_core_schema__(self, source_type: type[_t.Any], *_args) -> core_schema.CoreSchema: if _t.get_origin(source_type) == list: args = _t.get_args(source_type) if len(args) == 1 and issubclass(args[0], ds.UploadFile): @@ -160,10 +160,10 @@ class SelectOption(_te.TypedDict): class SelectGroup(_te.TypedDict): label: str - options: _t.List[SelectOption] + options: list[SelectOption] -SelectOptions = _te.TypeAliasType('SelectOptions', _t.Union[_t.List[SelectOption], _t.List[SelectGroup]]) +SelectOptions = _te.TypeAliasType('SelectOptions', _t.Union[list[SelectOption], list[SelectGroup]]) class SelectSearchResponse(pydantic.BaseModel): @@ -186,7 +186,7 @@ def unflatten(form_data: ds.FormData) -> NestedDict: if values == ['']: continue - d: _t.Dict[_t.Union[str, int], _t.Any] = result_dict + d: dict[_t.Union[str, int], _t.Any] = result_dict *path, last_key = name_to_loc(key) for part in path: diff --git a/src/python-fastui/fastui/json_schema.py b/src/python-fastui/fastui/json_schema.py index 3abd1e14..c03e69c5 100644 --- a/src/python-fastui/fastui/json_schema.py +++ b/src/python-fastui/fastui/json_schema.py @@ -24,7 +24,7 @@ __all__ = 'model_json_schema_to_fields', 'SchemeLocation' -def model_json_schema_to_fields(model: _t.Type[BaseModel]) -> _t.List[FormField]: +def model_json_schema_to_fields(model: type[BaseModel]) -> list[FormField]: schema = _t.cast(JsonSchemaObject, model.model_json_schema()) defs = schema.get('$defs', {}) return list(json_schema_obj_to_fields(schema, [], [], defs)) @@ -51,10 +51,10 @@ class JsonSchemaString(JsonSchemaBase): class JsonSchemaStringEnum(JsonSchemaBase, total=False): type: _ta.Required[_t.Literal['string']] - enum: _ta.Required[_t.List[str]] + enum: _ta.Required[list[str]] default: str placeholder: str - enum_labels: _t.Dict[str, str] + enum_labels: dict[str, str] class JsonSchemaStringSearch(JsonSchemaBase, total=False): @@ -110,7 +110,7 @@ class JsonSchemaArray(JsonSchemaBase, total=False): uniqueItems: bool minItems: int maxItems: int - prefixItems: _t.List[JsonSchemaAny] + prefixItems: list[JsonSchemaAny] items: JsonSchemaAny search_url: str placeholder: str @@ -121,9 +121,9 @@ class JsonSchemaArray(JsonSchemaBase, total=False): 'JsonSchemaObject', { 'type': _ta.Required[_t.Literal['object']], - 'properties': _t.Dict[str, JsonSchemaAny], + 'properties': dict[str, JsonSchemaAny], '$defs': JsonSchemaDefs, - 'required': _t.List[str], + 'required': list[str], 'title': str, 'description': str, }, @@ -136,20 +136,20 @@ class JsonSchemaNull(JsonSchemaBase): class JsonSchemaAnyOf(JsonSchemaBase): - anyOf: _t.List[JsonSchemaAny] + anyOf: list[JsonSchemaAny] class JsonSchemaAllOf(JsonSchemaBase): - allOf: _t.List[JsonSchemaAny] + allOf: list[JsonSchemaAny] JsonSchemaRef = _t.TypedDict('JsonSchemaRef', {'$ref': str}) -SchemeLocation: _ta.TypeAlias = '_t.List[str | int]' +SchemeLocation: _ta.TypeAlias = 'list[str | int]' def json_schema_obj_to_fields( - schema: JsonSchemaObject, loc: SchemeLocation, title: _t.List[str], defs: JsonSchemaDefs + schema: JsonSchemaObject, loc: SchemeLocation, title: list[str], defs: JsonSchemaDefs ) -> _t.Iterable[FormField]: required = set(schema.get('required', [])) if properties := schema.get('properties'): @@ -158,7 +158,7 @@ def json_schema_obj_to_fields( def json_schema_any_to_fields( - schema: JsonSchemaAny, loc: SchemeLocation, title: _t.List[str], required: bool, defs: JsonSchemaDefs + schema: JsonSchemaAny, loc: SchemeLocation, title: list[str], required: bool, defs: JsonSchemaDefs ) -> _t.Iterable[FormField]: dereferenced, required = deference_json_schema(schema, defs, required) title = title + [schema.get('title', dereferenced.get('title', loc_to_title(loc)))] @@ -177,7 +177,7 @@ def json_schema_any_to_fields( def json_schema_field_to_field( schema: JsonSchemaField, loc: SchemeLocation, - title: _t.List[str], + title: list[str], description: _t.Union[str, None], required: bool, ) -> FormField: @@ -213,7 +213,7 @@ def loc_to_title(loc: SchemeLocation) -> str: def json_schema_array_to_fields( schema: JsonSchemaArray, loc: SchemeLocation, - title: _t.List[str], + title: list[str], description: _t.Union[str, None], required: bool, defs: JsonSchemaDefs, @@ -248,7 +248,7 @@ def json_schema_array_to_fields( def special_string_field( schema: JsonSchemaConcrete, name: str, - title: _t.List[str], + title: list[str], description: _t.Union[str, None], required: bool, multiple: bool, @@ -318,7 +318,7 @@ def loc_to_name(loc: SchemeLocation) -> str: def deference_json_schema( schema: JsonSchemaAny, defs: JsonSchemaDefs, required: bool -) -> _t.Tuple[JsonSchemaConcrete, bool]: +) -> tuple[JsonSchemaConcrete, bool]: """ Convert a schema which might be a reference or union to a concrete schema. """ @@ -344,7 +344,7 @@ def deference_json_schema( else: raise NotImplementedError('`anyOf` schemas which are not simply `X | None` are not yet supported') elif all_of := schema.get('allOf'): - all_of = _t.cast(_t.List[JsonSchemaAny], all_of) + all_of = _t.cast(list[JsonSchemaAny], all_of) if len(all_of) == 1: new_schema, required = deference_json_schema(all_of[0], defs, required) new_schema.update({k: v for k, v in schema.items() if k != 'allOf'}) # type: ignore @@ -359,7 +359,7 @@ def as_title(s: _t.Any) -> str: return re.sub(r'[\-_]', ' ', str(s)).title() -type_lookup: _t.Dict[str, InputHtmlType] = { +type_lookup: dict[str, InputHtmlType] = { 'string': 'text', 'string-date': 'date', 'string-date-time': 'datetime-local', diff --git a/src/python-fastui/fastui/types.py b/src/python-fastui/fastui/types.py index 5bd65a71..d79fc306 100644 --- a/src/python-fastui/fastui/types.py +++ b/src/python-fastui/fastui/types.py @@ -2,11 +2,12 @@ import pydantic import typing_extensions as _te -from pydantic.json_schema import GenerateJsonSchema, CoreRef, JsonRef +from pydantic.json_schema import CoreRef, GenerateJsonSchema, JsonRef from pydantic_core import core_schema IS_PYDANTIC_211_OR_GREATER = tuple(int(v) for v in pydantic.VERSION.split('.')[:2]) >= (2, 11) + # TODO: replace with https://docs.pydantic.dev/dev/api/types/#pydantic.types.JsonValue, maybe? class JsonDataSchema: @staticmethod diff --git a/src/python-fastui/tests/test_auth_github.py b/src/python-fastui/tests/test_auth_github.py index c34f8f1e..d1234e6c 100644 --- a/src/python-fastui/tests/test_auth_github.py +++ b/src/python-fastui/tests/test_auth_github.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional import httpx import pytest @@ -10,12 +10,12 @@ @pytest.fixture -def github_requests() -> List[str]: +def github_requests() -> list[str]: return [] @pytest.fixture -def fake_github_app(github_requests: List[str]) -> FastAPI: +def fake_github_app(github_requests: list[str]) -> FastAPI: app = FastAPI() @app.post('/login/oauth/access_token') @@ -104,7 +104,7 @@ async def test_get_auth_url_scopes(httpx_client: httpx.AsyncClient): assert url == 'https://github.com/login/oauth/authorize?client_id=1234&scope=user%3Aemail+read%3Aorg' -async def test_exchange_ok(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): +async def test_exchange_ok(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') assert exchange.access_token == 'good_token' @@ -167,7 +167,7 @@ async def test_exchange_bad_state_file_exists(github_auth_provider_state: GitHub await github_auth_provider_state.exchange_code('good', 'bad_state') -async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): +async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') assert exchange.access_token == 'good_token' @@ -182,7 +182,7 @@ async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, gith async def test_exchange_ok_repeat_cached( - fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, github_requests: List[str] + fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, github_requests: list[str] ): github_auth_provider = GitHubAuthProvider( httpx_client=httpx_client, @@ -222,7 +222,7 @@ async def test_exchange_cached_purge(fake_github_app: FastAPI, httpx_client: htt async def test_exchange_redirect_url( - fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, github_requests: List[str] + fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, github_requests: list[str] ): github_auth_provider = GitHubAuthProvider( httpx_client=httpx_client, @@ -239,7 +239,7 @@ async def test_exchange_redirect_url( assert github_requests == ['/login/oauth/access_token code=good redirect_uri=/callback'] -async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): +async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') assert github_requests == ['/login/oauth/access_token code=good'] @@ -251,7 +251,7 @@ async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_ assert github_requests == ['/login/oauth/access_token code=good', '/user'] -async def test_get_github_user_emails(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): +async def test_get_github_user_emails(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') assert github_requests == ['/login/oauth/access_token code=good'] diff --git a/src/python-fastui/tests/test_forms.py b/src/python-fastui/tests/test_forms.py index 26f86080..b51efc86 100644 --- a/src/python-fastui/tests/test_forms.py +++ b/src/python-fastui/tests/test_forms.py @@ -1,7 +1,7 @@ import enum from contextlib import asynccontextmanager from io import BytesIO -from typing import List, Tuple, Union +from typing import Annotated, Union import pytest from fastapi import HTTPException @@ -9,7 +9,6 @@ from fastui.forms import FormFile, Textarea, fastui_form from pydantic import BaseModel, Field from starlette.datastructures import FormData, Headers, UploadFile -from typing_extensions import Annotated class SimpleForm(BaseModel): @@ -22,7 +21,7 @@ class FakeRequest: TODO replace this with httpx or similar maybe, perhaps this is sufficient """ - def __init__(self, form_data_list: List[Tuple[str, Union[str, UploadFile]]]): + def __init__(self, form_data_list: list[tuple[str, Union[str, UploadFile]]]): self._form_data = FormData(form_data_list) @asynccontextmanager @@ -296,7 +295,7 @@ async def test_file_constrained_submit_wrong_type(): class FormMultipleFiles(BaseModel): - files: Annotated[List[UploadFile], FormFile()] + files: Annotated[list[UploadFile], FormFile()] def test_multiple_files(): @@ -338,7 +337,7 @@ async def test_multiple_files_multiple(): class FixedTuple(BaseModel): - foo: Tuple[str, int, int] + foo: tuple[str, int, int] def test_fixed_tuple(): @@ -433,7 +432,7 @@ async def test_fixed_tuple_nested_submit(): def test_variable_tuple(): class VarTuple(BaseModel): - foo: Tuple[str, ...] + foo: tuple[str, ...] m = components.ModelForm(model=VarTuple, submit_url='/foo/') with pytest.raises(NotImplementedError, match='Array fields are not fully supported'): @@ -442,7 +441,7 @@ class VarTuple(BaseModel): def test_tuple_optional(): class TupleOptional(BaseModel): - foo: Tuple[str, Union[str, None]] + foo: tuple[str, Union[str, None]] m = components.ModelForm(model=TupleOptional, submit_url='/foo/') with pytest.raises(NotImplementedError, match='Tuples with optional fields are not yet supported'): @@ -480,7 +479,7 @@ class SelectEnum(str, enum.Enum): class FormSelectMultiple(BaseModel): select_single: SelectEnum = Field(title='Select Single', description='first field') select_single_2: SelectEnum = Field(title='Select Single') # unset description to test leakage from prev. field - select_multiple: List[SelectEnum] = Field(title='Select Multiple', description='third field') + select_multiple: list[SelectEnum] = Field(title='Select Multiple', description='third field') def test_form_description_leakage():