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()