Skip to content

1. Visibility

Declare fields once, then derive DTOs by choosing a visibility base.

Stock mypy and basedpyright check attribute access and assignment types. DTO constructors check field names at runtime; validate input values at the application boundary.

Source

"""Declare fields once, then derive DTOs by choosing a visibility base.

Stock mypy and basedpyright check attribute access and assignment types.
DTO constructors check field names at runtime; validate input values at the
application boundary.
"""

from datetime import UTC, datetime
from typing import assert_type

from fastsprout.core import (
    BaseSchema,
    Field,
    InternalField,
    ReadDTO,
    ReadField,
    ReadWriteDTO,
    WriteDTO,
    WriteField,
)


class User(BaseSchema):
    email: Field[str]  # read and write
    password: WriteField[str]  # (1)!
    password_hash: InternalField[str]  # (2)!
    created_at: ReadField[datetime] = ReadField(
        default_factory=lambda: datetime.now(UTC)
    )


class UserRead(ReadDTO, User): ...  # (3)!


class UserCreate(WriteDTO, User): ...


class UserUpdate(ReadWriteDTO, User): ...


def show_forbidden_access(value: User | type[User], name: str) -> None:
    # Dynamic access demonstrates the runtime guard. Direct attribute access
    # to these fields also produces a checker error (see the comments below).
    try:
        getattr(value, name)  # (4)!
    except AttributeError as error:
        print(error)


def main() -> None:
    read = UserRead(email="[email protected]")
    create = UserCreate(email="[email protected]", password="example-password")
    update = UserUpdate(email="[email protected]", password="changed-password")

    assert_type(read.email, str)
    assert_type(read.created_at, datetime)
    assert_type(create.password, str)
    assert_type(update.password, str)
    assert_type(update.created_at, datetime)
    read.email = "[email protected]"

    # Uncomment any of these to see mypy/basedpyright reject the access:
    # _ = read.password
    # _ = UserRead.password_hash
    # _ = create.created_at
    # read.email = 123

    print(read.email)
    show_forbidden_access(read, "password")
    show_forbidden_access(UserRead, "password_hash")
    show_forbidden_access(create, "created_at")
    show_forbidden_access(update, "password_hash")

    # ReadWriteDTO is the union of read and write fields, not a partial update:
    # required fields remain required, and internal fields stay forbidden.
    try:
        UserRead(email="[email protected]", password="example-password")
    except AttributeError as error:
        print(error)


if __name__ == "__main__":
    main()

Result

[email protected]
Field 'password' not accessible in DTO (operations: ['read'])
Field 'password_hash' not accessible in DTO (operations: ['read'])
Field 'created_at' not accessible in DTO (operations: ['write'])
Field 'password_hash' not accessible in DTO (operations: ['read', 'write'])
Field 'password' not accessible in DTO (operations: ['read'])