Path Templates
Raw S3 paths scattered across a codebase are a maintenance problem. A path like
credentials/user_id_123/passport written inline in five different places is five
places to update when the layout changes, and five places to introduce a typo.
Path templates solve this by centralizing path construction logic in a single, typed, testable place — without adding any fancy magic concepts.
The Problem
.at() accepts a plain string, which means path construction is entirely the
caller's responsibility:
async with AppUOW() as uow:
await uow.credentials.at(f"credentials/user_id_{user_id}/passport").put(passport)
This works, but it pushes path knowledge into call sites. Any refactor of your storage layout requires hunting down every string.
PathTemplate Protocol
FennFlow provides a minimal protocol for path templates:
from fennflow.path_template import PathTemplate
class PathTemplate(Protocol):
def render(
self
) -> str: ...
.at() accepts either a str or any object that satisfies PathTemplate — i.e.
any object with a render() -> str method. No base class to inherit, no
registration required.
Defining Templates
Define your templates as plain dataclasses in your application code and then use them directly with .at():
import asyncio
from dataclasses import dataclass
from fennflow import ConfigDict, UnitOfWork
from fennflow.backends import InMemoryBackendConfig
from fennflow.connectors import InMemoryConnectorConfig
from fennflow.repositories import GetRepository, S3RepoField
class AppUOW(UnitOfWork):
avatars = S3RepoField(GetRepository, bucket_name="avatars")
credentials = S3RepoField(GetRepository, bucket_name="credentials")
config = ConfigDict(
backend=InMemoryBackendConfig(),
connector=InMemoryConnectorConfig(),
)
@dataclass(slots=True)
class AvatarPath:
user_id: int
def render(self) -> str:
return f"avatars/user_{self.user_id}"
@dataclass(slots=True)
class PassportPath:
user_id: int
def render(self) -> str:
return f"credentials/user_{self.user_id}/passport"
@dataclass(slots=True)
class DriverLicensePath:
user_id: int
def render(self) -> str:
return f"credentials/user_{self.user_id}/driver_license"
async def main():
user_id = 535
async with AppUOW() as uow:
avatar_storage = uow.avatars.at(AvatarPath(user_id=user_id))
assert avatar_storage.cwd == "avatars/user_535/"
passport_storage = uow.credentials.at(PassportPath(user_id=user_id))
assert passport_storage.cwd == "credentials/user_535/passport/"
driver_license_storage = uow.credentials.at(DriverLicensePath(user_id=user_id))
assert driver_license_storage.cwd == "credentials/user_535/driver_license/"
if __name__ == "__main__":
asyncio.run(main())
The IDE resolves AvatarPath(user_id=user_id) as a typed constructor. Mypy checks
the field types. Autocomplete works on all template fields. If user_id changes
type or a new required field is added, the type checker catches every broken call
site immediately.
Multi-Parameter Templates
Templates are dataclasses — they can hold as many fields as needed:
@dataclass(slots=True)
class ReportPath:
org_id: int
year: int
month: int
def render(
self
) -> str:
return f"reports/org_{self.org_id}/{self.year}/{self.month:02d}"
await uow.reports.at(ReportPath(org_id=1, year=2025, month=6)).put(report)
Chaining Templates
Since .at() is chainable and templates are just another form of path segment,
templates and plain strings can be mixed and chained freely:
@dataclass(slots=True)
class UserPrefix:
user_id: int
def render(
self
) -> str:
return f"users/user_{self.user_id}"
@dataclass(slots=True)
class YearMonth:
year: int
month: int
def render(
self
) -> str:
return f"{self.year}/{self.month:02d}"
async with AppUOW() as uow:
# two templates chained
await uow.files.at(UserPrefix(user_id=1)).at(YearMonth(year=2025, month=6)).put(report)
# template chained with a plain string
await uow.files.at(UserPrefix(user_id=1)).at("archive").put(backup)