flexitracker 0.3.0__tar.gz → 0.4.0__tar.gz

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 (56) hide show
  1. {flexitracker-0.3.0 → flexitracker-0.4.0}/PKG-INFO +7 -5
  2. {flexitracker-0.3.0 → flexitracker-0.4.0}/README.md +6 -4
  3. flexitracker-0.4.0/install/README.md +95 -0
  4. {flexitracker-0.3.0 → flexitracker-0.4.0}/pyproject.toml +6 -0
  5. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/cli.py +43 -9
  6. flexitracker-0.4.0/src/flexitracker/logging_setup.py +64 -0
  7. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/outbox.py +6 -1
  8. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/sender.py +9 -1
  9. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_cli.py +19 -1
  10. flexitracker-0.4.0/tests/test_logging_setup.py +83 -0
  11. flexitracker-0.3.0/install/README.md +0 -43
  12. flexitracker-0.3.0/install/install.ps1 +0 -35
  13. flexitracker-0.3.0/install/install.sh +0 -35
  14. {flexitracker-0.3.0 → flexitracker-0.4.0}/.gitignore +0 -0
  15. {flexitracker-0.3.0 → flexitracker-0.4.0}/install/flexitracker.service +0 -0
  16. {flexitracker-0.3.0 → flexitracker-0.4.0}/pyinstaller_entry.py +0 -0
  17. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/__init__.py +0 -0
  18. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/__main__.py +0 -0
  19. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/_backend.py +0 -0
  20. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/config.py +0 -0
  21. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/core.py +0 -0
  22. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/idle.py +0 -0
  23. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/login.py +0 -0
  24. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/state_machine.py +0 -0
  25. {flexitracker-0.3.0 → flexitracker-0.4.0}/src/flexitracker/version.py +0 -0
  26. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_config.py +0 -0
  27. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_conformance.py +0 -0
  28. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_idle.py +0 -0
  29. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_login.py +0 -0
  30. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_outbox.py +0 -0
  31. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/test_sender_body.py +0 -0
  32. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/01-activation-debounced-backdated.json +0 -0
  33. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/02-idle-backdated-to-last-input.json +0 -0
  34. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/03-short-break-absorbed.json +0 -0
  35. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/04-brief-activity-debounced.json +0 -0
  36. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/05-sustained-return-backdated.json +0 -0
  37. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/06-heartbeats-while-active.json +0 -0
  38. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/07-recover-after-sudden-shutdown.json +0 -0
  39. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/08-locked-session-goes-idle.json +0 -0
  40. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/09-recover-anchors-later-evidence.json +0 -0
  41. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/10-suspend-longer-closes-span.json +0 -0
  42. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/11-suspend-with-immediate-wake.json +0 -0
  43. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/12-suspend-shorter-absorbed.json +0 -0
  44. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/13-backwards-clock-watermark.json +0 -0
  45. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/14-small-forward-clock-absorbed.json +0 -0
  46. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/15-recover-from-active-matches-loop.json +0 -0
  47. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/16-explicit-close-no-double-close.json +0 -0
  48. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/17-freshness-independent-of-poll.json +0 -0
  49. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/18-short-reading-pause.json +0 -0
  50. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/19-presence-must-be-sustained.json +0 -0
  51. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/20-gap-kills-pending-return.json +0 -0
  52. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/21-idle-backdating-exact.json +0 -0
  53. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/22-min-inactivity-exact-boundary.json +0 -0
  54. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/23-large-timestamps.json +0 -0
  55. {flexitracker-0.3.0 → flexitracker-0.4.0}/tests/vectors/24-all-kinds-lowercase.json +0 -0
  56. {flexitracker-0.3.0 → flexitracker-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexitracker
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: FlexiTracker activity daemon — pure-Python.
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -23,10 +23,12 @@ Recommended (all platforms):
23
23
  uv tool install flexitracker
24
24
  ```
25
25
 
26
- Or, on a machine that allows executables, download the standalone
27
- `flexitracker` / `flexitracker.exe` from the GitHub **Releases** page (it bundles
28
- its own Python runtime). See `install/README.md` for per-OS auto-start and the
29
- Windows SmartScreen trust step.
26
+ Or, on a machine that allows executables, download the standalone build from
27
+ the GitHub **Releases** page (it bundles its own Python runtime): one binary
28
+ on Linux, or on Windows two — a console one for `login`/`test` and a
29
+ windowless one for auto-start. See `install/README.md` for per-OS auto-start
30
+ (a single copy-pasteable command, no installer script) and the Windows
31
+ SmartScreen trust step.
30
32
 
31
33
  ## Use
32
34
 
@@ -15,10 +15,12 @@ Recommended (all platforms):
15
15
  uv tool install flexitracker
16
16
  ```
17
17
 
18
- Or, on a machine that allows executables, download the standalone
19
- `flexitracker` / `flexitracker.exe` from the GitHub **Releases** page (it bundles
20
- its own Python runtime). See `install/README.md` for per-OS auto-start and the
21
- Windows SmartScreen trust step.
18
+ Or, on a machine that allows executables, download the standalone build from
19
+ the GitHub **Releases** page (it bundles its own Python runtime): one binary
20
+ on Linux, or on Windows two — a console one for `login`/`test` and a
21
+ windowless one for auto-start. See `install/README.md` for per-OS auto-start
22
+ (a single copy-pasteable command, no installer script) and the Windows
23
+ SmartScreen trust step.
22
24
 
23
25
  ## Use
24
26
 
@@ -0,0 +1,95 @@
1
+ # Installing the FlexiTracker daemon
2
+
3
+ The daemon is a pure-Python program. The **recommended** install on every OS is
4
+ with [uv](https://docs.astral.sh/uv/) — no administrator rights, no compiler, and
5
+ it works on managed machines that block unsigned executables and installer
6
+ scripts alike.
7
+
8
+ Every auto-start step below is a single command you run yourself and can read
9
+ before pasting — **no installer script (PowerShell, VBScript, or shell) is
10
+ downloaded or executed for you.** This also renders on the web app's Machines
11
+ tab, matching your detected OS, with a copy button.
12
+
13
+ ## Recommended: uv (all platforms)
14
+
15
+ ```bash
16
+ uv tool install flexitracker
17
+ flexitracker login
18
+ flexitracker test
19
+ ```
20
+
21
+ `login` opens your browser to authorize the machine — sign in, approve, and the
22
+ key lands in the config automatically; you never see or paste it. On a
23
+ headless or scripted box, use `flexitracker login --key <YOUR_ACCESS_KEY>`
24
+ with a key from the web UI's "Add machine" button instead.
25
+
26
+ `uv tool install` places two commands on `PATH`: the console `flexitracker`
27
+ used above (for `login`/`test`, where you want to see the output), and a
28
+ second, windowless `flexitracker-daemon` command for auto-start below — it runs
29
+ the identical daemon with no console window.
30
+
31
+ ### Auto-start on login
32
+
33
+ - **Windows** — run this once in `cmd.exe` or PowerShell (no admin rights
34
+ needed; it only touches your own user's registry):
35
+
36
+ ```
37
+ reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v FlexiTracker /t REG_SZ /d "\"%USERPROFILE%\.local\bin\flexitracker-daemon.exe\"" /f
38
+ ```
39
+
40
+ To undo it: `reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v FlexiTracker /f`
41
+
42
+ - **Linux** — systemd *user* service:
43
+
44
+ ```bash
45
+ mkdir -p ~/.config/systemd/user
46
+ cp flexitracker.service ~/.config/systemd/user/
47
+ systemctl --user daemon-reload
48
+ systemctl --user enable --now flexitracker.service
49
+ ```
50
+
51
+ (`flexitracker.service` is the unit file in this directory — it's a plain
52
+ declarative unit, not a script.) Check status/logs with
53
+ `systemctl --user status flexitracker` / `journalctl --user -u flexitracker -f`.
54
+ If the machine doesn't keep a session open past logout, also run
55
+ `loginctl enable-linger "$USER"` once so the user service can still start.
56
+ To undo it: `systemctl --user disable --now flexitracker.service`.
57
+
58
+ ## Alternative: standalone executables (machines that allow exes)
59
+
60
+ If your machine permits running executables, download from the project's
61
+ GitHub **Releases** page — it bundles its own Python runtime, so nothing else
62
+ is needed. Windows ships **two** executables; use the right one for the job:
63
+
64
+ | File | Use it for |
65
+ |------|------------|
66
+ | `flexitracker-windows-x86_64.exe` | `login` and `test`, run from a terminal — it has a console, so you see the output. |
67
+ | `flexitracker-daemon-windows-x86_64.exe` | Auto-start only — a windowless build with no console window, ever. |
68
+
69
+ Linux has one binary, `flexitracker-linux-x86_64`, used for everything.
70
+
71
+ ```bat
72
+ flexitracker-windows-x86_64.exe login
73
+ flexitracker-windows-x86_64.exe test
74
+ ```
75
+
76
+ ### Auto-start on login (standalone executable)
77
+
78
+ Save `flexitracker-daemon-windows-x86_64.exe` somewhere permanent (e.g.
79
+ `%LOCALAPPDATA%\flexitracker\flexitracker-daemon.exe`), then register it the
80
+ same way as the `uv` path, pointed at that path instead:
81
+
82
+ ```
83
+ reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v FlexiTracker /t REG_SZ /d "\"<path to flexitracker-daemon-windows-x86_64.exe>\"" /f
84
+ ```
85
+
86
+ On Linux, use the same `flexitracker.service` + `systemctl --user` steps
87
+ above, pointing `ExecStart` at wherever you saved `flexitracker-linux-x86_64`.
88
+
89
+ ### Windows SmartScreen (unsigned executables)
90
+
91
+ Both standalone executables are **not code-signed**, so Windows SmartScreen may
92
+ warn on first run. To allow one: click **More info → Run anyway**, or
93
+ right-click the file → **Properties** → tick **Unblock** → **OK**. If your
94
+ organization blocks unsigned executables entirely, use the **uv** path above
95
+ instead — it does not run a downloaded executable.
@@ -17,6 +17,12 @@ dependencies = []
17
17
  [project.scripts]
18
18
  flexitracker = "flexitracker.cli:main"
19
19
 
20
+ # Windows-subsystem launcher (no console ever) for `uv tool install`, used only
21
+ # for the auto-started background daemon. `login`/`test` use the console
22
+ # `flexitracker` entry point above so their output is visible.
23
+ [project.gui-scripts]
24
+ flexitracker-daemon = "flexitracker.cli:main"
25
+
20
26
  [dependency-groups]
21
27
  dev = ["pytest>=8"]
22
28
  # Freezing tool for the standalone single-file executable (GitHub Release asset,
@@ -9,6 +9,7 @@ sends no data), and the default daemon run.
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
+ import logging
12
13
  import os
13
14
  import socket
14
15
  import sys
@@ -18,12 +19,16 @@ from typing import Optional
18
19
 
19
20
  from ._backend import default_backend_url
20
21
  from .config import Config
21
- from .core import machine_descriptor
22
+ from .core import EventKind, machine_descriptor
22
23
  from .idle import Sample, platform_source
24
+ from .logging_setup import LOGGER_NAME, configure_logging
23
25
  from .login import LoginError, browser_login
24
26
  from .outbox import Outbox
25
27
  from .sender import SenderError, fetch_thresholds, post_batch, whoami
26
28
  from .state_machine import Persisted, StateMachine, Tick
29
+ from .version import get_version
30
+
31
+ logger = logging.getLogger(LOGGER_NAME)
27
32
 
28
33
  HELP = """flexitracker — activity tracking daemon
29
34
 
@@ -100,8 +105,6 @@ def parse_args(argv: list) -> Args:
100
105
  elif arg == "--once":
101
106
  a.once = True
102
107
  elif arg in ("--version", "-V"):
103
- from .version import get_version
104
-
105
108
  print(f"flexitracker {get_version()}")
106
109
  sys.exit(0)
107
110
  elif arg in ("--help", "-h"):
@@ -138,7 +141,17 @@ def flush(cfg: Config, ob: Outbox) -> None:
138
141
  post_batch(cfg.backend_url, cfg.access_key, batch)
139
142
  ob.ack()
140
143
  except SenderError as e:
141
- print(f"flush deferred ({ob.pending_len()} pending): {e}", file=sys.stderr)
144
+ logger.warning("flush deferred (%d pending): %s", ob.pending_len(), e)
145
+
146
+
147
+ def log_events(events: list) -> None:
148
+ """INFO for a work/idle span start or end; DEBUG for a heartbeat, so a
149
+ default-level log stays small no matter how long the daemon runs."""
150
+ for e in events:
151
+ if e["kind"] == EventKind.HEARTBEAT:
152
+ logger.debug("heartbeat ts=%s", e["ts"])
153
+ else:
154
+ logger.info("%s ts=%s", e["kind"], e["ts"])
142
155
 
143
156
 
144
157
  def load_state(path: Path) -> Optional[Persisted]:
@@ -253,12 +266,29 @@ def run(argv: list) -> int:
253
266
  return 1
254
267
  cfg.save(config_path)
255
268
 
269
+ configure_logging(config_path)
270
+ machine = machine_desc()
271
+ logger.info(
272
+ "flexitracker %s starting (backend=%s, config=%s)", get_version(), cfg.backend_url, config_path
273
+ )
274
+ logger.info("machine: hostname=%s os=%s", machine["hostname"], machine["os"])
275
+
276
+ try:
277
+ return _run_daemon(args, cfg, config_path, machine)
278
+ except Exception:
279
+ # No console to see a traceback on under the windowed/no-console build
280
+ # — the rotating log file is the only place this can land.
281
+ logger.exception("daemon loop crashed")
282
+ return 1
283
+
284
+
285
+ def _run_daemon(args: Args, cfg: Config, config_path: Path, machine: dict) -> int:
256
286
  # Refresh thresholds; fall back to cached/defaults offline.
257
287
  try:
258
288
  cfg.thresholds = fetch_thresholds(cfg.backend_url, cfg.access_key, cfg.thresholds.poll_sec)
259
289
  cfg.save(config_path)
260
290
  except SenderError as e:
261
- print(f"warning: using cached thresholds ({e})", file=sys.stderr)
291
+ logger.warning("using cached thresholds (%s)", e)
262
292
  thresholds = cfg.thresholds.to_thresholds()
263
293
 
264
294
  state_path = config_path.with_name("state.json")
@@ -266,8 +296,8 @@ def run(argv: list) -> int:
266
296
  ob = Outbox.open(outbox_path)
267
297
  dropped = ob.trim_expired(now_ms())
268
298
  if dropped > 0:
269
- print(f"dropped {dropped} outbox event(s) older than the backend edit window", file=sys.stderr)
270
- ob.set_machine(machine_desc())
299
+ logger.warning("dropped %d outbox event(s) older than the backend edit window", dropped)
300
+ ob.set_machine(machine)
271
301
 
272
302
  if args.simulate:
273
303
  return simulate(cfg, ob)
@@ -275,25 +305,29 @@ def run(argv: list) -> int:
275
305
  persisted = load_state(state_path) or Persisted()
276
306
  sm = StateMachine(thresholds, persisted)
277
307
 
278
- ob.append(sm.recover(now_ms()))
308
+ recovered = sm.recover(now_ms())
309
+ log_events(recovered)
310
+ ob.append(recovered)
279
311
  save_state(state_path, sm.p)
280
312
  flush(cfg, ob)
281
313
 
282
314
  try:
283
315
  source = platform_source()
284
316
  except OSError as e:
285
- print(f"error: idle source: {e}", file=sys.stderr)
317
+ logger.error("idle source: %s", e)
286
318
  return 1
287
319
 
288
320
  last_mono: Optional[float] = None
289
321
  while True:
290
322
  s: Sample = source.sample()
323
+ logger.debug("sample idle_ms=%s locked=%s", s.idle_ms, s.locked)
291
324
  mono_now = time.monotonic()
292
325
  mono_elapsed_ms = None if last_mono is None else int((mono_now - last_mono) * 1000)
293
326
  last_mono = mono_now
294
327
  events = sm.step(
295
328
  Tick(now=now_ms(), idle_ms=s.idle_ms, locked=s.locked, mono_elapsed_ms=mono_elapsed_ms)
296
329
  )
330
+ log_events(events)
297
331
  ob.append(events)
298
332
  save_state(state_path, sm.p)
299
333
  flush(cfg, ob)
@@ -0,0 +1,64 @@
1
+ """Rotating file logging for the background daemon loop.
2
+
3
+ Only the daemon loop uses this — `login`, `test`, `--version`, `--help`, and
4
+ `--simulate` stay on plain `print()`, since those are explicit, human-invoked,
5
+ foreground commands, not the silent background loop this exists for.
6
+
7
+ INFO records lifecycle events only (startup, the detected machine descriptor,
8
+ work/idle span start+end). Per-tick sampling and heartbeats are DEBUG-only, so
9
+ a default-level log stays small no matter how long the daemon runs. A
10
+ `StreamHandler` on `stderr` is added only when `stderr` is actually attached
11
+ (interactive `login`/`test`/foreground runs) — a `--windowed` frozen build has
12
+ no console, so `sys.stderr` is `None` there and must not be touched.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import logging.handlers
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ LOGGER_NAME = "flexitracker"
25
+
26
+ # ~8MB worst case (current + 3 backups) — generous for INFO-level lifecycle
27
+ # logging across many days, bounded enough to never become a disk concern.
28
+ MAX_BYTES = 2_000_000
29
+ BACKUP_COUNT = 3
30
+
31
+
32
+ def configure_logging(
33
+ config_path: Path,
34
+ level: Optional[str] = None,
35
+ max_bytes: int = MAX_BYTES,
36
+ backup_count: int = BACKUP_COUNT,
37
+ ) -> logging.Logger:
38
+ """Attach a rotating file handler (plus an optional stderr mirror) to the
39
+ module logger. Idempotent: a second call is a no-op so re-entrant daemon
40
+ code paths and tests can call it freely."""
41
+ logger = logging.getLogger(LOGGER_NAME)
42
+ if logger.handlers:
43
+ return logger
44
+
45
+ resolved_level = (level or os.environ.get("FLEXITRACKER_LOG_LEVEL") or "INFO").upper()
46
+ logger.setLevel(resolved_level)
47
+
48
+ log_path = config_path.with_name("flexitracker.log")
49
+ log_path.parent.mkdir(parents=True, exist_ok=True)
50
+ file_handler = logging.handlers.RotatingFileHandler(
51
+ log_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
52
+ )
53
+ file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
54
+ logger.addHandler(file_handler)
55
+
56
+ # None under a --windowed frozen build (no console attached) — never a
57
+ # closed/broken stream, so this is the correct, sufficient guard.
58
+ if sys.stderr is not None:
59
+ stream_handler = logging.StreamHandler(sys.stderr)
60
+ stream_handler.setLevel(logging.WARNING)
61
+ stream_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
62
+ logger.addHandler(stream_handler)
63
+
64
+ return logger
@@ -9,10 +9,15 @@ either implementation can resume the other's outbox (proven by the harness).
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
+ import logging
12
13
  import os
13
14
  from pathlib import Path
14
15
  from typing import Optional
15
16
 
17
+ from .logging_setup import LOGGER_NAME
18
+
19
+ logger = logging.getLogger(LOGGER_NAME)
20
+
16
21
  # Events older than the backend's edit window are rejected on arrival; holding
17
22
  # them only grows the file.
18
23
  MAX_EVENT_AGE_MS = 120 * 86_400_000
@@ -49,7 +54,7 @@ class Outbox:
49
54
  except OSError:
50
55
  pass
51
56
  extra = f" (previous contents kept at {aside})" if moved else ""
52
- print(f"warning: outbox unreadable ({e}); starting with an empty queue{extra}")
57
+ logger.warning("outbox unreadable (%s); starting with an empty queue%s", e, extra)
53
58
  return cls(path, 0, [], None)
54
59
  return cls(
55
60
  path,
@@ -14,6 +14,14 @@ from dataclasses import dataclass
14
14
  from typing import Optional
15
15
 
16
16
  from .config import ThresholdCfg
17
+ from .version import get_version
18
+
19
+ # Cloudflare's bot-fight mode blocks urllib's default "Python-urllib/x.y" User-
20
+ # Agent outright (HTTP 403, body "error code: 1010") on every route, Access-
21
+ # bypassed or not — it's a zone-level WAF check, unrelated to Access. Every
22
+ # real request this client makes needs a non-default UA or it never leaves
23
+ # the edge.
24
+ USER_AGENT = f"flexitracker-daemon/{get_version()}"
17
25
 
18
26
 
19
27
  class SenderError(Exception):
@@ -22,7 +30,7 @@ class SenderError(Exception):
22
30
 
23
31
  def _request(url: str, key: Optional[str], method: str, body: Optional[dict]) -> dict:
24
32
  data = None
25
- headers = {}
33
+ headers = {"user-agent": USER_AGENT}
26
34
  if key:
27
35
  headers["authorization"] = f"Bearer {key}"
28
36
  if body is not None:
@@ -1,7 +1,9 @@
1
+ import logging
2
+
1
3
  import pytest
2
4
 
3
5
  from flexitracker import cli as cli_mod
4
- from flexitracker.cli import parse_args, run
6
+ from flexitracker.cli import log_events, parse_args, run
5
7
  from flexitracker.config import Config
6
8
  from flexitracker.login import LoginError
7
9
 
@@ -80,3 +82,19 @@ def test_login_browser_failure_writes_no_config(tmp_path, monkeypatch):
80
82
  rc = run(["login", "--backend-url", "https://backend.example", "--config", str(config_path)])
81
83
  assert rc == 1
82
84
  assert not config_path.exists()
85
+
86
+
87
+ def test_log_events_logs_spans_at_info_and_heartbeat_at_debug(caplog):
88
+ events = [
89
+ {"ts": 1000, "kind": "active"},
90
+ {"ts": 2000, "kind": "heartbeat"},
91
+ {"ts": 3000, "kind": "idle"},
92
+ ]
93
+ with caplog.at_level(logging.DEBUG, logger="flexitracker"):
94
+ log_events(events)
95
+ info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
96
+ debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG]
97
+ assert any("active" in m for m in info_messages)
98
+ assert any("idle" in m for m in info_messages)
99
+ assert any("heartbeat" in m for m in debug_messages)
100
+ assert not any("heartbeat" in m for m in info_messages)
@@ -0,0 +1,83 @@
1
+ import logging
2
+
3
+ import pytest
4
+
5
+ from flexitracker.logging_setup import LOGGER_NAME, configure_logging
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def _reset_logger():
10
+ # configure_logging is idempotent (no-op once handlers exist), so each
11
+ # test needs a clean slate rather than reusing the process-wide singleton.
12
+ logger = logging.getLogger(LOGGER_NAME)
13
+ logger.handlers.clear()
14
+ logger.setLevel(logging.NOTSET)
15
+ yield
16
+ logger.handlers.clear()
17
+ logger.setLevel(logging.NOTSET)
18
+
19
+
20
+ def _log_path(tmp_path):
21
+ return tmp_path / "flexitracker.log"
22
+
23
+
24
+ def test_writes_to_a_rotating_file_next_to_config(tmp_path):
25
+ config_path = tmp_path / "config.toml"
26
+ logger = configure_logging(config_path)
27
+ logger.info("hello")
28
+ for h in logger.handlers:
29
+ h.flush()
30
+ assert "hello" in _log_path(tmp_path).read_text(encoding="utf-8")
31
+
32
+
33
+ def test_default_level_is_info_debug_is_suppressed(tmp_path):
34
+ config_path = tmp_path / "config.toml"
35
+ logger = configure_logging(config_path)
36
+ logger.info("span start")
37
+ logger.debug("heartbeat tick")
38
+ for h in logger.handlers:
39
+ h.flush()
40
+ text = _log_path(tmp_path).read_text(encoding="utf-8")
41
+ assert "span start" in text
42
+ assert "heartbeat tick" not in text
43
+
44
+
45
+ def test_env_var_raises_level_to_debug(tmp_path, monkeypatch):
46
+ monkeypatch.setenv("FLEXITRACKER_LOG_LEVEL", "DEBUG")
47
+ config_path = tmp_path / "config.toml"
48
+ logger = configure_logging(config_path)
49
+ logger.debug("heartbeat tick")
50
+ for h in logger.handlers:
51
+ h.flush()
52
+ assert "heartbeat tick" in _log_path(tmp_path).read_text(encoding="utf-8")
53
+
54
+
55
+ def test_rotates_past_the_size_limit(tmp_path):
56
+ config_path = tmp_path / "config.toml"
57
+ logger = configure_logging(config_path, max_bytes=500, backup_count=2)
58
+ for i in range(200):
59
+ logger.info("padding line number %d to force rotation", i)
60
+ assert _log_path(tmp_path).exists()
61
+ assert (tmp_path / "flexitracker.log.1").exists()
62
+
63
+
64
+ def test_no_stderr_handler_when_stderr_is_absent(tmp_path, monkeypatch):
65
+ # Mirrors a --windowed frozen build, where sys.stderr is None.
66
+ monkeypatch.setattr("flexitracker.logging_setup.sys.stderr", None)
67
+ config_path = tmp_path / "config.toml"
68
+ logger = configure_logging(config_path)
69
+ assert len(logger.handlers) == 1
70
+
71
+
72
+ def test_stderr_handler_present_when_stderr_is_attached(tmp_path):
73
+ config_path = tmp_path / "config.toml"
74
+ logger = configure_logging(config_path)
75
+ assert len(logger.handlers) == 2
76
+
77
+
78
+ def test_second_call_is_a_no_op(tmp_path):
79
+ config_path = tmp_path / "config.toml"
80
+ configure_logging(config_path)
81
+ handlers_after_first = list(logging.getLogger(LOGGER_NAME).handlers)
82
+ configure_logging(config_path)
83
+ assert logging.getLogger(LOGGER_NAME).handlers == handlers_after_first
@@ -1,43 +0,0 @@
1
- # Installing the FlexiTracker daemon
2
-
3
- The daemon is a pure-Python program. The **recommended** install on every OS is
4
- with [uv](https://docs.astral.sh/uv/) — no administrator rights, no compiler, and
5
- it works on managed machines that block unsigned executables.
6
-
7
- ## Recommended: uv (all platforms)
8
-
9
- ```bash
10
- uv tool install flexitracker
11
- flexitracker login
12
- flexitracker test
13
- ```
14
-
15
- `login` opens your browser to authorize the machine — sign in, approve, and the
16
- key lands in the config automatically; you never see or paste it. On a
17
- headless or scripted box, use `flexitracker login --key <YOUR_ACCESS_KEY>`
18
- with a key from the web UI's "Add machine" button instead.
19
-
20
- Then enable auto-start on login:
21
-
22
- - **Linux:** `./install.sh` (installs a systemd *user* service).
23
- - **Windows:** `powershell -ExecutionPolicy Bypass -File install.ps1` (registers a
24
- login task).
25
-
26
- ## Alternative: standalone executable (machines that allow exes)
27
-
28
- If your machine permits running executables, download the standalone
29
- `flexitracker.exe` (Windows) or `flexitracker` (Linux) from the project's GitHub
30
- **Releases** page — it bundles its own Python runtime, so nothing else is needed.
31
-
32
- ```bat
33
- flexitracker.exe login
34
- flexitracker.exe test
35
- ```
36
-
37
- ### Windows SmartScreen (unsigned executable)
38
-
39
- The standalone executable is **not code-signed**, so Windows SmartScreen may warn
40
- on first run. To allow it: click **More info → Run anyway**, or right-click the
41
- file → **Properties** → tick **Unblock** → **OK**. If your organization blocks
42
- unsigned executables entirely, use the **uv** path above instead — it does not run
43
- an executable.
@@ -1,35 +0,0 @@
1
- # FlexiTracker (Windows) — install the daemon into your user profile with uv
2
- # (no admin, no compiler) and register a login task so it starts on sign-in.
3
- #
4
- # This is the recommended path on managed machines that block unsigned
5
- # executables but permit a user-scope Python toolchain. If your machine allows
6
- # executables, you can instead download the standalone flexitracker.exe from the
7
- # GitHub Releases page (see install/README.md for the SmartScreen trust step).
8
-
9
- $ErrorActionPreference = "Stop"
10
-
11
- if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
12
- Write-Error "uv is required: https://docs.astral.sh/uv/ (run: irm https://astral.sh/uv/install.ps1 | iex)"
13
- exit 1
14
- }
15
-
16
- Write-Host "Installing flexitracker with uv…"
17
- uv tool install --upgrade flexitracker
18
-
19
- $bin = (Get-Command flexitracker -ErrorAction SilentlyContinue).Source
20
- if (-not $bin) { $bin = Join-Path $env:USERPROFILE ".local\bin\flexitracker.exe" }
21
-
22
- # Authorize this machine if not already configured.
23
- $cfg = Join-Path $env:APPDATA ".config\flexitracker\config.toml"
24
- if (-not (Test-Path $cfg)) {
25
- Write-Host "`nAuthorize this machine (paste the access key from the web app):"
26
- & $bin configure
27
- }
28
-
29
- # Register a login task that starts the daemon windowless.
30
- $action = New-ScheduledTaskAction -Execute $bin
31
- $trigger = New-ScheduledTaskTrigger -AtLogOn
32
- $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -Hidden
33
- Register-ScheduledTask -TaskName "FlexiTracker" -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null
34
-
35
- Write-Host "`nInstalled and registered to start on login. Check with: flexitracker test"
@@ -1,35 +0,0 @@
1
- #!/bin/sh
2
- # FlexiTracker (Linux) — install the daemon into your user profile with uv
3
- # (no admin, no compiler) and enable it as a systemd user service on login.
4
- set -e
5
-
6
- if ! command -v uv >/dev/null 2>&1; then
7
- echo "uv is required: https://docs.astral.sh/uv/ (curl -LsSf https://astral.sh/uv/install.sh | sh)" >&2
8
- exit 1
9
- fi
10
-
11
- echo "Installing flexitracker with uv…"
12
- uv tool install --upgrade flexitracker
13
-
14
- BIN="$HOME/.local/bin/flexitracker"
15
- if [ ! -x "$BIN" ]; then
16
- # Fall back to whatever uv put on PATH.
17
- BIN="$(command -v flexitracker || echo "$BIN")"
18
- fi
19
-
20
- # Authorize this machine if not already configured.
21
- if [ ! -f "$HOME/.config/flexitracker/config.toml" ]; then
22
- echo
23
- echo "Authorize this machine (paste the access key from the web app):"
24
- "$BIN" configure
25
- fi
26
-
27
- # Install + enable the user service.
28
- mkdir -p "$HOME/.config/systemd/user"
29
- cp "$(dirname "$0")/flexitracker.service" "$HOME/.config/systemd/user/flexitracker.service"
30
- systemctl --user daemon-reload
31
- systemctl --user enable --now flexitracker.service
32
-
33
- echo
34
- echo "Installed and started. Check with: flexitracker test"
35
- echo "Auto-starts on login. (You may need: loginctl enable-linger \"$USER\")"
File without changes
File without changes