Skip to content

API Reference

This page explains fnb's internal APIs and class structure. Use this reference when extending fnb or using it from other projects.

Package Overview

fnb consists of the following modules:

  • cli: CLI entry point (Typer-based)
  • config: Configuration models (Pydantic-based)
  • reader: Configuration file loading and discovery
  • gear: rsync execution engine (SSH automation with pexpect)
  • fetcher: Remote fetch operations
  • backuper: Local backup operations
  • generator: Configuration file generation
  • env: Environment variable handling

Configuration Management

config module

Configuration data models and validation for fnb (Fetch'n'Backup).

This module defines Pydantic models for representing and validating fnb task configurations. It provides the core data structures used throughout the application for managing fetch and backup operations.

FnbConfig

Bases: BaseModel

Main configuration container for fnb (Fetch'n'Backup) applications.

This Pydantic model represents the complete configuration structure for fnb, containing all fetch and backup task definitions. It provides methods to query and filter tasks based on various criteria.

The configuration typically maps to a TOML file structure with [fetch.label] and [backup.label] sections, where each section defines a single task.

Attributes:

Name Type Description
fetch dict[str, RsyncTaskConfig]

Dictionary mapping task labels to fetch task configurations. Keys are task labels, values are RsyncTaskConfig objects.

backup dict[str, RsyncTaskConfig]

Dictionary mapping task labels to backup task configurations. Keys are task labels, values are RsyncTaskConfig objects.

Examples:

Basic configuration structure:

>>> config = FnbConfig(
...     fetch={
...         "logs": RsyncTaskConfig(
...             label="logs", host="user@server", source="~/logs/",
...             target="./backup/logs/", options=["-av"], enabled=True
...         )
...     },
...     backup={
...         "logs": RsyncTaskConfig(
...             label="logs", host="none", source="./backup/logs/",
...             target="/mnt/backup/", options=["-av"], enabled=True
...         )
...     }
... )
>>> len(config.fetch)
1
>>> len(config.backup)
1

Query enabled tasks:

>>> enabled_fetch = config.get_enabled_tasks("fetch")
>>> len(enabled_fetch)
1

Find task by label:

>>> task = config.get_task_by_label("fetch", "logs")
>>> task.label
'logs'

get_enabled_tasks

get_enabled_tasks(kind: Literal['fetch', 'backup']) -> list[RsyncTaskConfig]

Retrieve all enabled tasks of the specified type.

Filters the task configurations to return only those marked as enabled, which are the tasks that should be executed by fnb operations.

Parameters:

Name Type Description Default
kind Literal['fetch', 'backup']

Type of tasks to retrieve, either "fetch" or "backup".

required

Returns:

Type Description
list[RsyncTaskConfig]

list[RsyncTaskConfig]: List of enabled task configurations.

list[RsyncTaskConfig]

Empty list if no enabled tasks of the specified kind exist.

Examples:

Get enabled fetch tasks:

>>> config = FnbConfig(fetch={"task1": RsyncTaskConfig(..., enabled=True)})
>>> tasks = config.get_enabled_tasks("fetch")
>>> len(tasks)
1

No enabled tasks:

>>> config = FnbConfig(fetch={"task1": RsyncTaskConfig(..., enabled=False)})
>>> tasks = config.get_enabled_tasks("fetch")
>>> len(tasks)
0
Source code in src/fnb/config.py
def get_enabled_tasks(
    self, kind: Literal["fetch", "backup"]
) -> list[RsyncTaskConfig]:
    """Retrieve all enabled tasks of the specified type.

    Filters the task configurations to return only those marked as enabled,
    which are the tasks that should be executed by fnb operations.

    Args:
        kind: Type of tasks to retrieve, either "fetch" or "backup".

    Returns:
        list[RsyncTaskConfig]: List of enabled task configurations.
        Empty list if no enabled tasks of the specified kind exist.

    Examples:
        Get enabled fetch tasks:

        >>> config = FnbConfig(fetch={"task1": RsyncTaskConfig(..., enabled=True)})
        >>> tasks = config.get_enabled_tasks("fetch")
        >>> len(tasks)
        1

        No enabled tasks:

        >>> config = FnbConfig(fetch={"task1": RsyncTaskConfig(..., enabled=False)})
        >>> tasks = config.get_enabled_tasks("fetch")
        >>> len(tasks)
        0
    """
    tasks: dict[str, RsyncTaskConfig] = getattr(self, kind)
    return [task for task in tasks.values() if task.enabled]

get_task_by_label

get_task_by_label(kind: Literal['fetch', 'backup'], label: str) -> RsyncTaskConfig | None

Find a specific task by its label within a task category.

Searches through tasks of the specified kind to find one with the matching label. Labels are unique within each category (fetch/backup) but can be reused across categories.

Parameters:

Name Type Description Default
kind Literal['fetch', 'backup']

Category of task to search ("fetch" or "backup").

required
label str

The task label to search for. Case-sensitive.

required

Returns:

Type Description
RsyncTaskConfig | None

RsyncTaskConfig | None: The matching task configuration if found,

RsyncTaskConfig | None

None if no task with the specified label exists in the category.

Examples:

Find existing task:

>>> config = FnbConfig(
...     fetch={"logs": RsyncTaskConfig(label="logs", ...)}
... )
>>> task = config.get_task_by_label("fetch", "logs")
>>> task.label
'logs'

Task not found:

>>> task = config.get_task_by_label("fetch", "nonexistent")
>>> task is None
True

Same label in different categories:

>>> config = FnbConfig(
...     fetch={"data": RsyncTaskConfig(label="data", ...)},
...     backup={"data": RsyncTaskConfig(label="data", ...)}
... )
>>> fetch_task = config.get_task_by_label("fetch", "data")
>>> backup_task = config.get_task_by_label("backup", "data")
>>> fetch_task is not backup_task
True
Source code in src/fnb/config.py
def get_task_by_label(
    self, kind: Literal["fetch", "backup"], label: str
) -> RsyncTaskConfig | None:
    """Find a specific task by its label within a task category.

    Searches through tasks of the specified kind to find one with the
    matching label. Labels are unique within each category (fetch/backup)
    but can be reused across categories.

    Args:
        kind: Category of task to search ("fetch" or "backup").
        label: The task label to search for. Case-sensitive.

    Returns:
        RsyncTaskConfig | None: The matching task configuration if found,
        None if no task with the specified label exists in the category.

    Examples:
        Find existing task:

        >>> config = FnbConfig(
        ...     fetch={"logs": RsyncTaskConfig(label="logs", ...)}
        ... )
        >>> task = config.get_task_by_label("fetch", "logs")
        >>> task.label
        'logs'

        Task not found:

        >>> task = config.get_task_by_label("fetch", "nonexistent")
        >>> task is None
        True

        Same label in different categories:

        >>> config = FnbConfig(
        ...     fetch={"data": RsyncTaskConfig(label="data", ...)},
        ...     backup={"data": RsyncTaskConfig(label="data", ...)}
        ... )
        >>> fetch_task = config.get_task_by_label("fetch", "data")
        >>> backup_task = config.get_task_by_label("backup", "data")
        >>> fetch_task is not backup_task
        True
    """
    tasks = getattr(self, kind)
    for task in tasks.values():
        if task.label == label:
            return task  # type: ignore[no-any-return]
    return None

RsyncTaskConfig

Bases: BaseModel

Configuration model for a single rsync task in fnb.

This Pydantic model represents a single task configuration that defines how data should be transferred using rsync. Tasks can be either fetch (remote → local) or backup (local → external) operations.

The model provides validation for all configuration fields and computed properties to generate properly formatted rsync source/target paths.

Attributes:

Name Type Description
label str

Unique identifier for the task within its category (fetch/backup). Used to reference the task from CLI commands.

summary str

Human-readable description of what this task does. Displayed in status reports and helps document the task purpose.

host str

Remote host specification. Format "user@hostname" for remote operations, or "none" for local-only operations.

source str

Source path for the rsync operation. Can be absolute or relative. For remote tasks, this is the path on the remote host.

target str

Target path for the rsync operation. Can be absolute or relative. For fetch tasks, this is typically a local path.

options list[str]

List of rsync command-line options to include in the operation. Common options: ["-auvz", "--delete", "--exclude=pattern"].

enabled bool

Whether this task is active and should be executed. Disabled tasks are ignored by all operations.

Examples:

Remote fetch task configuration:

>>> task = RsyncTaskConfig(
...     label="logs",
...     summary="Download server logs",
...     host="user@server.example.com",
...     source="~/logs/",
...     target="./backup/logs/",
...     options=["-auvz", "--delete"],
...     enabled=True
... )
>>> task.is_remote
True
>>> task.rsync_source
'user@server.example.com:~/logs/'

Local backup task configuration:

>>> task = RsyncTaskConfig(
...     label="documents",
...     summary="Backup documents to external drive",
...     host="none",
...     source="./documents/",
...     target="/mnt/backup/documents/",
...     options=["-av", "--exclude=*.tmp"],
...     enabled=True
... )
>>> task.is_remote
False
>>> task.rsync_source
'./documents/'

is_remote property

is_remote: bool

Determine if this task requires remote SSH connections.

Checks the host field to determine whether this task involves remote operations that require SSH authentication and network connectivity.

Returns:

Name Type Description
bool bool

True if the task involves a remote host (host != "none"),

bool

False for local-only operations.

Examples:

Remote task detection:

>>> task = RsyncTaskConfig(host="user@server.com", ...)
>>> task.is_remote
True

Local task detection:

>>> task = RsyncTaskConfig(host="none", ...)
>>> task.is_remote
False

Case insensitive:

>>> task = RsyncTaskConfig(host="NONE", ...)
>>> task.is_remote
False

rsync_source property

rsync_source: str

Generate properly formatted rsync source path.

Creates the source path string in the format expected by rsync, automatically prefixing with host information for remote operations.

Returns:

Name Type Description
str str

For remote tasks, returns "host:source". For local tasks,

str

returns source path unchanged.

Examples:

Remote task source formatting:

>>> task = RsyncTaskConfig(
...     host="user@server.com", source="~/data/", ...
... )
>>> task.rsync_source
'user@server.com:~/data/'

Local task source (no formatting):

>>> task = RsyncTaskConfig(
...     host="none", source="./local/data/", ...
... )
>>> task.rsync_source
'./local/data/'

rsync_target property

rsync_target: str

Get the target path for rsync operations.

Returns the target path as configured, without modification. For fnb's typical usage patterns, targets are usually local paths.

Returns:

Name Type Description
str str

The target path exactly as configured in the task.

Examples:

Local target path:

>>> task = RsyncTaskConfig(target="./backup/data/", ...)
>>> task.rsync_target
'./backup/data/'

Absolute target path:

>>> task = RsyncTaskConfig(target="/mnt/backup/data/", ...)
>>> task.rsync_target
'/mnt/backup/data/'

load_config

load_config(path: Path) -> FnbConfig

Load and validate fnb configuration from a TOML file.

Reads a TOML configuration file and converts it into a validated FnbConfig object. Performs comprehensive validation of the file format, syntax, and schema compliance to ensure the configuration is usable by fnb operations.

Parameters:

Name Type Description Default
path Path

Path to the TOML configuration file to load. Must be readable and contain valid TOML syntax with fnb-compatible structure.

required

Returns:

Name Type Description
FnbConfig FnbConfig

Validated configuration object containing all task definitions

FnbConfig

parsed from the TOML file.

Raises:

Type Description
FileNotFoundError

If the specified configuration file doesn't exist or cannot be accessed.

ValueError

If the file contains invalid TOML syntax, or if the content doesn't match the expected fnb configuration schema.

Exception

For other file system errors during reading or unexpected parsing failures.

Examples:

Load valid configuration:

>>> config = load_config(Path("./fnb.toml"))
>>> len(config.fetch)
2
>>> len(config.backup)
1

Handle missing file:

>>> config = load_config(Path("./missing.toml"))
FileNotFoundError: Configuration file not found at ./missing.toml

Handle invalid TOML:

>>> # File contains: [fetch.task1 label = "missing quote
>>> config = load_config(Path("./bad.toml"))
ValueError: Invalid TOML file at ./bad.toml: Expected '"' at line 1 col 45

Handle schema validation error:

>>> # File missing required 'source' field
>>> config = load_config(Path("./incomplete.toml"))
ValueError: Configuration validation failed: Field required: source
Source code in src/fnb/config.py
def load_config(path: Path) -> FnbConfig:
    """Load and validate fnb configuration from a TOML file.

    Reads a TOML configuration file and converts it into a validated FnbConfig
    object. Performs comprehensive validation of the file format, syntax, and
    schema compliance to ensure the configuration is usable by fnb operations.

    Args:
        path: Path to the TOML configuration file to load. Must be readable
            and contain valid TOML syntax with fnb-compatible structure.

    Returns:
        FnbConfig: Validated configuration object containing all task definitions
        parsed from the TOML file.

    Raises:
        FileNotFoundError: If the specified configuration file doesn't exist
            or cannot be accessed.
        ValueError: If the file contains invalid TOML syntax, or if the content
            doesn't match the expected fnb configuration schema.
        Exception: For other file system errors during reading or unexpected
            parsing failures.

    Examples:
        Load valid configuration:

        >>> config = load_config(Path("./fnb.toml"))
        >>> len(config.fetch)
        2
        >>> len(config.backup)
        1

        Handle missing file:

        >>> config = load_config(Path("./missing.toml"))
        FileNotFoundError: Configuration file not found at ./missing.toml

        Handle invalid TOML:

        >>> # File contains: [fetch.task1 label = "missing quote
        >>> config = load_config(Path("./bad.toml"))
        ValueError: Invalid TOML file at ./bad.toml: Expected '"' at line 1 col 45

        Handle schema validation error:

        >>> # File missing required 'source' field
        >>> config = load_config(Path("./incomplete.toml"))
        ValueError: Configuration validation failed: Field required: source
    """
    if not path.exists():
        raise FileNotFoundError(f"Configuration file not found at {path}")

    try:
        with path.open("rb") as f:
            data: dict[str, Any] = tomllib.load(f)
    except tomllib.TOMLDecodeError as e:
        raise ValueError(f"Invalid TOML file at {path}: {e}")
    except Exception as e:
        raise Exception(f"Error reading configuration file at {path}: {e}")

    try:
        return FnbConfig.model_validate(data)  # type: ignore[no-any-return]
    except Exception as e:
        raise ValueError(f"Configuration validation failed: {e}")

Configuration File Loading

reader module

Configuration file discovery and loading for fnb.

This module handles the discovery, loading, and validation of fnb configuration files from multiple standard locations. It provides comprehensive error handling and environment variable expansion for configuration values.

ConfigReader

ConfigReader(config_path: Path | None = None)

Configuration file reader and validator for fnb tasks.

This class handles the discovery, loading, and validation of fnb configuration files. It supports automatic config file detection across standard locations and provides comprehensive error handling for configuration issues.

Attributes:

Name Type Description
config_path

Path to the loaded configuration file.

config

Validated FnbConfig object containing all task configurations.

Loads and validates an fnb configuration file, either from the specified path or by searching standard locations. Automatically expands environment variables in configuration values.

Parameters:

Name Type Description Default
config_path Path | None

Explicit path to config file. If None, auto-detects by searching in order: ./fnb.toml, ./config.toml, config/.toml, ~/.config/fnb/config.toml, ~/.config/fnb/.toml

None

Raises:

Type Description
FileNotFoundError

If no config file found in any search location.

ValueError

If config file contains invalid TOML syntax or doesn't match the expected schema for fnb configurations.

Examples:

Load config with auto-detection:

>>> reader = ConfigReader()
>>> print(reader.config_path)  # doctest output
./fnb.toml

Load specific config file:

>>> reader = ConfigReader(Path("/path/to/custom.toml"))
>>> len(reader.config.fetch)
3

Handle missing config file:

>>> reader = ConfigReader(Path("/nonexistent.toml"))
FileNotFoundError: Config file not found: /nonexistent.toml
Source code in src/fnb/reader.py
def __init__(self, config_path: Path | None = None):
    """Initialize a ConfigReader with automatic config discovery.

    Loads and validates an fnb configuration file, either from the specified
    path or by searching standard locations. Automatically expands environment
    variables in configuration values.

    Args:
        config_path: Explicit path to config file. If None, auto-detects by
            searching in order: ./fnb.toml, ./config.toml, config/*.toml,
            ~/.config/fnb/config.toml, ~/.config/fnb/*.toml

    Raises:
        FileNotFoundError: If no config file found in any search location.
        ValueError: If config file contains invalid TOML syntax or doesn't
            match the expected schema for fnb configurations.

    Examples:
        Load config with auto-detection:

        >>> reader = ConfigReader()
        >>> print(reader.config_path)  # doctest output
        ./fnb.toml

        Load specific config file:

        >>> reader = ConfigReader(Path("/path/to/custom.toml"))
        >>> len(reader.config.fetch)
        3

        Handle missing config file:

        >>> reader = ConfigReader(Path("/nonexistent.toml"))
        FileNotFoundError: Config file not found: /nonexistent.toml
    """
    self.config_path = config_path or self._get_default_config_path()
    self.config = self._load_file(self.config_path)
    self._expand_env_vars()

print_status

print_status(check_dirs: bool = True) -> None

Display a comprehensive status summary of all configured tasks.

Prints an organized overview of both fetch and backup tasks, showing source/target paths, enabled status, and directory existence checks. This provides users with a complete picture of their fnb configuration.

Parameters:

Name Type Description Default
check_dirs bool

If True, validates that local target directories exist and reports their status. Remote paths are skipped from validation.

True

Returns:

Name Type Description
None None

Prints formatted status information to stdout.

Examples:

Basic status display:

>>> reader = ConfigReader()
>>> reader.print_status()
📄 Config file: ./fnb.toml

📦 Fetch Tasks (remote → local): ✅ logs: user@server:~/logs/ → ./backup/logs/ 📁 Target for logs exists: ./backup/logs

💾 Backup Tasks (local → external): ✅ logs: ./backup/logs/ → /mnt/external/backup/ 📁 Target for logs exists: /mnt/external/backup

Status without directory checking:

>>> reader.print_status(check_dirs=False)
📄 Config file: ./fnb.toml

📦 Fetch Tasks (remote → local): ✅ logs: user@server:~/logs/ → ./backup/logs/

💾 Backup Tasks (local → external): ✅ logs: ./backup/logs/ → /mnt/external/backup/

Source code in src/fnb/reader.py
def print_status(self, check_dirs: bool = True) -> None:
    """Display a comprehensive status summary of all configured tasks.

    Prints an organized overview of both fetch and backup tasks, showing
    source/target paths, enabled status, and directory existence checks.
    This provides users with a complete picture of their fnb configuration.

    Args:
        check_dirs: If True, validates that local target directories exist
            and reports their status. Remote paths are skipped from validation.

    Returns:
        None: Prints formatted status information to stdout.

    Examples:
        Basic status display:

        >>> reader = ConfigReader()
        >>> reader.print_status()
        📄 Config file: ./fnb.toml

        📦 Fetch Tasks (remote → local):
         ✅ logs: user@server:~/logs/ → ./backup/logs/
            📁 Target for logs exists: ./backup/logs

        💾 Backup Tasks (local → external):
         ✅ logs: ./backup/logs/ → /mnt/external/backup/
            📁 Target for logs exists: /mnt/external/backup

        Status without directory checking:

        >>> reader.print_status(check_dirs=False)
        📄 Config file: ./fnb.toml

        📦 Fetch Tasks (remote → local):
         ✅ logs: user@server:~/logs/ → ./backup/logs/

        💾 Backup Tasks (local → external):
         ✅ logs: ./backup/logs/ → /mnt/external/backup/
    """
    print(f"📄 Config file: {self.config_path}")

    self._print_fetch_tasks(check_dirs)
    self._print_backup_tasks(check_dirs)

    print("")  # Add final empty line

rsync Execution Engine

gear module

Core rsync execution and directory management utilities for fnb.

This module provides the fundamental operations for executing rsync commands with SSH password automation and local directory validation. It handles the low-level details of process execution, authentication, and error handling.

run_rsync

run_rsync(source: str, target: str, options: list[str], ssh_password: str | None = None, timeout: int = 30) -> CompletedProcess | bool

Execute an rsync command with optional SSH password automation.

This function handles both local and remote rsync operations. For remote operations requiring SSH authentication, it can automatically provide passwords using pexpect to handle interactive prompts.

Parameters:

Name Type Description Default
source str

Source path for rsync operation. Can be local path or remote in format "user@host:path/". Remote paths trigger SSH authentication.

required
target str

Destination path for rsync operation. Usually a local path.

required
options list[str]

List of rsync command-line options to include in the operation. Common options: ["-auvz", "--delete", "--dry-run"].

required
ssh_password str | None

SSH password for automatic authentication. If None, rsync runs without password automation and will fall back to interactive password prompts or SSH key authentication. If provided, uses pexpect for password automation.

None
timeout int

Maximum seconds to wait for SSH password prompt. Only used when ssh_password is provided.

30

Returns:

Type Description
CompletedProcess | bool

subprocess.CompletedProcess | bool: For operations without password

CompletedProcess | bool

automation, returns CompletedProcess object with execution details.

CompletedProcess | bool

For password-automated operations, returns True if successful,

CompletedProcess | bool

False if failed.

Raises:

Type Description
CalledProcessError

If rsync execution fails (non-zero exit).

TIMEOUT

If SSH password prompt times out.

EOF

If SSH connection unexpectedly closes.

Exception

For any other errors during execution.

Examples:

Local rsync operation:

>>> run_rsync("./source/", "./target/", ["-av"])
CompletedProcess(args=['rsync', '-av', './source/', './target/'],
                returncode=0)

Remote rsync with SSH password:

>>> run_rsync("user@server:~/data/", "./backup/",
...           ["-auvz", "--delete"], ssh_password="mypass")
True

Dry run to preview changes:

>>> run_rsync("user@server:~/logs/", "./logs/",
...           ["-av", "--dry-run"], ssh_password="mypass")
True

Remote sync with custom timeout:

>>> run_rsync("user@server:~/files/", "./files/",
...           ["-av"], ssh_password="mypass", timeout=60)
True
Source code in src/fnb/gear.py
def run_rsync(
    source: str,
    target: str,
    options: list[str],
    ssh_password: str | None = None,
    timeout: int = 30,
) -> subprocess.CompletedProcess | bool:
    """Execute an rsync command with optional SSH password automation.

    This function handles both local and remote rsync operations. For remote
    operations requiring SSH authentication, it can automatically provide
    passwords using pexpect to handle interactive prompts.

    Args:
        source: Source path for rsync operation. Can be local path or remote
            in format "user@host:path/". Remote paths trigger SSH authentication.
        target: Destination path for rsync operation. Usually a local path.
        options: List of rsync command-line options to include in the operation.
            Common options: ["-auvz", "--delete", "--dry-run"].
        ssh_password: SSH password for automatic authentication. If None,
            rsync runs without password automation and will fall back to
            interactive password prompts or SSH key authentication. If provided,
            uses pexpect for password automation.
        timeout: Maximum seconds to wait for SSH password prompt. Only used
            when ssh_password is provided.

    Returns:
        subprocess.CompletedProcess | bool: For operations without password
        automation, returns CompletedProcess object with execution details.
        For password-automated operations, returns True if successful,
        False if failed.

    Raises:
        subprocess.CalledProcessError: If rsync execution fails (non-zero exit).
        pexpect.TIMEOUT: If SSH password prompt times out.
        pexpect.EOF: If SSH connection unexpectedly closes.
        Exception: For any other errors during execution.

    Examples:
        Local rsync operation:

        >>> run_rsync("./source/", "./target/", ["-av"])
        CompletedProcess(args=['rsync', '-av', './source/', './target/'],
                        returncode=0)

        Remote rsync with SSH password:

        >>> run_rsync("user@server:~/data/", "./backup/",
        ...           ["-auvz", "--delete"], ssh_password="mypass")
        True

        Dry run to preview changes:

        >>> run_rsync("user@server:~/logs/", "./logs/",
        ...           ["-av", "--dry-run"], ssh_password="mypass")
        True

        Remote sync with custom timeout:

        >>> run_rsync("user@server:~/files/", "./files/",
        ...           ["-av"], ssh_password="mypass", timeout=60)
        True
    """
    cmd = ["rsync"] + options + [source, target]
    cmd_str = " ".join(cmd)

    logger.info(f"Executing: {cmd_str}")

    try:
        if ssh_password:
            # Use pexpect for interactive SSH passwort automation
            return _run_rsync_with_password(
                command=cmd_str,
                ssh_password=ssh_password,
                timeout=timeout,
            )
        else:
            # Regular non-interactive execution
            return subprocess.run(cmd, check=True)

    except subprocess.CalledProcessError as e:
        logger.error(f"rsync failed with exit code {e.returncode}")
        # Re-raise to allow caller to handle
        raise
    except Exception as e:
        logger.error(f"Error executing rsync: {e}")
        raise

verify_directory

verify_directory(path: str, create: bool = False) -> Path

Verify that a local directory exists, optionally creating it if missing.

This function validates local directory paths and ensures they exist for rsync operations. It includes safety checks to prevent operations on remote paths and provides clear error handling for various failure scenarios.

Parameters:

Name Type Description Default
path str

Local directory path to verify or create. Must be a local path without ":" characters (which indicate remote paths).

required
create bool

If True, automatically create the directory and any missing parent directories. If False, only verify existence without creation.

False

Returns:

Name Type Description
Path Path

Validated Path object pointing to the existing directory.

Raises:

Type Description
ValueError

If path contains ":" indicating a remote path, or if path exists but is not a directory (e.g., it's a file).

FileNotFoundError

If directory doesn't exist and create=False.

OSError

If directory creation fails due to permissions or other filesystem issues.

Examples:

Verify existing directory:

>>> verify_directory("./backup")
PosixPath('./backup')

Create directory if missing:

>>> verify_directory("./new/nested/path", create=True)
Created directory: ./new/nested/path
PosixPath('./new/nested/path')

Handle remote path error:

>>> verify_directory("user@server:~/path")
ValueError: Remote paths are not supported for verification: user@server:~/path

Handle file vs directory conflict:

>>> verify_directory("./existing_file.txt")
ValueError: Path exists but is not a directory: ./existing_file.txt

Handle missing directory without create:

>>> verify_directory("./missing")
FileNotFoundError: Directory does not exist: ./missing
Source code in src/fnb/gear.py
def verify_directory(path: str, create: bool = False) -> Path:
    """Verify that a local directory exists, optionally creating it if missing.

    This function validates local directory paths and ensures they exist for
    rsync operations. It includes safety checks to prevent operations on
    remote paths and provides clear error handling for various failure scenarios.

    Args:
        path: Local directory path to verify or create. Must be a local path
            without ":" characters (which indicate remote paths).
        create: If True, automatically create the directory and any missing
            parent directories. If False, only verify existence without creation.

    Returns:
        Path: Validated Path object pointing to the existing directory.

    Raises:
        ValueError: If path contains ":" indicating a remote path, or if path
            exists but is not a directory (e.g., it's a file).
        FileNotFoundError: If directory doesn't exist and create=False.
        OSError: If directory creation fails due to permissions or other
            filesystem issues.

    Examples:
        Verify existing directory:

        >>> verify_directory("./backup")
        PosixPath('./backup')

        Create directory if missing:

        >>> verify_directory("./new/nested/path", create=True)
        Created directory: ./new/nested/path
        PosixPath('./new/nested/path')

        Handle remote path error:

        >>> verify_directory("user@server:~/path")
        ValueError: Remote paths are not supported for verification: user@server:~/path

        Handle file vs directory conflict:

        >>> verify_directory("./existing_file.txt")
        ValueError: Path exists but is not a directory: ./existing_file.txt

        Handle missing directory without create:

        >>> verify_directory("./missing")
        FileNotFoundError: Directory does not exist: ./missing
    """
    # Check if the path is remote
    if ":" in path:
        raise ValueError(f"Remote paths are not supported for verification: {path}")

    dir_path = Path(path)

    if dir_path.exists():
        if not dir_path.is_dir():
            raise ValueError(f"Path exists but is not a directory: {dir_path}")
        return dir_path

    if not create:
        raise FileNotFoundError(f"Directory does not exist: {dir_path}")

    try:
        dir_path.mkdir(parents=True, exist_ok=True)
        logger.info(f"Created directory: {dir_path}")
        return dir_path
    except OSError as e:
        raise OSError(f"Failed to create directory {dir_path}: {e}")

Fetch Operations

fetcher module

Fetch operations for fnb (Fetch'n'Backup) tool.

This module handles remote-to-local fetch operations using rsync with SSH authentication. Provides reliable data transfer from remote servers to local storage with comprehensive error handling.

Key features: - Remote server → Local directory transfer - Uses rsync via gear.run_rsync for reliable copying - SSH-based transfer with optional password automation - Reads configuration from [fetch.LABEL] sections in config.toml

Separated from backuper.py for clarity and future extensibility: - Adding delay or throttling between fetches - Supporting different types of remote automation - Custom handling for partial/incremental fetch

run

run(task: RsyncTaskConfig, dry_run: bool = False, ssh_password: str | None = None, create_dirs: bool = False) -> bool

Execute a fetch operation to download data from remote server to local storage.

Performs an rsync-based fetch operation that downloads data from a remote server to local storage. Handles SSH authentication, directory validation, and provides comprehensive error handling for various failure scenarios.

Parameters:

Name Type Description Default
task RsyncTaskConfig

The fetch task configuration defining source, target, and options. Must be a valid RsyncTaskConfig with appropriate fetch settings.

required
dry_run bool

If True, performs a preview run showing what would be transferred without actually moving files. Automatically adds --dry-run to rsync options.

False
ssh_password str | None

SSH password for remote authentication. If None, attempts to retrieve password from environment variables based on task host. If no password is found, falls back to interactive password prompts. For local tasks (host="none"), this parameter is ignored with a warning.

None
create_dirs bool

If True, automatically creates the target directory if it doesn't exist. If False, the operation fails if the target directory is missing.

False

Returns:

Name Type Description
bool bool

True if the fetch operation completed successfully, False if there

bool

were recoverable errors like directory issues.

Raises:

Type Description
ValueError

If task is None or contains invalid configuration.

FileNotFoundError

If target directory doesn't exist and create_dirs=False, or if configuration references non-existent paths.

CalledProcessError

If the rsync command execution fails with a non-zero exit code, indicating transfer errors.

Exception

For unexpected errors during SSH authentication, network issues, or other system-level failures.

Examples:

Basic fetch operation:

>>> task = RsyncTaskConfig(
...     label="logs", host="user@server", source="~/logs/",
...     target="./backup/logs/", options=["-av"], enabled=True
... )
>>> result = run(task)
Fetching logs from user@server:~/logs/ to ./backup/logs/
Fetch completed successfully: logs
>>> result
True

Dry run to preview changes:

>>> result = run(task, dry_run=True)
Fetching logs from user@server:~/logs/ to ./backup/logs/
(DRY RUN - no files will be modified)
Fetch completed successfully: logs
>>> result
True

Fetch with automatic directory creation:

>>> result = run(task, create_dirs=True)
Created directory: ./backup/logs
Fetching logs from user@server:~/logs/ to ./backup/logs/
>>> result
True

Fetch with SSH password override:

>>> result = run(task, ssh_password="mypassword")
Using SSH password from command line for host: user@server
Fetching logs from user@server:~/logs/ to ./backup/logs/
>>> result
True

Handle missing target directory:

>>> result = run(task, create_dirs=False)  # target doesn't exist
Target directory error: Directory does not exist: ./backup/logs
>>> result
False
Source code in src/fnb/fetcher.py
def run(
    task: RsyncTaskConfig,
    dry_run: bool = False,
    ssh_password: str | None = None,
    create_dirs: bool = False,
) -> bool:
    """Execute a fetch operation to download data from remote server to local storage.

    Performs an rsync-based fetch operation that downloads data from a remote server
    to local storage. Handles SSH authentication, directory validation, and provides
    comprehensive error handling for various failure scenarios.

    Args:
        task: The fetch task configuration defining source, target, and options.
            Must be a valid RsyncTaskConfig with appropriate fetch settings.
        dry_run: If True, performs a preview run showing what would be transferred
            without actually moving files. Automatically adds --dry-run to rsync options.
        ssh_password: SSH password for remote authentication. If None, attempts to
            retrieve password from environment variables based on task host. If no
            password is found, falls back to interactive password prompts. For local
            tasks (host="none"), this parameter is ignored with a warning.
        create_dirs: If True, automatically creates the target directory if it doesn't
            exist. If False, the operation fails if the target directory is missing.

    Returns:
        bool: True if the fetch operation completed successfully, False if there
        were recoverable errors like directory issues.

    Raises:
        ValueError: If task is None or contains invalid configuration.
        FileNotFoundError: If target directory doesn't exist and create_dirs=False,
            or if configuration references non-existent paths.
        subprocess.CalledProcessError: If the rsync command execution fails with
            a non-zero exit code, indicating transfer errors.
        Exception: For unexpected errors during SSH authentication, network issues,
            or other system-level failures.

    Examples:
        Basic fetch operation:

        >>> task = RsyncTaskConfig(
        ...     label="logs", host="user@server", source="~/logs/",
        ...     target="./backup/logs/", options=["-av"], enabled=True
        ... )
        >>> result = run(task)
        Fetching logs from user@server:~/logs/ to ./backup/logs/
        Fetch completed successfully: logs
        >>> result
        True

        Dry run to preview changes:

        >>> result = run(task, dry_run=True)
        Fetching logs from user@server:~/logs/ to ./backup/logs/
        (DRY RUN - no files will be modified)
        Fetch completed successfully: logs
        >>> result
        True

        Fetch with automatic directory creation:

        >>> result = run(task, create_dirs=True)
        Created directory: ./backup/logs
        Fetching logs from user@server:~/logs/ to ./backup/logs/
        >>> result
        True

        Fetch with SSH password override:

        >>> result = run(task, ssh_password="mypassword")
        Using SSH password from command line for host: user@server
        Fetching logs from user@server:~/logs/ to ./backup/logs/
        >>> result
        True

        Handle missing target directory:

        >>> result = run(task, create_dirs=False)  # target doesn't exist
        Target directory error: Directory does not exist: ./backup/logs
        >>> result
        False
    """
    if task is None:
        raise ValueError("Task cannot be None")

    if not task.is_remote:
        if ssh_password:
            logger.warning("SSH password provided for local task, ignoring")
        ssh_password = None
    elif ssh_password is None and task.is_remote:
        # Try to get the password from environment variables
        ssh_password = get_ssh_password(task.host)
        if ssh_password:
            logger.info(f"Using SSH password from environment for host: {task.host}")

    source = task.rsync_source
    target = task.rsync_target
    options = task.options.copy()

    if dry_run and "--dry-run" not in options:
        options.append("--dry-run")

    try:
        print(f"Fetching {task.label}")  # User-facing output
        logger.debug(f"Fetching {task.label} from {source} to {target}")
        if dry_run:
            print("(DRY RUN - no files will be modified)")  # User-facing output

        # For fetch, we only need to verify the target directory exists
        # Target is always local in fetch operations
        try:
            verify_directory(target, create=create_dirs)
        except (FileNotFoundError, ValueError) as e:
            logger.error(f"Target directory error: {e}")
            return False

        run_rsync(
            source=source,
            target=target,
            options=options,
            ssh_password=ssh_password,
        )

        print(f"Fetch completed successfully: {task.label}")  # User-facing output
        return True

    except subprocess.CalledProcessError as e:
        logger.error(f"Fetch failed with error code {e.returncode}: {task.label}")
        # Re-raise to allow caller to handle
        raise
    except Exception as e:
        logger.error(f"Fetch failed with error: {str(e)}")
        raise

Backup Operations

backuper module

Backup operations for fnb (Fetch'n'Backup) tool.

This module handles local-to-external backup operations using rsync. Supports backing up to cloud storage, NAS devices, or external drives.

Key features: - Local directory → External storage transfer - Uses rsync via gear.run_rsync for reliable copying - Reads configuration from [backup.LABEL] sections in config.toml - Supports various external destinations (OneDrive, NAS, etc.)

Separated from fetcher.py to allow for future specialization: - Adding snapshot-style folder naming (e.g., YYYY-MM-DD/) - Cloud API integration or notifications - Verification or checksum logic post-backup

run

run(task: RsyncTaskConfig, dry_run: bool = False, create_dirs: bool = False) -> bool

Execute a backup operation to copy local data to external storage destinations.

Performs an rsync-based backup operation that copies data from local storage to external destinations like cloud storage, NAS devices, or external drives. Validates both source and target directories and provides comprehensive error handling.

Parameters:

Name Type Description Default
task RsyncTaskConfig

The backup task configuration defining source, target, and rsync options. Must be a valid RsyncTaskConfig configured for backup operations.

required
dry_run bool

If True, performs a preview run showing what would be transferred without actually moving files. Automatically adds --dry-run to rsync options.

False
create_dirs bool

If True, automatically creates both source and target directories if they don't exist. If False, the operation fails if either directory is missing.

False

Returns:

Name Type Description
bool bool

True if the backup operation completed successfully, False if there

bool

were recoverable errors like directory access issues.

Raises:

Type Description
ValueError

If task is None or contains invalid configuration.

FileNotFoundError

If source or target directories don't exist and create_dirs=False, or if configuration references non-existent paths.

CalledProcessError

If the rsync command execution fails with a non-zero exit code, indicating transfer or permission errors.

Exception

For unexpected errors during file system operations, permission issues, or other system-level failures.

Examples:

Basic backup operation:

>>> task = RsyncTaskConfig(
...     label="documents", host="none", source="./documents/",
...     target="/mnt/backup/documents/", options=["-av"], enabled=True
... )
>>> result = run(task)
Backing up documents from ./documents/ to /mnt/backup/documents/
Backup completed successfully: documents
>>> result
True

Dry run to preview changes:

>>> result = run(task, dry_run=True)
Backing up documents from ./documents/ to /mnt/backup/documents/
(DRY RUN - no files will be modified)
Backup completed successfully: documents
>>> result
True

Backup with automatic directory creation:

>>> result = run(task, create_dirs=True)
Created directory: ./documents
Created directory: /mnt/backup/documents
Backing up documents from ./documents/ to /mnt/backup/documents/
>>> result
True

Handle missing source directory:

>>> result = run(task, create_dirs=False)  # source doesn't exist
Source directory error: Directory does not exist: ./documents
>>> result
False

Handle missing target directory:

>>> result = run(task, create_dirs=False)  # target doesn't exist
Target directory error: Directory does not exist: /mnt/backup/documents
>>> result
False

Backup with rsync options:

>>> task.options = ["-av", "--delete", "--exclude=*.tmp"]
>>> result = run(task)
Backing up documents from ./documents/ to /mnt/backup/documents/
>>> result
True
Source code in src/fnb/backuper.py
def run(
    task: RsyncTaskConfig, dry_run: bool = False, create_dirs: bool = False
) -> bool:
    """Execute a backup operation to copy local data to external storage destinations.

    Performs an rsync-based backup operation that copies data from local storage to
    external destinations like cloud storage, NAS devices, or external drives. Validates
    both source and target directories and provides comprehensive error handling.

    Args:
        task: The backup task configuration defining source, target, and rsync options.
            Must be a valid RsyncTaskConfig configured for backup operations.
        dry_run: If True, performs a preview run showing what would be transferred
            without actually moving files. Automatically adds --dry-run to rsync options.
        create_dirs: If True, automatically creates both source and target directories
            if they don't exist. If False, the operation fails if either directory is missing.

    Returns:
        bool: True if the backup operation completed successfully, False if there
        were recoverable errors like directory access issues.

    Raises:
        ValueError: If task is None or contains invalid configuration.
        FileNotFoundError: If source or target directories don't exist and create_dirs=False,
            or if configuration references non-existent paths.
        subprocess.CalledProcessError: If the rsync command execution fails with
            a non-zero exit code, indicating transfer or permission errors.
        Exception: For unexpected errors during file system operations, permission
            issues, or other system-level failures.

    Examples:
        Basic backup operation:

        >>> task = RsyncTaskConfig(
        ...     label="documents", host="none", source="./documents/",
        ...     target="/mnt/backup/documents/", options=["-av"], enabled=True
        ... )
        >>> result = run(task)
        Backing up documents from ./documents/ to /mnt/backup/documents/
        Backup completed successfully: documents
        >>> result
        True

        Dry run to preview changes:

        >>> result = run(task, dry_run=True)
        Backing up documents from ./documents/ to /mnt/backup/documents/
        (DRY RUN - no files will be modified)
        Backup completed successfully: documents
        >>> result
        True

        Backup with automatic directory creation:

        >>> result = run(task, create_dirs=True)
        Created directory: ./documents
        Created directory: /mnt/backup/documents
        Backing up documents from ./documents/ to /mnt/backup/documents/
        >>> result
        True

        Handle missing source directory:

        >>> result = run(task, create_dirs=False)  # source doesn't exist
        Source directory error: Directory does not exist: ./documents
        >>> result
        False

        Handle missing target directory:

        >>> result = run(task, create_dirs=False)  # target doesn't exist
        Target directory error: Directory does not exist: /mnt/backup/documents
        >>> result
        False

        Backup with rsync options:

        >>> task.options = ["-av", "--delete", "--exclude=*.tmp"]
        >>> result = run(task)
        Backing up documents from ./documents/ to /mnt/backup/documents/
        >>> result
        True
    """
    if task is None:
        raise ValueError("Task cannot be None")

    source = task.rsync_source
    target = task.rsync_target
    options = task.options.copy()

    if dry_run and "--dry-run" not in options:
        options.append("--dry-run")

    try:
        print(f"Backing up {task.label}")  # User-facing output
        logger.debug(f"Backing up {task.label} from {source} to {target}")
        if dry_run:
            print("(DRY RUN - no files will be modified)")  # User-facing output

        # Ensure source directory exists (for backup, source is always local)
        try:
            verify_directory(source, create=create_dirs)
        except (FileNotFoundError, ValueError) as e:
            logger.error(f"Source directory error: {e}")
            return False

        # Ensure target directory exists (for backup, source is always local)
        try:
            verify_directory(target, create=create_dirs)
        except (FileNotFoundError, ValueError) as e:
            logger.error(f"Target directory error: {e}")
            return False

        run_rsync(source=source, target=target, options=options, ssh_password=None)

        print(f"Backup completed successfully: {task.label}")  # User-facing output
        return True

    except subprocess.CalledProcessError as e:
        logger.error(f"Backup failed with error code {e.returncode}: {task.label}")
        # Re-raise to allow caller to handle
        raise
    except Exception as e:
        logger.error(f"Backup failed with error: {str(e)}")
        raise

Configuration File Generation

generator module

Configuration file generation for fnb initialization.

This module handles the generation of starter configuration files from templates, providing users with ready-to-customize fnb configurations for their backup workflows.

Key features: - Template-based file generation with flexible discovery - Optional text replacements and header customization - Safety checks to prevent accidental overwrites - Support for both config and environment file generation

Usage patterns

fnb init [--force][--no-env] fnb init config [--force] fnb init env [--force]

ConfigKind

Bases: str, Enum

Types of configuration files that can be generated.

This enum inherits from str to all: 1. Direct string comparison (kind == "all") 2. Automatic string conversion (str(kind)) 3. Easy conversion from string input (ConfigKind("all"))

create_config_file

create_config_file(force: bool = False) -> bool

Create a default fnb.toml file in the current directory.

Parameters:

Name Type Description Default
force bool

If True, overwrite existing file without confirmation.

False

Returns:

Name Type Description
bool bool

True if successful, False otherwise.

Source code in src/fnb/generator.py
def create_config_file(force: bool = False) -> bool:
    """Create a default fnb.toml file in the current directory.

    Args:
        force (bool): If True, overwrite existing file without confirmation.

    Returns:
        bool: True if successful, False otherwise.
    """
    dest = Path("./fnb.toml")

    header_comment = (
        "# >>>>> Generated by fnb init",
        f"# >>>>> At {datetime.now().isoformat()}",
    )
    replacements = {"enabled = true": "enabled = false  # Set to true once configured"}

    success = create_file_from_template(
        template_name="config.toml",
        dest_path=dest,
        force=force,
        replacements=replacements,
        header_comment=("\n").join(header_comment),
    )

    if success:
        typer.echo("Edit this file to configure your backup tasks.")

    return success

create_env_file

create_env_file(force: bool = False) -> bool

Create a .env.plain file from the sample template.

Parameters:

Name Type Description Default
force bool

If True, overwrite existing file without confirmation.

False

Returns:

Name Type Description
bool bool

True if successful, False otherwise.

Source code in src/fnb/generator.py
def create_env_file(force: bool = False) -> bool:
    """Create a .env.plain file from the sample template.

    Args:
        force (bool): If True, overwrite existing file without confirmation.

    Returns:
        bool: True if successful, False otherwise.
    """
    dest = Path("./.env.plain")

    header_comment = (
        "# >>>>> Generated by fnb init",
        f"# >>>>> At {datetime.now().isoformat()}",
    )

    success = create_file_from_template(
        template_name="env.sample",
        dest_path=dest,
        force=force,
        header_comment=("\n").join(header_comment),
    )

    if success:
        typer.echo("Edit this file to configure your SSH passwords.")

    return success

create_file_from_template

create_file_from_template(template_name: str, dest_path: Path, force: bool = False, replacements: dict[str, str] | None = None, header_comment: str | None = None) -> bool

Generate a file from a template with optional content customization.

Creates a new file by copying from a template and applying optional text replacements and header comments. Provides safety checks to prevent accidental overwrites and handles template processing errors gracefully.

Parameters:

Name Type Description Default
template_name str

Name of the template file to use as source.

required
dest_path Path

Path where the generated file should be created.

required
force bool

If True, overwrites existing files without prompting. If False, aborts if destination file already exists.

False
replacements dict[str, str] | None

Optional dictionary of string replacements to apply to template content. Keys are old strings, values are replacements.

None
header_comment str | None

Optional comment to prepend to the generated file, useful for adding generation timestamps or custom headers.

None

Returns:

Name Type Description
bool bool

True if file was created successfully, False if operation

bool

failed due to template not found, file conflicts, or I/O errors.

Examples:

Basic file generation:

>>> success = create_file_from_template(
...     "config.toml", Path("./fnb.toml")
... )
✅ Created ./fnb.toml from template.
>>> success
True

Generate with replacements:

>>> replacements = {"enabled = true": "enabled = false"}
>>> success = create_file_from_template(
...     "config.toml", Path("./fnb.toml"),
...     replacements=replacements
... )
>>> success
True

Generate with header comment:

>>> header = "# Generated by fnb init at 2024-01-01"
>>> success = create_file_from_template(
...     "env.sample", Path("./.env"),
...     header_comment=header
... )
>>> success
True

Handle existing file (no force):

>>> success = create_file_from_template(
...     "config.toml", Path("./existing.toml"), force=False
... )
❌ ./existing.toml already exists. Use --force to overwrite.
>>> success
False

Force overwrite existing file:

>>> success = create_file_from_template(
...     "config.toml", Path("./existing.toml"), force=True
... )
✅ Created ./existing.toml from template.
>>> success
True
Source code in src/fnb/generator.py
def create_file_from_template(
    template_name: str,
    dest_path: Path,
    force: bool = False,
    replacements: dict[str, str] | None = None,
    header_comment: str | None = None,
) -> bool:
    """Generate a file from a template with optional content customization.

    Creates a new file by copying from a template and applying optional text
    replacements and header comments. Provides safety checks to prevent
    accidental overwrites and handles template processing errors gracefully.

    Args:
        template_name: Name of the template file to use as source.
        dest_path: Path where the generated file should be created.
        force: If True, overwrites existing files without prompting.
            If False, aborts if destination file already exists.
        replacements: Optional dictionary of string replacements to apply
            to template content. Keys are old strings, values are replacements.
        header_comment: Optional comment to prepend to the generated file,
            useful for adding generation timestamps or custom headers.

    Returns:
        bool: True if file was created successfully, False if operation
        failed due to template not found, file conflicts, or I/O errors.

    Examples:
        Basic file generation:

        >>> success = create_file_from_template(
        ...     "config.toml", Path("./fnb.toml")
        ... )
        ✅ Created ./fnb.toml from template.
        >>> success
        True

        Generate with replacements:

        >>> replacements = {"enabled = true": "enabled = false"}
        >>> success = create_file_from_template(
        ...     "config.toml", Path("./fnb.toml"),
        ...     replacements=replacements
        ... )
        >>> success
        True

        Generate with header comment:

        >>> header = "# Generated by fnb init at 2024-01-01"
        >>> success = create_file_from_template(
        ...     "env.sample", Path("./.env"),
        ...     header_comment=header
        ... )
        >>> success
        True

        Handle existing file (no force):

        >>> success = create_file_from_template(
        ...     "config.toml", Path("./existing.toml"), force=False
        ... )
        ❌ ./existing.toml already exists. Use --force to overwrite.
        >>> success
        False

        Force overwrite existing file:

        >>> success = create_file_from_template(
        ...     "config.toml", Path("./existing.toml"), force=True
        ... )
        ✅ Created ./existing.toml from template.
        >>> success
        True
    """
    if dest_path.exists() and not force:
        typer.echo(f"❌ {dest_path} already exists. Use --force to overwrite.")
        return False

    # Find the template
    src = find_template(template_name)
    if not src:
        typer.echo(
            f"❌ Template file {template_name} not found. Check if fnb is installed correctly."
        )
        return False

    try:
        # Create parent directories if needed
        dest_path.parent.mkdir(parents=True, exist_ok=True)

        # Read the template
        with src.open("r") as source_file:
            content = source_file.read()

        # Add header comment if provided
        if header_comment and not content.startswith(header_comment):
            content = f"{header_comment}\n\n{content}"

        # Apply replacements if provided
        if replacements:
            for old, new in replacements.items():
                content = content.replace(old, new)

        # Write the modified content
        with dest_path.open("w") as dest_file:
            dest_file.write(content)

        typer.echo(f"✅ Created {dest_path} from template.")
        return True

    except Exception as e:
        typer.echo(f"❌ Error creating file: {e}")
        return False

find_template

find_template(template_name: str) -> Union[Path, None]

Locate a template file by searching multiple possible locations.

Implements a flexible template discovery system that works in both development and installed package environments. Searches through multiple locations in priority order to find the requested template.

Parameters:

Name Type Description Default
template_name str

Name of the template file to locate (e.g., "config.toml").

required

Returns:

Type Description
Union[Path, None]

Path | None: Path to the first template file found in the search order,

Union[Path, None]

or None if the template doesn't exist in any location.

Examples:

Find config template:

>>> path = find_template("config.toml")
>>> path.name
'config.toml'

Template not found:

>>> path = find_template("nonexistent.toml")
>>> path is None
True

Find environment template:

>>> path = find_template("env.sample")
>>> path.exists()
True
Source code in src/fnb/generator.py
def find_template(template_name: str) -> Union[Path, None]:
    """Locate a template file by searching multiple possible locations.

    Implements a flexible template discovery system that works in both
    development and installed package environments. Searches through
    multiple locations in priority order to find the requested template.

    Args:
        template_name: Name of the template file to locate (e.g., "config.toml").

    Returns:
        Path | None: Path to the first template file found in the search order,
        or None if the template doesn't exist in any location.

    Examples:
        Find config template:

        >>> path = find_template("config.toml")
        >>> path.name
        'config.toml'

        Template not found:

        >>> path = find_template("nonexistent.toml")
        >>> path is None
        True

        Find environment template:

        >>> path = find_template("env.sample")
        >>> path.exists()
        True
    """
    # Try multiple possible paths to find the template
    possible_sources = [
        importlib.resources.files("fnb.assets").joinpath(
            template_name
        ),  # Installed package
        Path(__file__).parent.parent
        / "assets"
        / template_name,  # Relative to this file
        Path(f"src/fnb/assets/{template_name}"),  # Project root in dev
    ]

    for p in possible_sources:
        try:
            # Check if it's a Traversable or Path and if it exists
            if hasattr(p, "exists") and p.exists():
                return Path(str(p)) if not isinstance(p, Path) else p
        except (OSError, AttributeError):
            # Skip if we can't check existence or convert to Path
            continue
    return None

run

run(kind: ConfigKind = ALL, force: bool = False) -> None

CLI entry point for config file generation.

Parameters:

Name Type Description Default
kind str

Kind of configuration file to generate.

ALL
force bool

If True, overwrite existing file without confirmation.

False
Source code in src/fnb/generator.py
def run(kind: ConfigKind = ConfigKind.ALL, force: bool = False) -> None:
    """CLI entry point for config file generation.

    Args:
        kind (str): Kind of configuration file to generate.
        force (bool): If True, overwrite existing file without confirmation.
    """
    try:
        config_kind = ConfigKind(kind.lower())
    except ValueError:
        typer.echo(f"❌ Invalid configuration kind: {kind}")
        typer.echo("Valid kinds: all, config, env")
        sys.exit(1)

    # Track overall success
    success = True

    # Generate config.toml if requested
    if config_kind in [ConfigKind.ALL, ConfigKind.CONFIG]:
        config_success = create_config_file(force)
        success = success and config_success

    # Generate .env if requested
    if config_kind in [ConfigKind.ALL, ConfigKind.ENV]:
        env_success = create_env_file(force)
        success = success and env_success

    # Exit with error if any file creation failed
    if not success:
        sys.exit(1)

CLI Interface

cli module

Command-line interface entry point for the fnb (Fetch'n'Backup) tool.

This script defines the main CLI commands using Typer framework, providing a user-friendly interface for backup workflow operations.

Available commands: - fetch: Pull data from remote server to local storage - backup: Push local data to cloud or external backup destinations - sync: Run both fetch and backup operations sequentially - status: Show the current status of all configured tasks - init: Generate initial configuration files (.toml, .env)

Each command delegates to its corresponding module: - fetch -> fnb.fetcher - backup -> fnb.backuper - status -> fnb.reader - init -> fnb.generator

Shared options include: - --config: Path to config file (default: auto-detect) - --dry-run: Preview without making changes - --ssh-password: For remote SSH login if required

Configuration is defined in a config.toml file, which can be initialized with: fnb init

To expose this CLI as a fnb command, set up project.scripts in pyproject.toml.

backup

backup(label: str, dry_run: bool = dry_run(), create_dirs: bool = create_dirs(), config: str = config(), log_level: str = log_level(), verbose: bool = verbose(), quiet: bool = quiet()) -> None

Backup local data to external storage or cloud destinations using rsync.

Executes a backup operation based on the task configuration defined in the config file. The backup operation copies data from local storage to external destinations like cloud storage, NAS, or external drives using rsync.

Parameters:

Name Type Description Default
label str

Task label that identifies the backup configuration in the [backup.LABEL] section of the config file.

required
dry_run bool

If True, preview the rsync operation without actually transferring files. Shows what would be done.

dry_run()
create_dirs bool

If True, automatically create source and target directories if they don't exist. If False, operation fails if directories missing.

create_dirs()
config str

Path to the configuration file containing task definitions. Defaults to "./fnb.toml" in current directory.

config()

Returns:

Name Type Description
None None

Executes rsync operation and prints status messages.

Raises:

Type Description
Exit

If operation fails due to: - Task label not found in configuration - Configuration file not found or invalid - Source or target directories don't exist and create_dirs=False - rsync command execution failure - Insufficient permissions for target location

Examples:

Backup logs to external storage:

>>> fnb backup logs
Backing up logs from ./backup/logs/ to /mnt/external/backup/
Backup completed successfully: logs

Preview backup operation without transferring files:

>>> fnb backup logs --dry-run
Backing up logs from ./backup/logs/ to /mnt/external/backup/
(DRY RUN - no files will be modified)

Backup with auto-create directories:

>>> fnb backup logs --create-dirs
Created directory: ./backup/logs
Created directory: /mnt/external/backup
Backing up logs from ./backup/logs/ to /mnt/external/backup/

Use custom config file:

>>> fnb backup logs --config /path/to/custom.toml
...
Source code in src/fnb/cli.py
@app.command()
def backup(
    label: str,
    dry_run: bool = Options.dry_run(),
    create_dirs: bool = Options.create_dirs(),
    config: str = Options.config(),
    log_level: str = Options.log_level(),
    verbose: bool = Options.verbose(),
    quiet: bool = Options.quiet(),
) -> None:
    """Backup local data to external storage or cloud destinations using rsync.

    Executes a backup operation based on the task configuration defined in the
    config file. The backup operation copies data from local storage to external
    destinations like cloud storage, NAS, or external drives using rsync.

    Args:
        label: Task label that identifies the backup configuration in the
            [backup.LABEL] section of the config file.
        dry_run: If True, preview the rsync operation without actually
            transferring files. Shows what would be done.
        create_dirs: If True, automatically create source and target directories
            if they don't exist. If False, operation fails if directories missing.
        config: Path to the configuration file containing task definitions.
            Defaults to "./fnb.toml" in current directory.

    Returns:
        None: Executes rsync operation and prints status messages.

    Raises:
        typer.Exit: If operation fails due to:
            - Task label not found in configuration
            - Configuration file not found or invalid
            - Source or target directories don't exist and create_dirs=False
            - rsync command execution failure
            - Insufficient permissions for target location

    Examples:
        Backup logs to external storage:

        >>> fnb backup logs
        Backing up logs from ./backup/logs/ to /mnt/external/backup/
        Backup completed successfully: logs

        Preview backup operation without transferring files:

        >>> fnb backup logs --dry-run
        Backing up logs from ./backup/logs/ to /mnt/external/backup/
        (DRY RUN - no files will be modified)

        Backup with auto-create directories:

        >>> fnb backup logs --create-dirs
        Created directory: ./backup/logs
        Created directory: /mnt/external/backup
        Backing up logs from ./backup/logs/ to /mnt/external/backup/

        Use custom config file:

        >>> fnb backup logs --config /path/to/custom.toml
        ...
    """
    # Setup logging first
    setup_logging(log_level=log_level, verbose=verbose, quiet=quiet)

    try:
        config_path = Path(config) if config else None
        reader = ConfigReader(config_path)
        task = reader.config.get_task_by_label("backup", label)

        if task is None:
            typer.echo(f"❌ Label not found: {label}")
            raise typer.Exit(1)

        backuper.run(
            task,
            dry_run=dry_run,
            create_dirs=create_dirs,
        )

    except FileNotFoundError as e:
        typer.echo(f"❌ {e}")
        typer.echo("Use --create-dirs option to create missing directories.")
        raise typer.Exit(1)
    except Exception as e:
        typer.echo(f"❌ Error: {e}")
        raise typer.Exit(1)

fetch

fetch(label: str, dry_run: bool = dry_run(), create_dirs: bool = create_dirs(), ssh_password: str | None = ssh_password(), config: str = config(), log_level: str = log_level(), verbose: bool = verbose(), quiet: bool = quiet()) -> None

Fetch data from a remote server to local storage using rsync.

Executes a fetch operation based on the task configuration defined in the config file. The fetch operation downloads data from a remote server to local storage using rsync with SSH authentication.

Parameters:

Name Type Description Default
label str

Task label that identifies the fetch configuration in the [fetch.LABEL] section of the config file.

required
dry_run bool

If True, preview the rsync operation without actually transferring files. Shows what would be done.

dry_run()
create_dirs bool

If True, automatically create the target directory if it doesn't exist. If False, operation fails if target missing.

create_dirs()
ssh_password str | None

SSH password for remote authentication. If provided, overrides any password defined in .env files. If None, attempts to use password from environment variables.

ssh_password()
config str

Path to the configuration file containing task definitions. Defaults to "./fnb.toml" in current directory.

config()

Returns:

Name Type Description
None None

Executes rsync operation and prints status messages.

Raises:

Type Description
Exit

If operation fails due to: - Task label not found in configuration - Configuration file not found or invalid - Target directory doesn't exist and create_dirs=False - rsync command execution failure - SSH authentication failure

Examples:

Fetch logs from remote server:

>>> fnb fetch logs
Fetching logs from user@server:~/logs/ to ./backup/logs/
Fetch completed successfully: logs

Preview fetch operation without transferring files:

>>> fnb fetch logs --dry-run
Fetching logs from user@server:~/logs/ to ./backup/logs/
(DRY RUN - no files will be modified)

Fetch with SSH password and auto-create directories:

>>> fnb fetch logs --ssh-password mypass --create-dirs
Using SSH password from command line for host: user@server
Created directory: ./backup/logs
Fetching logs from user@server:~/logs/ to ./backup/logs/

Use custom config file:

>>> fnb fetch logs --config /path/to/custom.toml
...
Source code in src/fnb/cli.py
@app.command()
def fetch(
    label: str,
    dry_run: bool = Options.dry_run(),
    create_dirs: bool = Options.create_dirs(),
    ssh_password: str | None = Options.ssh_password(),
    config: str = Options.config(),
    log_level: str = Options.log_level(),
    verbose: bool = Options.verbose(),
    quiet: bool = Options.quiet(),
) -> None:
    """Fetch data from a remote server to local storage using rsync.

    Executes a fetch operation based on the task configuration defined in the
    config file. The fetch operation downloads data from a remote server to
    local storage using rsync with SSH authentication.

    Args:
        label: Task label that identifies the fetch configuration in the
            [fetch.LABEL] section of the config file.
        dry_run: If True, preview the rsync operation without actually
            transferring files. Shows what would be done.
        create_dirs: If True, automatically create the target directory
            if it doesn't exist. If False, operation fails if target missing.
        ssh_password: SSH password for remote authentication. If provided,
            overrides any password defined in .env files. If None, attempts
            to use password from environment variables.
        config: Path to the configuration file containing task definitions.
            Defaults to "./fnb.toml" in current directory.

    Returns:
        None: Executes rsync operation and prints status messages.

    Raises:
        typer.Exit: If operation fails due to:
            - Task label not found in configuration
            - Configuration file not found or invalid
            - Target directory doesn't exist and create_dirs=False
            - rsync command execution failure
            - SSH authentication failure

    Examples:
        Fetch logs from remote server:

        >>> fnb fetch logs
        Fetching logs from user@server:~/logs/ to ./backup/logs/
        Fetch completed successfully: logs

        Preview fetch operation without transferring files:

        >>> fnb fetch logs --dry-run
        Fetching logs from user@server:~/logs/ to ./backup/logs/
        (DRY RUN - no files will be modified)

        Fetch with SSH password and auto-create directories:

        >>> fnb fetch logs --ssh-password mypass --create-dirs
        Using SSH password from command line for host: user@server
        Created directory: ./backup/logs
        Fetching logs from user@server:~/logs/ to ./backup/logs/

        Use custom config file:

        >>> fnb fetch logs --config /path/to/custom.toml
        ...
    """
    # Setup logging first
    setup_logging(log_level=log_level, verbose=verbose, quiet=quiet)

    try:
        config_path = Path(config) if config else None
        reader = ConfigReader(config_path)
        task = reader.config.get_task_by_label("fetch", label)

        if task is None:
            typer.echo(f"❌ Label not found: {label}")
            raise typer.Exit(1)

        fetcher.run(
            task,
            dry_run=dry_run,
            ssh_password=ssh_password,
            create_dirs=create_dirs,
        )

    except FileNotFoundError as e:
        typer.echo(f"❌ {e}")
        typer.echo("Use --create-dirs option to create missing directories.")
        raise typer.Exit(1)
    except Exception as e:
        typer.echo(f"❌ Error: {e}")
        raise typer.Exit(1)

init

init(kind: str = Argument('all', help='Kind of configuration file to generate (all, config, env)'), force: bool = Option(False, '--force', '-f', help='Overwrite existing file without confirmation')) -> None

Generate default configuration files for fnb in the current directory.

Creates template configuration files to help users get started with fnb. By default, generates both fnb.toml (task configuration) and .env.plain (SSH password template). Individual file types can be specified.

Parameters:

Name Type Description Default
kind str

Type of configuration file to generate. Options: - "all": Generate both config.toml and .env files (default) - "config": Generate only fnb.toml configuration file - "env": Generate only .env.plain environment file

Argument('all', help='Kind of configuration file to generate (all, config, env)')
force bool

If True, overwrite existing files without user confirmation. If False, prompts before overwriting existing files.

Option(False, '--force', '-f', help='Overwrite existing file without confirmation')

Returns:

Name Type Description
None None

Creates files in current directory and prints status messages.

Raises:

Type Description
ValueError

If invalid kind argument is provided.

Exit

If file creation fails or user cancels overwrite.

Examples:

Generate all configuration files:

>>> fnb init
✅ Created ./fnb.toml from template.
✅ Created ./.env.plain from template.

Generate only the main config file:

>>> fnb init config
✅ Created ./fnb.toml from template.

Force overwrite existing files:

>>> fnb init --force
✅ Created ./fnb.toml from template.
✅ Created ./.env.plain from template.
Source code in src/fnb/cli.py
@app.command()
def init(
    kind: str = typer.Argument(
        "all", help="Kind of configuration file to generate (all, config, env)"
    ),
    force: bool = typer.Option(
        False, "--force", "-f", help="Overwrite existing file without confirmation"
    ),
) -> None:
    """Generate default configuration files for fnb in the current directory.

    Creates template configuration files to help users get started with fnb.
    By default, generates both fnb.toml (task configuration) and .env.plain
    (SSH password template). Individual file types can be specified.

    Args:
        kind: Type of configuration file to generate. Options:
            - "all": Generate both config.toml and .env files (default)
            - "config": Generate only fnb.toml configuration file
            - "env": Generate only .env.plain environment file
        force: If True, overwrite existing files without user confirmation.
            If False, prompts before overwriting existing files.

    Returns:
        None: Creates files in current directory and prints status messages.

    Raises:
        ValueError: If invalid kind argument is provided.
        typer.Exit: If file creation fails or user cancels overwrite.

    Examples:
        Generate all configuration files:

        >>> fnb init
        ✅ Created ./fnb.toml from template.
        ✅ Created ./.env.plain from template.

        Generate only the main config file:

        >>> fnb init config
        ✅ Created ./fnb.toml from template.

        Force overwrite existing files:

        >>> fnb init --force
        ✅ Created ./fnb.toml from template.
        ✅ Created ./.env.plain from template.
    """
    try:
        # Convert str to ConfigKind and delegate to generator module
        config_kind = generator.ConfigKind(kind.lower())
        generator.run(kind=config_kind, force=force)
    except ValueError as e:
        typer.echo(f"❌ Error: {e}")
        raise typer.Exit(code=1)

setup_logging

setup_logging(log_level: str = log_level(), verbose: bool = verbose(), quiet: bool = quiet()) -> str

Setup logging configuration based on command line arguments.

Parameters:

Name Type Description Default
log_level str

Explicit log level

log_level()
verbose bool

Enable verbose mode (DEBUG level)

verbose()
quiet bool

Enable quiet mode (WARNING level)

quiet()

Returns:

Type Description
str

The effective log level that was set

Source code in src/fnb/cli.py
def setup_logging(
    log_level: str = Options.log_level(),
    verbose: bool = Options.verbose(),
    quiet: bool = Options.quiet(),
) -> str:
    """Setup logging configuration based on command line arguments.

    Args:
        log_level: Explicit log level
        verbose: Enable verbose mode (DEBUG level)
        quiet: Enable quiet mode (WARNING level)

    Returns:
        The effective log level that was set
    """
    # Determine effective log level
    if verbose:
        effective_level = "DEBUG"
    elif quiet:
        effective_level = "WARNING"
    else:
        effective_level = log_level.upper()

    # Configure loguru
    configure_logger(level=effective_level, enable_file_logging=True)

    return effective_level

status

status(config: str | None = config(default=None), log_level: str = log_level(), verbose: bool = verbose(), quiet: bool = quiet()) -> None

Display a summary of all enabled fetch and backup tasks from configuration.

Reads the fnb configuration file and displays an organized summary of all enabled tasks, showing source and target paths for both fetch and backup operations. Also validates that target directories exist locally.

Parameters:

Name Type Description Default
config str | None

Path to configuration file. If None, auto-detects config file by searching in standard locations: - ./fnb.toml (current directory) - ./config.toml - ~/.config/fnb/config.toml (user config directory)

config(default=None)

Returns:

Name Type Description
None None

Prints task summary to stdout.

Raises:

Type Description
Exit

If no config file found or configuration is invalid.

FileNotFoundError

If specified config file doesn't exist.

ValueError

If config file contains invalid TOML or schema errors.

Examples:

Show status with auto-detected config:

>>> fnb status
📄 Config file: ./fnb.toml

📦 Fetch Tasks (remote → local): ✅ logs: user@server:~/logs/ → ./backup/logs/

💾 Backup Tasks (local → external): ✅ logs: ./backup/logs/ → /mnt/external/backup/

Use specific config file:

>>> fnb status --config /path/to/custom.toml
📄 Config file: /path/to/custom.toml
...
Source code in src/fnb/cli.py
@app.command()
def status(
    config: str | None = Options.config(default=None),
    log_level: str = Options.log_level(),
    verbose: bool = Options.verbose(),
    quiet: bool = Options.quiet(),
) -> None:
    """Display a summary of all enabled fetch and backup tasks from configuration.

    Reads the fnb configuration file and displays an organized summary of all
    enabled tasks, showing source and target paths for both fetch and backup
    operations. Also validates that target directories exist locally.

    Args:
        config: Path to configuration file. If None, auto-detects config file
            by searching in standard locations:
            - ./fnb.toml (current directory)
            - ./config.toml
            - ~/.config/fnb/config.toml (user config directory)

    Returns:
        None: Prints task summary to stdout.

    Raises:
        typer.Exit: If no config file found or configuration is invalid.
        FileNotFoundError: If specified config file doesn't exist.
        ValueError: If config file contains invalid TOML or schema errors.

    Examples:
        Show status with auto-detected config:

        >>> fnb status
        📄 Config file: ./fnb.toml

        📦 Fetch Tasks (remote → local):
         ✅ logs: user@server:~/logs/ → ./backup/logs/

        💾 Backup Tasks (local → external):
         ✅ logs: ./backup/logs/ → /mnt/external/backup/

        Use specific config file:

        >>> fnb status --config /path/to/custom.toml
        📄 Config file: /path/to/custom.toml
        ...
    """
    # Setup logging first
    setup_logging(log_level=log_level, verbose=verbose, quiet=quiet)

    try:
        config_path = Path(config) if config else None
        reader = ConfigReader(config_path)
        reader.print_status()
    except FileNotFoundError:
        typer.echo("❌ No config file found. Run 'fnb init' to create one.")
        raise typer.Exit(code=1)
    except Exception as e:
        typer.echo(f"❌ Error: {e}")
        raise typer.Exit(code=1)

sync

sync(label: str, dry_run: bool = dry_run(), create_dirs: bool = create_dirs(), ssh_password: str | None = ssh_password(), config: str | None = config(default=None), log_level: str = log_level(), verbose: bool = verbose(), quiet: bool = quiet()) -> None

Execute both fetch and backup operations sequentially for a given label.

This is a convenience command that runs both fetch (remote → local) and backup (local → external) operations in sequence for the same label. This provides a complete data pipeline from remote source to backup destination.

Parameters:

Name Type Description Default
label str

Task label that identifies both fetch and backup configurations. Must exist in both [fetch.LABEL] and [backup.LABEL] sections.

required
dry_run bool

If True, preview both operations without actually transferring files. Shows what would be done for both fetch and backup.

dry_run()
create_dirs bool

If True, automatically create directories for both operations if they don't exist.

create_dirs()
ssh_password str | None

SSH password for remote authentication during fetch. Only used for the fetch operation, not backup.

ssh_password()
config str | None

Path to the configuration file. If None, auto-detects config file by searching standard locations.

config(default=None)

Returns:

Name Type Description
None None

Executes both operations and prints status messages.

Raises:

Type Description
Exit

If either operation fails due to: - Task label not found in fetch or backup configuration - Configuration file issues - Directory access problems - rsync execution failures

Examples:

Sync logs from remote to backup:

>>> fnb sync logs
📦 Fetch logs from user@server:~/logs/ → ./backup/logs/
Fetching logs from user@server:~/logs/ to ./backup/logs/
Fetch completed successfully: logs
💾 Backup logs from ./backup/logs/ → /mnt/external/backup/
Backing up logs from ./backup/logs/ to /mnt/external/backup/
Backup completed successfully: logs

✅ Sync operation completed for 'logs'

Preview complete sync pipeline:

>>> fnb sync logs --dry-run
📦 Fetch logs from user@server:~/logs/ → ./backup/logs/
(DRY RUN - no files will be modified)
💾 Backup logs from ./backup/logs/ → /mnt/external/backup/
(DRY RUN - no files will be modified)

✅ Sync preview completed for 'logs'

Sync with SSH authentication and directory creation:

>>> fnb sync logs --ssh-password mypass --create-dirs
📦 Fetch logs from user@server:~/logs/ → ./backup/logs/
Using SSH password from command line for host: user@server
Created directory: ./backup/logs
💾 Backup logs from ./backup/logs/ → /mnt/external/backup/
Created directory: /mnt/external/backup

✅ Sync operation completed for 'logs'

Source code in src/fnb/cli.py
@app.command()
def sync(
    label: str,
    dry_run: bool = Options.dry_run(),
    create_dirs: bool = Options.create_dirs(),
    ssh_password: str | None = Options.ssh_password(),
    config: str | None = Options.config(default=None),
    log_level: str = Options.log_level(),
    verbose: bool = Options.verbose(),
    quiet: bool = Options.quiet(),
) -> None:
    """Execute both fetch and backup operations sequentially for a given label.

    This is a convenience command that runs both fetch (remote → local) and
    backup (local → external) operations in sequence for the same label.
    This provides a complete data pipeline from remote source to backup destination.

    Args:
        label: Task label that identifies both fetch and backup configurations.
            Must exist in both [fetch.LABEL] and [backup.LABEL] sections.
        dry_run: If True, preview both operations without actually transferring
            files. Shows what would be done for both fetch and backup.
        create_dirs: If True, automatically create directories for both
            operations if they don't exist.
        ssh_password: SSH password for remote authentication during fetch.
            Only used for the fetch operation, not backup.
        config: Path to the configuration file. If None, auto-detects config
            file by searching standard locations.

    Returns:
        None: Executes both operations and prints status messages.

    Raises:
        typer.Exit: If either operation fails due to:
            - Task label not found in fetch or backup configuration
            - Configuration file issues
            - Directory access problems
            - rsync execution failures

    Examples:
        Sync logs from remote to backup:

        >>> fnb sync logs
        📦 Fetch logs from user@server:~/logs/ → ./backup/logs/
        Fetching logs from user@server:~/logs/ to ./backup/logs/
        Fetch completed successfully: logs
        💾 Backup logs from ./backup/logs/ → /mnt/external/backup/
        Backing up logs from ./backup/logs/ to /mnt/external/backup/
        Backup completed successfully: logs

        ✅ Sync operation completed for 'logs'

        Preview complete sync pipeline:

        >>> fnb sync logs --dry-run
        📦 Fetch logs from user@server:~/logs/ → ./backup/logs/
        (DRY RUN - no files will be modified)
        💾 Backup logs from ./backup/logs/ → /mnt/external/backup/
        (DRY RUN - no files will be modified)

        ✅ Sync preview completed for 'logs'

        Sync with SSH authentication and directory creation:

        >>> fnb sync logs --ssh-password mypass --create-dirs
        📦 Fetch logs from user@server:~/logs/ → ./backup/logs/
        Using SSH password from command line for host: user@server
        Created directory: ./backup/logs
        💾 Backup logs from ./backup/logs/ → /mnt/external/backup/
        Created directory: /mnt/external/backup

        ✅ Sync operation completed for 'logs'
    """
    # Setup logging first
    setup_logging(log_level=log_level, verbose=verbose, quiet=quiet)

    try:
        config_path = Path(config) if config else None
        reader = ConfigReader(config_path)

        # Get fetch task
        fetch_task = reader.config.get_task_by_label("fetch", label)
        if fetch_task and fetch_task.enabled:
            typer.echo(f"📦 Fetch {label} from {fetch_task.host}{fetch_task.target}")
            fetcher.run(
                fetch_task,
                dry_run=dry_run,
                ssh_password=ssh_password,
                create_dirs=create_dirs,
            )
        else:
            typer.echo(f"⚠️  Skipping fetch: no enabled task found for label '{label}'")

        # Get backup task
        backup_task = reader.config.get_task_by_label("backup", label)
        if backup_task and backup_task.enabled:
            typer.echo(
                f"💾 Backup {label} from {backup_task.source}{backup_task.target}"
            )
            backuper.run(
                backup_task,
                dry_run=dry_run,
                create_dirs=create_dirs,
            )
        else:
            typer.echo(f"⚠️  Skipping backup: no enabled task found for label '{label}'")

        typer.echo(
            f"\n✅ Sync {'preview' if dry_run else 'operation'} completed for '{label}'"
        )

    except FileNotFoundError as e:
        typer.echo(f"❌ {e}")
        typer.echo("Use --create-dirs option to create missing directories.")
        raise typer.Exit(1)
    except Exception as e:
        typer.echo(f"❌ Error: {e}")
        raise typer.Exit(1)

version

version() -> None

Display the current version of fnb (Fetch'n'Backup) tool.

This command shows the installed version number of the fnb CLI tool. Useful for troubleshooting, compatibility checking, and support requests.

Returns:

Name Type Description
None None

Prints version information to stdout and exits.

Examples:

Display current version:

>>> fnb version
fnb version 0.10.0
Source code in src/fnb/cli.py
@app.command()
def version() -> None:
    """Display the current version of fnb (Fetch'n'Backup) tool.

    This command shows the installed version number of the fnb CLI tool.
    Useful for troubleshooting, compatibility checking, and support requests.

    Returns:
        None: Prints version information to stdout and exits.

    Examples:
        Display current version:

        >>> fnb version
        fnb version 0.10.0
    """
    typer.echo(f"fnb version {__version__}")

Environment Variable Handling

env module

Environment variable management for fnb SSH authentication.

This module handles loading environment variables from .env files and provides SSH password retrieval for specific hosts with flexible fallback mechanisms.

Key features: - Hierarchical .env file loading (global and local precedence) - Host-specific password configuration with normalization - Clean interface for retrieving SSH passwords with fallbacks - Uses python-dotenv for reliable environment variable handling

get_ssh_password

get_ssh_password(host: str) -> str | None

Retrieve SSH password for a specific host from environment variables.

Implements a flexible password lookup system that supports both host-specific and default password configurations. Automatically normalizes host names to valid environment variable names and provides fallback to default passwords.

The lookup order prioritizes host-specific passwords over defaults, allowing fine-grained control while maintaining convenience for simple setups.

When no password is found, fnb automatically falls back to interactive password input where SSH/rsync will prompt the user directly in the terminal.

Parameters:

Name Type Description Default
host str

The hostname specification, can be in formats: - "user@hostname" (full SSH specification) - "hostname" (hostname only) Special characters are normalized for environment variable lookup.

required

Returns:

Type Description
str | None

str | None: The SSH password if found in environment variables,

str | None

None if no password is configured. When None is returned, fnb will

str | None

fall back to interactive password input via SSH's standard prompts.

Examples:

Get password for specific host:

>>> # With FNB_PASSWORD_USER_EXAMPLE_COM="hostpass" in environment
>>> password = get_ssh_password("user@example.com")
>>> password
'hostpass'

Get default password when host-specific not found:

>>> # With FNB_PASSWORD_DEFAULT="defaultpass" in environment
>>> password = get_ssh_password("newserver.com")
>>> password
'defaultpass'

No password found (triggers interactive input):

>>> password = get_ssh_password("unknown.server")
>>> password is None
True
>>> # fnb will then prompt: "user@unknown.server's password:"

Host normalization examples:

>>> # These hosts map to the same environment variable:
>>> # "user@my-server.com" -> FNB_PASSWORD_USER_MY_SERVER_COM
>>> # "admin@my.server.com" -> FNB_PASSWORD_ADMIN_MY_SERVER_COM
Note

Host normalization rules: - @ symbols become underscores - Dots (.) become underscores - Hyphens (-) become underscores - Case insensitive (converted to uppercase)

Environment variable lookup order: 1. FNB_PASSWORD_{NORMALIZED_HOST} - Host-specific password 2. FNB_PASSWORD_DEFAULT - Fallback for all hosts 3. None (triggers interactive SSH password prompt)

Source code in src/fnb/env.py
def get_ssh_password(host: str) -> str | None:
    """Retrieve SSH password for a specific host from environment variables.

    Implements a flexible password lookup system that supports both host-specific
    and default password configurations. Automatically normalizes host names to
    valid environment variable names and provides fallback to default passwords.

    The lookup order prioritizes host-specific passwords over defaults, allowing
    fine-grained control while maintaining convenience for simple setups.

    When no password is found, fnb automatically falls back to interactive
    password input where SSH/rsync will prompt the user directly in the terminal.

    Args:
        host: The hostname specification, can be in formats:
            - "user@hostname" (full SSH specification)
            - "hostname" (hostname only)
            Special characters are normalized for environment variable lookup.

    Returns:
        str | None: The SSH password if found in environment variables,
        None if no password is configured. When None is returned, fnb will
        fall back to interactive password input via SSH's standard prompts.

    Examples:
        Get password for specific host:

        >>> # With FNB_PASSWORD_USER_EXAMPLE_COM="hostpass" in environment
        >>> password = get_ssh_password("user@example.com")
        >>> password
        'hostpass'

        Get default password when host-specific not found:

        >>> # With FNB_PASSWORD_DEFAULT="defaultpass" in environment
        >>> password = get_ssh_password("newserver.com")
        >>> password
        'defaultpass'

        No password found (triggers interactive input):

        >>> password = get_ssh_password("unknown.server")
        >>> password is None
        True
        >>> # fnb will then prompt: "user@unknown.server's password:"

        Host normalization examples:

        >>> # These hosts map to the same environment variable:
        >>> # "user@my-server.com" -> FNB_PASSWORD_USER_MY_SERVER_COM
        >>> # "admin@my.server.com" -> FNB_PASSWORD_ADMIN_MY_SERVER_COM

    Note:
        Host normalization rules:
        - @ symbols become underscores
        - Dots (.) become underscores
        - Hyphens (-) become underscores
        - Case insensitive (converted to uppercase)

        Environment variable lookup order:
        1. FNB_PASSWORD_{NORMALIZED_HOST} - Host-specific password
        2. FNB_PASSWORD_DEFAULT - Fallback for all hosts
        3. None (triggers interactive SSH password prompt)
    """
    # Load .env files if not already loaded
    load_env_files()

    # First try to find a password for the specific host
    # Replace any characters that can't be in an
    # environment variable with underscore
    normalized_host = host.replace("@", "_").replace(".", "_").replace("-", "_")
    password = os.environ.get(f"FNB_PASSWORD_{normalized_host}")

    # If not found, try the default password
    if password is None:
        password = os.environ.get("FNB_PASSWORD_DEFAULT")

    return password

load_env_files

load_env_files() -> bool

Load environment variables from .env files with hierarchical precedence.

Implements a multi-layered environment variable loading system that supports both global user settings and project-specific overrides. Files are loaded in precedence order, with local files taking priority over global ones.

The loading order ensures that project-specific settings can override user-wide defaults, providing flexibility for different deployment scenarios.

Returns:

Name Type Description
bool bool

True if at least one .env file was found and loaded successfully,

bool

False if no .env files exist in any of the search locations.

Examples:

Load existing environment files:

>>> loaded = load_env_files()
>>> loaded
True

No environment files found:

>>> loaded = load_env_files()  # No .env files exist
>>> loaded
False

Check environment variables after loading:

>>> load_env_files()
True
>>> import os
>>> password = os.environ.get("FNB_PASSWORD_DEFAULT")
>>> password is not None
True
Note

Loading order (later overrides earlier): 1. ~/.config/fnb/.env - Global user configuration 2. ./.env - Local project configuration (highest priority)

Source code in src/fnb/env.py
def load_env_files() -> bool:
    """Load environment variables from .env files with hierarchical precedence.

    Implements a multi-layered environment variable loading system that supports
    both global user settings and project-specific overrides. Files are loaded
    in precedence order, with local files taking priority over global ones.

    The loading order ensures that project-specific settings can override
    user-wide defaults, providing flexibility for different deployment scenarios.

    Returns:
        bool: True if at least one .env file was found and loaded successfully,
        False if no .env files exist in any of the search locations.

    Examples:
        Load existing environment files:

        >>> loaded = load_env_files()
        >>> loaded
        True

        No environment files found:

        >>> loaded = load_env_files()  # No .env files exist
        >>> loaded
        False

        Check environment variables after loading:

        >>> load_env_files()
        True
        >>> import os
        >>> password = os.environ.get("FNB_PASSWORD_DEFAULT")
        >>> password is not None
        True

    Note:
        Loading order (later overrides earlier):
        1. ~/.config/fnb/.env - Global user configuration
        2. ./.env - Local project configuration (highest priority)
    """
    # Track if we loaded any env files
    loaded = False

    # Global config location
    app_name = "fnb"
    config_dir = platformdirs.user_config_path(app_name)

    global_env = config_dir / ".env"
    if global_env.exists():
        load_dotenv(global_env)
        loaded = True

    # Local config (higher priority)
    local_env = Path("./.env")
    if local_env.exists():
        load_dotenv(local_env)
        loaded = True

    return loaded

Usage Examples

Basic Programmatic Usage

from pathlib import Path
from fnb.reader import ConfigReader
from fnb.fetcher import run as run_fetch
from fnb.backuper import run as run_backup

# Load configuration
reader = ConfigReader(Path("./config.toml"))

# Get tasks
fetch_task = reader.config.get_task_by_label("fetch", "docs")
backup_task = reader.config.get_task_by_label("backup", "docs")

# Execute fetch
if fetch_task and fetch_task.enabled:
    print(f"Fetching {fetch_task.label}...")
    run_fetch(fetch_task, dry_run=False)

# Execute backup
if backup_task and backup_task.enabled:
    print(f"Backing up {backup_task.label}...")
    run_backup(backup_task, dry_run=False)

Custom Configuration Extension

from typing import Optional
from pydantic import Field
from fnb.config import RsyncTaskConfig

class ExtendedTaskConfig(RsyncTaskConfig):
    """Extended task configuration with notification options"""
    notify_email: Optional[str] = Field(None, description="Email address for completion notifications")
    notify_on_error: bool = Field(True, description="Whether to send notifications on error")
    retention_days: Optional[int] = Field(None, description="Number of days to retain backups")

Batch Processing Example

from fnb.reader import ConfigReader
from fnb.fetcher import run as run_fetch
from fnb.backuper import run as run_backup

def run_all_tasks(config_path: str, dry_run: bool = True):
    """Execute all tasks sequentially"""
    reader = ConfigReader(config_path)

    # Execute all fetch tasks
    for task in reader.config.get_enabled_tasks("fetch"):
        print(f"Fetching: {task.label}")
        run_fetch(task, dry_run=dry_run)

    # Execute all backup tasks
    for task in reader.config.get_enabled_tasks("backup"):
        print(f"Backing up: {task.label}")
        run_backup(task, dry_run=dry_run)