Type Annotations#

Restly is typed for Pyright, VS Code with Pylance, and mypy. Its views and its reference types are generic classes, and they take type parameters in two places:

  • Reference fields name the model they point to, as in fr.MustExist[int, User], fr.IDRef[User] and fr.IDSchema[User]. Restly also reads this parameter at runtime, to find the row. A type checker sees the type of the value that your code gets.

  • Views take optional type parameters, as in fr.AsyncRestView[User, UserSchema, UserCreate, UserUpdate, int]. They change nothing at runtime. They give exact types to the methods that you override and call.

A view without type parameters works, and it is the best place to start. Add parameters where you want your editor to know the model and the schemas.

Restly also rewrites the signatures of the endpoint methods when it registers a view. Type checkers do not see that step; see What a type checker cannot see.

Start without type parameters#

For normal CRUD, a bare view class is enough:

import fastapi_restly as fr
from fastapi import FastAPI
from sqlalchemy.orm import DeclarativeBase, Mapped

app = FastAPI()


class Base(fr.ModelDefaults, DeclarativeBase):
    pass


class User(fr.IDMixin, Base):
    name: Mapped[str]
    email: Mapped[str]


@fr.include_view(app)
class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User

Type the schema and its reference fields#

The view’s schema usually subclasses fr.IDSchema; see Choose a base class. You can also give the base class the model, as in fr.IDSchema[User]:

class UserSchema(fr.IDSchema[User]):
    name: str
    email: str

This form records the model for type checkers. It has no effect at runtime, and the bare fr.IDSchema works just as well.

A reference field has the model as a type parameter. The type checker then knows what your code receives in data.<field>, for example in a business method:

Field

What the type checker sees

author_id: fr.MustExist[int, User]

int: the plain id

author: fr.IDRef[User]

IDRef[User]: a reference; read .id

author: fr.IDSchema[User]

IDSchema[User]: a reference; read .id

products: list[fr.IDRef[Product]]

a list of IDRef[Product]

fr.MustExist takes the type of the primary key first, then the model: fr.MustExist[UUID, Account] for a UUID key. When to use which form, and what each one does at runtime, is on Relationships.

Type the methods that you override#

AsyncRestView and RestView take five type parameters, in this order:

Parameter

What it types

Default

the model

the objects that the methods load, build and return

Any

the response type

the return value of to_single_response

BaseSchema

the create body

data in create

pydantic.BaseModel

the update body

data in update

pydantic.BaseModel

the id type

id in get_one and the handlers

int

Each parameter has a default, so you can stop after the ones you need. The starter project names the model and the schema (app/users/views.py):

class UserView(fr.AsyncRestView[User, UserSchema]):
    prefix = "/users"
    model = User
    schema = UserSchema

The parameters help most where you override a view method, usually a business method: create, update or delete. Here is a view with all five parameters and typed overrides:

from sqlalchemy import ColumnElement


class UserSchema(fr.IDSchema[User]):
    name: str
    email: str


class UserCreate(fr.BaseSchema):
    name: str
    email: str


class UserUpdate(fr.BaseSchema):
    name: str
    email: str


@fr.include_view(app)
class UserView(
    fr.AsyncRestView[User, UserSchema, UserCreate, UserUpdate, int]
):
    prefix = "/users"
    model = User
    schema = UserSchema
    schema_create = UserCreate
    schema_update = UserUpdate

    def where_id(self, id: int) -> ColumnElement[bool]:
        return User.id == id

    async def create(self, data: UserCreate) -> User:
        return await super().create(data)

    async def update(self, obj: User, data: UserUpdate) -> User:
        return await super().update(obj, data)

With these parameters, data in create is a UserCreate, obj in update is a User, and the type checker checks the return types too. Note these points:

  • create takes the create body and returns the model. update takes the loaded obj and the update body. The handler handle_update loads the row, and returns 404 if it does not exist, before update runs.

  • where_id gets the id from the path, so its type is the id type, the last parameter. It returns a SQL condition, a ColumnElement[bool]; see Look a row up by another key.

  • get_one and get_many are final, so you call them and do not override them. A type checker reports an override, as it does for a handler. Their return types follow the model parameter: await self.get_one(id) is a User, and so is each row of self.select().

  • For the response type, give the view’s schema: the response class that Restly builds is a subclass of it. A view that sets its own schema_response gives that class instead, as in fr.AsyncRestView[User, UserResponse].

Details

A project base view can stay generic in the model, so each view that subclasses it names its own model. The SaaS example does this (app/views.py):

from typing import TypeVar

ModelT = TypeVar("ModelT")


class AuthenticatedView(fr.AsyncRestView[ModelT]):
    ...


class ProjectView(AuthenticatedView[Project]):
    prefix = "/projects"
    model = Project

Type a custom route#

A custom route needs no view type parameters. Normal Python annotations are enough:

@fr.include_view(app)
class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User

    @fr.get("/health")
    async def health(self) -> dict[str, str]:
        return {"status": "ok"}

When a custom route calls the view’s methods, the parameters type those calls too: get_one(id) returns the model, and handle_update(id, data) takes the update body.

The classes that derive_schema_response() returns cannot be used in an annotation. In a route, use the markers of fastapi_restly.views instead, such as ViewResponse: a type checker reads each marker as Any. See Name the view’s classes in a custom route.

What a type checker cannot see#

  • Type checkers work well on the public API: models, schemas, reference types, class attributes, and the view methods such as get_one, create and handle_update.

  • They help less with the endpoint methods (*_endpoint), whose signatures Restly fills in at registration.

  • mypy gives a setting that a base view sets without an annotation the type of its value. So after id_type = UUID on a base view, id_type = int in a subclass is an error. This holds for every setting, such as tags and include_filters. Annotate the setting on the base view with the type that Restly declares for it, such as id_type: ClassVar[type[Any] | None] = UUID. The View Reference lists these types. Pyright needs no annotation.

Check your project#

To check your use of Restly, run Pyright or mypy on your project. Restly keeps a set of small example apps in tests/typing/. CI checks them with Pyright and with mypy, both in strict mode. They show each typed form on this page, and they are a good place to look when your type checker complains.

Internals#

Restly does some work at runtime when it registers a view. It copies the inherited endpoint methods, and it changes their signatures for FastAPI; see view registration. Static type checkers do not see this.

See also#