yaffo 0.0.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yaffo/__init__.py +0 -0
- yaffo/__main__.py +144 -0
- yaffo/app.py +79 -0
- yaffo/common.py +69 -0
- yaffo/config.py +154 -0
- yaffo/distance_units.py +72 -0
- yaffo/i18n.py +183 -0
- yaffo/logging_config.py +110 -0
- yaffo/template_filters.py +144 -0
- yaffo/themes.py +346 -0
- yaffo/version.py +45 -0
- yaffo-0.0.11.dist-info/METADATA +254 -0
- yaffo-0.0.11.dist-info/RECORD +16 -0
- yaffo-0.0.11.dist-info/WHEEL +5 -0
- yaffo-0.0.11.dist-info/licenses/LICENSE +21 -0
- yaffo-0.0.11.dist-info/top_level.txt +1 -0
yaffo/__init__.py
ADDED
|
File without changes
|
yaffo/__main__.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Entry point for `python -m yaffo` and the packaged (PyInstaller) app.
|
|
2
|
+
|
|
3
|
+
One frozen binary plays three roles, selected by the YAFFO_ROLE env var, because a
|
|
4
|
+
PyInstaller app's `sys.executable` is the app itself and its bootloader ignores
|
|
5
|
+
`-m`/`-c` — so a child process is started by re-executing this same entry with a
|
|
6
|
+
role set, not by invoking a module path:
|
|
7
|
+
|
|
8
|
+
- (unset) "web": serve the Flask app via waitress (a production WSGI server, not
|
|
9
|
+
the Flask dev server), run migrations, and supervise the host + watcher children.
|
|
10
|
+
- "host": run the task-queue host (which itself spawns workers via multiprocessing).
|
|
11
|
+
- "watcher": run the filesystem watcher.
|
|
12
|
+
|
|
13
|
+
`multiprocessing.freeze_support()` must run first: in a spawned task worker the
|
|
14
|
+
bootloader/freeze_support intercept startup and run the worker, never reaching main().
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import atexit
|
|
19
|
+
import multiprocessing
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import webbrowser
|
|
25
|
+
|
|
26
|
+
HOST = "127.0.0.1"
|
|
27
|
+
PORT = 5001
|
|
28
|
+
WEB_THREADS = 8
|
|
29
|
+
WORKERS = max(2, (os.cpu_count() or 4) - 1)
|
|
30
|
+
RECYCLE = 100
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _child_cmd() -> list[str]:
|
|
34
|
+
"""Command to re-launch this entry for a child role. Frozen: just the app
|
|
35
|
+
binary (args ignored; role travels via env). Dev: `python -m yaffo`."""
|
|
36
|
+
if getattr(sys, "frozen", False):
|
|
37
|
+
return [sys.executable]
|
|
38
|
+
return [sys.executable, "-m", "yaffo"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _start_background() -> list[subprocess.Popen]:
|
|
42
|
+
from yaffo.logging_config import get_logger
|
|
43
|
+
|
|
44
|
+
logger = get_logger(__name__, "webapp")
|
|
45
|
+
procs: list[subprocess.Popen] = []
|
|
46
|
+
for role in ("host", "watcher"):
|
|
47
|
+
try:
|
|
48
|
+
procs.append(subprocess.Popen(_child_cmd(), env={**os.environ, "YAFFO_ROLE": role}))
|
|
49
|
+
logger.info(f"started {role}")
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.exception(f"failed to start {role}")
|
|
52
|
+
return procs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _stop_background(procs: list[subprocess.Popen]) -> None:
|
|
56
|
+
for proc in procs:
|
|
57
|
+
if proc.poll() is None:
|
|
58
|
+
proc.terminate()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_menubar(procs: list[subprocess.Popen], url: str) -> None:
|
|
62
|
+
"""Run a macOS menu-bar item on the main thread (the AppKit run loop that gives
|
|
63
|
+
the app a face and keeps it alive). 'Quit' tears down the host/watcher children
|
|
64
|
+
so nothing is orphaned — the failure mode of the headless, faceless build."""
|
|
65
|
+
import rumps
|
|
66
|
+
|
|
67
|
+
from yaffo.common import RESOURCES_DIR
|
|
68
|
+
|
|
69
|
+
icon = RESOURCES_DIR / "branding" / "menubar.png"
|
|
70
|
+
icon_kwargs = {"icon": str(icon), "template": False} if icon.exists() else {"title": "📷"}
|
|
71
|
+
|
|
72
|
+
class YaffoApp(rumps.App):
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
super().__init__("Yaffo", quit_button=None, **icon_kwargs)
|
|
75
|
+
self.menu = [
|
|
76
|
+
rumps.MenuItem("Open Yaffo", callback=lambda _: webbrowser.open(url)),
|
|
77
|
+
None,
|
|
78
|
+
rumps.MenuItem("Quit Yaffo", callback=self._quit),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def _quit(self, _) -> None:
|
|
82
|
+
_stop_background(procs)
|
|
83
|
+
rumps.quit_application()
|
|
84
|
+
|
|
85
|
+
YaffoApp().run()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _run_web() -> None:
|
|
89
|
+
from waitress import serve
|
|
90
|
+
from yaffo.app import create_app
|
|
91
|
+
from yaffo.logging_config import get_logger
|
|
92
|
+
from yaffo.scripts.db.migrate import run_migrations
|
|
93
|
+
|
|
94
|
+
logger = get_logger(__name__, "webapp")
|
|
95
|
+
run_migrations()
|
|
96
|
+
procs = _start_background()
|
|
97
|
+
atexit.register(_stop_background, procs)
|
|
98
|
+
|
|
99
|
+
app = create_app()
|
|
100
|
+
url = f"http://{HOST}:{PORT}"
|
|
101
|
+
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
import rumps # noqa: F401 (probe: present in the bundle, absent in plain dev)
|
|
105
|
+
except Exception:
|
|
106
|
+
# No menu bar (e.g. Linux / a bare dev run): serve on the main thread.
|
|
107
|
+
logger.info(f"serving Yaffo at {url} (no menu bar)")
|
|
108
|
+
serve(app, host=HOST, port=PORT, threads=WEB_THREADS)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
logger.info(f"serving Yaffo at {url} (menu bar)")
|
|
112
|
+
threading.Thread(
|
|
113
|
+
target=serve, args=(app,),
|
|
114
|
+
kwargs={"host": HOST, "port": PORT, "threads": WEB_THREADS},
|
|
115
|
+
daemon=True,
|
|
116
|
+
).start()
|
|
117
|
+
_run_menubar(procs, url)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_host() -> None:
|
|
121
|
+
from yaffo.taskq.host import main as host_main
|
|
122
|
+
|
|
123
|
+
host_main()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_watcher() -> None:
|
|
127
|
+
from yaffo.background_tasks.watcher import main as watcher_main
|
|
128
|
+
|
|
129
|
+
watcher_main()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main() -> None:
|
|
133
|
+
role = os.environ.get("YAFFO_ROLE", "web")
|
|
134
|
+
if role == "host":
|
|
135
|
+
_run_host()
|
|
136
|
+
elif role == "watcher":
|
|
137
|
+
_run_watcher()
|
|
138
|
+
else:
|
|
139
|
+
_run_web()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
multiprocessing.freeze_support()
|
|
144
|
+
main()
|
yaffo/app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from flask import Flask
|
|
7
|
+
from yaffo import themes
|
|
8
|
+
from yaffo.db import db
|
|
9
|
+
from yaffo.common import DB_PATH
|
|
10
|
+
from yaffo.distance_units import supported_distance_unit_options
|
|
11
|
+
from yaffo.i18n import init_i18n, select_locale, supported_locale_options, text_direction
|
|
12
|
+
from yaffo.logging_config import get_logger
|
|
13
|
+
from yaffo.template_filters import init_template_filters
|
|
14
|
+
from yaffo.routes.init_routes import init_routes
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__, 'webapp')
|
|
17
|
+
|
|
18
|
+
def create_app(db_path: Path = DB_PATH, config: Optional[dict] = None):
|
|
19
|
+
app = Flask(__name__)
|
|
20
|
+
|
|
21
|
+
# Configure werkzeug logger to use our logging system
|
|
22
|
+
werkzeug_logger = logging.getLogger('werkzeug')
|
|
23
|
+
werkzeug_logger.setLevel(logging.ERROR)
|
|
24
|
+
|
|
25
|
+
# Set Flask app logger to use our webapp logger
|
|
26
|
+
app.logger.handlers = logger.handlers
|
|
27
|
+
app.logger.setLevel(logger.level)
|
|
28
|
+
|
|
29
|
+
logger.info("Starting Photo Organizer application")
|
|
30
|
+
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
|
|
31
|
+
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
|
32
|
+
app.config["SQLALCHEMY_ECHO"] = os.environ.get("SQLALCHEMY_ECHO", "").lower() == "true"
|
|
33
|
+
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
|
34
|
+
app.config["SESSION_COOKIE_SECURE"] = False
|
|
35
|
+
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
|
36
|
+
|
|
37
|
+
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
38
|
+
app.config['SESSION_TYPE'] = 'filesystem' # or 'redis', 'memcached', etc.
|
|
39
|
+
app.config['SESSION_PERMANENT'] = True
|
|
40
|
+
#app.config['SESSION_USE_SIGNER'] = True
|
|
41
|
+
|
|
42
|
+
# Caller overrides (e.g. tests) win over the defaults above.
|
|
43
|
+
if config:
|
|
44
|
+
app.config.update(config)
|
|
45
|
+
|
|
46
|
+
db.init_app(app)
|
|
47
|
+
init_i18n(app)
|
|
48
|
+
|
|
49
|
+
# Make url_map available in all templates
|
|
50
|
+
@app.context_processor
|
|
51
|
+
def inject_url_map():
|
|
52
|
+
return {'url_map': app.url_map}
|
|
53
|
+
|
|
54
|
+
@app.context_processor
|
|
55
|
+
def inject_theme():
|
|
56
|
+
return {'theme': themes.get_theme()}
|
|
57
|
+
|
|
58
|
+
@app.context_processor
|
|
59
|
+
def inject_i18n():
|
|
60
|
+
locale = select_locale()
|
|
61
|
+
return {
|
|
62
|
+
"current_locale": locale,
|
|
63
|
+
"distance_unit_options": supported_distance_unit_options(),
|
|
64
|
+
"supported_locales": supported_locale_options(),
|
|
65
|
+
"text_direction": text_direction(locale),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Register template filters
|
|
69
|
+
|
|
70
|
+
init_template_filters(app)
|
|
71
|
+
init_routes(app)
|
|
72
|
+
return app
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
app = create_app()
|
|
76
|
+
# threaded so a long streaming response (e.g. the index-photos scan) doesn't block
|
|
77
|
+
# the page's other requests on the single-user dev server. Port 5001, not 5000 —
|
|
78
|
+
# macOS AirPlay Receiver (Control Center) binds *:5000 and answers with a 403.
|
|
79
|
+
app.run(debug=True, threaded=True, port=5001)
|
yaffo/common.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from platformdirs import user_cache_dir, user_data_dir
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
app_author = "Jason Turan"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
app_name = "yaffo"
|
|
9
|
+
|
|
10
|
+
# Media-type discriminator values. Defined here (a dependency-free leaf module) so
|
|
11
|
+
# both yaffo.common and yaffo.db.models can expose them without an import cycle;
|
|
12
|
+
# models re-exports these, so `from yaffo.db.models import MEDIA_TYPE_*` still works.
|
|
13
|
+
MEDIA_TYPE_PHOTO = "photo"
|
|
14
|
+
MEDIA_TYPE_VIDEO = "video"
|
|
15
|
+
|
|
16
|
+
# Containers that play inline in the browser's <video> (H.264/HEVC in MP4/MOV/M4V).
|
|
17
|
+
PLAYABLE_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v"}
|
|
18
|
+
# All cataloged video. The non-playable containers (avi/mkv/wmv/flv) are still
|
|
19
|
+
# indexed for metadata + poster + faces (exiftool/ffmpeg handle them); the detail
|
|
20
|
+
# view offers "open externally" instead of an inline player. See docs/development/video.md.
|
|
21
|
+
VIDEO_EXTENSIONS = PLAYABLE_VIDEO_EXTENSIONS | {".avi", ".mkv", ".wmv", ".flv"}
|
|
22
|
+
PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".heic"}
|
|
23
|
+
MEDIA_EXTENSIONS = PHOTO_EXTENSIONS | VIDEO_EXTENSIONS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def media_type_for_path(path: Path) -> str:
|
|
27
|
+
"""The MEDIA_TYPE_* a file belongs to, from its suffix. Defaults to photo for
|
|
28
|
+
anything that isn't a known video extension (the import path only ever sees
|
|
29
|
+
files already filtered to MEDIA_EXTENSIONS)."""
|
|
30
|
+
return MEDIA_TYPE_VIDEO if path.suffix.lower() in VIDEO_EXTENSIONS else MEDIA_TYPE_PHOTO
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_browser_playable_video(path: "Path | str") -> bool:
|
|
34
|
+
"""Whether a video plays inline in an HTML5 <video> (a container-level check).
|
|
35
|
+
Non-playable containers are cataloged but opened in an external player."""
|
|
36
|
+
return Path(path).suffix.lower() in PLAYABLE_VIDEO_EXTENSIONS
|
|
37
|
+
|
|
38
|
+
# Where the DB, thumbnails, temp/trash, and logs live. Set YAFFO_DATA_DIR to
|
|
39
|
+
# override (invoke sets it to ~/Pictures for dev). Otherwise default to the OS
|
|
40
|
+
# per-user data dir, so an installed app gets its own home (the photo library
|
|
41
|
+
# itself is configured in-app, independent of this).
|
|
42
|
+
if os.environ.get("YAFFO_DATA_DIR"):
|
|
43
|
+
data_dir = Path(os.environ["YAFFO_DATA_DIR"])
|
|
44
|
+
else:
|
|
45
|
+
data_dir = Path(user_data_dir(app_name, app_author))
|
|
46
|
+
|
|
47
|
+
ROOT_DIR = Path(data_dir)
|
|
48
|
+
# Ensure the DB/log home exists before anything writes into it (logging opens a
|
|
49
|
+
# file here at import time, ahead of any migration step).
|
|
50
|
+
ROOT_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
DB_PATH = ROOT_DIR / f"{app_name}.db"
|
|
53
|
+
QUEUE_DB_PATH = ROOT_DIR / f"{app_name}-queue.db"
|
|
54
|
+
|
|
55
|
+
# Downloaded vision models (CLIP ONNX encoders) cache here on first use, like
|
|
56
|
+
# InsightFace's ~/.insightface — kept out of the photo library / data dir.
|
|
57
|
+
MODEL_CACHE_DIR = Path(user_cache_dir(app_name, app_author))
|
|
58
|
+
|
|
59
|
+
# Read-only assets shipped with the source tree and bundled into the app. When
|
|
60
|
+
# frozen by PyInstaller they live under sys._MEIPASS (the spec adds `resources`
|
|
61
|
+
# there); in dev `parents[1]` is the repo root, where `resources/` sits beside the
|
|
62
|
+
# `yaffo/` package. Models, when pre-bundled by the build script, live under
|
|
63
|
+
# BUNDLED_MODELS_DIR; loaders prefer them and fall back to a network download.
|
|
64
|
+
if getattr(sys, "frozen", False):
|
|
65
|
+
BUNDLE_ROOT = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
|
66
|
+
else:
|
|
67
|
+
BUNDLE_ROOT = Path(__file__).resolve().parents[1]
|
|
68
|
+
RESOURCES_DIR = BUNDLE_ROOT / "resources"
|
|
69
|
+
BUNDLED_MODELS_DIR = RESOURCES_DIR / "models"
|
yaffo/config.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Optional user config, read once at startup from ``<data dir>/config.toml``.
|
|
2
|
+
|
|
3
|
+
TOML (not YAML/JSON) because it's the Python-conventional config format: the stdlib
|
|
4
|
+
``tomllib`` reads it with no dependency, it supports comments, and the project
|
|
5
|
+
already uses TOML (pyproject.toml). Edit the file and restart the app to apply.
|
|
6
|
+
|
|
7
|
+
On startup the file is migrated to the current template: new sections/keys (and their
|
|
8
|
+
documentation comments) are added while the user's set values are preserved. The
|
|
9
|
+
template is the source of truth for structure + comments + defaults, so we re-render
|
|
10
|
+
it with the user's overrides substituted in rather than round-tripping through a dict
|
|
11
|
+
(``tomllib`` can read but not write TOML, and would drop every comment).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import tomllib
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from yaffo.common import ROOT_DIR
|
|
21
|
+
|
|
22
|
+
CONFIG_PATH = ROOT_DIR / "config.toml"
|
|
23
|
+
|
|
24
|
+
_TEMPLATE = """\
|
|
25
|
+
# Yaffo configuration. Edit a value and restart the app to apply it.
|
|
26
|
+
|
|
27
|
+
[logging]
|
|
28
|
+
# Verbosity of the log files (yaffo.log, background_tasks.log).
|
|
29
|
+
# One of: DEBUG, INFO, WARNING, ERROR
|
|
30
|
+
level = "INFO"
|
|
31
|
+
# Max size (MB) of each rolling log file before it rotates, and how many old files
|
|
32
|
+
# to keep.
|
|
33
|
+
max_file_mb = 5
|
|
34
|
+
backup_count = 3
|
|
35
|
+
# How many recent page/theme/automation generation runs to keep under model_logs/.
|
|
36
|
+
max_model_log_runs = 50
|
|
37
|
+
|
|
38
|
+
[database]
|
|
39
|
+
# SQLite durability vs. write speed (PRAGMA synchronous), paired with WAL.
|
|
40
|
+
# NORMAL = fast; the last transaction can be lost on an OS/power/disk failure
|
|
41
|
+
# (never corruption). The default — best for bulk photo indexing.
|
|
42
|
+
# FULL = fsync on every commit; survives OS/power/disk failures with no lost
|
|
43
|
+
# writes, at a write-throughput cost.
|
|
44
|
+
# One of: NORMAL, FULL
|
|
45
|
+
synchronous = "NORMAL"
|
|
46
|
+
|
|
47
|
+
[tasks]
|
|
48
|
+
# Background worker processes: more = more parallel indexing, more CPU/RAM. Defaults
|
|
49
|
+
# to the CPU count when unset; a `--workers` CLI flag still overrides this.
|
|
50
|
+
# workers = 4
|
|
51
|
+
# Recycle (restart) a worker after this many tasks, to bound slow leaks.
|
|
52
|
+
recycle = 100
|
|
53
|
+
|
|
54
|
+
[ai]
|
|
55
|
+
# Caps per AI generation run (page/theme/automation builder), to bound cost.
|
|
56
|
+
# max_iterations: tool-use loop steps before the run stops.
|
|
57
|
+
# max_output_tokens: tokens per model call — shared with the thinking budget, so
|
|
58
|
+
# setting it too low risks responses getting cut off mid-generation.
|
|
59
|
+
max_iterations = 25
|
|
60
|
+
max_output_tokens = 64000
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_SECTION_RE = re.compile(r"^\s*\[([A-Za-z0-9_.-]+)\]\s*$")
|
|
65
|
+
# A `key = value` line, optionally commented (e.g. the `# workers = 4` default).
|
|
66
|
+
_KEY_RE = re.compile(r"^(\s*)#?\s*([A-Za-z_][\w-]*)\s*=\s*(.*)$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _toml_value(value: Any) -> str:
|
|
70
|
+
"""Render a Python scalar as a TOML literal (covers the value types this config
|
|
71
|
+
uses: str, int, float, bool)."""
|
|
72
|
+
if isinstance(value, bool):
|
|
73
|
+
return "true" if value else "false"
|
|
74
|
+
if isinstance(value, (int, float)):
|
|
75
|
+
return str(value)
|
|
76
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
77
|
+
return f'"{escaped}"'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _render(user: dict[str, Any]) -> str:
|
|
81
|
+
"""Render _TEMPLATE (the canonical structure + comments + defaults), substituting
|
|
82
|
+
any value the user has set so their overrides survive the migration. A commented
|
|
83
|
+
default (e.g. `# workers = 4`) is activated when the user has set that key. Pure
|
|
84
|
+
doc-comment lines and unknown user keys are left untouched / dropped."""
|
|
85
|
+
section: str | None = None
|
|
86
|
+
out: list[str] = []
|
|
87
|
+
for line in _TEMPLATE.splitlines():
|
|
88
|
+
sm = _SECTION_RE.match(line)
|
|
89
|
+
if sm:
|
|
90
|
+
section = sm.group(1)
|
|
91
|
+
out.append(line)
|
|
92
|
+
continue
|
|
93
|
+
km = _KEY_RE.match(line)
|
|
94
|
+
if km and section and section in user and km.group(2) in user[section]:
|
|
95
|
+
indent, key = km.group(1), km.group(2)
|
|
96
|
+
out.append(f"{indent}{key} = {_toml_value(user[section][key])}")
|
|
97
|
+
continue
|
|
98
|
+
out.append(line)
|
|
99
|
+
return "\n".join(out) + "\n"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _atomic_write(text: str) -> None:
|
|
103
|
+
"""Write config.toml via a temp file + rename so concurrently-starting processes
|
|
104
|
+
(flask, watcher, host, spawn workers all import this) never see a torn file."""
|
|
105
|
+
tmp = CONFIG_PATH.with_suffix(".toml.tmp")
|
|
106
|
+
tmp.write_text(text)
|
|
107
|
+
os.replace(tmp, CONFIG_PATH)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load() -> dict[str, Any]:
|
|
111
|
+
if not CONFIG_PATH.exists():
|
|
112
|
+
try:
|
|
113
|
+
_atomic_write(_TEMPLATE) # seed a documented default on first run
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
return tomllib.loads(_TEMPLATE)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
existing = CONFIG_PATH.read_text()
|
|
120
|
+
user = tomllib.loads(existing)
|
|
121
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
122
|
+
# Leave an unreadable/corrupt file alone (don't clobber something the user
|
|
123
|
+
# could still fix); fall back to code defaults via get()/get_int().
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
# Migrate: re-render the template with the user's values, rewriting only when the
|
|
127
|
+
# result actually differs (so a settled config doesn't get rewritten every launch).
|
|
128
|
+
merged_text = _render(user)
|
|
129
|
+
if merged_text != existing:
|
|
130
|
+
try:
|
|
131
|
+
_atomic_write(merged_text)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
return tomllib.loads(merged_text)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_config = _load()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get(section: str, key: str, default: Any = None) -> Any:
|
|
141
|
+
"""Read config[section][key], falling back to default if absent."""
|
|
142
|
+
return _config.get(section, {}).get(key, default)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_int(section: str, key: str, default: int, minimum: int = 1) -> int:
|
|
146
|
+
"""Read an integer config value, falling back to `default` when absent, the wrong
|
|
147
|
+
type, or below `minimum` — so a typo in config.toml can't feed a nonsensical value
|
|
148
|
+
(e.g. 0 workers, a negative retention) into the system."""
|
|
149
|
+
raw = get(section, key, default)
|
|
150
|
+
try:
|
|
151
|
+
value = int(raw)
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
return default
|
|
154
|
+
return value if value >= minimum else default
|
yaffo/distance_units.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from flask_babel import lazy_gettext
|
|
6
|
+
from sqlalchemy.exc import OperationalError
|
|
7
|
+
|
|
8
|
+
from yaffo.db import db
|
|
9
|
+
from yaffo.db.models import ApplicationSettings
|
|
10
|
+
|
|
11
|
+
DISTANCE_UNIT_SETTING = "distance_unit"
|
|
12
|
+
DISTANCE_UNIT_MILES = "mi"
|
|
13
|
+
DISTANCE_UNIT_KILOMETERS = "km"
|
|
14
|
+
DEFAULT_DISTANCE_UNIT = DISTANCE_UNIT_MILES
|
|
15
|
+
KILOMETERS_PER_MILE = 1.609344
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DistanceUnitOption:
|
|
20
|
+
code: str
|
|
21
|
+
label: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def supported_distance_unit_options() -> list[DistanceUnitOption]:
|
|
25
|
+
return [
|
|
26
|
+
DistanceUnitOption(code=DISTANCE_UNIT_MILES, label=lazy_gettext("Miles")),
|
|
27
|
+
DistanceUnitOption(code=DISTANCE_UNIT_KILOMETERS, label=lazy_gettext("Kilometers")),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_distance_unit(unit: str | None) -> str | None:
|
|
32
|
+
if not unit:
|
|
33
|
+
return None
|
|
34
|
+
normalized = unit.strip().lower()
|
|
35
|
+
return normalized if normalized in {DISTANCE_UNIT_MILES, DISTANCE_UNIT_KILOMETERS} else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_saved_distance_unit(session=None) -> str:
|
|
39
|
+
session = session or db.session
|
|
40
|
+
try:
|
|
41
|
+
row = session.query(ApplicationSettings).filter_by(name=DISTANCE_UNIT_SETTING).first()
|
|
42
|
+
except OperationalError:
|
|
43
|
+
return DEFAULT_DISTANCE_UNIT
|
|
44
|
+
return normalize_distance_unit(row.value if row else None) or DEFAULT_DISTANCE_UNIT
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_distance_unit(unit: str) -> bool:
|
|
48
|
+
normalized = normalize_distance_unit(unit)
|
|
49
|
+
if normalized is None:
|
|
50
|
+
return False
|
|
51
|
+
row = db.session.query(ApplicationSettings).filter_by(name=DISTANCE_UNIT_SETTING).first()
|
|
52
|
+
if row is None:
|
|
53
|
+
db.session.add(ApplicationSettings(name=DISTANCE_UNIT_SETTING, type="string", value=normalized))
|
|
54
|
+
else:
|
|
55
|
+
row.value = normalized
|
|
56
|
+
db.session.commit()
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def kilometers_per_unit(unit: str) -> float:
|
|
61
|
+
return {
|
|
62
|
+
DISTANCE_UNIT_MILES: KILOMETERS_PER_MILE,
|
|
63
|
+
DISTANCE_UNIT_KILOMETERS: 1.0,
|
|
64
|
+
}[normalize_distance_unit(unit) or DEFAULT_DISTANCE_UNIT]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def distance_to_kilometers(value: float, unit: str) -> float:
|
|
68
|
+
return value * kilometers_per_unit(unit)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def kilometers_to_distance(value_kilometers: float, unit: str) -> float:
|
|
72
|
+
return value_kilometers / kilometers_per_unit(unit)
|