User preference scenarios

These ten programs cover the lifecycle of preferences in a desktop or terminal application. They use only Upref and the Python standard library. Run them from an installed checkout, for example:

python examples/first_run_preferences.py
python examples/reset_preferences.py

On Windows, .\.venv\Scripts\python.exe can replace python without activating the environment. Every file in these scenarios is temporary and removed on exit, so each invocation begins with fresh demonstration data. To adapt a recipe to real preferences, create ConfigStore("your-app") without a temporary directory override and remove the demo’s seed data.

First-run setup

first_run_preferences.py asks for theme, language, font size, and notifications using Field. Parsers and validators reject invalid choices and out-of-range sizes. Press Enter four times to accept the defaults, or try purple as the theme and 9 as the font size to see retries.

File presence marks completed setup in this recipe. No file is written if collection is cancelled. A second startup is simulated in the same process and reuses the saved preferences without asking again. Existing values are not business-validated on that path; see typed_settings.py when manually edited preferences need validation on every startup.

"""Collect user preferences once, then reuse them on later application starts."""

from pathlib import Path
from tempfile import TemporaryDirectory

from upref import (
    Config,
    ConfigStore,
    Field,
    PromptCancelled,
    Prompter,
    collect,
    parse_bool,
)
from upref.tty import TTYPrompter

DEFAULTS: Config = {
    "theme": "system",
    "language": "en",
    "font_size": 14,
    "notifications": True,
}
SCHEMA = {
    "theme": Field(
        "Theme: system, light, or dark",
        parser=str.strip,
        validator=lambda value: value in ("system", "light", "dark"),
    ),
    "language": Field(
        "Language: en or fr",
        parser=str.strip,
        validator=lambda value: value in ("en", "fr"),
    ),
    "font_size": Field(
        "Font size: 10 to 32",
        parser=int,
        validator=lambda value: type(value) is int and 10 <= value <= 32,
    ),
    "notifications": Field("Enable notifications", parser=parse_bool),
}


def configure(store: ConfigStore, prompter: Prompter) -> Config:
    """Ask on first launch; file presence marks a completed setup."""
    initial = store.load(defaults=DEFAULTS)
    if store.exists():
        return initial
    values = collect(SCHEMA, initial=initial, interface=prompter, mode="all")
    store.save(values)
    return values


def main() -> None:
    """Simulate two launches in one temporary application directory."""
    with TemporaryDirectory(prefix="upref-first-run-") as directory:
        store = ConfigStore("first-run-demo", directory=Path(directory).resolve())
        prompter = TTYPrompter(keep_current=True)
        try:
            print("First startup: Enter accepts each suggested preference.")
            print(configure(store, prompter))
        except PromptCancelled:
            print("Cancelled; setup will be offered again on the next startup.")
            return
        print("Second startup: preferences reused without prompting.")
        print(configure(store, prompter))


if __name__ == "__main__":
    main()

Editing and applying a draft

apply_preferences.py starts with saved preferences and edits a detached draft. The final yes/no prompt defaults to False: Enter discards the draft, yes saves it, and Ctrl+C cancels the interaction. A language preference outside the editing schema survives a successful save.

For example, enter dark, 20, and yes to apply changes. Repeat with no to observe the unchanged saved preferences. A load/edit/save interaction assumes one writer; applications with simultaneous writers must coordinate the whole operation as described in Using ConfigStore.

"""Edit a draft and require an explicit Apply decision before saving it."""

from pathlib import Path
from tempfile import TemporaryDirectory

from upref import ConfigStore, Field, PromptCancelled, Prompter, collect, parse_bool
from upref.tty import TTYPrompter


def edit_preferences(store: ConfigStore, prompter: Prompter) -> bool:
    """Return whether the complete draft was applied; cancellation propagates."""
    draft = collect(
        {
            "theme": Field(
                "Theme: system, light, or dark",
                parser=str.strip,
                validator=lambda value: value in ("system", "light", "dark"),
            ),
            "font_size": Field(
                "Font size: 10 to 32",
                parser=int,
                validator=lambda value: type(value) is int and 10 <= value <= 32,
            ),
        },
        initial=store.load(),
        interface=prompter,
        mode="all",
    )
    decision = collect(
        {"apply": Field("Apply these changes", parser=parse_bool)},
        initial={"apply": False},
        interface=prompter,
        mode="all",
    )
    if decision["apply"] is not True:
        return False
    # This load/edit/save sequence assumes a single writer for these preferences.
    store.save(draft)
    return True


def main() -> None:
    """Demonstrate Apply, discard, and cancellation with existing preferences."""
    with TemporaryDirectory(prefix="upref-apply-") as directory:
        store = ConfigStore("apply-demo", directory=Path(directory).resolve())
        store.save({"theme": "system", "font_size": 14, "language": "fr"})
        try:
            applied = edit_preferences(store, TTYPrompter(keep_current=True))
        except PromptCancelled:
            print("Cancelled; saved preferences unchanged.")
        else:
            print("Changes applied." if applied else "Draft discarded.")
        print("Saved preferences:", store.load())


if __name__ == "__main__":
    main()

Resetting preferences

reset_preferences.py removes a saved font-size override, then the entire appearance section, then the configuration file. The intermediate output shows that notifications remain disabled while appearance returns to defaults.

Load the raw saved mapping when removing overrides. Loading with defaults and saving that result would persist those default values. Likewise, update({"appearance": {}}) does not clear a section because mappings merge recursively. Remove the key from the loaded mapping and save explicitly.

"""Remove saved overrides to restore one preference, a section, or all defaults."""

from pathlib import Path
from tempfile import TemporaryDirectory

from upref import Config, ConfigStore

DEFAULTS: Config = {
    "appearance": {"theme": "system", "font_size": 14},
    "notifications": True,
}


def main() -> None:
    """Show why deleting an override differs from saving a resolved default."""
    with TemporaryDirectory(prefix="upref-reset-") as directory:
        store = ConfigStore("reset-demo", directory=Path(directory).resolve())
        store.save(
            {
                "appearance": {"theme": "dark", "font_size": 20},
                "notifications": False,
            }
        )
        saved = store.load()  # Do not merge defaults into the data being edited.
        appearance = saved["appearance"]
        if not isinstance(appearance, dict):
            raise ValueError("appearance must be a mapping")
        appearance.pop("font_size", None)
        store.save(saved)
        print("One preference reset:", store.load(defaults=DEFAULTS))

        saved = store.load()
        saved.pop("appearance", None)
        store.save(saved)  # update({'appearance': {}}) would retain old nested keys.
        print(
            "Appearance reset; notifications retained:", store.load(defaults=DEFAULTS)
        )
        assert store.load() == {"notifications": False}

        store.delete()
        print("All defaults restored:", store.load(defaults=DEFAULTS))
        assert not store.exists()  # Loading defaults does not recreate the file.


if __name__ == "__main__":
    main()

Recent documents

recent_files.py normalizes paths, moves reopened documents to the front, and limits the history to two entries in the demonstration. The result is ['notes.txt', 'draft.md']; the separate theme preference remains dark. No document is opened or created. Existing history entries are assumed to have been normalized by this application; malformed lists are rejected before saving.

"""Maintain a bounded, most-recent-first history without opening any files."""

from os.path import normcase
from pathlib import Path
from tempfile import TemporaryDirectory

from upref import ConfigStore


def remember_file(store: ConfigStore, path: Path, *, limit: int = 5) -> list[str]:
    """Record an absolute path once, preserving unrelated preferences."""
    if type(limit) is not int or limit <= 0:
        raise ValueError("limit must be a positive integer")
    recent = store.load().get("recent_files", [])
    if not isinstance(recent, list) or not all(
        isinstance(item, str) for item in recent
    ):
        raise ValueError("recent_files must be a list of strings")
    candidate = str(path.expanduser().resolve())
    # Existing entries were normalized by earlier calls to this function.
    retained = [
        item
        for item in recent
        if isinstance(item, str) and normcase(item) != normcase(candidate)
    ]
    updated = [candidate, *retained][:limit]
    store.update({"recent_files": list(updated)})
    return updated


def main() -> None:
    """Move a reopened document to the front and trim the history."""
    with TemporaryDirectory(prefix="upref-recent-") as directory:
        root = Path(directory).resolve()
        store = ConfigStore("recent-demo", directory=root)
        store.save({"theme": "dark"})
        for name in ("notes.txt", "budget.csv", "draft.md", "notes.txt"):
            recent = remember_file(store, root / name, limit=2)
        print("Recent documents:", [Path(item).name for item in recent])
        print("Unrelated theme retained:", store.load()["theme"])


if __name__ == "__main__":
    main()

Window position and size

window_preferences.py restores a window saved on a larger monitor into a 1280 by 720 display. Invalid value types fall back to defaults; bounds are clamped so the restored window is visible. The example does not open a window.

The geometry model uses the primary display’s usable rectangle with origin (0, 0). For multiple monitors, obtain the actual work areas and offsets from the GUI toolkit. Persist normal window bounds when closing, together with a separate maximized flag; minimized bounds are not useful restoration data.

"""Restore usable window geometry after a display size changes, without a GUI."""

from pathlib import Path
from tempfile import TemporaryDirectory

from upref import Config, ConfigStore, ConfigValue


def integer_or(value: ConfigValue, default: int) -> int:
    """Use an integer preference while excluding booleans and invalid types."""
    return value if type(value) is int else default


def restore_window(saved: Config, screen_width: int, screen_height: int) -> Config:
    """Fit geometry inside a primary display whose usable origin is (0, 0)."""
    if screen_width <= 0 or screen_height <= 0:
        raise ValueError("Screen dimensions must be positive")
    width = min(max(integer_or(saved.get("width"), 900), 320), screen_width)
    height = min(max(integer_or(saved.get("height"), 600), 240), screen_height)
    return {
        "x": min(max(integer_or(saved.get("x"), 0), 0), screen_width - width),
        "y": min(max(integer_or(saved.get("y"), 0), 0), screen_height - height),
        "width": width,
        "height": height,
        "maximized": saved.get("maximized") is True,
    }


def main() -> None:
    """Adapt saved geometry from a larger monitor to a 1280 by 720 display."""
    with TemporaryDirectory(prefix="upref-window-") as directory:
        store = ConfigStore("window-demo", directory=Path(directory).resolve())
        store.save({"window": {"x": 2500, "y": -200, "width": 1600, "height": 900}})
        saved = store.load()["window"]
        if not isinstance(saved, dict):
            raise ValueError("window must be a mapping")
        restored = restore_window(saved, 1280, 720)
        print("Visible window geometry:", restored)
        # In a real GUI, capture normal (not minimized) bounds on window close.
        store.update({"window": restored})


if __name__ == "__main__":
    main()

Several accounts in one application

account_preferences.py stores work and personal settings under separate account keys. Updating the work theme preserves the personal account. Unknown accounts receive defaults, and signing out removes the active selection while retaining the saved preferences.

Account IDs are mapping keys, not filenames or credentials. These are accounts inside the same OS user’s application, not an access-control boundary between different OS users. The recipe uses flat account preferences; nested defaults would need an explicit recursive merge strategy.

"""Separate account preferences within one OS user's application configuration."""

from pathlib import Path
from tempfile import TemporaryDirectory

from upref import Config, ConfigStore


def preferences_for(store: ConfigStore, account_id: str) -> Config:
    """Resolve one account's flat preferences with application defaults."""
    accounts = store.load().get("accounts", {})
    if not isinstance(accounts, dict):
        raise ValueError("accounts must be a mapping")
    account = accounts.get(account_id, {})
    if not isinstance(account, dict):
        raise ValueError("Each account must have a preference mapping")
    resolved: Config = {"theme": "system", "language": "en"}
    resolved.update(account)
    return resolved


def main() -> None:
    """Switch accounts and change one account without replacing the other."""
    with TemporaryDirectory(prefix="upref-accounts-") as directory:
        store = ConfigStore("accounts-demo", directory=Path(directory).resolve())
        # Account IDs are mapping keys, not filesystem paths or credentials.
        store.save(
            {
                "active_account": "personal",
                "accounts": {
                    "personal": {"theme": "light"},
                    "work": {"language": "fr"},
                },
            }
        )
        store.update(
            {"active_account": "work", "accounts": {"work": {"theme": "dark"}}}
        )
        print("Work preferences:", preferences_for(store, "work"))
        print("Personal preferences unchanged:", preferences_for(store, "personal"))
        print("New account defaults:", preferences_for(store, "new-account"))
        # Signing out removes the session selection, not the user's preferences.
        saved = store.load()
        saved.pop("active_account", None)
        store.save(saved)
        print("Signed out; account preferences retained.")


if __name__ == "__main__":
    main()

Preferences for the current session

session_overrides.py makes precedence explicit: application defaults, saved preferences, then command-line options. Try:

python examples/session_overrides.py --theme dark --font-size 20
python examples/session_overrides.py --theme dark --remember

The first command prints a dark session while saved preferences remain light. The second persists only the supplied theme override. It does not write every application default. Both commands operate inside a temporary demo directory, so --remember does not affect a later invocation of this example.

"""Apply CLI preferences for this session, persisting them only with --remember."""

import argparse
from collections.abc import Sequence
from pathlib import Path
from tempfile import TemporaryDirectory

from upref import Config, ConfigStore

DEFAULTS: Config = {"theme": "system", "font_size": 14, "language": "en"}


def font_size_argument(raw: str) -> int:
    """Parse a CLI font size in the application's supported range."""
    try:
        size = int(raw)
    except ValueError as error:
        raise argparse.ArgumentTypeError("font size must be an integer") from error
    if not 10 <= size <= 32:
        raise argparse.ArgumentTypeError("font size must be between 10 and 32")
    return size


def session_preferences(
    store: ConfigStore, overrides: Config, *, remember: bool = False
) -> Config:
    """Use defaults below saved values below already validated CLI overrides."""
    resolved = store.load(defaults=DEFAULTS)
    resolved.update(overrides)  # These preferences are flat scalar values.
    if remember and overrides:
        store.update(overrides)  # Persist only explicit overrides, not all defaults.
    return resolved


def main(argv: Sequence[str] | None = None) -> None:
    """Compare effective and saved preferences inside an isolated demo store."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--theme", choices=("system", "light", "dark"))
    parser.add_argument("--font-size", type=font_size_argument)
    parser.add_argument(
        "--remember", action="store_true", help="save CLI overrides in the demo store"
    )
    arguments = parser.parse_args(argv)
    overrides: Config = {}
    if arguments.theme is not None:
        overrides["theme"] = arguments.theme
    if arguments.font_size is not None:
        overrides["font_size"] = arguments.font_size
    with TemporaryDirectory(prefix="upref-session-") as directory:
        store = ConfigStore("session-demo", directory=Path(directory).resolve())
        store.save({"theme": "light", "font_size": 16})
        effective = session_preferences(store, overrides, remember=arguments.remember)
        print("This session:", effective)
        print("Saved overrides:", store.load())
        print("The demo directory is removed on exit, including with --remember.")


if __name__ == "__main__":
    main()

Importing and exporting portable preferences

import_export_preferences.py exports only theme, language, and font size to JSON. Machine-local document history is excluded. Import rejects unknown keys, invalid values, duplicate keys, and malformed JSON before the application chooses to apply the preview. Boolean font sizes are rejected explicitly.

The demonstration transfers preferences between two stores while preserving ['target-only.txt'] as the target’s local history. This is a small, local file exchange example; its JSON export uses an ordinary file write. Configuration persistence through ConfigStore continues to use atomic replacement.

"""Export portable preferences and validate an import before explicitly applying it."""

import json
from pathlib import Path
from tempfile import TemporaryDirectory

from upref import Config, ConfigStore

PORTABLE_KEYS = ("theme", "font_size", "language")


def validate_portable(data: object) -> Config:
    """Accept a partial, known preference mapping and reject invalid values."""
    if not isinstance(data, dict) or any(key not in PORTABLE_KEYS for key in data):
        raise ValueError("Import must contain only theme, font_size, and language")
    result: Config = {}
    if "theme" in data:
        theme = data["theme"]
        if not isinstance(theme, str) or theme not in ("system", "light", "dark"):
            raise ValueError("Unsupported theme")
        result["theme"] = theme
    if "font_size" in data:
        font_size = data["font_size"]
        if type(font_size) is not int or not 10 <= font_size <= 32:
            raise ValueError("font_size must be an integer between 10 and 32")
        result["font_size"] = font_size
    if "language" in data:
        language = data["language"]
        if not isinstance(language, str) or language not in ("en", "fr"):
            raise ValueError("Unsupported language")
        result["language"] = language
    return result


def export_preferences(store: ConfigStore, destination: Path) -> None:
    """Write only portable preferences, excluding machine-local and private keys."""
    saved = store.load()
    portable = validate_portable(
        {key: saved[key] for key in PORTABLE_KEYS if key in saved}
    )
    destination.write_text(
        json.dumps(portable, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )


def unique_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
    """Reject repeated JSON keys rather than silently choosing the last value."""
    result: dict[str, object] = {}
    for key, value in pairs:
        if key in result:
            raise ValueError(f"Duplicate preference key: {key}")
        result[key] = value
    return result


def preview_import(source: Path) -> Config:
    """Read and validate a JSON import without changing any saved preference."""
    return validate_portable(
        json.loads(source.read_text(encoding="utf-8"), object_pairs_hook=unique_keys)
    )


def main() -> None:
    """Transfer appearance preferences while retaining local document history."""
    with TemporaryDirectory(prefix="upref-transfer-") as directory:
        root = Path(directory).resolve()
        source = ConfigStore("transfer-demo", filename="source.yaml", directory=root)
        target = ConfigStore("transfer-demo", filename="target.yaml", directory=root)
        source.save(
            {
                "theme": "dark",
                "font_size": 18,
                "language": "fr",
                "recent_files": ["source-only.txt"],
            }
        )
        target.save({"theme": "light", "recent_files": ["target-only.txt"]})
        export = root / "preferences.json"
        export_preferences(source, export)
        before = target.path.read_bytes()
        preview = preview_import(export)
        print("Validated import preview:", preview)
        assert target.path.read_bytes() == before
        target.update(preview)  # The application explicitly chooses to apply it.
        print("Local history retained:", target.load()["recent_files"])


if __name__ == "__main__":
    main()

Backing up and restoring settings

backup_restore.py saves a value snapshot in a separate configuration file, changes preferences, then restores the snapshot. A missing or malformed backup raises before the current file is replaced. The backup itself remains unchanged.

The snapshot preserves values rather than YAML comments or formatting. This single-writer recipe does not provide backup rotation or a transaction across the two files. Coordinate other writers when using it in a shared application.

"""Keep a configuration snapshot and restore it after an unwanted settings change."""

from pathlib import Path
from tempfile import TemporaryDirectory

from upref import ConfigStore


def restore_backup(store: ConfigStore, backup: ConfigStore) -> None:
    """Validate an existing backup completely before replacing the current file."""
    if not backup.exists():
        raise FileNotFoundError(f"No preferences backup: {backup.path}")
    # The demo assumes one writer; coordinate backup and restore in shared apps.
    restored = backup.load()
    store.save(restored)


def main() -> None:
    """Back up values, change them, and recover the earlier snapshot."""
    with TemporaryDirectory(prefix="upref-backup-") as directory:
        root = Path(directory).resolve()
        store = ConfigStore("backup-demo", directory=root)
        backup = ConfigStore("backup-demo", filename="backup.yaml", directory=root)
        store.save({"theme": "dark", "font_size": 16, "notifications": False})
        backup.save(store.load())  # A value snapshot, not a copy of YAML comments.
        before = backup.path.read_bytes()
        store.update({"theme": "light", "font_size": 30})
        print("Changed preferences:", store.load())
        restore_backup(store, backup)
        print("Restored preferences:", store.load())
        assert backup.path.read_bytes() == before


if __name__ == "__main__":
    main()

Previewing a legacy migration

migration_preview.py creates a temporary legacy file with a reminder value and its unit. Automatic descriptor detection extracts the value but drops the unit. source_format="raw" preserves both.

Both previews use dry_run=True and create no destination directory. The example then explicitly applies the raw conversion and verifies that the old file is unchanged. For migration of an actual v1 file, see migrate_v1.py and Migrating from Upref v1.

"""Preview an ambiguous v1 file and explicitly preserve its raw preference data."""

from pathlib import Path
from tempfile import TemporaryDirectory

from upref import ConfigStore


def main() -> None:
    """Compare auto and raw conversions without touching real legacy settings."""
    with TemporaryDirectory(prefix="upref-migration-preview-") as directory:
        root = Path(directory).resolve()
        legacy = ConfigStore("migration-demo", filename="old.conf", directory=root)
        legacy.save({"reminder_interval": {"value": 30, "unit": "minutes"}})
        original = legacy.path.read_bytes()
        store = ConfigStore("migration-demo", directory=root / "v2")
        automatic = store.import_legacy("old", legacy_directory=root, dry_run=True)
        preview = store.import_legacy(
            "old",
            legacy_directory=root,
            source_format="raw",
            dry_run=True,
        )
        print("Auto preview loses the unit:", automatic)
        print("Raw preview preserves the unit:", preview)
        assert not store.path.parent.exists()
        imported = store.import_legacy(
            "old", legacy_directory=root, source_format="raw"
        )
        assert imported == preview
        assert legacy.path.read_bytes() == original
        print("Migration applied; legacy file unchanged.")


if __name__ == "__main__":
    main()