trackerkeeper 0.1.0__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.
Files changed (89) hide show
  1. trackerkeeper/__init__.py +55 -0
  2. trackerkeeper/__main__.py +3 -0
  3. trackerkeeper/_version.py +24 -0
  4. trackerkeeper/app.py +445 -0
  5. trackerkeeper/assets/__init__.py +2 -0
  6. trackerkeeper/assets/trackerkeeper.svg +12 -0
  7. trackerkeeper/async_io.py +299 -0
  8. trackerkeeper/autostart/__init__.py +52 -0
  9. trackerkeeper/autostart/_linux.py +129 -0
  10. trackerkeeper/autostart/_macos.py +163 -0
  11. trackerkeeper/autostart/_msix.py +181 -0
  12. trackerkeeper/autostart/_unsupported.py +22 -0
  13. trackerkeeper/autostart/_windows.py +96 -0
  14. trackerkeeper/bake.py +217 -0
  15. trackerkeeper/blur/__init__.py +212 -0
  16. trackerkeeper/blur/_dwm.py +348 -0
  17. trackerkeeper/blur/_faux_frost.py +143 -0
  18. trackerkeeper/blur/_kwin.py +478 -0
  19. trackerkeeper/blur/_macos.py +356 -0
  20. trackerkeeper/blur/_unsupported.py +39 -0
  21. trackerkeeper/boot_timing.py +75 -0
  22. trackerkeeper/breadboard.py +1554 -0
  23. trackerkeeper/bus.py +110 -0
  24. trackerkeeper/catalog.py +176 -0
  25. trackerkeeper/color_tokens.py +697 -0
  26. trackerkeeper/credentials.py +471 -0
  27. trackerkeeper/custom_tooltip.py +254 -0
  28. trackerkeeper/dashboard.py +704 -0
  29. trackerkeeper/deliver.py +442 -0
  30. trackerkeeper/design_tokens.py +415 -0
  31. trackerkeeper/diagnostics.py +168 -0
  32. trackerkeeper/drag_repaint/__init__.py +68 -0
  33. trackerkeeper/drag_repaint/_kwin.py +192 -0
  34. trackerkeeper/drag_repaint/_unsupported.py +41 -0
  35. trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
  36. trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
  37. trackerkeeper/frosted_dialog.py +377 -0
  38. trackerkeeper/i18n/__init__.py +101 -0
  39. trackerkeeper/i18n/fmt.py +137 -0
  40. trackerkeeper/icon_button.py +137 -0
  41. trackerkeeper/icons.py +532 -0
  42. trackerkeeper/identity.py +135 -0
  43. trackerkeeper/item_dialog.py +185 -0
  44. trackerkeeper/kde_titlebar.py +196 -0
  45. trackerkeeper/keyboard_focus.py +353 -0
  46. trackerkeeper/log.py +112 -0
  47. trackerkeeper/macos_menubar.py +282 -0
  48. trackerkeeper/macos_window.py +172 -0
  49. trackerkeeper/metadata.py +121 -0
  50. trackerkeeper/noborder/__init__.py +61 -0
  51. trackerkeeper/noborder/_kwin.py +247 -0
  52. trackerkeeper/noborder/_unsupported.py +42 -0
  53. trackerkeeper/notifications/__init__.py +84 -0
  54. trackerkeeper/notifications/_linux.py +68 -0
  55. trackerkeeper/notifications/_macos.py +159 -0
  56. trackerkeeper/notifications/_unsupported.py +22 -0
  57. trackerkeeper/notifications/_windows.py +115 -0
  58. trackerkeeper/platform_compat.py +149 -0
  59. trackerkeeper/power/__init__.py +84 -0
  60. trackerkeeper/power/_linux.py +79 -0
  61. trackerkeeper/power/_unsupported.py +18 -0
  62. trackerkeeper/power/_windows.py +52 -0
  63. trackerkeeper/rig.py +338 -0
  64. trackerkeeper/scaffold.py +310 -0
  65. trackerkeeper/selector.py +529 -0
  66. trackerkeeper/settings.py +226 -0
  67. trackerkeeper/settings_dialog.py +307 -0
  68. trackerkeeper/settings_migration.py +101 -0
  69. trackerkeeper/single_instance.py +330 -0
  70. trackerkeeper/smooth_scroll.py +155 -0
  71. trackerkeeper/sources.py +318 -0
  72. trackerkeeper/system_accent.py +363 -0
  73. trackerkeeper/terminal.py +423 -0
  74. trackerkeeper/test_bridge.py +514 -0
  75. trackerkeeper/theme.py +725 -0
  76. trackerkeeper/top_bar.py +154 -0
  77. trackerkeeper/tray.py +184 -0
  78. trackerkeeper/ui_helpers.py +2294 -0
  79. trackerkeeper/update_chip.py +119 -0
  80. trackerkeeper/updates.py +216 -0
  81. trackerkeeper/win_frameless.py +364 -0
  82. trackerkeeper/window.py +609 -0
  83. trackerkeeper/windows_shortcut.py +355 -0
  84. trackerkeeper-0.1.0.dist-info/METADATA +144 -0
  85. trackerkeeper-0.1.0.dist-info/RECORD +89 -0
  86. trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
  87. trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
  88. trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
  89. trackerkeeper-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,181 @@
1
+ """Windows MSIX (packaged-app) launch-on-login backend.
2
+
3
+ Packaged apps can't use the per-user Run key — Windows ignores Run-key
4
+ entries from packages. Autostart for a packaged app is a *startup task*
5
+ declared in the manifest (``windows.startupTask`` with
6
+ ``TaskId="<app>Startup"``) and toggled at runtime through the
7
+ ``Windows.ApplicationModel.StartupTask`` WinRT API. The user can always
8
+ override us from Settings -> Apps -> Startup; once they disable it there,
9
+ Windows forbids programmatic re-enable (state ``DisabledByUser``).
10
+
11
+ Mirrors the public backend API (``is_supported``/``is_enabled``/``enable``/
12
+ ``disable``). Every call is defensively wrapped: a WinRT/projection failure
13
+ degrades to "unsupported" instead of raising, so a packaging quirk can never
14
+ crash startup.
15
+
16
+ Threading — why every call hops to a worker thread:
17
+ ``StartupTask``'s API is asynchronous, and the only synchronous way to consume
18
+ an ``IAsyncOperation`` is its blocking ``.get()`` — which WinRT forbids on a
19
+ single-threaded apartment. The Qt GUI thread is an STA (Qt and ``comtypes``
20
+ initialise it that way), so calling ``.get()`` there raises ``RuntimeError:
21
+ Cannot call blocking method from single-threaded apartment`` (observed
22
+ in-package 2026-06-18). Each WinRT touch therefore runs on a short-lived thread
23
+ initialised ``MULTI_THREADED`` (an MTA), where the blocking wait is legal; only
24
+ a plain ``bool`` crosses back to the GUI thread. (Synchronous WinRT calls are
25
+ fine on the STA; only the blocking await is not.) ``_TASK_ID`` must match
26
+ ``AppxManifest.xml`` exactly.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+
33
+ from trackerkeeper import identity
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Must match the <desktop:StartupTask TaskId="..."> in AppxManifest.xml.
38
+ _TASK_ID = f"{identity.app()}Startup"
39
+
40
+
41
+ def _run_in_mta(fn):
42
+ """Run ``fn`` on a dedicated MTA thread and return its result (or None).
43
+
44
+ WinRT's blocking ``IAsyncOperation.get()`` cannot run on the Qt GUI thread
45
+ (an STA). A fresh thread initialised ``MULTI_THREADED`` runs the blocking
46
+ waits legally; it is discarded after the call, so the apartment lifecycle
47
+ stays clean and the rare autostart toggles never touch a shared pool. Any
48
+ failure (no winrt, apartment init refused, projection error) degrades to
49
+ ``None`` so the public API can never raise.
50
+ """
51
+ import threading
52
+
53
+ box = {}
54
+
55
+ def _target():
56
+ try:
57
+ from winrt.runtime import (
58
+ ApartmentType,
59
+ init_apartment,
60
+ uninit_apartment,
61
+ )
62
+ except Exception as e: # pragma: no cover — Windows/MSIX-only
63
+ logger.debug("winrt apartment API unavailable: %s", e)
64
+ return
65
+ try:
66
+ init_apartment(ApartmentType.MULTI_THREADED)
67
+ except Exception as e: # pragma: no cover — Windows/MSIX-only
68
+ logger.debug("init_apartment(MTA) failed: %s", e)
69
+ return
70
+ try:
71
+ box["result"] = fn()
72
+ except Exception as e: # pragma: no cover — Windows/MSIX-only
73
+ logger.debug("StartupTask worker call failed: %s", e)
74
+ finally:
75
+ try:
76
+ uninit_apartment()
77
+ except Exception: # pragma: no cover — Windows/MSIX-only
78
+ pass
79
+
80
+ # Sanctioned raw-thread exception: the WinRT call needs its own COM MTA
81
+ # apartment, so it gets a dedicated thread (start + join), not a pool slot.
82
+ t = threading.Thread(target=_target, name="trackerkeeper-startuptask", daemon=True)
83
+ t.start()
84
+ t.join()
85
+ return box.get("result")
86
+
87
+
88
+ def _resolve(op):
89
+ """Block on a WinRT IAsyncOperation. Legal only off the GUI STA — callers
90
+ reach it through ``_run_in_mta``. The projection exposes ``.get()`` for
91
+ synchronous callers; fall back to asyncio if a build only awaits."""
92
+ get = getattr(op, "get", None)
93
+ if callable(get):
94
+ return get()
95
+ import asyncio
96
+
97
+ return asyncio.run(op) # pragma: no cover — projection-dependent
98
+
99
+
100
+ def _get_task():
101
+ """Our StartupTask, or None if the API/projection is unavailable.
102
+ MUST run inside ``_run_in_mta`` — it performs the blocking WinRT wait."""
103
+ try:
104
+ from winrt.windows.applicationmodel import StartupTask
105
+
106
+ return _resolve(StartupTask.get_async(_TASK_ID))
107
+ except Exception as e: # pragma: no cover — Windows/MSIX-only
108
+ logger.debug("StartupTask.get_async(%s) failed: %s", _TASK_ID, e)
109
+ return None
110
+
111
+
112
+ def _is_enabled_state(state) -> bool:
113
+ try:
114
+ from winrt.windows.applicationmodel import StartupTaskState
115
+
116
+ return state in (StartupTaskState.ENABLED, StartupTaskState.ENABLED_BY_POLICY)
117
+ except Exception: # pragma: no cover — Windows/MSIX-only
118
+ return False
119
+
120
+
121
+ # ── Worker-thread implementations (each runs inside an MTA via _run_in_mta) ──
122
+
123
+
124
+ def _is_supported_impl() -> bool:
125
+ return _get_task() is not None
126
+
127
+
128
+ def _is_enabled_impl() -> bool:
129
+ task = _get_task()
130
+ if task is None:
131
+ return False
132
+ try:
133
+ return _is_enabled_state(task.state)
134
+ except Exception as e: # pragma: no cover — Windows/MSIX-only
135
+ logger.debug("StartupTask state read failed: %s", e)
136
+ return False
137
+
138
+
139
+ def _enable_impl() -> bool:
140
+ task = _get_task()
141
+ if task is None:
142
+ return False
143
+ try:
144
+ return _is_enabled_state(_resolve(task.request_enable_async()))
145
+ except Exception as e: # pragma: no cover — Windows/MSIX-only
146
+ logger.debug("StartupTask request_enable failed: %s", e)
147
+ return False
148
+
149
+
150
+ def _disable_impl() -> bool:
151
+ task = _get_task()
152
+ if task is None:
153
+ return False
154
+ try:
155
+ task.disable()
156
+ return True
157
+ except Exception as e: # pragma: no cover — Windows/MSIX-only
158
+ logger.debug("StartupTask disable failed: %s", e)
159
+ return False
160
+
161
+
162
+ # ── Public API — mirrors the platform-agnostic backend contract ─────────────
163
+
164
+
165
+ def is_supported() -> bool:
166
+ return bool(_run_in_mta(_is_supported_impl))
167
+
168
+
169
+ def is_enabled() -> bool:
170
+ return bool(_run_in_mta(_is_enabled_impl))
171
+
172
+
173
+ def enable() -> bool:
174
+ """Request enable. Returns True only if Windows actually enabled it — a
175
+ user who turned it off in Settings (DisabledByUser) blocks us, and the
176
+ caller should point them at Settings -> Apps -> Startup."""
177
+ return bool(_run_in_mta(_enable_impl))
178
+
179
+
180
+ def disable() -> bool:
181
+ return bool(_run_in_mta(_disable_impl))
@@ -0,0 +1,22 @@
1
+ """Stub autostart backend for platforms with no launch-on-login mechanism
2
+ (Linux, Windows, and macOS each have a real backend). Every call returns
3
+ False so the settings UI can hide the toggle and call sites no-op cleanly.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ def is_supported() -> bool:
10
+ return False
11
+
12
+
13
+ def is_enabled() -> bool:
14
+ return False
15
+
16
+
17
+ def enable() -> bool:
18
+ return False
19
+
20
+
21
+ def disable() -> bool:
22
+ return False
@@ -0,0 +1,96 @@
1
+ """Windows launch-on-login backend. Manages a value under the per-user
2
+ Run key (``HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run``) —
3
+ no elevation needed, honored by Explorer at login, and surfaced to the
4
+ user in Task Manager's Startup tab and Settings → Apps → Startup.
5
+
6
+ The command targets the gui-script launcher exe when one exists (pip /
7
+ pipx install) so login launches without a console window. A source
8
+ checkout falls back to ``pythonw.exe -m <app>`` (then plain
9
+ ``python.exe`` if pythonw is missing).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from trackerkeeper import identity
19
+
20
+ try:
21
+ import winreg
22
+ except ImportError: # pragma: no cover — non-Windows; tests patch this attr
23
+ winreg = None # type: ignore[assignment]
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
28
+ _VALUE_NAME = identity.app()
29
+
30
+
31
+ def _launch_command() -> str:
32
+ """The command line to register: the launcher exe if we have one,
33
+ else a ``-m <app>`` invocation, preferring pythonw so a login
34
+ launch doesn't flash a console."""
35
+ from trackerkeeper.windows_shortcut import _launcher_exe
36
+
37
+ exe = _launcher_exe()
38
+ if exe is not None:
39
+ return f'"{exe}"'
40
+ interp = Path(sys.executable or "python")
41
+ pythonw = interp.with_name("pythonw.exe")
42
+ if pythonw.is_file():
43
+ interp = pythonw
44
+ return f'"{interp}" -m {identity.app()}'
45
+
46
+
47
+ def is_supported() -> bool:
48
+ return winreg is not None
49
+
50
+
51
+ def is_enabled() -> bool:
52
+ if winreg is None:
53
+ return False
54
+ try:
55
+ with winreg.OpenKey(
56
+ winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_READ
57
+ ) as key:
58
+ value, _kind = winreg.QueryValueEx(key, _VALUE_NAME)
59
+ return bool(value)
60
+ except FileNotFoundError:
61
+ return False
62
+ except Exception as e:
63
+ logger.debug("autostart is_enabled read failed: %s", e)
64
+ return False
65
+
66
+
67
+ def enable() -> bool:
68
+ if winreg is None:
69
+ return False
70
+ try:
71
+ with winreg.CreateKeyEx(
72
+ winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_SET_VALUE
73
+ ) as key:
74
+ winreg.SetValueEx(key, _VALUE_NAME, 0, winreg.REG_SZ, _launch_command())
75
+ return True
76
+ except Exception as e:
77
+ logger.debug("autostart enable failed: %s", e)
78
+ return False
79
+
80
+
81
+ def disable() -> bool:
82
+ """Remove the Run-key value. Returns True if a value was removed,
83
+ False if there was nothing to do (or removal failed)."""
84
+ if winreg is None:
85
+ return False
86
+ try:
87
+ with winreg.OpenKey(
88
+ winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_SET_VALUE
89
+ ) as key:
90
+ winreg.DeleteValue(key, _VALUE_NAME)
91
+ return True
92
+ except FileNotFoundError:
93
+ return False
94
+ except Exception as e:
95
+ logger.debug("autostart disable failed: %s", e)
96
+ return False
trackerkeeper/bake.py ADDED
@@ -0,0 +1,217 @@
1
+ """``trackerkeeper bake`` — render the packaging tree from the one metadata source.
2
+
3
+ The oven half of the baking phase (docs/BAKING.md §2): walk
4
+ ``packaging/templates/**/*.j2``, render each template's BODY *and its filename*
5
+ (filenames carry ``{{ app_id_base }}`` etc.) from :func:`trackerkeeper.metadata.context`,
6
+ and write the result under ``packaging/``. None of the manifests is hand-authored.
7
+
8
+ The verify half is :func:`check`: re-render into memory and diff against the
9
+ committed files — reporting a hand-edit, a stale render, a missing file, AND an
10
+ orphan (a committed file no template produces). A CI gate (``trackerkeeper bake --check``,
11
+ mirrored by ``tests/test_bake.py``) fails the build on any drift. jellytoast
12
+ *lints* its manifests after the fact; trackerkeeper regenerates and proves nothing drifted.
13
+
14
+ Jinja2 is a build-time-only dependency (the ``bake`` extra) — the shipped app
15
+ never imports it. ``StrictUndefined`` makes a template that references a missing
16
+ context key a hard error, so a template/metadata mismatch fails loudly at render.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import datetime
23
+ import os
24
+ import re
25
+ import stat
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from trackerkeeper import metadata
30
+
31
+ TEMPLATES_SUBDIR = "templates"
32
+
33
+
34
+ class BakeError(RuntimeError):
35
+ """A render/verify failure (missing engine, colliding outputs, …)."""
36
+
37
+
38
+ def _repo_root() -> Path:
39
+ """The repo root — the dir holding pyproject.toml (and packaging/)."""
40
+ return metadata._find_pyproject().parent
41
+
42
+
43
+ def _environment(templates_dir: Path):
44
+ try:
45
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined
46
+ except ImportError as exc: # pragma: no cover — exercised only without the extra
47
+ raise BakeError(
48
+ "trackerkeeper bake needs Jinja2 — install the bake extra: pip install 'trackerkeeper[bake]'"
49
+ ) from exc
50
+ return Environment(
51
+ loader=FileSystemLoader(str(templates_dir)),
52
+ undefined=StrictUndefined, # a missing context key is a hard error
53
+ keep_trailing_newline=True,
54
+ trim_blocks=True,
55
+ lstrip_blocks=True,
56
+ autoescape=False, # shell / desktop / XML — escape per-field with |e
57
+ )
58
+
59
+
60
+ def _committed_files(packaging_dir: Path) -> list[Path]:
61
+ """Every committed (non-source) file under ``packaging/`` — skips the
62
+ ``templates/`` sources and any stray ``__pycache__``."""
63
+ out: list[Path] = []
64
+ for path in sorted(packaging_dir.rglob("*")):
65
+ if not path.is_file():
66
+ continue
67
+ parts = path.relative_to(packaging_dir).parts
68
+ if TEMPLATES_SUBDIR in parts or "__pycache__" in parts:
69
+ continue
70
+ out.append(path)
71
+ return out
72
+
73
+
74
+ def render(packaging_dir: Path, ctx: dict | None = None) -> dict[str, str]:
75
+ """Render every ``*.j2`` under ``<packaging>/templates`` and return
76
+ ``{output_relpath: rendered_text}`` — the output path has the rendered
77
+ filename (``{{ app_id_base }}`` resolved) with the ``.j2`` suffix dropped.
78
+ Keys are POSIX relpaths on every OS. Raises :class:`BakeError` if two
79
+ templates resolve to the same output path (one would silently shadow the
80
+ other)."""
81
+ ctx = metadata.context() if ctx is None else ctx
82
+ templates_dir = packaging_dir / TEMPLATES_SUBDIR
83
+ env = _environment(templates_dir)
84
+ out: dict[str, str] = {}
85
+ for tpl in sorted(templates_dir.rglob("*.j2")):
86
+ rel = tpl.relative_to(templates_dir)
87
+ out_rel = env.from_string(rel.with_suffix("").as_posix()).render(**ctx) # render the path too
88
+ if out_rel in out:
89
+ raise BakeError(f"two templates render to the same output path: {out_rel}")
90
+ out[out_rel] = env.get_template(rel.as_posix()).render(**ctx)
91
+ return out
92
+
93
+
94
+ def write(packaging_dir: Path, ctx: dict | None = None) -> dict[str, str]:
95
+ """Render and write the tree under ``packaging_dir``. Writes LF newlines on
96
+ every OS (committed files are LF; shell scripts break with CRLF) and gives
97
+ ``.sh`` outputs the executable bit. Returns the rendered map."""
98
+ rendered = render(packaging_dir, ctx)
99
+ for rel, body in rendered.items():
100
+ dest = packaging_dir / rel
101
+ dest.parent.mkdir(parents=True, exist_ok=True)
102
+ dest.write_text(body, encoding="utf-8", newline="\n") # never CRLF
103
+ # Exec bits are POSIX semantics — on Windows chmod can't grant X and
104
+ # the bit isn't meaningful; the scripts run on Linux/macOS anyway.
105
+ if dest.suffix == ".sh" and os.name == "posix":
106
+ dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
107
+ return rendered
108
+
109
+
110
+ def check(packaging_dir: Path, ctx: dict | None = None) -> list[str]:
111
+ """Return a list of drift descriptions (empty ⇒ in sync). Catches: a missing
112
+ file, a content/byte difference (CRLF included — compared as bytes), a ``.sh``
113
+ that lost its executable bit, and an ORPHAN (a committed file no template
114
+ produces)."""
115
+ rendered = render(packaging_dir, ctx)
116
+ drift: list[str] = []
117
+ for rel, body in rendered.items():
118
+ dest = packaging_dir / rel
119
+ if not dest.is_file():
120
+ drift.append(f"missing (run `trackerkeeper bake`): {rel}")
121
+ continue
122
+ if dest.read_bytes() != body.encode("utf-8"):
123
+ drift.append(f"drift (re-run `trackerkeeper bake`): {rel}")
124
+ elif (
125
+ dest.suffix == ".sh"
126
+ and os.name == "posix" # exec bits don't exist on Windows
127
+ and not (dest.stat().st_mode & stat.S_IXUSR)
128
+ ):
129
+ drift.append(f"not executable (re-run `trackerkeeper bake`): {rel}")
130
+ produced = set(rendered)
131
+ for path in _committed_files(packaging_dir):
132
+ rel = path.relative_to(packaging_dir).as_posix()
133
+ if rel not in produced:
134
+ drift.append(f"orphan (delete it, or add a template): {rel}")
135
+ return drift
136
+
137
+
138
+ def main(argv: list[str] | None = None) -> int:
139
+ parser = argparse.ArgumentParser(
140
+ prog="trackerkeeper-bake",
141
+ description="Render the packaging tree from [tool.trackerkeeper.metadata] (docs/BAKING.md §2).",
142
+ )
143
+ parser.add_argument(
144
+ "--check",
145
+ action="store_true",
146
+ help="verify committed files match a fresh render; exit 1 on drift (the CI gate)",
147
+ )
148
+ parser.add_argument(
149
+ "--packaging",
150
+ type=Path,
151
+ default=None,
152
+ help="packaging dir (default: <repo>/packaging)",
153
+ )
154
+ parser.add_argument(
155
+ "--release-version",
156
+ default=None,
157
+ metavar="VER",
158
+ help="inject a <release> entry into version-bearing manifests — the "
159
+ "release-time render (docs/BAKING.md §6.2). Omit for the committed, "
160
+ "version-free tree.",
161
+ )
162
+ parser.add_argument(
163
+ "--release-date",
164
+ default=None,
165
+ metavar="YYYY-MM-DD",
166
+ help="date for the injected <release> (defaults to empty)",
167
+ )
168
+ args = parser.parse_args(argv)
169
+ if args.release_date and not args.release_version:
170
+ parser.error("--release-date has no effect without --release-version")
171
+ if args.release_version and args.packaging is None:
172
+ # A release render injects a <release> the COMMITTED tree must never carry
173
+ # (docs/BAKING.md §6.2) — writing it in-place would dirty packaging/ and
174
+ # break `trackerkeeper bake --check`. Force an explicit target (CI renders into
175
+ # packaging/ in its ephemeral checkout via --packaging packaging).
176
+ parser.error(
177
+ "--release-version would overwrite the committed packaging tree; pass an "
178
+ "explicit --packaging <dir> (the committed tree must stay version-free)"
179
+ )
180
+ packaging_dir = args.packaging or (_repo_root() / "packaging")
181
+
182
+ # Release-time render: fold the version into the context so the metainfo gets
183
+ # a <release>. --check stays version-free (it gates the committed tree). A date
184
+ # is ALWAYS set (AppStream rejects a <release> with an empty date).
185
+ ctx = None
186
+ if args.release_version:
187
+ ctx = metadata.context()
188
+ ctx["release_version"] = args.release_version
189
+ ctx["release_date"] = (
190
+ args.release_date or datetime.datetime.now(datetime.timezone.utc).date().isoformat()
191
+ )
192
+ # The Windows VSVersionInfo filevers must be 4 INTEGERS. Take each dotted
193
+ # segment's leading digit run so a PEP 440 pre-release (0.1.0rc1) yields
194
+ # (0, 1, 0, 0) — not a bare 0rc1 that breaks the eval'd version file.
195
+ _segs = (args.release_version.split("+")[0].split(".") + ["0", "0", "0", "0"])[:4]
196
+ ctx["filevers"] = tuple(int(re.match(r"\d*", s).group() or "0") for s in _segs)
197
+
198
+ try:
199
+ if args.check:
200
+ drift = check(packaging_dir)
201
+ if drift:
202
+ print("packaging out of sync with [tool.trackerkeeper.metadata]:", file=sys.stderr)
203
+ print(" " + "\n ".join(drift), file=sys.stderr)
204
+ return 1
205
+ print("packaging is in sync with [tool.trackerkeeper.metadata].")
206
+ return 0
207
+ rendered = write(packaging_dir, ctx)
208
+ except BakeError as exc:
209
+ print(f"trackerkeeper bake: {exc}", file=sys.stderr)
210
+ return 1
211
+ print(f"rendered {len(rendered)} file(s) into {packaging_dir}:")
212
+ print(" " + "\n ".join(sorted(rendered)))
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__": # pragma: no cover
217
+ raise SystemExit(main())