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.
- trackerkeeper/__init__.py +55 -0
- trackerkeeper/__main__.py +3 -0
- trackerkeeper/_version.py +24 -0
- trackerkeeper/app.py +445 -0
- trackerkeeper/assets/__init__.py +2 -0
- trackerkeeper/assets/trackerkeeper.svg +12 -0
- trackerkeeper/async_io.py +299 -0
- trackerkeeper/autostart/__init__.py +52 -0
- trackerkeeper/autostart/_linux.py +129 -0
- trackerkeeper/autostart/_macos.py +163 -0
- trackerkeeper/autostart/_msix.py +181 -0
- trackerkeeper/autostart/_unsupported.py +22 -0
- trackerkeeper/autostart/_windows.py +96 -0
- trackerkeeper/bake.py +217 -0
- trackerkeeper/blur/__init__.py +212 -0
- trackerkeeper/blur/_dwm.py +348 -0
- trackerkeeper/blur/_faux_frost.py +143 -0
- trackerkeeper/blur/_kwin.py +478 -0
- trackerkeeper/blur/_macos.py +356 -0
- trackerkeeper/blur/_unsupported.py +39 -0
- trackerkeeper/boot_timing.py +75 -0
- trackerkeeper/breadboard.py +1554 -0
- trackerkeeper/bus.py +110 -0
- trackerkeeper/catalog.py +176 -0
- trackerkeeper/color_tokens.py +697 -0
- trackerkeeper/credentials.py +471 -0
- trackerkeeper/custom_tooltip.py +254 -0
- trackerkeeper/dashboard.py +704 -0
- trackerkeeper/deliver.py +442 -0
- trackerkeeper/design_tokens.py +415 -0
- trackerkeeper/diagnostics.py +168 -0
- trackerkeeper/drag_repaint/__init__.py +68 -0
- trackerkeeper/drag_repaint/_kwin.py +192 -0
- trackerkeeper/drag_repaint/_unsupported.py +41 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
- trackerkeeper/frosted_dialog.py +377 -0
- trackerkeeper/i18n/__init__.py +101 -0
- trackerkeeper/i18n/fmt.py +137 -0
- trackerkeeper/icon_button.py +137 -0
- trackerkeeper/icons.py +532 -0
- trackerkeeper/identity.py +135 -0
- trackerkeeper/item_dialog.py +185 -0
- trackerkeeper/kde_titlebar.py +196 -0
- trackerkeeper/keyboard_focus.py +353 -0
- trackerkeeper/log.py +112 -0
- trackerkeeper/macos_menubar.py +282 -0
- trackerkeeper/macos_window.py +172 -0
- trackerkeeper/metadata.py +121 -0
- trackerkeeper/noborder/__init__.py +61 -0
- trackerkeeper/noborder/_kwin.py +247 -0
- trackerkeeper/noborder/_unsupported.py +42 -0
- trackerkeeper/notifications/__init__.py +84 -0
- trackerkeeper/notifications/_linux.py +68 -0
- trackerkeeper/notifications/_macos.py +159 -0
- trackerkeeper/notifications/_unsupported.py +22 -0
- trackerkeeper/notifications/_windows.py +115 -0
- trackerkeeper/platform_compat.py +149 -0
- trackerkeeper/power/__init__.py +84 -0
- trackerkeeper/power/_linux.py +79 -0
- trackerkeeper/power/_unsupported.py +18 -0
- trackerkeeper/power/_windows.py +52 -0
- trackerkeeper/rig.py +338 -0
- trackerkeeper/scaffold.py +310 -0
- trackerkeeper/selector.py +529 -0
- trackerkeeper/settings.py +226 -0
- trackerkeeper/settings_dialog.py +307 -0
- trackerkeeper/settings_migration.py +101 -0
- trackerkeeper/single_instance.py +330 -0
- trackerkeeper/smooth_scroll.py +155 -0
- trackerkeeper/sources.py +318 -0
- trackerkeeper/system_accent.py +363 -0
- trackerkeeper/terminal.py +423 -0
- trackerkeeper/test_bridge.py +514 -0
- trackerkeeper/theme.py +725 -0
- trackerkeeper/top_bar.py +154 -0
- trackerkeeper/tray.py +184 -0
- trackerkeeper/ui_helpers.py +2294 -0
- trackerkeeper/update_chip.py +119 -0
- trackerkeeper/updates.py +216 -0
- trackerkeeper/win_frameless.py +364 -0
- trackerkeeper/window.py +609 -0
- trackerkeeper/windows_shortcut.py +355 -0
- trackerkeeper-0.1.0.dist-info/METADATA +144 -0
- trackerkeeper-0.1.0.dist-info/RECORD +89 -0
- trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
- trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
- trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
- 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())
|