Skip to content

ci: add pytest workflow on windows-latest#2

Open
D3vCrow wants to merge 24 commits intomasterfrom
ci/add-pytest-workflow
Open

ci: add pytest workflow on windows-latest#2
D3vCrow wants to merge 24 commits intomasterfrom
ci/add-pytest-workflow

Conversation

@D3vCrow
Copy link
Copy Markdown
Owner

@D3vCrow D3vCrow commented Apr 15, 2026

Summary

  • Adds .github/workflows/test.yml — runs pytest on every push to master and every PR.
  • Uses windows-latest (toolbox is Windows-only: ctypes.windll, WMI, bundled Tk).
  • Installs requirements.txt + the runtime deps the test suite imports at module load (customtkinter, psutil, pillow, scapy, requests, pywin32-ctypes).
  • Pinning the full third-party set is intentionally deferred to Plan B-B4 to keep this PR surgical.

Test plan

  • CI run on this PR turns green (117/117 tests pass on Windows runner)
  • Open a no-op PR after merge → confirm workflow auto-triggers

D3vCrow added 24 commits April 16, 2026 01:10
Runs pytest on every push to master and on every pull request. Uses
windows-latest because the toolbox is Windows-only (ctypes.windll, WMI,
Tk bundled). Installs requirements.txt + the runtime deps the tests
need at import time; the full pin is deferred to Plan B-B4.
- Add self._filter_lock (threading.RLock) guarding _active_categories and
  _active_severities.
- Add _snapshot_filters() helper returning immutable frozenset pair under
  the lock.
- Wrap mutations in _toggle_category / _toggle_severity with the lock.
- Route every read site through _snapshot_filters(): _load_historical,
  _filter_historical, _export_historical, _filter_live, _start_live_poll,
  and the _process_queue "live" hot path (previously L2275-2283).
- Tests: 4 new cases in tests/test_account_activity_monitor.py cover
  frozenset type, post-snapshot mutation isolation, concurrent
  reader/writer with no RuntimeError, and snapshot immutability.

Closes A4 from plans/2026-04-17-debt-review-PLAN_A_critical.md.
…ity_monitor.py

Closes B1l from plans/PLAN_B_sweep.md. Enables A7 consolidation of the
4th portable launcher (previously blocked by the space-containing module
name, which could not be imported via `from tools.X import App`).

Main.py uses AST discovery (no filename hardcoded) and the UI display
name comes from TOOL_NAME in the module body, so the rename is
transparent to users. Updated 6 references:

- tools/_runner.py docstring + comment (example now references
  lowercase filename; spaces-in-path support is still tested via
  tmp-fixture in test_runner_handles_spaces_in_filename).
- tests/test_common_threadsafe.py comment.
- tests/test_tool_runner.py docstring.
- HANDOFF.md "Where to Find Things" table.
- SPEC.md Tools table.

Audits/ and plans/ retain the historical filename by design.
Delete 4 duplicate tools/*.py under portable/ (3 silently diverged;
network_stability_monitor.py was missing A5 BoundedDeque). Launchers
now import from the canonical tools.* package. PyInstaller specs set
pathex=repo-root and hiddenimports=['tools.<module>',
'tools._common.threadsafe']. Gitignore exception for portable/build/*.spec
so the specs are versioned (build artefact subdirs stay ignored).
Drift guard test prevents reintroduction. HANDOFF/SPEC docs updated.

EXE rebuild + launcher smoke-tests remain with the user.
_delete_dir_contents now runs os.walk(followlinks=False) against any
directory entry it is about to rmtree/send2trash; if ANY symlink is
found below, the entry is refused entirely and a structured
symlink_in_tree_refused log is emitted (path + offender). Stricter
than rmtree stop-at-link behavior -- a louder, auditable signal that
matches the Plan A/A1 stance.

Also: every successful delete now emits a trashed or permanent_deleted
structured line with path + byte count, closing the log-each-deletion
-outcome bullet from the plan.

New helper: _tree_has_symlink(root) -> Optional[str] returns the first
offender path so the caller can log it.

Tests: nested-symlink refusal, helper happy/sad paths, structured
success log. Symlink-creation cases skip when the dev env lacks
Windows symlink privilege (matches the pre-existing skip pattern).

PERMANENT second-confirmation (plan bullet) is a no-op here: the UI
hardcodes DEFAULT_DELETE_MODE = TRASH so PERMANENT is unreachable from
buttons; the CLI --permanent path already requires both --yes and
--categories (stricter than one askyesno). No gap to close.
Adds the four missing helper modules the Tier B sweep depends on so
B1-B3 can proceed surgically. Each module centralises a pattern
currently duplicated across ~10 tools:

* paths.py       -- REPO_ROOT / TOOLS_DIR / TESTS_DIR / PLANS_DIR /
                    AUDITS_DIR / COMMON_DIR / PORTABLE_DIR. One
                    derivation, imported everywhere.
* subprocess.py  -- CREATE_NO_WINDOW + run_hidden / popen_hidden
                    wrappers that OR-merge caller flags so the
                    hidden-window bit never gets dropped.
                    (Replaces the _CNW / _CREATE_NO_WINDOW / inline
                    0x08000000 zoo.)
* ui_theme.py    -- apply_dark_treeview_style(master) centralises the
                    dark Treeview palette duplicated in 5+ tools.
* exceptions.py  -- narrow_excepts(*types) decorator and
                    suppress_and_log context manager. Pick per-site:
                    decorator for whole-function degradation, context
                    manager for a few lines.

_common/__init__.py now re-exports the commonly-used names so callers
can do `from tools._common import TOOLS_DIR, run_hidden`. Submodule
imports remain valid and preferred for narrow imports.

Tests: test_common_paths.py + test_common_subprocess.py (11 new
tests). ui_theme and exceptions are thin enough that their smoke
coverage lives in downstream usage sites in B1-B3 rather than
stand-alone tests here.

Zero callsite changes in this commit -- foundation only. B1 will
start migrating tools onto the new helpers.
29 tests covering pure helpers and JSON persistence:

decision_dice (15): hex_to_rgb/rgb_to_hex clamp+float-floor, lerp_color
endpoints, build_pool weighted expansion + zero/negative-weight fallbacks,
load/save_profiles + load/save_journal (missing-file, roundtrip,
corrupt-JSON, 500-entry cap), play_sound unknown-kind + daemon-thread
smoke.

folder_size_analyzer (14): format_size 0 B / B / KB / MB / GB / TB
boundaries + mid-unit rounding, format_date zero-sentinel + known
timestamp, get_folder_size totals / hidden-dir skip / empty-dir
ctime-sentinel / progress callback cadence, get_drive_info valid +
bogus path, FolderInfo defaults + documented always-True __lt__ quirk.

No UI instantiation — imports ctk at module load, exercises pure helpers
and fs roundtrips against tmp_path. Full suite: 163 pass (pre-existing
screen_lock failures are Chris's WIP, not introduced here).
…moke

88 new tests across three previously-untested tools:

security_audit (17): now_ts shape, _age_days recent + missing-sentinel,
_is_suspicious_path safe-override + suspicious buckets, Finding.key
stability / dedup window / severity-ignorance, SecurityAuditEngine state
load (missing + corrupt) + save/reload roundtrip, baseline lifecycle
(save/get/clear), get_checks returns all 10 category callables bound to
the engine.

network_pattern_analyzer (18): _parse_timestamp strict-format + None on
bad input, _calculate_duration_seconds + _get_date_range, full coverage
of _analyze_* methods (summary, time-distribution 24 buckets, category
frequency with UNKNOWN fallback, average duration per-category,
repeating time windows above-average filter, sequential correlations
< 10min pair detection). Uses __new__ to skip the Tk root.

ffmpeg_studio (53): _crf_label bucket boundaries + out-of-range empty,
_guess_ratio h=0 guard + 11 known ratios + custom fallback, GPU/codec
label parsers, _chroma_depth_keys, _pix_fmt dispatch incl. NVENC
yuv444p16le quirk + AMD 444+10-bit p010le fallback, _check_compat_warning
for AMD AMF 4:4:4, _build_video_codec_args covers CPU/NVENC/AMF/QSV
branches + AMD QP clamp + unknown-speed fallback + unknown-GPU fallback.

Subprocess-dependent helpers (_test_encoder, _probe_hardware,
_detect_audio_devices, SecurityAuditEngine.check_*) deliberately skipped
— need ffmpeg/registry/psutil mocking, out of scope for B4 smoke.

Full suite: 251 pass (pre-existing screen_lock failures unchanged).
Adds get_config / get_bool / get_path helpers with optional python-dotenv
fallback at <repo>/.env. Real env vars beat .env; defaults beat both.
.env.example documents the AUTOMATIONS_* keys the per-tool swaps will use.

26 unit tests cover precedence, bool parsing, Path expansion, and the
one-shot dotenv loader. Re-exported from tools._common.

Sets up B3 sweep of hardcoded constants across system_cleaner,
network_stability_monitor, and ffmpeg_studio.
Replaces 3 hardcoded C:/Windows/... literals in the cleaner category
table with os.path.expandvars() — matches the existing %LOCALAPPDATA%
pattern already at L466 and handles non-C: Windows installs.

- L448 win_temp   -> %SystemRoot%/Temp
- L475 prefetch   -> %SystemRoot%/Prefetch
- L485 win_update -> %SystemRoot%/SoftwareDistribution/Download

No behavior change on standard Windows installs. No new imports.
Moves three module-level defaults to tools._common.config lookups so
they can be overridden via env or .env without editing source:

- AUTO_EXPORT_ENABLED -> AUTOMATIONS_NSM_AUTO_EXPORT   (default True)
- AUTO_EXPORT_TIME    -> AUTOMATIONS_NSM_EXPORT_TIME   (default 23:30)
- EXPORT_FOLDER       -> AUTOMATIONS_NSM_EXPORT_DIR    (default exports)

Defaults match the prior hardcoded values exactly — no behavior change
for zero-config users. Documented in .env.example.
Moves the three ctk.StringVar defaults for the Record/Capture/Convert
tabs to tools._common.config.get_path lookups so each can be overridden
independently via env or .env:

- output_dir_var -> AUTOMATIONS_FFMPEG_OUTPUT_DIR  (default ~/Videos)
- cap_out_var    -> AUTOMATIONS_FFMPEG_CAPTURE_DIR (default ~/Pictures)
- conv_out_var   -> AUTOMATIONS_FFMPEG_CONVERT_DIR (default ~/Videos)

Existing os.path.expanduser call is dropped — get_path handles tilde
expansion on both env values and Path defaults. Defaults match prior
behavior for zero-config users.
- winotify toast dedup + persisted notif state (T10-T12)
- rotating session scan + 1h live-window liveness (T14)
- flat session list, Last Turn tokens column
- Cost Composition + Turn Stats panels in Session Detail
- 72/72 tests green
Pilot for Plan B B2 broad-except sweep on the smallest tested tool
(433 LOC, 6 sites). Validates @narrow_excepts + inline-tuple split.

Triage:
  load_exports inner  -> (OSError, json.JSONDecodeError, UnicodeDecodeError)
  load_exports outer  -> (OSError, tk.TclError)
  analyze_patterns    -> (KeyError, ValueError, TypeError, AttributeError)
  export_analysis     -> (OSError, TypeError)
  _parse_timestamp    -> @narrow_excepts(ValueError, TypeError, default=None)
  run_tool boundary   -> broad kept + log.exception + noqa BLE001

Findings for the remaining 9 files in the sweep:
- @narrow_excepts decorator fits single-expression helpers cleanly
  (1/6 sites here); most sites need inline tuple narrow because their
  bodies continue with cleanup / UI updates after the except.
- Entry-point "boundary swallow" pattern (run_tool) stays broad but is
  now logged via log.exception with an inline noqa. This is the
  precedent for the other 9 tools.
- Pre-existing print(...) error logging in load_exports left untouched
  (surgical rule - drift not in scope for B2).

Tests: tests/test_network_pattern_analyzer.py 18/18 pass. Full suite
310 pass / 4 skip / 1 unrelated flaky security_audit _age_days
(Windows mtime vs time.time() precision race, not caused by this change).
4 broad-except sites triaged (bare `except:` at get_drive_info
replaced - was catching KeyboardInterrupt and SystemExit too):

  get_drive_info    -> @narrow_excepts(OSError, PermissionError, default=...)
  _scan_directory   -> (OSError, PermissionError, TypeError)
  _export_results   -> (OSError, TypeError, ValueError)
  run_tool boundary -> broad kept + log.exception + noqa BLE001

Decorator fit: 1/4 sites (get_drive_info is a textbook single-return
helper - psutil.disk_usage wrapped with a zeroed default).

Tests: tests/test_folder_size_analyzer.py 14/14 pass (including
test_get_drive_info_returns_zeros_for_bogus_path which exercises the
decorator's default-return path).
6 broad-except sites triaged across sound + persistence + boundary:

  _beep_thread     -> (OSError, RuntimeError, ValueError)
  load_profiles    -> (OSError, json.JSONDecodeError, UnicodeDecodeError)
  save_profiles    -> @narrow_excepts(OSError, TypeError)
  load_journal     -> (OSError, json.JSONDecodeError, UnicodeDecodeError)
  save_journal     -> @narrow_excepts(OSError, TypeError)
  run_tool boundary -> broad kept + log.exception + noqa BLE001

Decorator fit: 2/6 (save_profiles + save_journal are single-try-pass
writers). load_profiles/load_journal kept inline because the except
falls through to a different return path (defaults dict/list).

Tests: tests/test_decision_dice.py 15/15 pass, including the corrupt-
JSON paths that validate the narrow exception tuples catch the right
types. Full suite 311 pass / 4 skip.
20 broad-except sites narrowed across 4 categories:

  Subprocess probes/runs (6 sites):
    _test_encoder        -> @narrow_excepts(OSError, subprocess.*, TimeoutExpired, False)
    _probe_hardware      -> (OSError, subprocess.SubprocessError)
    _detect_audio_devices -> @narrow_excepts(OSError, subprocess.*, default=[])
    start-record error   -> (OSError, subprocess.SubprocessError, ValueError)
    capture subprocess   -> (OSError, subprocess.SubprocessError, ValueError)
    convert subprocess   -> (OSError, subprocess.SubprocessError, ValueError)

  File I/O (8 sites): all -> OSError (log read/close, os.remove cleanup)

  Process termination (1): -> (OSError, BrokenPipeError, TimeoutExpired)

  Tk UI (5 sites): w.configure, ov.destroy, PhotoImage -> tk.TclError
                   (PhotoImage also catches OSError for missing bg file)

Decorator fit: 2/20 (_test_encoder, _detect_audio_devices - both are
capability-probe helpers with a clear single default return).

No boundary pattern needed: run_tool() at line 339 is not wrapped in
try/except (CTkToplevel has its own error handling).

Tests: tests/test_ffmpeg_studio.py 53/53 pass.
21 broad-except sites narrowed across the 10-category scanner:

  Top-level helpers (3):
    safe_run fallback    -> (OSError, subprocess.SubprocessError)
    is_admin             -> @narrow_excepts(AttributeError, OSError, False)
    _age_days            -> @narrow_excepts(OSError, ValueError, 9999)

  State persistence (2):
    _load_state          -> (OSError, json.JSONDecodeError, UnicodeDecodeError)
    save_state           -> (OSError, TypeError)

  Category checks (13):
    scheduled tasks outer -> (OSError, subprocess.SubprocessError, csv.Error)
    scheduled tasks inner -> (KeyError, ValueError, AttributeError)
    process signature    -> (OSError, TypeError, AttributeError)
    process name lookup  -> (psutil.NoSuchProcess/AccessDenied/Zombie, OSError)
    RDP winreg           -> (OSError, FileNotFoundError)
    firewall netsh       -> (OSError, subprocess.SubprocessError, ValueError)
    file walk splitext   -> (TypeError, AttributeError)
    file walk encode     -> (AttributeError, UnicodeError)
    proxy winreg         -> (OSError, FileNotFoundError)
    cert date parse      -> (ValueError, TypeError)
    cert store outer     -> (OSError, subprocess.SubprocessError, ValueError)
    PAC winreg           -> OSError
    event log loop       -> (OSError, subprocess.SubprocessError, ValueError)

  Export + boundaries (3):
    export JSON          -> (OSError, TypeError, ValueError)
    future.result (scan) -> broad + log.exception + noqa BLE001
    _safe_check wrapper  -> broad + log.exception + noqa BLE001

The two ThreadPool boundaries stay broad on purpose: a category check
can raise anything (subprocess, winreg, psutil, socket...) and must not
take down the scanner; each is logged via log.exception for forensics
and converted into an INFO Finding.

Decorator fit: 2/21 (is_admin + _age_days - both single-return helpers
with clear sentinel defaults).

Tests: tests/test_security_audit.py 17/17 pass, including the
_age_days + _load_state corrupt-JSON paths that validate the narrow
tuples catch the right types.
24 broad-except sites -> 1 boundary + 3 @narrow_excepts + 20 inline tuples.

Inline pairs reflect real call-site surfaces:
- ctypes.windll blocks     -> (AttributeError, OSError)
- subprocess.run/Popen     -> (OSError, subprocess.SubprocessError)
- psutil iter + proc.info  -> (psutil.Error, AttributeError)
- Tk widget teardown races -> (tk.TclError, AttributeError)
- send2trash delete        -> OSError (TrashPermissionError subclasses it)
- _log_struct JSON dump    -> (OSError, TypeError, ValueError)

Decorator-fit helpers (single-return, no state):
- is_admin, _enable_privilege -> default=False on (AttributeError, OSError)
- _get_system_uptime          -> default=timedelta(0) on (psutil.Error, OSError)

Boundary:
- _restart_explorer_worker wraps taskkill + psutil iter + explorer.exe spawn;
  keeps broad except with log.exception + noqa: BLE001 + UX message.

Also narrowed the send2trash import guard to ImportError.

Decorator fit: 3/24 (12.5%). Tests: 311 passed, 4 skipped - full suite unchanged.
…nshot outputs

Fixes L1/L2 from audits/2026-04-17-security-probe.md (both Critical).
User-tainted tkinter Entry flowed into subprocess.Popen/run for FFmpeg
with no validation, letting any UI-reachable attacker drop files
outside the intended base (Startup folders, PATH dirs, %TEMP%).

- New module-level _validate_output_dir + OutputDirError: rejects
  empty input, '..' segments, and relative paths. Optional
  allowed_bases kwarg for strict containment (used by tests/PoCs).
- Wrap start_recording and _do_capture call sites with
  try/except OutputDirError -> messagebox + early return.
- 13 regression tests (unit + subprocess-invoked PoCs) all green.
- Patch L1/L2 PoCs: L1 inserts repo root on sys.path, L2 switches to
  allowed_bases= matching the consolidated helper.

Deviation from plan: default is NOT Path.home()-only containment,
which would break F:\ / D:\ output workflows. Callers pass
allowed_bases= explicitly when strict home containment is needed.
…-zero check

Bound 0 <= x < 0.01 occasionally fails on Windows when the file mtime
read outpaces the timer tick, returning a tiny negative (~1e-12).
abs(x) < 0.01 preserves the "near zero" intent without forbidding the
jitter.
…ck_key

block_key() only accepts single key names; combo strings like "alt+tab",
"alt+f4", "ctrl+shift+esc", "win" were silently raising
ValueError('Unsupported key name: ...') on every install. The
suppress=True hook already intercepts these combos at the OS level, so
removing them from _CRITICAL_COMBOS / _EXTRA_COMBOS is safety-neutral.

Critical combos now: ("left windows", "right windows") -- both valid
single key names that succeed.

Tests updated to exercise the new contract:
- test_install_hook_fails_fast_if_critical_combo_unsupported now
  triggers fail-fast on "left windows" instead of "alt+tab".
- test_install_hook_rollback_unblocks_on_critical_failure now
  exercises rollback after 1 success + 1 failure (only 2 critical
  combos total) instead of 2+1.

Also: gitignore security/poc/ to prevent stray PoCs from being tracked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant