fs-schema
Typed schemas for filesystem layouts. Dataclass-like declarations turn directory contracts into validated, navigable Python values.
from dataclasses import dataclass
import fs_schema as fss
@dataclass
class Contents:
title: str
class DataDownload(fss.Schema):
schema = {
"packs": {
fss.FILES: ["upload.log", "request.log"],
fss.Dir(alias="days", fmt="{day:%Y-%m-%d}"): {
"parts": fss.File(fmt="{stem}.{ext}", max=4),
},
},
"contents": fss.File("contents.json", schema=Contents),
}
download = fss.raise_mismatch(DataDownload.bind("."))
with open(download.packs.upload_log, encoding="utf-8") as stream:
print(stream.read())
part = download.packs.days[-1].parts[-1]
print(part.kwargs.stem, part.path.stat().st_size)
contents: Contents = fss.raise_exn(download.contents.load())
print(contents.title)
Binding validates an existing layout. Templates are collections; indexing selects
a concrete match whose parsed captures are available through .args and
.kwargs. relative_to() plans output paths without claiming they exist.
Format-backed collections plan concrete files or recursively navigable
directories; only the top-level plan binds the whole schema. Dataclass schemas
use Mashumaro for JSON; install the mashumaro or faster orjson extra.
Expanded quickstart
# pyright: reportAttributeAccessIssue=false, reportCallIssue=false
# pyright: reportIndexIssue=false, reportGeneralTypeIssues=false
# pyright: reportArgumentType=false, reportAssignmentType=false
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
import fs_schema as fss
@dataclass
class Manifest:
delivery_id: str
expected_parts: int
@dataclass
class CuratedManifest:
delivery_id: str
partitions: int
class Batch(fss.Schema):
schema = {
# Typed numeric captures make this collection sort numerically
"parts": fss.File(
fmt="part-{part:d}.jsonl",
sort=lambda part: part.kwargs.part,
),
}
class Delivery(fss.Schema):
schema = {
"manifest": fss.File("manifest.json", schema=Manifest),
"batches": {
# days names the repeated directory collection in Python
fss.Dir(
alias="days",
fmt="{day:%Y-%m-%d}",
sort=lambda day: day.kwargs.day,
): Batch,
},
}
class Curated(fss.Schema):
schema = {
"manifest": fss.File("manifest.json", schema=CuratedManifest),
fss.Dir(alias="days", fmt="{day:%Y-%m-%d}"): {
"parts": fss.File(fmt="part-{part:d}.parquet"),
},
}
def curate(source: Path, target: Path) -> Curated | fss.MismatchErr:
# Bind proves the input layout before any child is used
if fss.is_mismatch(delivery := Delivery.bind(source)):
return delivery
# The declared File schema supplies this dataclass loader
manifest: Manifest = fss.raise_exn(delivery.manifest.load())
# Templates are ordered collections; an item is one capture-bearing match
latest = delivery.batches.days[-1]
fs = Curated.relative_to(target)
parquet = convert_parts([part.path for part in latest.parts])
# One spec: exact files by alias, collection members as (captures, body) pairs.
fs.create(
manifest=CuratedManifest(manifest.delivery_id, partitions=1),
days=[({"day": latest.kwargs.day}, {"parts": [({"part": 0}, parquet)]})],
)
return fs.bind()
def convert_parts(parts: Sequence[Path]) -> bytes:
# Stand-in for an application converter; keeps this example executable.
return b"\n".join(part.read_bytes() for part in parts)
Development
Dev:
./run.sh setup:install-pre-commit
./run.sh uv:venv:sync
./run.sh pretty / check / tests
./run.sh docs:serve
Maintainer:
./run.sh pkg:bump VERSION
./run.sh release:check
./run.sh release:tag
./run.sh gh:ci [ref]
./run.sh gh:docs [ref]
./run.sh gh:publish:testpypi [ref]
./run.sh gh:publish:pypi
./run.sh gh:release
./run.sh release:testpypi
./run.sh release:pypi
Lock: edit pyproject.toml, then ./run.sh uv:lock
MRE: ./run.sh docker:check / docker:test
Commands:
help Usual path + command list
pkg:version Read or update the package version and lockfile
pkg:bump Update version, lockfile, & commit
pkg:build Build sdist and wheel into dist/
Setup
setup:install-pre-commit Install git hooks (once per clone; sync first)
setup:shellcheck Install pinned shellcheck if not found
setup:check-image Assert /etc/os-release and apt pkgs match pinned versions
setup:check-installs Checks all host deps present
Dep management
uv:venv:sync Sync .venv from uv.lock (no resolve)
uv:lock Write uv.lock. Edit pyproject.toml by hand first (no uv add)
uv:lock:bootstrap Re-create uv.lock from scratch, ignore current pins
Dev: checks
lint ruff check + shellcheck
typecheck basedpyright
check pre-commit + docs build
tests pytest against the installed package
Dev: formatters
fmt ruff format
pretty fmt + ruff check --fix
Docs
docs:build Build the docs site into site/
docs:serve Serve docs locally
CI / maintainer
ci:tox Local tox matrix in parallel (one worker per CPU)
release:check Build, inspect, and clean-install both local artifacts
release:tag Create annotated vVERSION tag at clean release HEAD
release:testpypi Run local TestPyPI release
release:pypi Run local tagged PyPI release
gh:ci Dispatch and watch remote test.yml
gh:docs Dispatch and watch remote docs.yml
gh:publish:testpypi Dispatch, test, publish, and verify through gh
gh:release Create gh release for current vVERSION tag
gh:publish:pypi Dispatch, test, publish, and verify prod release through gh
Docker
docker:build Build image
docker:check GHA Checks job (same ./run.sh chain)
docker:test Build prod image + ./run.sh tests
docker:shell Dev shell. Host tree at /app, host uid
docker:prune Dangling image / buildx prune
docker:matrix docker:test for each PYTHON_VERSIONS (local MRE; GHA uses baipp matrix)
API reference · Tutorial · Changelog · Source · MIT LICENSE
Changelog
Changelog
API by git tag. Signatures: reference.
v0.6.0
create writes a plan. Nested directory overrides merge. exists_opt(None) is None.
- Write
relative_to,create,root,format,parse,putCreating with schemas relative_tofills paths.createwrites that plan and does not check the tree. quickstartcreateon a bound schema raisesTypeError:use root().dir.create()makes that directory and required child directories. No files, no optional directories, no collection members.Noneleaves an optional child absent. An unknown alias raisesKeyError.- A collection takes a filename-to-body map, or a list of
(captures, payload)pairs. Keys that are capture names are rejected. delivery - A file collection whose captures were set by
formattakes the body alone. A directory collection does not. one member - A missing capture raises
TypeErrorand names it. Two values for one capture raiseValueError. -
putreplaces the path in one step. No body writes an empty file. Body: bytes, text, a file to copy,save, or a dataclass as JSON. Reading and writing -
Declare
Schema,File,DirInheritance and replacement - A nested directory override merges children with the base. A file override replaces that file.
- On the class, a file or a collection exposes
alias,fmt,match,min,max.Coincident.parts.match. - An exact directory with a schema class is that class.
Parent.child.file.match. -
A directory collection exposes
fmtandmatchon the collection, not the child schema's fields. -
Bind
Schema.bind,exists_opt,MismatchErrApplying schemas bindtakes a string, aPath, or any__fspath__value, including a schema node. A planned root binds itself.exists_opt(None)returnsNone. Fixed directories and files- A wrong collection count names dangling symlinks that matched:
(dangling: gone.png). -
Read follows a live symlink.
putreplaces a symlink at that path and does not write through it. -
Read Integrations
glomis no longer installed with the package. Navigation examples are in the docs.
v0.5.0
Exact names and collections use different constructors. MismatchErr is falsy.
- Declare
File,Dir,dt,optional,skip_mismatchDefining schemas - Exact
FileandDirtake a positional name. A collection is keyword-only:fmt,match, or both. optional=Trueon an exact name ismin=0. optional filesort,sort_rev, andskip_mismatchon an exact name raiseValueError.dt("%Y-%m-%d")capturests.dt("%Y-%m-%d", "day")names it.dt("%Y-%m-%d", "")stays positional.-
skip_mismatchdrops a formatted name that failsmatch, and a directory member whose children do not match. -
Bind
Schema.bind,MismatchErr,is_mismatchApplying schemas MismatchErris falsy. A bound schema is truthy.if not resultworks forSchema | MismatchErr.- A result that might be another schema still needs
is_mismatch. - A nested schema binds as that class. An optional exact child is
Nonewhen absent.
v0.4.6
First public. Alpha. import fs_schema as fss. Python 3.10–3.14.
- Declare — mapping;
name|fmt|match; counts, alias, sort; one-base inherit/replace.Schema,Layout,File,Dir,FILES,dtDefining schemas - Bind — first failure; extras ignored.
Schema.bind,SchemaRoot.bind,MismatchErr,is_mismatch,raise_mismatchApplying schemas - Read — attr/item access;
PathLike.Located,Match,exists_opt,.path,__fspath__,.exists,.read_bytes,.read_text,.args,.kwargs,.filter,.find,.where,.getUsing schemas - Write — plan then bind; JSON dataclasses via extras
mashumaro,orjson.Schema.relative_to,SchemaRoot,.format,.root,.put,.load,put,raise_exnCreating with schemas - Types — generated child attrs; dynamic names stay a union.
SchemaRoot[S],__version__Static typing