Skip to content

Local Backend

The local backend stores files on the local filesystem. It is built-in and requires no extra dependencies.

Installation

Built-in — no extra dependencies. Available with any remote-store install.

Usage

from remote_store import BackendConfig, RegistryConfig, Registry, StoreProfile

config = RegistryConfig(
    backends={"local": BackendConfig(type="local", options={"root": "/data"})},
    stores={"files": StoreProfile(backend="local", root_path="files")},
)

with Registry(config) as registry:
    store = registry.get_store("files")
    store.write_text("readme.txt", "Hello!")

Options

Option Type Description
root str Root directory for file storage (required)

Capabilities

All capabilities are supported except USER_METADATA — passing non-empty metadata= to a Local-backed store raises CapabilityNotSupported. The local backend is otherwise the reference implementation. See the capabilities matrix for full details.

Caveats

  • overwrite=False has a TOCTOU race. The exists-check and write are separate operations. Concurrent writers can both pass the check and overwrite each other.
  • move() uses shutil.move(), which delegates to os.rename() on the same filesystem (atomic) but falls back to copy+delete across filesystems. write_atomic() uses os.replace() and is truly atomic.

See the Concurrency and Atomicity Guarantees guide for details.

See also

API Reference

LocalBackend

LocalBackend(root: str)

Bases: Backend

Local filesystem backend using only the Python standard library.

move() uses shutil.move, which calls os.rename for same-filesystem moves (atomic) but falls back to copy-then-delete for cross-filesystem moves (not atomic). ATOMIC_MOVE is declared because within-root moves are always same-filesystem.

Parameters:

  • root (str) –

    Absolute path to the root directory on the local filesystem.

check_health

check_health() -> None

Confirm the root directory exists, is a directory, and is readable.

A lightweight two-syscall probe (is_dir then os.access) that touches no file content and is safe to call repeatedly.

The type half is load-bearing rather than pedantic. exists() and is_folder() answer the store root by definition — they say the root is a folder without looking — so this is the only operation that observes what is actually at the root path. Testing mere existence would let a root replaced by a regular file report a healthy, empty store from every probe on the backend.

Raises:

  • NotFound –

    If the root directory does not exist, or something that is not a directory occupies its path.

  • PermissionDenied –

    If the root directory exists but is not readable.

resolve

resolve(path: str) -> ResolutionPlan

Return a ResolutionPlan with local filesystem details.

Parameters:

  • path (str) –

    Backend-relative key.

Returns:

exists

exists(path: str) -> bool

Return True if a file or folder exists at path; never NotFound.

Returns False when a path component is itself a file, since the directory descent cannot proceed. The store root ("" or ".") is the one path that answers True without being looked for: it is a folder by definition, so it reads as present even on a store whose root directory has been removed.

Raises:

is_file

is_file(path: str) -> bool

Return True if path is an existing regular file.

False if path is absent, names a directory, or has a file as an ancestor component — and False for the store root ("" or "."), which is a folder, never a regular file.

Raises:

is_folder

is_folder(path: str) -> bool

Return True if path is an existing directory.

False if path is absent, names a file, or has a file as an ancestor component. The store root ("" or ".") is the exception to the absent case: it is a folder by definition, so it answers True even on a store whose root directory has been removed.

Raises:

read

read(path: str) -> BinaryIO

Open path for reading and return a lazy binary stream.

The stream reads on demand from the open file handle, so memory stays constant regardless of file size.

Raises:

  • NotFound –

    If the file does not exist, or a path component is itself a file (the directory descent fails with ENOTDIR).

  • InvalidPath –

    If path names a directory.

  • PermissionDenied –

    If the OS denies read access to the file.

read_bytes

read_bytes(path: str) -> bytes

Read and return the full file content as bytes.

Materialises the whole file in memory (unlike the lazy read stream), so size is bounded by available memory.

Raises:

  • NotFound –

    If the file does not exist, or a path component is itself a file (ENOTDIR on descent).

  • InvalidPath –

    If path names a directory.

  • PermissionDenied –

    If the OS denies read access to the file.

write

write(
    path: str,
    content: WritableContent,
    *,
    overwrite: bool = False,
    metadata: Mapping[str, str] | None = None,
) -> WriteResult

Write content to path, creating parent directories as needed.

The bytes land directly at the final path (no temp-and-rename), so a crash or error mid-write can leave a partial or truncated file there — use write_atomic when readers must never observe a half-written file.

Raises:

  • AlreadyExists –

    If the file exists and overwrite is False.

  • InvalidPath –

    If path is the store root, or names a directory, or an ancestor of path exists as a regular file.

  • PermissionDenied –

    If the OS denies write access.

write_atomic

write_atomic(
    path: str,
    content: WritableContent,
    *,
    overwrite: bool = False,
    metadata: Mapping[str, str] | None = None,
) -> WriteResult

Write content to path atomically via a temp file plus os.replace.

Readers never observe a partial file: the body is written to a temporary file in the destination directory and atomically renamed over path on success (the temp file is removed on error). The rename is atomic because the temp file shares path's directory, hence its filesystem.

Raises:

  • AlreadyExists –

    If the file exists and overwrite is False.

  • InvalidPath –

    If path is the store root, or names a directory, or an ancestor of path exists as a regular file.

  • PermissionDenied –

    If the OS denies write access.

open_atomic

open_atomic(
    path: str, *, overwrite: bool = False
) -> Iterator[BinaryIO]

Yield a writable stream promoted to path atomically on clean exit.

Writes go to a temp file in path's directory; on normal exit it is atomically renamed over path (os.replace), and on any exception the temp file is removed and path is left untouched. Readers therefore never see a partially written file.

Raises:

  • AlreadyExists –

    If the file exists and overwrite is False.

  • InvalidPath –

    If path is the store root, or names a directory, or an ancestor of path exists as a regular file.

  • PermissionDenied –

    If the OS denies write access.

delete

delete(path: str, *, missing_ok: bool = False) -> None

Delete the file at path.

Raises:

  • NotFound –

    If the file does not exist (or a path component is itself a file) and missing_ok is False.

  • InvalidPath –

    If path names a directory — a type mismatch that missing_ok does not silence (use delete_folder).

  • PermissionDenied –

    If the OS denies the unlink.

delete_folder

delete_folder(
    path: str,
    *,
    recursive: bool = False,
    missing_ok: bool = False,
) -> None

Delete the folder at path.

recursive=True removes the folder and its whole subtree (shutil.rmtree); this is not atomic — an error partway through can leave the tree partially deleted. recursive=False removes only an empty folder (rmdir).

Unlike exists, is_folder and get_folder_info, this method answers the store root from the filesystem rather than by definition: on a store whose root directory has been removed, delete_folder(root) raises NotFound while exists(root) still reports the root present. That is deliberate — the root rule does not reach a folder delete, and SFTPBackend answers this cell the same way — and Store refuses a root delete before it reaches a backend, so a caller going through the facade never sees the disagreement.

Raises:

glob

glob(pattern: str) -> Iterator[FileInfo]

Yield files matching pattern under the root, one at a time.

Lazily walks Path.glob; entries that escape the root through a symlink are skipped rather than returned, and directories are omitted — only files are yielded.

list_files

list_files(
    path: str,
    *,
    recursive: bool = False,
    max_depth: int | None = None,
) -> Iterator[FileInfo]

Yield files under path, one FileInfo at a time.

Lazy: entries are produced as the directory is scanned, so listing a large tree holds only the current entry in memory. A missing or non-folder path yields nothing (no error). With recursive and max_depth set, traversal is pruned at the depth bound during the os.walk rather than filtered afterwards.

list_folders

list_folders(path: str) -> Iterator[FolderEntry]

Yield immediate subfolders of path as FolderEntry records.

Lazy single-level scan; a missing or non-folder path yields nothing.

iter_children

iter_children(
    path: str,
) -> Iterator[FileInfo | FolderEntry]

Yield the immediate files and folders under path in one scan.

Overrides the base (which chains list_files and list_folders, two passes) to walk the directory once, yielding FileInfo for files and FolderEntry for folders. A missing or non-folder path yields nothing.

get_file_info

get_file_info(path: str) -> FileInfo

Return metadata for the file at path from a single stat.

Raises:

  • NotFound –

    If the file does not exist.

  • InvalidPath –

    If path names a directory, not a file.

get_folder_info

get_folder_info(path: str) -> FolderInfo

Return aggregate metadata for the folder at path.

File count, total size, and latest modification time are computed by walking the entire subtree and stat-ing every file, so cost is O(files-in-subtree) — not a constant-time lookup.

The store root ("" or ".") always answers, never raising: it is a folder by definition, so a store whose root directory is missing aggregates to zero rather than reporting itself absent.

Raises:

  • NotFound –

    If the folder does not exist.

  • InvalidPath –

    If path names a file, not a folder.

move

move(
    src: str, dst: str, *, overwrite: bool = False
) -> None

Move or rename the file src to dst.

Atomic when src and dst are on the same filesystem (os.rename via shutil.move) — which within a single root they always are, so ATOMIC_MOVE is declared. A cross-filesystem move (e.g. a bind-mounted subtree) falls back to copy-then-delete, which is not atomic. src == dst is a no-op; parent directories of dst are created as needed.

Raises:

  • NotFound –

    If src does not exist.

  • InvalidPath –

    If src or dst is the store root, or names a directory, or an ancestor of dst exists as a regular file.

  • AlreadyExists –

    If dst exists, src != dst, and overwrite is False.

  • PermissionDenied –

    If the OS denies the operation.

copy

copy(
    src: str, dst: str, *, overwrite: bool = False
) -> None

Copy the file src to dst, preserving mode and mtime (copy2).

Not atomic: content is streamed to dst, so a crash mid-copy can leave a partial file at the destination. src == dst is a no-op; parent directories of dst are created as needed.

Raises:

  • NotFound –

    If src does not exist.

  • InvalidPath –

    If src or dst is the store root, or names a directory, or an ancestor of dst exists as a regular file.

  • AlreadyExists –

    If dst exists, src != dst, and overwrite is False.

  • PermissionDenied –

    If the OS denies the operation.