10. DTO Visibility
Declare visibility on the entity's fields, then derive each DTO with a short class declaration. UserRead exposes readable fields, UserCreate exposes writable fields, and UserUpdate exposes both. The DTO remains a User subclass. Mypy and basedpyright reject direct access to forbidden attributes; the runtime guard also rejects dynamic access and forbidden constructor arguments.
Run from the repository root:
Source
"""Declare fields once, then derive DTOs by choosing a visibility base.
Run: uv run --extra data-sql python examples/core/10_dto_visibility.py
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: object, 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)
else:
raise AssertionError(f"Field {name!r} should be inaccessible")
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)
assert isinstance(read, User)
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)
else:
raise AssertionError("The read constructor must reject password")
if __name__ == "__main__":
main()
WriteFieldaccepts input, but the field cannot appear in a read DTO.InternalFieldstays on the entity and is excluded from every DTO.- The DTO inherits the entity's fields and types without redeclaring them.
getattrbypasses static checking, so the runtime guard rejects access.
Result
The script prints the updated email, then five AttributeError messages for forbidden fields:
The errors cover read, write, and internal field restrictions, including a forbidden constructor argument.
[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'])