jx.
jx58 min read

Push To Talk Hotkey Conflict Detector Pywin32

My push-to-talk key was Ctrl+Shift+Space for about four years. Then one Tuesday it just… stopped working in Discord while OBS kept happily muting my mic on the same chord, and I spent twenty minutes shouting into a meeting before someone DM'd me to ask if I was okay. The fix took about ninety seconds once I knew where to look. The diagnosis took the entire afternoon, because Windows has no central registry of who owns which hotkey — every app calls RegisterHotKey, the first one wins, and the loser silently does nothing. No error, no log line, no toast. Just a key that used to work and now doesn't.

This article walks through building a small Python tool — pywin32, a win32 message pump, and a CLI front-end — that figures out who is squatting on your push-to-talk combo before you have to find out the embarrassing way. By the end you will have a working repository with a RegisterHotKey probe, a parser that reads Discord, OBS, Steam, and Teams config files, a watch-mode that reports new conflicts the moment another app grabs your chord, a PyInstaller-built .exe, and a smoke test that fakes a conflict so you can prove the detector actually catches one. If the OS will not tell you who owns a hotkey, you have to ask every suspect in the room.

The target reader is a Windows developer or power user who lives in voice chat, has been bitten by a silent hotkey collision at least once, and would rather spend an evening building the right diagnostic tool than another twenty minutes guessing. You should be comfortable with Python and willing to read a little win32 API documentation — nothing here requires deep C-level Windows internals, but you will leave understanding the message pump well enough to extend it for your own keys.

Step 1: Bootstrapping a Testable Win32 Message Pump Skeleton

A push-to-talk conflict detector lives or dies on its ability to observe the keyboard at the operating-system level. On Windows that means a Win32 message loop — a long-lived thread that pulls keystrokes off the thread queue and dispatches them to handlers. Before we wire up any hooks, hotkeys, or audio integrations, we need a skeleton that we can run, test, and shut down cleanly.

This first step delivers the project scaffold: a Python package laid out for uv and pytest, the pywin32 dependency guarded for Windows-only environments, and a tiny MessagePump class with the GUI module injected through its constructor. Dependency injection is the trick that lets us exercise the pump on a non-Windows developer machine without ever importing the real win32gui.

Setup

We carve out four files under codebase/:

  • pyproject.toml — declares the ptt-conflict-detector distribution, pins pywin32 behind a sys_platform == 'win32' marker, and points pytest at the src/ layout.
  • src/ptt_conflict_detector/__init__.py — re-exports MessagePump so callers can from ptt_conflict_detector import MessagePump.
  • src/ptt_conflict_detector/message_pump.py — the skeleton itself.
  • tests/test_message_pump.py — four unit tests that exercise the loop with a MagicMock stand-in for win32gui.

The dependency block looks like this:

[project]
name = "ptt-conflict-detector"
version = "0.1.0"
description = "Detect hotkey conflicts that break push-to-talk on Windows."
requires-python = ">=3.9"
dependencies = [
    "pywin32 ; sys_platform == 'win32'",
]

[project.optional-dependencies]
dev = [
    "pytest>=8",
]

The conditional dependency marker is load-bearing. Without it, pip install -e . on a Linux CI runner or a macOS authoring box would fail trying to build pywin32 wheels that simply do not exist for that platform. With the marker, the install is a no-op outside Windows and the import is deferred until a real run.

Implementation

The pump itself is deliberately small. The whole module fits on one screen:

from __future__ import annotations

WM_QUIT = 0x0012


def _default_win32():
    import win32gui

    return win32gui


class MessagePump:
    def __init__(self, win32=None):
        self._win32 = win32
        self._loader = _default_win32

    def _gui(self):
        if self._win32 is None:
            self._win32 = self._loader()
        return self._win32

    def run(self):
        gui = self._gui()
        while True:
            status, msg = gui.GetMessage(None, 0, 0)
            if status == 0:
                return
            gui.TranslateMessage(msg)
            gui.DispatchMessage(msg)

    def post_quit(self, exit_code=0):
        self._gui().PostQuitMessage(exit_code)

The constructor accepts a win32 argument that defaults to None. Production callers leave it unset, and the lazy _gui() helper imports win32gui only when run() or post_quit() is actually called. Tests pass a MagicMock directly, so the real Windows extension is never touched in CI.

The run() loop is the canonical Win32 idiom translated to Python. GetMessage blocks until a message arrives; a return status of 0 means WM_QUIT was received and the loop must terminate. Any other status means we have a real message tuple to translate (for key-up/down virtual-key resolution) and then dispatch to the registered window procedure.

Keeping post_quit on the same object gives callers — including tests — a single seam to shut the loop down. We will reuse this when the conflict-detection thread needs to terminate during teardown. The whole class stays under the codebase rule of two levels of if nesting and zero nested try/except.

Verification

Run the test suite from inside codebase/:

python3 -m pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0 -- /Applications/Xcode.app/Contents/Developer/usr/bin/python3
cachedir: .pytest_cache
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests

collected 4 items

tests/test_message_pump.py::test_run_dispatches_messages_until_wm_quit PASSED [ 25%]
tests/test_message_pump.py::test_run_returns_immediately_when_first_message_is_quit PASSED [ 50%]
tests/test_message_pump.py::test_post_quit_delegates_to_win32_with_default_exit_code PASSED [ 75%]
tests/test_message_pump.py::test_post_quit_forwards_custom_exit_code PASSED [100%]

============================== 4 passed in 0.15s ===============================

Four tests cover the four meaningful behaviors: a normal run dispatches every message until WM_QUIT, an immediate quit returns without ever calling TranslateMessage, post_quit defaults to exit code zero, and post_quit forwards a custom exit code through to the win32 layer.

What we built

We now have a project that you can clone, install in editable mode, and test on any operating system. The Windows-only pieces are quarantined behind the dependency marker and the lazy import, so contributors on macOS or Linux can still run the suite green.

The MessagePump class is the seam around which the rest of the detector will grow. By the time we add keyboard hooks in a later step, that subsystem will simply push events into a queue and let the pump dispatch them — we will not have to refactor the control flow.

Dependency injection through the constructor argument is the single design decision that makes this scaffold cheap to test. Future steps can hand in fakes, partial mocks, or even a recorded log of real Win32 messages, and the pump will not care.

With the scaffold green, the next step can focus on the actual problem domain: registering a low-level keyboard hook and observing how other apps' global hotkeys intercept the events before our push-to-talk binding ever sees them.

Repository

The companion code for this article: https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32

The state of the code after this step: fdfaa49

Key commits to step through:

  • fdfaa49 — step 1: project scaffold and Win32 message pump skeleton

Step 2: Registering Global Hotkeys and Decoding WM_HOTKEY Payloads

Step 1 left us with a Win32 message pump that runs green on macOS through dependency injection. The pump can spin, dispatch, and shut down — but it has nothing to dispatch yet. Before the detector can flag conflicts it must first own at least one global hotkey of its own, so it has a baseline to compare against when other processes steal the keystroke.

This step adds the two missing pieces. A HotkeyRegistrar wraps RegisterHotKey and UnregisterHotKey so the rest of the codebase can claim a chord without touching pywin32 directly. A decode_wm_hotkey helper parses the WM_HOTKEY message Windows sends back — the modifier flags and virtual-key code are packed together inside lparam, and the bit math is easy to get wrong if it lives inline at every call site.

Setup

No new third-party dependencies are needed; pywin32 already exposes RegisterHotKey, UnregisterHotKey, and the WM_HOTKEY constant. The deltas all live inside the existing package:

  • src/ptt_conflict_detector/hotkey.py — new module hosting HotkeyRegistrar, HotkeyEvent, decode_wm_hotkey, and the MOD_* / WM_HOTKEY constants.
  • src/ptt_conflict_detector/__init__.py — re-export the new public names so from ptt_conflict_detector import HotkeyRegistrar keeps working.
  • tests/test_hotkey.py — eight new pytest cases covering decoder edge cases, lazy import behavior, and registrar lifecycle.

The Windows API constants come from winuser.h. We define them in Python rather than importing them from win32con so the module loads cleanly on macOS during testing:

WM_HOTKEY = 0x0312

MOD_ALT = 0x0001
MOD_CONTROL = 0x0002
MOD_SHIFT = 0x0004
MOD_WIN = 0x0008
MOD_NOREPEAT = 0x4000

Implementation

The decoder is a pure function — no win32 dependency, just bit math against the message tuple pywin32 hands us. Keeping it free of side effects means the four decoder tests are about a microsecond each and never touch the GUI layer.

@dataclass(frozen=True)
class HotkeyEvent:
    hotkey_id: int
    modifiers: int
    vk_code: int


def decode_wm_hotkey(msg):
    _hwnd, message, wparam, lparam = msg
    if message != WM_HOTKEY:
        raise ValueError(
            f"expected WM_HOTKEY ({WM_HOTKEY:#06x}), got {message:#06x}"
        )
    return HotkeyEvent(
        hotkey_id=wparam,
        modifiers=lparam & 0xFFFF,
        vk_code=(lparam >> 16) & 0xFFFF,
    )

Windows packs two 16-bit values into the 32-bit lparam: modifiers live in the low word, the virtual-key code in the high word. The masking with 0xFFFF is load-bearing — without it, a stray sign bit or upper-word leak would corrupt the modifier flag set and silently report the wrong chord. The message != WM_HOTKEY guard turns programming mistakes (passing in a WM_KEYDOWN payload by accident) into a loud ValueError rather than a wrong HotkeyEvent.

The registrar follows the same dependency-injection pattern as the message pump: a win32 argument defaults to None, and the real win32gui only gets imported when an actual register() call happens.

class HotkeyRegistrar:
    def __init__(self, win32=None):
        self._win32 = win32
        self._loader = _default_win32
        self._registered: list[tuple[object, int]] = []

    def _gui(self):
        if self._win32 is None:
            self._win32 = self._loader()
        return self._win32

    def register(self, hwnd, hotkey_id, modifiers, vk_code):
        self._gui().RegisterHotKey(hwnd, hotkey_id, modifiers, vk_code)
        self._registered.append((hwnd, hotkey_id))

    def unregister(self, hwnd, hotkey_id):
        self._gui().UnregisterHotKey(hwnd, hotkey_id)
        self._registered = [
            entry for entry in self._registered if entry != (hwnd, hotkey_id)
        ]

    def unregister_all(self):
        for hwnd, hotkey_id in list(self._registered):
            self.unregister(hwnd, hotkey_id)

The registrar tracks every (hwnd, hotkey_id) pair it has claimed. That bookkeeping is what makes unregister_all() safe to call during teardown — Win32 leaks the hotkey claim to the next process that asks for the same chord if you forget to release it, so a global cleanup hook is the kind of thing you want to write once and reuse forever. Iterating over a snapshot via list(self._registered) avoids mutating the source list while looping, which is the one Python-level invariant that is easy to miss.

Each public method stays inside the codebase rule: zero nested try/except, at most one level of if. The win32 surface area is two function calls, no try/finally gymnastics around them — if Windows refuses to register a chord because another app already owns it, pywin32 raises and the call site decides how to react.

Verification

Run the suite from inside codebase/:

python3 -m pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0 -- /Applications/Xcode.app/Contents/Developer/usr/bin/python3
cachedir: .pytest_cache
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests
collecting ... collected 12 items

tests/test_hotkey.py::test_decode_wm_hotkey_extracts_id_modifiers_and_vk_code PASSED [  8%]
tests/test_hotkey.py::test_decode_wm_hotkey_handles_zero_modifiers PASSED [ 16%]
tests/test_hotkey.py::test_decode_wm_hotkey_isolates_low_and_high_words_of_lparam PASSED [ 25%]
tests/test_hotkey.py::test_decode_wm_hotkey_rejects_non_hotkey_message PASSED [ 33%]
tests/test_hotkey.py::test_register_delegates_to_win32_register_hotkey PASSED [ 41%]
tests/test_hotkey.py::test_unregister_delegates_and_drops_entry PASSED   [ 50%]
tests/test_hotkey.py::test_unregister_all_releases_every_registered_hotkey PASSED [ 58%]
tests/test_hotkey.py::test_register_lazy_imports_win32gui_only_on_use PASSED [ 66%]
tests/test_message_pump.py::test_run_dispatches_messages_until_wm_quit PASSED [ 75%]
tests/test_message_pump.py::test_run_returns_immediately_when_first_message_is_quit PASSED [ 83%]
tests/test_message_pump.py::test_post_quit_delegates_to_win32_with_default_exit_code PASSED [ 91%]
tests/test_message_pump.py::test_post_quit_forwards_custom_exit_code PASSED [100%]

============================== 12 passed in 0.11s ==============================

The new test file adds eight cases on top of the four message-pump cases that already existed. Four of those drive the decoder — happy path, zero modifiers, a worst-case lparam packed with 0xABCD000F to prove the masking works, and a ValueError when the wrong message type comes in. The remaining four drive the registrar — basic register delegation, single-entry unregister, bulk unregister, and a lazy-import assertion that proves win32gui is not imported until register() is actually called.

What we built

We now own a typed HotkeyEvent dataclass and a decode_wm_hotkey function that turns the opaque pywin32 message tuple into something the rest of the codebase can pattern-match on by name. Callers never have to think about which 16 bits of lparam mean what again — the decoder owns that knowledge in one place.

The HotkeyRegistrar adds a small but important guarantee: any hotkey claimed through it is tracked, and unregister_all() releases the lot in one call. That bookkeeping is invisible during normal operation but becomes the thing that prevents a stale hotkey from blocking the next launch when the previous run crashed.

The lazy-import seam carries forward from step 1. The registrar tests inject a MagicMock, run on macOS, and never touch a real DLL. The same code will pick up the real win32gui on a Windows box without any conditional branching.

With registration and decoding solved, the next step can connect the two halves: drive the message pump from step 1, route WM_HOTKEY messages through decode_wm_hotkey, and start measuring whether the chord we registered actually reaches us — or whether some other process intercepted it first. That comparison is the heart of the conflict detector.

Repository

The companion code for this article: https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32

The state of the code after this step: ce87746

Key commits to step through:

  • fdfaa49 — step 1: project scaffold and Win32 message pump skeleton
  • ce87746 — step 2: hotkey registrar and WM_HOTKEY decoder

Step 3: Surveying Owned, Taken, and Free Hotkey Slots

Steps 1 and 2 left us with a Win32 message pump and a HotkeyRegistrar that can claim and release chords through RegisterHotKey. That is enough to listen for our push-to-talk binding, but it gives no answer to the central question of a conflict detector: which combinations are already locked down by some other process before we even ask for them?

Windows offers no API to list every registered hotkey, and there is no event that fires when a competing app claims one. The only honest signal is RegisterHotKey itself — if it raises ERROR_HOTKEY_ALREADY_REGISTERED (winerror 1409), somebody else owns the chord. This step builds that probe, exposes the registrar's own bindings so we can short-circuit chords we already hold, and stitches both halves together into a single snapshot view.

Setup

No new third-party dependencies are needed; the probe leans on the same RegisterHotKey / UnregisterHotKey pair as the registrar. The deltas are confined to the package:

  • src/ptt_conflict_detector/probe.py — new module hosting HotkeyProbe, HotkeyAvailability, the ERROR_HOTKEY_ALREADY_REGISTERED constant, and a snapshot_combos helper.
  • src/ptt_conflict_detector/hotkey.py — promotes the registrar's internal list of (hwnd, hotkey_id) tuples into a typed HotkeyBinding dataclass and exposes a bindings property so callers can ask "what do we already own?" without poking at private state.
  • src/ptt_conflict_detector/__init__.py — re-exports the new public names.
  • tests/test_probe.py — seven new pytest cases covering probe behavior, the registrar's bindings view, and the combined snapshot.

We also bring ERROR_HOTKEY_ALREADY_REGISTERED in as a named constant. Magic numbers in error-handling paths are exactly the kind of thing that goes wrong silently six months later when somebody mis-types 1490 and the whole branch becomes dead code.

ERROR_HOTKEY_ALREADY_REGISTERED = 1409

Implementation

The probe itself is a small class with one real behavior: try to register a chord under a sentinel id, and immediately release it. If the registration succeeds, the chord was free. If it raises with winerror 1409, somebody else owns it. Anything else is an unexpected Win32 failure and bubbles up.

class HotkeyProbe:
    def __init__(self, win32=None, probe_id=0xBFFF):
        self._win32 = win32
        self._loader = _default_win32
        self._probe_id = probe_id

    def _gui(self):
        if self._win32 is None:
            self._win32 = self._loader()
        return self._win32

    def is_in_use(self, modifiers, vk_code):
        gui = self._gui()
        try:
            gui.RegisterHotKey(0, self._probe_id, modifiers, vk_code)
        except Exception as exc:
            if _is_already_registered_error(exc):
                return True
            raise
        gui.UnregisterHotKey(0, self._probe_id)
        return False

Two details are load-bearing here. The probe_id defaults to 0xBFFF — high enough to stay clear of the small integer ids the registrar hands out for real bindings, low enough to remain inside Windows' valid range. And the immediate UnregisterHotKey on the success path is what makes the probe non-destructive: a probe that left its claim behind would itself become the thing other probes detect as "in use", which is the kind of self-poisoning bug that would take a long debugging session to find.

The _is_already_registered_error helper is split out as a free function so the discrimination logic lives in one place. The codebase rule allows two levels of if nesting, and a try/except with an if inside it already eats one of those levels, so any further branching has to move into a helper.

def _is_already_registered_error(exc):
    return getattr(exc, "winerror", None) == ERROR_HOTKEY_ALREADY_REGISTERED

Using getattr(..., None) rather than exc.winerror keeps the test surface honest. The real pywintypes.error exposes a winerror attribute, but a vanilla Exception raised in a test or by some other failure mode does not. Treating a missing attribute as "this is not the error we know how to handle" lets unexpected errors propagate untouched, which is the behavior test_probe_propagates_unexpected_register_errors pins down.

The registrar gets a small upgrade to support the snapshot. The old _registered list of bare (hwnd, hotkey_id) tuples becomes a list of typed HotkeyBinding records that carry the modifiers and vk_code too, and a new bindings property exposes that view to outside callers.

@dataclass(frozen=True)
class HotkeyBinding:
    hwnd: object
    hotkey_id: int
    modifiers: int
    vk_code: int


class HotkeyRegistrar:
    def register(self, hwnd, hotkey_id, modifiers, vk_code):
        self._gui().RegisterHotKey(hwnd, hotkey_id, modifiers, vk_code)
        self._bindings.append(
            HotkeyBinding(
                hwnd=hwnd,
                hotkey_id=hotkey_id,
                modifiers=modifiers,
                vk_code=vk_code,
            )
        )

    @property
    def bindings(self):
        return tuple(self._bindings)

Returning a tuple from the property is a small but deliberate choice. Callers cannot mutate the registrar's internal state by accident, and immutable snapshots are exactly what snapshot_combos needs to safely classify chords without worrying that another thread is registering in the background.

The snapshot helper ties the two halves together. For each combo it asks three questions in order: do we already own it (no probe needed), does the probe say it is taken, or is it free? The result is a tuple of HotkeyAvailability records — one per combo, in the order the caller asked.

def snapshot_combos(combos, registrar, probe):
    owned = {(b.modifiers, b.vk_code) for b in registrar.bindings}
    return tuple(
        HotkeyAvailability(
            modifiers=combo[0],
            vk_code=combo[1],
            state=_classify(combo, owned, probe),
        )
        for combo in combos
    )


def _classify(combo, owned, probe):
    if combo in owned:
        return "owned"
    if probe.is_in_use(*combo):
        return "taken"
    return "free"

The owned-set short-circuit matters for two reasons. First, re-registering a chord we already hold either no-ops or raises depending on the host — the answer is already known without touching the OS. Second, the probe uses hwnd=0, so probing one of our own real claims would briefly clash with itself. Letting the registrar's own view answer first sidesteps the whole problem.

Verification

Run the suite from inside codebase/:

python3 -m pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0 -- /Applications/Xcode.app/Contents/Developer/usr/bin/python3
cachedir: .pytest_cache
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests
collecting ... collected 19 items

tests/test_hotkey.py::test_decode_wm_hotkey_extracts_id_modifiers_and_vk_code PASSED [  5%]
tests/test_hotkey.py::test_decode_wm_hotkey_handles_zero_modifiers PASSED [ 10%]
tests/test_hotkey.py::test_decode_wm_hotkey_isolates_low_and_high_words_of_lparam PASSED [ 15%]
tests/test_hotkey.py::test_decode_wm_hotkey_rejects_non_hotkey_message PASSED [ 21%]
tests/test_hotkey.py::test_register_delegates_to_win32_register_hotkey PASSED [ 26%]
tests/test_hotkey.py::test_unregister_delegates_and_drops_entry PASSED   [ 31%]
tests/test_hotkey.py::test_unregister_all_releases_every_registered_hotkey PASSED [ 36%]
tests/test_hotkey.py::test_register_lazy_imports_win32gui_only_on_use PASSED [ 42%]
tests/test_message_pump.py::test_run_dispatches_messages_until_wm_quit PASSED [ 47%]
tests/test_message_pump.py::test_run_returns_immediately_when_first_message_is_quit PASSED [ 52%]
tests/test_message_pump.py::test_post_quit_delegates_to_win32_with_default_exit_code PASSED [ 57%]
tests/test_message_pump.py::test_post_quit_forwards_custom_exit_code PASSED [ 63%]
tests/test_probe.py::test_probe_reports_free_when_register_succeeds PASSED [ 68%]
tests/test_probe.py::test_probe_reports_in_use_when_register_raises_already_registered PASSED [ 73%]
tests/test_probe.py::test_probe_propagates_unexpected_register_errors PASSED [ 78%]
tests/test_probe.py::test_probe_many_returns_one_result_per_combo PASSED [ 84%]
tests/test_probe.py::test_probe_lazy_imports_win32gui_only_on_use PASSED [ 89%]
tests/test_probe.py::test_registrar_exposes_bindings_with_modifiers_and_vk_code PASSED [ 94%]
tests/test_probe.py::test_snapshot_classifies_owned_taken_and_free_combos PASSED [100%]

============================== 19 passed in 0.15s ==============================

The new file contributes seven cases on top of the twelve carried over from steps 1 and 2. Five of those drive the probe — free path, already-registered path, unexpected-error propagation, batched probe_many, and a lazy-import assertion mirroring the same seam used by the registrar. The remaining two cover the registrar's new bindings view and the end-to-end snapshot_combos classification, where a single owned chord, a clashing chord, and a free chord come back labelled correctly in one call.

What we built

We now have a probe that can ask Windows "is anyone holding this chord?" without leaving residue behind. The behavior is deliberately conservative: the probe owns no state, mutates no global counters, and either returns a boolean or re-raises whatever Win32 threw at it.

The registrar's new HotkeyBinding type turns what used to be an internal tuple list into a typed, queryable surface. Anything else in the codebase — the snapshot helper today, an event-emitter tomorrow — can iterate over the bindings without reaching into private attributes or duplicating the bookkeeping.

The snapshot_combos helper is the first piece of code in this project that produces output a human can read directly. Pass it a list of candidate chords, get back a labelled report. That report is the natural feed for a CLI command, a tray notification, or a Markdown diagnostic dump.

With detection wired up, the next step can focus on the policy layer: deciding which chord to claim when our preferred one is taken, and how to communicate the fallback to the user before the silent push-to-talk failure they came here to avoid in the first place.

Repository

The companion code for this article: https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32

The state of the code after this step: 7d77a01

Key commits to step through:

  • fdfaa49 — step 1: project scaffold and Win32 message pump skeleton
  • ce87746 — step 2: hotkey registrar and WM_HOTKEY decoder
  • 7d77a01 — step 3: hotkey probe + owned/taken/free snapshot

Step 4: Modeling Hotkey Combos and Resolving the First Available Chord

Step 3 left us able to classify any (modifiers, vk_code) tuple as owned, taken, or free. That is the raw signal — but the rest of the codebase, and certainly any future CLI flag or config file, does not want to think in bitmasks. We need a small, well-named value type for a chord, a parser that turns "Ctrl+Shift+F12" into that value, and a formatter that round-trips it back to the same string.

Once the combo type exists, the second half of this step writes the actual conflict-detection algorithm: given an ordered list of fallback candidates, walk it, ask the registrar and the probe about each one, and return the first chord we can actually use — along with the list of earlier candidates that were already claimed by other processes. That HotkeyResolution record is what the next step's CLI will render to the user.

Setup

No new third-party dependencies — this entire step is pure Python on top of the registrar and probe we already have. The deltas are:

  • src/ptt_conflict_detector/combo.py — new module hosting HotkeyCombo, parse_combo, format_combo, plus the MODIFIER_FLAGS and VK_CODES lookup tables.
  • src/ptt_conflict_detector/resolver.py — new module hosting HotkeyResolution and the resolve_first_available walker.
  • src/ptt_conflict_detector/__init__.py — re-exports the new public names so callers can import them straight from the package.
  • tests/test_combo.py — fifteen pytest cases covering parsing, formatting, error paths, and the as_tuple() helper.
  • tests/test_resolver.py — six cases covering the happy path, the fallback path, the owned short-circuit, the all-taken case, the empty list, and the "stop probing once we find one" invariant.

The combo lookup tables sit at module scope and are built once at import time. Letters az, digits 09, function keys F1F24, and a handful of named keys (space, enter, tab, escape, backspace) are all that the push-to-talk use case actually needs.

Implementation

The combo type itself is a frozen dataclass — two ints, hashable, comparable, and impossible to mutate by accident once constructed. The as_tuple() method exists for one reason: the registrar's bindings view and the probe's is_in_use API both speak in (modifiers, vk_code) tuples, and we want a single call site that bridges the two shapes rather than scattering (combo.modifiers, combo.vk_code) everywhere.

@dataclass(frozen=True)
class HotkeyCombo:
    modifiers: int
    vk_code: int

    def as_tuple(self):
        return (self.modifiers, self.vk_code)

parse_combo accepts the human form — "Ctrl+Shift+F12", "Win+Space", even "cTrL+sHiFt+f12" — and produces a HotkeyCombo. Case is folded, modifier order is irrelevant, and exactly one non-modifier key must appear. The parser is split into four small helpers (_split_segments, _partition_segments, _record_key_segment, _resolve_vk) so the top-level function stays at two lines of logic and no nested branching, matching the codebase rule that limits if nesting to two levels.

def parse_combo(text):
    segments = _split_segments(text)
    modifiers, key_part = _partition_segments(segments, text)
    return HotkeyCombo(modifiers=modifiers, vk_code=_resolve_vk(key_part, text))

The error cases are spelled out as distinct ValueError messages — empty segment, unknown key, multiple non-modifier keys, no non-modifier key. Each one is pinned by its own test. Vague error messages would be the difference between a user fixing their typo in seconds and filing a bug report; the parser is the closest thing this project has to a public API surface, so it pays to be specific.

format_combo is the inverse and is the function the resolver and the future CLI use to print a chord. Modifiers are emitted in a canonical order (Ctrl, Alt, Shift, Win) regardless of how they were originally written, so two combos that compare equal also format identically. Unknown vk codes fall back to a 0xAB-style hex literal, which keeps the function total — it never raises on output — and gives a debuggable rendering even for keys the lookup table does not know.

def format_combo(combo):
    parts = [name for name, flag in _MODIFIER_PRINT_ORDER if combo.modifiers & flag]
    parts.append(_VK_PRINT_NAMES.get(combo.vk_code, f"0x{combo.vk_code:02X}"))
    return "+".join(parts)

With the combo type in hand, the resolver is short. resolve_first_available takes the ordered list of candidates the caller is willing to fall back to, plus the registrar and the probe from step 3. It builds the owned-set up front so we never probe the OS for a chord we already hold, walks the list, and returns as soon as it finds a candidate that is not classified as "taken".

def resolve_first_available(candidates, registrar, probe):
    owned = {(b.modifiers, b.vk_code) for b in registrar.bindings}
    conflicts = []
    for combo in candidates:
        state = _classify_candidate(combo, owned, probe)
        if state != "taken":
            return HotkeyResolution(chosen=combo, conflicts=tuple(conflicts))
        conflicts.append(combo)
    return HotkeyResolution(chosen=None, conflicts=tuple(conflicts))

The classification helper mirrors snapshot_combos from step 3, but specialised for the candidate walker — first the owned short-circuit, then the probe, otherwise free. Splitting it into its own function keeps the loop body at one level of indentation and means the same three-way decision is implemented once, not duplicated between the snapshot view and the resolver.

def _classify_candidate(combo, owned, probe):
    if combo.as_tuple() in owned:
        return "owned"
    if probe.is_in_use(*combo.as_tuple()):
        return "taken"
    return "free"

The HotkeyResolution return shape is the load-bearing design choice of this step. chosen is the combo the caller should now register — or None when every candidate was taken. conflicts is the ordered tuple of candidates that came before the chosen one and lost; the caller can show the user exactly what was tried and what blocked it. That diagnostic transparency is the whole reason a fallback resolver exists rather than a single "pick a chord" call that swallows the reasoning.

Verification

Run the suite from inside codebase/:

python3 -m pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0 -- /Applications/Xcode.app/Contents/Developer/usr/bin/python3
cachedir: .pytest_cache
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests
collecting ... collected 40 items

tests/test_combo.py::test_parse_combo_recognizes_ctrl_shift_f12 PASSED   [  2%]
tests/test_combo.py::test_parse_combo_is_case_insensitive_for_modifiers_and_keys PASSED [  5%]
tests/test_combo.py::test_parse_combo_supports_win_modifier_and_space_key PASSED [  7%]
tests/test_combo.py::test_parse_combo_supports_named_keys_and_letter_keys PASSED [ 10%]
tests/test_combo.py::test_parse_combo_supports_full_function_key_range PASSED [ 12%]
tests/test_combo.py::test_parse_combo_accepts_modifier_order_in_any_arrangement PASSED [ 15%]
tests/test_combo.py::test_parse_combo_rejects_unknown_key_name PASSED    [ 17%]
tests/test_combo.py::test_parse_combo_rejects_multiple_non_modifier_keys PASSED [ 20%]
tests/test_combo.py::test_parse_combo_rejects_combo_with_only_modifiers PASSED [ 22%]
tests/test_combo.py::test_parse_combo_rejects_empty_segments PASSED      [ 25%]
tests/test_combo.py::test_format_combo_orders_modifiers_canonically PASSED [ 27%]
tests/test_combo.py::test_format_combo_renders_lone_key_with_no_modifiers PASSED [ 30%]
tests/test_combo.py::test_format_combo_round_trips_through_parse PASSED  [ 32%]
tests/test_combo.py::test_format_combo_emits_hex_for_unknown_vk_code PASSED [ 35%]
tests/test_combo.py::test_combo_as_tuple_matches_modifier_and_vk_pair PASSED [ 37%]
tests/test_hotkey.py::test_decode_wm_hotkey_extracts_id_modifiers_and_vk_code PASSED [ 40%]
tests/test_hotkey.py::test_decode_wm_hotkey_handles_zero_modifiers PASSED [ 42%]
tests/test_hotkey.py::test_decode_wm_hotkey_isolates_low_and_high_words_of_lparam PASSED [ 45%]
tests/test_hotkey.py::test_decode_wm_hotkey_rejects_non_hotkey_message PASSED [ 47%]
tests/test_hotkey.py::test_register_delegates_to_win32_register_hotkey PASSED [ 50%]
tests/test_hotkey.py::test_unregister_delegates_and_drops_entry PASSED   [ 52%]
tests/test_hotkey.py::test_unregister_all_releases_every_registered_hotkey PASSED [ 55%]
tests/test_hotkey.py::test_register_lazy_imports_win32gui_only_on_use PASSED [ 57%]
tests/test_message_pump.py::test_run_dispatches_messages_until_wm_quit PASSED [ 60%]
tests/test_message_pump.py::test_run_returns_immediately_when_first_message_is_quit PASSED [ 62%]
tests/test_message_pump.py::test_post_quit_delegates_to_win32_with_default_exit_code PASSED [ 65%]
tests/test_message_pump.py::test_post_quit_forwards_custom_exit_code PASSED [ 67%]
tests/test_probe.py::test_probe_reports_free_when_register_succeeds PASSED [ 70%]
tests/test_probe.py::test_probe_reports_in_use_when_register_raises_already_registered PASSED [ 72%]
tests/test_probe.py::test_probe_propagates_unexpected_register_errors PASSED [ 75%]
tests/test_probe.py::test_probe_many_returns_one_result_per_combo PASSED [ 77%]
tests/test_probe.py::test_probe_lazy_imports_win32gui_only_on_use PASSED [ 80%]
tests/test_probe.py::test_registrar_exposes_bindings_with_modifiers_and_vk_code PASSED [ 82%]
tests/test_probe.py::test_snapshot_classifies_owned_taken_and_free_combos PASSED [ 85%]
tests/test_resolver.py::test_resolve_returns_first_candidate_when_free PASSED [ 87%]
tests/test_resolver.py::test_resolve_falls_back_to_next_candidate_when_first_is_taken PASSED [ 90%]
tests/test_resolver.py::test_resolve_returns_owned_combo_without_probing_the_os PASSED [ 92%]
tests/test_resolver.py::test_resolve_returns_none_when_every_candidate_is_taken PASSED [ 95%]
tests/test_resolver.py::test_resolve_handles_empty_candidate_list PASSED [ 97%]
tests/test_resolver.py::test_resolve_stops_probing_after_first_available_hit PASSED [100%]

============================== 40 passed in 0.20s ==============================

The suite grows from nineteen to forty cases. Fifteen of the new tests live in test_combo.py and exercise both directions of the parse/format round-trip, the rejection paths for malformed input, and the as_tuple() bridge to the registrar's tuple shape. The remaining six in test_resolver.py lock down the resolver's contract: it returns the first free candidate, it falls back when the first is taken, it short-circuits on owned chords without calling the probe, it returns chosen=None when every candidate is taken, it handles the empty-list edge case, and — crucially — it stops probing the OS the moment it finds a usable candidate.

That last invariant matters because HotkeyProbe.is_in_use performs a real RegisterHotKey / UnregisterHotKey round-trip per call. A naive resolver that probed every candidate up front would make the cost grow with the size of the fallback list even when the first candidate is free, and would generate spurious calls to the OS that another listening tool might notice.

What we built

We now have a typed value for a hotkey chord and a pair of total functions for moving between that value and its human-readable string form. The parser owns the validation rules in one place, the formatter owns the canonical rendering, and both are pinned by direct round-trip tests. Anything else in the codebase — the resolver today, a config-file loader tomorrow — can ingest user-supplied chord strings without inventing its own splitting or modifier-flag arithmetic.

The resolver is the first piece of code in the project that makes a decision on the user's behalf rather than reporting a fact. Given a preference order, it picks the chord the caller should claim and bundles the rejected candidates into the result so nothing is hidden. The HotkeyResolution shape is intentionally small — two fields — and immutable, which makes it trivial to pass around and trivial to render.

Together the combo type and the resolver turn the probe's raw signal into a complete conflict-resolution loop. We can take a list like ["Ctrl+Shift+F12", "Alt+F12", "Ctrl+Space"], ask the resolver to walk it, and get back both the winner and the chords that other processes already own. That output is exactly what a CLI command needs to display, and exactly what a tray app would log on startup.

The next step can focus on stitching this together into a runnable program: parse a user's preference list from arguments or config, hand it to the resolver, register the winner through the registrar, and print a human-readable report of what happened. None of that requires any further additions to the core library — it is composition of pieces we already have.

Repository

The companion code for this article: https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32

The state of the code after this step: bc387de

Key commits to step through:

  • fdfaa49 — step 1: project scaffold and Win32 message pump skeleton
  • ce87746 — step 2: hotkey registrar and WM_HOTKEY decoder
  • 7d77a01 — step 3: hotkey probe + owned/taken/free snapshot
  • bc387de — step 4: HotkeyCombo + parse/format + resolve_first_available

Step 5: Scanning Discord, OBS, Steam, and Teams for Already-Claimed Hotkeys

Step 4 left us with a resolver that, given an ordered list of candidate chords, picks the first one that is not currently claimed by the OS. The OS view is necessary but not sufficient: most push-to-talk collisions come from a small handful of always-on apps — Discord, OBS Studio, Steam, and Microsoft Teams — whose bindings sit in config files on disk before they are ever pushed into the global hotkey table. We want to surface those bindings before the user picks a chord, so the report can say "Ctrl+Shift+M is already bound to Teams toggle_mute" instead of just "taken by some other process".

This step introduces a scanner module that walks a registry of known offenders, expands each app's config-file path templates against the environment, parses any config files it finds with an app-specific extractor, and cross-references the result with a list of currently-running process names. The output is a tuple of AppFinding records — one per known app — that downstream code can join against the resolver's candidate list to produce human-readable conflict messages.

Setup

No new third-party dependencies. Everything is stdlib: json for Teams and OBS payloads, re for the Steam VDF scrape and the ${VAR} path templating, dataclasses for the result types. The deltas are:

  • src/ptt_conflict_detector/scanner.py — new module hosting KnownApp, AppFinding, ConfiguredHotkey, the KNOWN_APPS registry, the three per-app extractors (extract_obs_hotkeys, extract_steam_hotkeys, extract_teams_hotkeys), the expand_path helper, and the top-level scan_known_apps orchestrator.
  • src/ptt_conflict_detector/__init__.py — re-export the new public names (KNOWN_APPS, AppFinding, ConfiguredHotkey, KnownApp, expand_path, the three extractors, scan_known_apps) so callers can import them straight from the package root.
  • tests/test_scanner.py — twenty-one pytest cases covering path expansion, every extractor's happy path and failure modes, and the orchestrator's behaviour under each branch of the process/config matrix.

The scanner is designed to be driven by the caller's choice of filesystem and environment so the test suite can run on macOS without inventing Windows paths or installing any of the four target apps. Default values are wired in only at the outermost entry point.

Implementation

The first piece is a tiny templating function. Every app's config path lives behind a Windows environment variable like ${APPDATA} or ${PROGRAMFILES(X86)}, and we want the scanner to gracefully skip candidates whose variables are not set (for example on a non-Windows test machine) rather than emit None/discord/settings.json and try to stat it. The regex allows parentheses inside the variable name so PROGRAMFILES(X86) round-trips correctly.

_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_()]*)\}")


def expand_path(template, env):
    missing = []

    def replace(match):
        key = match.group(1)
        value = env.get(key)
        if value is None:
            missing.append(key)
            return ""
        return value

    expanded = _ENV_VAR_PATTERN.sub(replace, template)
    if missing:
        return None
    return expanded

The missing list is the load-bearing detail. re.sub does not have a "fail the whole substitution if any callback signals an error" mode, so the closure records the misses while still returning a string from each callback. Once the substitution finishes we inspect missing and decide whether to return the assembled path or None. Three tests pin this: the straight substitution, the parenthesised variable name, and the missing-variable fallback.

Next come the three extractors. Each accepts a raw file body (a string) and returns a tuple of (purpose, HotkeyCombo) pairs. They never touch the filesystem and never raise on malformed input — a corrupt config from one app must not sink the whole scan, so every extractor traps its own parse errors and returns an empty tuple when it cannot make sense of the input.

The OBS extractor scans an INI file's [Hotkeys] section. Each value is a JSON object whose bindings array lists one or more {key, modifiers} entries. The key field is a constant like OBS_KEY_F10 that we strip down to the suffix and look up in the same VK_CODES table the combo parser uses; the modifiers field is a {control, alt, shift, command} flag bag we OR together with the existing MOD_* constants from step 2.

def extract_obs_hotkeys(text):
    pairs = _read_ini_section(text, "Hotkeys")
    results = []
    for purpose, value in pairs:
        results.extend(_parse_obs_binding(purpose, value))
    return tuple(results)

The top-level function stays at the two-line body the codebase rule encourages, and the work is split across four private helpers (_read_ini_section, _parse_obs_binding, _obs_binding_combos, _obs_entry_to_combo) that each handle exactly one shape transition — text to ini pairs, ini value to bindings list, bindings list to combos, combo dict to HotkeyCombo. Three tests cover the OBS path: a happy-path scan that picks up two distinct purposes, a guard that lines outside [Hotkeys] are ignored, and a malformed-JSON case that proves one bad entry does not break the others.

The Steam extractor is the messiest of the three because Valve's VDF is not a standardised format. Rather than ship a full parser, we lean on a regex that matches any "<name>" "<value>" pair where the name ends in Key or key — the only fields with hotkey payloads in config.vdf. The value is a comma-separated list mixing modifier names (shift, ctrl) and a KEY_* constant; anything that does not resolve to a known modifier or key silently drops out.

_STEAM_HOTKEY_PATTERN = re.compile(
    r'"([A-Za-z_]*[Kk]ey)"\s+"([^"]+)"'
)


def extract_steam_hotkeys(text):
    results = []
    for match in _STEAM_HOTKEY_PATTERN.finditer(text):
        purpose = match.group(1)
        combo = _steam_value_to_combo(match.group(2))
        if combo is not None:
            results.append((purpose, combo))
    return tuple(results)

Two tests lock the contract: the realistic Steam fragment exposing the in-game overlay screenshot key (KEY_F12) and the overlay shortcut (shift,KEY_TAB), plus a guard that proves a "PublicKey" field full of base64 garbage does not poison the result. The regex is deliberately scoped — PublicKey does match the name pattern, but its value contains no comma-separated KEY_* token, so the value parser returns None and the entry is dropped without raising.

The Teams extractor is the simplest because Microsoft already ships chord strings in our own preferred form. The desktop config is a JSON file shaped like {"hotkeys": {"toggle_mute": "Ctrl+Shift+M"}}, so we hand each value straight to parse_combo from step 4 and discard anything that fails to parse.

def extract_teams_hotkeys(text):
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return ()
    hotkeys_map = data.get("hotkeys") if isinstance(data, dict) else None
    if not isinstance(hotkeys_map, dict):
        return ()
    return tuple(_teams_pairs(hotkeys_map))

Four tests cover the Teams path: the happy parse, the invalid-JSON fallback to an empty tuple, the mixed good/bad combo case where one entry parses and the other is dropped, and a guard that an unexpectedly-typed "hotkeys": "broken" does not blow up the extractor. The recurring theme — return empty rather than raise — is what makes the scanner safe to call against any file the user happens to have on disk.

The four extractors plug into a single registry, KNOWN_APPS, that pairs each app's display name with its likely process names, a list of config-path templates to probe, and an optional extractor. Discord's entry has no extractor: its bindings live inside an Electron LevelDB store that we deliberately do not parse here, so we only report whether the process is running and whether the settings directory exists.

KNOWN_APPS = (
    KnownApp(
        name="Discord",
        process_names=("discord.exe", "discordptb.exe", "discordcanary.exe"),
        config_paths=(
            "${APPDATA}/discord/settings.json",
            "${APPDATA}/discordptb/settings.json",
            "${APPDATA}/discordcanary/settings.json",
        ),
        extractor=None,
    ),
    KnownApp(
        name="OBS",
        process_names=("obs64.exe", "obs32.exe", "obs.exe"),
        config_paths=(
            "${APPDATA}/obs-studio/basic/profiles/Untitled/basic.ini",
        ),
        extractor=extract_obs_hotkeys,
    ),
    KnownApp(
        name="Steam",
        process_names=("steam.exe",),
        config_paths=(
            "${PROGRAMFILES(X86)}/Steam/config/config.vdf",
            "${PROGRAMFILES}/Steam/config/config.vdf",
        ),
        extractor=extract_steam_hotkeys,
    ),
    KnownApp(
        name="Microsoft Teams",
        process_names=("teams.exe", "ms-teams.exe"),
        config_paths=(
            "${APPDATA}/Microsoft/Teams/desktop-config.json",
        ),
        extractor=extract_teams_hotkeys,
    ),
)

Each app lists multiple process names where the user has a choice of channel (Discord stable/PTB/Canary, OBS 64-bit/32-bit, Teams classic/new) and multiple config paths where the install location can vary (Steam under either Program Files). The orchestrator walks every template in order and accumulates the ones that actually resolve to a file we can read.

The top-level entry point is scan_known_apps. It takes the registry (defaulting to KNOWN_APPS), an env mapping for path expansion, a small fs object exposing exists/read_text, and an iterable of currently-running process names the caller has already enumerated. Each input has a real default so production callers can omit them, but the tests inject MagicMock filesystems and synthetic environments to drive every code path without touching the host disk.

def scan_known_apps(known_apps=KNOWN_APPS, env=None, fs=None, processes=()):
    effective_env = env if env is not None else _default_env()
    effective_fs = fs if fs is not None else _default_fs()
    process_names = _lower_set(processes)
    return tuple(
        _scan_app(app, effective_env, effective_fs, process_names)
        for app in known_apps
    )

The per-app work lives in _scan_app, which does three things in sequence: case-insensitively check whether any of the app's process names appear in the running set, walk the config-path templates expanding each one against the environment and skipping ones with missing variables, and feed every readable file body through the app's extractor. The function stays linear — no nested branching — by delegating each decision to a private helper (_read_if_exists for the existence/read pair, _extract for the optional-extractor case).

def _scan_app(app, env, fs, process_names):
    running = any(name in process_names for name in app.process_names)
    found_paths = []
    hotkeys = []
    for template in app.config_paths:
        path = expand_path(template, env)
        if path is None:
            continue
        contents = _read_if_exists(fs, path)
        if contents is None:
            continue
        found_paths.append(path)
        hotkeys.extend(_extract(app, contents))
    return AppFinding(
        app=app.name,
        running=running,
        config_paths_found=tuple(found_paths),
        hotkeys=tuple(
            ConfiguredHotkey(app=app.name, purpose=purpose, combo=combo)
            for purpose, combo in hotkeys
        ),
    )

_read_if_exists wraps the fs.exists + fs.read_text pair in a single try/except OSError so a permission-denied or locked-file error gracefully degrades to "no findings for this path" instead of cratering the scan halfway through the registry. The codebase rule forbidding nested try/except is satisfied — there is exactly one try per code path — and the orchestrator stays at one level of indentation.

Verification

Run the suite from inside codebase/:

python3 -m pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0 -- /Applications/Xcode.app/Contents/Developer/usr/bin/python3
cachedir: .pytest_cache
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests
collecting ... collected 61 items

tests/test_combo.py::test_parse_combo_recognizes_ctrl_shift_f12 PASSED   [  1%]
tests/test_combo.py::test_parse_combo_is_case_insensitive_for_modifiers_and_keys PASSED [  3%]
tests/test_combo.py::test_parse_combo_supports_win_modifier_and_space_key PASSED [  4%]
tests/test_combo.py::test_parse_combo_supports_named_keys_and_letter_keys PASSED [  6%]
tests/test_combo.py::test_parse_combo_supports_full_function_key_range PASSED [  8%]
tests/test_combo.py::test_parse_combo_accepts_modifier_order_in_any_arrangement PASSED [  9%]
tests/test_combo.py::test_parse_combo_rejects_unknown_key_name PASSED    [ 11%]
tests/test_combo.py::test_parse_combo_rejects_multiple_non_modifier_keys PASSED [ 13%]
tests/test_combo.py::test_parse_combo_rejects_combo_with_only_modifiers PASSED [ 14%]
tests/test_combo.py::test_parse_combo_rejects_empty_segments PASSED      [ 16%]
tests/test_combo.py::test_format_combo_orders_modifiers_canonically PASSED [ 18%]
tests/test_combo.py::test_format_combo_renders_lone_key_with_no_modifiers PASSED [ 19%]
tests/test_combo.py::test_format_combo_round_trips_through_parse PASSED  [ 21%]
tests/test_combo.py::test_format_combo_emits_hex_for_unknown_vk_code PASSED [ 22%]
tests/test_combo.py::test_combo_as_tuple_matches_modifier_and_vk_pair PASSED [ 24%]
tests/test_hotkey.py::test_decode_wm_hotkey_extracts_id_modifiers_and_vk_code PASSED [ 26%]
tests/test_hotkey.py::test_decode_wm_hotkey_handles_zero_modifiers PASSED [ 27%]
tests/test_hotkey.py::test_decode_wm_hotkey_isolates_low_and_high_words_of_lparam PASSED [ 29%]
tests/test_hotkey.py::test_decode_wm_hotkey_rejects_non_hotkey_message PASSED [ 31%]
tests/test_hotkey.py::test_register_delegates_to_win32_register_hotkey PASSED [ 32%]
tests/test_hotkey.py::test_unregister_delegates_and_drops_entry PASSED   [ 34%]
tests/test_hotkey.py::test_unregister_all_releases_every_registered_hotkey PASSED [ 36%]
tests/test_hotkey.py::test_register_lazy_imports_win32gui_only_on_use PASSED [ 37%]
tests/test_message_pump.py::test_run_dispatches_messages_until_wm_quit PASSED [ 39%]
tests/test_message_pump.py::test_run_returns_immediately_when_first_message_is_quit PASSED [ 40%]
tests/test_message_pump.py::test_post_quit_delegates_to_win32_with_default_exit_code PASSED [ 42%]
tests/test_message_pump.py::test_post_quit_forwards_custom_exit_code PASSED [ 44%]
tests/test_probe.py::test_probe_reports_free_when_register_succeeds PASSED [ 45%]
tests/test_probe.py::test_probe_reports_in_use_when_register_raises_already_registered PASSED [ 47%]
tests/test_probe.py::test_probe_propagates_unexpected_register_errors PASSED [ 49%]
tests/test_probe.py::test_probe_many_returns_one_result_per_combo PASSED [ 50%]
tests/test_probe.py::test_probe_lazy_imports_win32gui_only_on_use PASSED [ 52%]
tests/test_probe.py::test_registrar_exposes_bindings_with_modifiers_and_vk_code PASSED [ 54%]
tests/test_probe.py::test_snapshot_classifies_owned_taken_and_free_combos PASSED [ 55%]
tests/test_resolver.py::test_resolve_returns_first_candidate_when_free PASSED [ 57%]
tests/test_resolver.py::test_resolve_falls_back_to_next_candidate_when_first_is_taken PASSED [ 59%]
tests/test_resolver.py::test_resolve_returns_owned_combo_without_probing_the_os PASSED [ 60%]
tests/test_resolver.py::test_resolve_returns_none_when_every_candidate_is_taken PASSED [ 62%]
tests/test_resolver.py::test_resolve_handles_empty_candidate_list PASSED [ 63%]
tests/test_resolver.py::test_resolve_stops_probing_after_first_available_hit PASSED [ 65%]
tests/test_scanner.py::test_expand_path_substitutes_env_vars PASSED      [ 67%]
tests/test_scanner.py::test_expand_path_supports_env_vars_with_parens PASSED [ 68%]
tests/test_scanner.py::test_expand_path_returns_none_when_env_var_missing PASSED [ 70%]
tests/test_scanner.py::test_extract_obs_hotkeys_decodes_known_section PASSED [ 72%]
tests/test_scanner.py::test_extract_obs_hotkeys_ignores_lines_outside_hotkeys_section PASSED [ 73%]
tests/test_scanner.py::test_extract_obs_hotkeys_skips_invalid_json PASSED [ 75%]
tests/test_scanner.py::test_extract_steam_hotkeys_finds_screenshot_and_overlay_keys PASSED [ 77%]
tests/test_scanner.py::test_extract_steam_hotkeys_drops_values_that_do_not_parse PASSED [ 78%]
tests/test_scanner.py::test_extract_teams_hotkeys_parses_combo_strings PASSED [ 80%]
tests/test_scanner.py::test_extract_teams_hotkeys_returns_empty_for_invalid_json PASSED [ 81%]
tests/test_scanner.py::test_extract_teams_hotkeys_skips_unparseable_combos PASSED [ 83%]
tests/test_scanner.py::test_extract_teams_hotkeys_tolerates_non_dict_hotkeys_field PASSED [ 85%]
tests/test_scanner.py::test_scan_marks_app_running_when_process_matches_case_insensitively PASSED [ 86%]
tests/test_scanner.py::test_scan_marks_app_not_running_when_no_process_matches PASSED [ 88%]
tests/test_scanner.py::test_scan_reads_config_files_when_present PASSED  [ 90%]
tests/test_scanner.py::test_scan_skips_missing_config_files PASSED       [ 91%]
tests/test_scanner.py::test_scan_skips_paths_with_missing_env_vars PASSED [ 93%]
tests/test_scanner.py::test_scan_tolerates_filesystem_read_errors PASSED [ 95%]
tests/test_scanner.py::test_scan_uses_default_registry_when_called_without_apps PASSED [ 96%]
tests/test_scanner.py::test_known_apps_covers_all_four_target_offenders PASSED [ 98%]
tests/test_scanner.py::test_known_apps_discord_entry_has_no_extractor PASSED [100%]

============================== 61 passed in 0.16s ==============================

The suite grows from forty cases to sixty-one. The twenty-one new tests in test_scanner.py cover every branch of the new module: three for path expansion, three for the OBS extractor, two for Steam, four for Teams, and nine for the orchestrator (process matching, config reads, missing files, missing env vars, OSError tolerance, default registry, and the registry-completeness guards that pin Discord's no-extractor invariant and the presence of all four target apps).

The orchestrator tests in particular are worth calling out: each one injects a MagicMock filesystem with explicit exists.side_effect and read_text.return_value so the test exercises a specific decision branch (file present and readable, file present but unreadable, file missing, env var missing) without ever depending on the host machine's state. The same pattern keeps the suite reproducible on the CI runner and on a developer's macOS box.

What we built

We now have a scanner that produces a single tuple[AppFinding, ...] summarising what every known offender is doing on this machine: whether the process is running, which of its config files we found, and what hotkeys it has bound. The four target apps — Discord, OBS, Steam, Microsoft Teams — are wired into a default registry, but KNOWN_APPS is just a tuple of KnownApp records and a caller can prepend their own entries to scan custom apps without touching the module.

Three concrete invariants are now enforced by tests. First, a corrupt or unreadable config file for one app cannot break the scan of any other app — every extractor swallows its own parse errors and every filesystem read is wrapped in an OSError guard. Second, missing environment variables short-circuit the path expansion and skip the candidate rather than producing a half-resolved path; the scanner behaves correctly on machines where the relevant Windows env vars are unset. Third, process matching is case-insensitive, so Discord.exe and discord.exe both register as "Discord is running".

The output type, AppFinding, is shaped to feed two downstream consumers we will write in later steps. A CLI report can iterate the tuple and print one section per app, listing the discovered hotkeys with their purposes. The resolver from step 4 can join the same tuple's hotkeys field against its candidate list and label each conflict with the exact app + purpose responsible — turning "Ctrl+Shift+M is taken" into "Ctrl+Shift+M is taken by Microsoft Teams toggle_mute".

The next step can take this scanner together with the resolver and message pump and assemble the actual end-to-end CLI: read a preference list from arguments or a config file, run scan_known_apps to enumerate offenders, hand the candidate list to resolve_first_available, register the winner through HotkeyRegistrar, and start the MessagePump to dispatch WM_HOTKEY messages. The core library is now complete enough that the entry point is composition rather than new logic.

Repository

The companion code for this article: https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32

The state of the code after this step: a92bd93

Key commits to step through:

  • fdfaa49 — step 1: project scaffold and Win32 message pump skeleton
  • ce87746 — step 2: hotkey registrar and WM_HOTKEY decoder
  • 7d77a01 — step 3: hotkey probe + owned/taken/free snapshot
  • bc387de — step 4: HotkeyCombo + parse/format + resolve_first_available
  • a92bd93 — step 5: scanner for Discord/OBS/Steam/Teams hotkey configs and running processes

Step 6: Wrapping the Detector in a scan/watch CLI with a Stable, Diff-Friendly Report

Step 5 left us with scan_known_apps, which returns a tuple[AppFinding, ...] describing what every known offender is doing on the machine — process state, discovered config files, parsed hotkeys. That tuple is the entire conflict-detection engine in data form, but it has no user-facing surface yet: a human running the project wants to type a command and get a verdict, not import a Python module and pretty-print a dataclass.

This step assembles the engine into a real command-line tool. Two subcommands — scan for a one-shot snapshot and watch for a quiet poll-and-diff loop — share a single pure rendering pipeline, and a --candidate flag layers a conflict report on top of the scan output. Every external moving part (the scanner, time.sleep, sys.stdout.write) is reached through an injectable parameter so the whole CLI is unit-testable on a machine that has never seen pywin32.

Setup

No new third-party dependencies. The CLI is built from the standard library: argparse for the parser, sys/time for the production defaults of the write and sleep callbacks, and the existing combo/scanner modules from earlier steps. The deltas are:

  • src/ptt_conflict_detector/cli.py — new module hosting the rendering helpers (format_scan_report, format_conflict_report, find_conflicts), the polling loop (watch), the argparse wiring (build_parser), and the main entry point with injectable scan_fn/sleep_fn/write_fn ports.
  • src/ptt_conflict_detector/__main__.py — a three-line module that calls sys.exit(main()) so python -m ptt_conflict_detector … resolves to the same main the tests drive.
  • src/ptt_conflict_detector/__init__.py — re-export the new public names (build_parser, find_conflicts, format_scan_report, format_conflict_report, main, watch) at the package root.
  • tests/test_cli.py — fourteen pytest cases pinning the report shape, the watch loop's emit-on-change discipline, and every main branch (scan, scan-with-candidate, parse error, watch, watch-with-candidate, case-insensitive candidate parsing).

Implementation

The first building block is find_conflicts. Given a candidate HotkeyCombo and the full findings tuple, it returns every (app, purpose) pair whose configured combo equals the candidate. Equality is the dataclass equality from step 4 — same modifier bitmask plus same VK code — so a Ctrl+Space candidate matches Discord's ptt binding regardless of how either side spelled the chord originally.

def find_conflicts(candidate, findings):
    matches = []
    for finding in findings:
        for hotkey in finding.hotkeys:
            if hotkey.combo == candidate:
                matches.append((finding.app, hotkey.purpose))
    return tuple(matches)

format_scan_report is the workhorse renderer. It produces a stable, line-oriented string with one section per app, a header that reports the running/not-running state, an indented list of config files actually found on disk, and an indented list of parsed hotkeys for each file. Sections are joined with a single blank line and the output always ends with a trailing newline so the watch loop can compare two reports for equality without trimming whitespace.

def format_scan_report(findings):
    sections = [_format_finding_section(finding) for finding in findings]
    body = "\n\n".join(sections).rstrip()
    return body + "\n"

The body of _format_finding_section is split into two private helpers — _format_config_paths and _format_hotkeys — so the public renderer stays at one level of indentation. The "no config files found" line is emitted when the templates resolved but nothing on disk matched, and the "no hotkeys parsed" line is only emitted when a config file was read but the extractor returned an empty tuple. Those two states mean different things to a debugging user (env variables unset versus a config file with no bound hotkeys), and the report draws the distinction explicitly.

def _format_hotkeys(finding):
    if not finding.config_paths_found:
        return []
    if not finding.hotkeys:
        return ["  no hotkeys parsed"]
    return [
        f"  {hotkey.purpose}: {format_combo(hotkey.combo)}"
        for hotkey in finding.hotkeys
    ]

format_conflict_report is the candidate-mode renderer. For each candidate combo the user passed on the command line, it emits either OK <combo> — no conflicts found or a multi-line CONFLICT <combo> block with one indented bullet per claiming app. The two prefixes line up at column 10 so a quick grep CONFLICT over the output narrows in on actionable rows without losing the OK context above and below.

def _format_candidate_line(candidate, findings):
    conflicts = find_conflicts(candidate, findings)
    label = format_combo(candidate)
    if not conflicts:
        return f"OK    {label} — no conflicts found"
    header = f"CONFLICT  {label}"
    bullets = [f"    - claimed by {app} for {purpose}" for app, purpose in conflicts]
    return "\n".join([header, *bullets])

The watch function is the polling loop. It accepts the four ports it needs — scan_fn, render_fn, sleep_fn, write_fn — plus an interval and a max_ticks cap. On every tick it scans, renders, and compares against the previous rendered string; it only calls write_fn when the rendered output actually changes, which keeps a terminal silent for as long as the conflict picture is stable. The max_ticks cap is what makes the loop bounded for tests; production callers pass 10**9 and rely on Ctrl+C.

def watch(scan_fn, render_fn, sleep_fn, write_fn, interval, max_ticks):
    previous = None
    for tick in range(max_ticks):
        findings = scan_fn()
        rendered = render_fn(findings)
        if rendered != previous:
            write_fn(rendered)
            previous = rendered
        if tick + 1 < max_ticks:
            sleep_fn(interval)

The if tick + 1 < max_ticks guard around the sleep is load-bearing for the test suite. Without it, max_ticks=1 would sleep once before returning, and a test that picks a long realistic interval like 999.0 would wait nearly seventeen minutes for the function to return. With the guard, the sleep only happens between ticks, and max_ticks=1 returns immediately.

build_parser wires the two subcommands. scan takes only the shared --candidate flag (repeatable, defaults to an empty list). watch adds --interval (a float, default 5.0 seconds) and --max-ticks (an int, default 10**9 so a normal user can run it forever). The --candidate argument flows into both subcommands through a private helper, so adding a new flag to both at once is a single-line change.

def build_parser():
    parser = argparse.ArgumentParser(
        prog="ptt-conflict-detector",
        description="Detect hotkey conflicts that break push-to-talk on Windows.",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    scan_p = sub.add_parser("scan", help="Scan known offenders once and print their claimed hotkeys.")
    _add_candidate_arg(scan_p)

    watch_p = sub.add_parser("watch", help="Continuously re-scan and emit when the report changes.")
    watch_p.add_argument("--interval", type=float, default=5.0)
    watch_p.add_argument("--max-ticks", type=int, default=10**9)
    _add_candidate_arg(watch_p)

    return parser

The renderer that main hands to either subcommand is built lazily by _build_renderer. Without candidates, the renderer is just format_scan_report. With candidates, it returns a closure that concatenates the scan report and the conflict report so a single watch tick re-emits when either half changes — a newly-running Discord process or a newly-claimed candidate combo will both wake the loop up.

def _build_renderer(candidates):
    if not candidates:
        return format_scan_report
    def render(findings):
        return format_scan_report(findings) + format_conflict_report(candidates, findings)
    return render

Finally, main is the composition root. It parses arguments, resolves each port (production defaults are sys.stdout.write, time.sleep, and a thunk over scan_known_apps(KNOWN_APPS)), parses the candidate strings through parse_combo from step 4, and dispatches to either a single render-and-write call (scan) or the bounded watch loop (watch). A ValueError from candidate parsing is caught at this top level and turned into an error: … line plus exit code 2, so a typo on the command line produces a clear message instead of a stack trace.

def main(argv=None, scan_fn=None, sleep_fn=None, write_fn=None):
    args = build_parser().parse_args(argv)
    write = write_fn or sys.stdout.write
    sleep = sleep_fn or time.sleep
    scanner = scan_fn or _default_scan_fn()

    try:
        candidates = _parse_candidates(args.candidate)
    except ValueError as exc:
        write(f"error: {exc}\n")
        return 2

    render = _build_renderer(candidates)
    if args.command == "scan":
        write(render(scanner()))
        return 0
    if args.command == "watch":
        watch(
            scan_fn=scanner,
            render_fn=render,
            sleep_fn=sleep,
            write_fn=write,
            interval=args.interval,
            max_ticks=args.max_ticks,
        )
        return 0
    return 1

The four-port shape (scan_fn, sleep_fn, write_fn, plus the implicit argv) is what makes the whole CLI testable from a developer machine. The fourteen new tests in tests/test_cli.py inject a list-appending write_fn, a list-appending sleep_fn, and a lambda: next(scans) over a pre-built sequence of findings — no subprocess, no clock, no pywin32 shim required.

Verification

Run the suite from inside codebase/:

python3 -m pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0 -- /Applications/Xcode.app/Contents/Developer/usr/bin/python3
cachedir: .pytest_cache
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests
collecting ... collected 75 items

tests/test_cli.py::test_find_conflicts_returns_every_app_that_claims_combo PASSED [  1%]
tests/test_cli.py::test_find_conflicts_returns_empty_when_no_app_claims_combo PASSED [  2%]
tests/test_cli.py::test_format_scan_report_marks_running_and_lists_hotkeys PASSED [  4%]
tests/test_cli.py::test_format_scan_report_says_no_config_when_paths_empty PASSED [  5%]
tests/test_cli.py::test_format_scan_report_says_no_hotkeys_when_paths_found_but_empty PASSED [  6%]
tests/test_cli.py::test_format_conflict_report_marks_conflict_and_ok_lines PASSED [  8%]
tests/test_cli.py::test_watch_emits_first_tick_then_only_on_change PASSED [  9%]
tests/test_cli.py::test_watch_skips_final_sleep_when_max_ticks_is_one PASSED [ 10%]
tests/test_cli.py::test_main_scan_writes_report_to_injected_sink PASSED  [ 12%]
tests/test_cli.py::test_main_scan_with_candidate_appends_conflict_report PASSED [ 13%]
tests/test_cli.py::test_main_scan_rejects_unparseable_candidate PASSED   [ 14%]
tests/test_cli.py::test_main_watch_runs_bounded_loop_and_emits_when_changed PASSED [ 16%]
tests/test_cli.py::test_main_watch_with_candidate_includes_conflict_in_change_detection PASSED [ 17%]
tests/test_cli.py::test_parse_combo_is_used_by_main_for_candidate_strings PASSED [ 18%]
tests/test_combo.py::test_parse_combo_recognizes_ctrl_shift_f12 PASSED   [ 20%]
tests/test_combo.py::test_parse_combo_is_case_insensitive_for_modifiers_and_keys PASSED [ 21%]
tests/test_combo.py::test_parse_combo_supports_win_modifier_and_space_key PASSED [ 22%]
tests/test_combo.py::test_parse_combo_supports_named_keys_and_letter_keys PASSED [ 24%]
tests/test_combo.py::test_parse_combo_supports_full_function_key_range PASSED [ 25%]
tests/test_combo.py::test_parse_combo_accepts_modifier_order_in_any_arrangement PASSED [ 26%]
tests/test_combo.py::test_parse_combo_rejects_unknown_key_name PASSED    [ 28%]
tests/test_combo.py::test_parse_combo_rejects_multiple_non_modifier_keys PASSED [ 29%]
tests/test_combo.py::test_parse_combo_rejects_combo_with_only_modifiers PASSED [ 30%]
tests/test_combo.py::test_parse_combo_rejects_empty_segments PASSED      [ 32%]
tests/test_combo.py::test_format_combo_orders_modifiers_canonically PASSED [ 33%]
tests/test_combo.py::test_format_combo_renders_lone_key_with_no_modifiers PASSED [ 34%]
tests/test_combo.py::test_format_combo_round_trips_through_parse PASSED  [ 36%]
tests/test_combo.py::test_format_combo_emits_hex_for_unknown_vk_code PASSED [ 37%]
tests/test_combo.py::test_combo_as_tuple_matches_modifier_and_vk_pair PASSED [ 38%]
tests/test_hotkey.py::test_decode_wm_hotkey_extracts_id_modifiers_and_vk_code PASSED [ 40%]
tests/test_hotkey.py::test_decode_wm_hotkey_handles_zero_modifiers PASSED [ 41%]
tests/test_hotkey.py::test_decode_wm_hotkey_isolates_low_and_high_words_of_lparam PASSED [ 42%]
tests/test_hotkey.py::test_decode_wm_hotkey_rejects_non_hotkey_message PASSED [ 44%]
tests/test_hotkey.py::test_register_delegates_to_win32_register_hotkey PASSED [ 45%]
tests/test_hotkey.py::test_unregister_delegates_and_drops_entry PASSED   [ 46%]
tests/test_hotkey.py::test_unregister_all_releases_every_registered_hotkey PASSED [ 48%]
tests/test_hotkey.py::test_register_lazy_imports_win32gui_only_on_use PASSED [ 49%]
tests/test_message_pump.py::test_run_dispatches_messages_until_wm_quit PASSED [ 50%]
tests/test_message_pump.py::test_run_returns_immediately_when_first_message_is_quit PASSED [ 52%]
tests/test_message_pump.py::test_post_quit_delegates_to_win32_with_default_exit_code PASSED [ 53%]
tests/test_message_pump.py::test_post_quit_forwards_custom_exit_code PASSED [ 54%]
tests/test_probe.py::test_probe_reports_free_when_register_succeeds PASSED [ 56%]
tests/test_probe.py::test_probe_reports_in_use_when_register_raises_already_registered PASSED [ 57%]
tests/test_probe.py::test_probe_propagates_unexpected_register_errors PASSED [ 58%]
tests/test_probe.py::test_probe_many_returns_one_result_per_combo PASSED [ 60%]
tests/test_probe.py::test_probe_lazy_imports_win32gui_only_on_use PASSED [ 61%]
tests/test_probe.py::test_registrar_exposes_bindings_with_modifiers_and_vk_code PASSED [ 62%]
tests/test_probe.py::test_snapshot_classifies_owned_taken_and_free_combos PASSED [ 64%]
tests/test_resolver.py::test_resolve_returns_first_candidate_when_free PASSED [ 65%]
tests/test_resolver.py::test_resolve_falls_back_to_next_candidate_when_first_is_taken PASSED [ 66%]
tests/test_resolver.py::test_resolve_returns_owned_combo_without_probing_the_os PASSED [ 68%]
tests/test_resolver.py::test_resolve_returns_none_when_every_candidate_is_taken PASSED [ 69%]
tests/test_resolver.py::test_resolve_handles_empty_candidate_list PASSED [ 70%]
tests/test_resolver.py::test_resolve_stops_probing_after_first_available_hit PASSED [ 72%]
tests/test_scanner.py::test_expand_path_substitutes_env_vars PASSED      [ 73%]
tests/test_scanner.py::test_expand_path_supports_env_vars_with_parens PASSED [ 74%]
tests/test_scanner.py::test_expand_path_returns_none_when_env_var_missing PASSED [ 76%]
tests/test_scanner.py::test_extract_obs_hotkeys_decodes_known_section PASSED [ 77%]
tests/test_scanner.py::test_extract_obs_hotkeys_ignores_lines_outside_hotkeys_section PASSED [ 78%]
tests/test_scanner.py::test_extract_obs_hotkeys_skips_invalid_json PASSED [ 80%]
tests/test_scanner.py::test_extract_steam_hotkeys_finds_screenshot_and_overlay_keys PASSED [ 81%]
tests/test_scanner.py::test_extract_steam_hotkeys_drops_values_that_do_not_parse PASSED [ 82%]
tests/test_scanner.py::test_extract_teams_hotkeys_parses_combo_strings PASSED [ 84%]
tests/test_scanner.py::test_extract_teams_hotkeys_returns_empty_for_invalid_json PASSED [ 85%]
tests/test_scanner.py::test_extract_teams_hotkeys_skips_unparseable_combos PASSED [ 86%]
tests/test_scanner.py::test_extract_teams_hotkeys_tolerates_non_dict_hotkeys_field PASSED [ 88%]
tests/test_scanner.py::test_scan_marks_app_running_when_process_matches_case_insensitively PASSED [ 89%]
tests/test_scanner.py::test_scan_marks_app_not_running_when_no_process_matches PASSED [ 90%]
tests/test_scanner.py::test_scan_reads_config_files_when_present PASSED  [ 92%]
tests/test_scanner.py::test_scan_skips_missing_config_files PASSED       [ 93%]
tests/test_scanner.py::test_scan_skips_paths_with_missing_env_vars PASSED [ 94%]
tests/test_scanner.py::test_scan_tolerates_filesystem_read_errors PASSED [ 96%]
tests/test_scanner.py::test_scan_uses_default_registry_when_called_without_apps PASSED [ 97%]
tests/test_scanner.py::test_known_apps_covers_all_four_target_offenders PASSED [ 98%]
tests/test_scanner.py::test_known_apps_discord_entry_has_no_extractor PASSED [100%]

============================== 75 passed in 0.29s ==============================

The suite grows from sixty-one cases to seventy-five. The fourteen new tests cover every public surface of the CLI: two for find_conflicts (a multi-app hit and a clean miss), three for format_scan_report (a happy path, the no-config-files state, and the no-hotkeys-parsed state), one for format_conflict_report (mixed CONFLICT and OK lines), two for watch (emit-on-change discipline and the no-trailing-sleep guard), and six for main (scan, scan-with-candidate, candidate parse failure, watch, watch-with-candidate, and a case-insensitive candidate round-trip).

The watch tests in particular are worth calling out. They build a synthetic sequence of findings, drive watch with sleep_fn=sleeps.append and write_fn=written.append, and then assert on the exact length and content of both lists. That style — turn every side effect into a list, then assert on the list — is what keeps the loop deterministic and what proves the emit-on-change invariant holds across a four-tick run with two duplicates and one transition.

What we built

We now have a real command-line tool. python -m ptt_conflict_detector scan produces a stable, sectioned report of what every known offender is doing on this machine, and python -m ptt_conflict_detector watch keeps re-running that scan on an interval while staying silent until the picture actually changes.

Adding --candidate "Ctrl+Space" flips both subcommands into conflict-detection mode: the scan output is followed by an OK/CONFLICT line per candidate, with claiming app and purpose bulleted underneath each conflict. The flag is repeatable, so a user can pass their preferred and fallback chords in one invocation and read the verdict for all of them at once.

Two invariants are now pinned by tests. The renderer is a pure function over the scanner's output, so identical findings produce byte-identical strings — the watch loop's equality check is reliable and a future test that snapshots the report can do so on any host. The CLI's external ports (scan_fn, sleep_fn, write_fn) are all injectable, so the whole main entry point runs offline on macOS with MagicMock-style stand-ins, and the production defaults wire in sys.stdout.write, time.sleep, and the bundled scanner only at the outermost call site.

This is the final composition step for the conflict-detection half of the project. The engine from steps 1–5 — message pump, registrar, probe, combo parser, resolver, scanner — is now reachable from a single command, with an exit code suitable for CI checks and a renderer that downstream tooling can swap for JSON without touching the loop or the parser.

Repository

The companion code for this article: https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32

The state of the code after this step: ef67d77

Key commits to step through:

  • fdfaa49 — step 1: project scaffold and Win32 message pump skeleton
  • ce87746 — step 2: hotkey registrar and WM_HOTKEY decoder
  • 7d77a01 — step 3: hotkey probe + owned/taken/free snapshot
  • bc387de — step 4: HotkeyCombo + parse/format + resolve_first_available
  • a92bd93 — step 5: scanner for Discord/OBS/Steam/Teams hotkey configs and running processes
  • ef67d77 — step 6: argparse CLI with scan/watch subcommands, candidate conflict report, and emit-on-change polling

Step 7: Packaging the Detector as a One-File PyInstaller Binary with an Offline End-to-End Smoke Test

Step 6 left us with a fully composed CLI — scan, watch, and a repeatable --candidate flag that turns either subcommand into a conflict report. The engine works on a developer machine and the seventy-five-case suite proves every renderer and port in isolation, but a Windows user who just wants to know whether their Ctrl+Space is safe cannot pip install a virtualenv to find out. We need a shippable artifact and a test that exercises the whole pipeline from CLI argv to exit code.

This step adds three things at once. A PyInstaller spec at the codebase root bundles __main__.py plus every module the engine reaches into a single console executable. A small packaging module wraps the spec invocation behind a runner seam so the unit suite never needs PyInstaller installed. And a --exit-on-conflict flag on scan plus a four-case smoke test stage a fake Teams config under a temp APPDATA and prove that a conflicting candidate makes the binary exit non-zero with a human-readable verdict.

Setup

No new runtime dependencies — PyInstaller is a build-time tool, not a library the shipped binary depends on. The deltas are:

  • ptt_conflict_detector.spec at the codebase root — the PyInstaller spec. Sets console=True, names the executable ptt-conflict-detector, points Analysis at src/ptt_conflict_detector/__main__.py, and lists every package module (cli, combo, hotkey, message_pump, probe, resolver, scanner) as a hidden import so PyInstaller's static analyzer cannot prune any of them.
  • src/ptt_conflict_detector/packaging.py — a three-function module: default_spec_path() resolves the spec relative to the package, build_command() composes the exact python -m PyInstaller … argv as a pure function, and build() invokes the composed command through an injectable runner (default: subprocess.run with check=True).
  • src/ptt_conflict_detector/cli.py — gains an --exit-on-conflict flag on the scan subparser plus a _has_any_conflict helper, so main can return exit code 1 when the candidate is claimed by a scanned app.
  • tests/test_packaging.py — eight pytest cases pinning the spec file's contents (entrypoint, name, hidden imports, console flag), the argv composition (interpreter, --noconfirm, optional --distpath/--workpath), and the build orchestrator's behaviour (runner injection, check=True, missing-spec error).
  • tests/test_smoke.py — four pytest cases that stage a fake Microsoft Teams desktop-config.json under a tmp_path-rooted APPDATA, run main(["scan", "--candidate", …]) against the real scanner, and assert the exact exit codes and human-readable output the packaged binary will emit.

Implementation

The spec file is plain Python evaluated by PyInstaller. The pieces that earn their keep are the hiddenimports list and the pathex entry. PyInstaller's static analyzer follows imports from __main__.py, and because every module is already imported transitively through cli.py, the discovery would normally succeed — but listing the modules explicitly is cheap insurance against a future refactor that pushes one of them behind a conditional import.

a = Analysis(
    ["src/ptt_conflict_detector/__main__.py"],
    pathex=["src"],
    binaries=[],
    datas=[],
    hiddenimports=[
        "ptt_conflict_detector.cli",
        "ptt_conflict_detector.combo",
        "ptt_conflict_detector.hotkey",
        "ptt_conflict_detector.message_pump",
        "ptt_conflict_detector.probe",
        "ptt_conflict_detector.resolver",
        "ptt_conflict_detector.scanner",
    ],
    hookspath=[],
    runtime_hooks=[],
    excludes=[],
    cipher=block_cipher,
)

console=True on the EXE block is non-negotiable for a CLI: without it, double-clicking the .exe on Windows would spawn a hidden process and the user would never see the conflict report. name="ptt-conflict-detector" pins the output filename so the README can document a stable invocation, and upx=False keeps the build reproducible across hosts that may or may not have UPX installed.

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name="ptt-conflict-detector",
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=False,
    console=True,
    target_arch=None,
)

The packaging module is the test-friendly wrapper around the spec. build_command is a pure function — no I/O, no subprocess — so the unit tests can assert on the exact argv. The use of sys.executable (rather than a literal "pyinstaller") means the build runs against the same interpreter the developer is using, which is what keeps the bundled stdlib in sync with the interpreter that imports it.

def build_command(spec_path, distpath=None, workpath=None, noconfirm=True):
    cmd = [sys.executable, "-m", "PyInstaller", str(spec_path)]
    if noconfirm:
        cmd.append("--noconfirm")
    if distpath is not None:
        cmd.extend(["--distpath", str(distpath)])
    if workpath is not None:
        cmd.extend(["--workpath", str(workpath)])
    return cmd

build() is the orchestrator. It resolves the spec path, refuses to proceed if the spec is missing (a FileNotFoundError is a far better failure mode than a confusing PyInstaller traceback five seconds later), composes the argv via build_command, and dispatches to an injectable runner. The production default is subprocess.run(..., check=True), which raises CalledProcessError on a non-zero exit — the convention every CI runner already understands.

def build(spec_path=None, distpath=None, workpath=None, runner=None):
    spec = Path(spec_path) if spec_path is not None else default_spec_path()
    if not spec.is_file():
        raise FileNotFoundError(f"PyInstaller spec not found: {spec}")
    cmd = build_command(spec, distpath=distpath, workpath=workpath)
    invoker = runner if runner is not None else subprocess.run
    return invoker(cmd, check=True)

The runner seam is the load-bearing trick. The eight test_packaging.py cases inject a list-appending stub for runner, which means the whole suite stays under a tenth of a second and never needs PyInstaller on disk. The production code path is identical except that runner defaults to the real subprocess.run — there is no separate "test mode" branch to drift out of sync with reality.

The CLI change is small but earns the scan subcommand its CI-grade exit semantics. A new --exit-on-conflict flag, off by default, asks main to return exit code 1 when any candidate is claimed by a scanned app. The default-off shape preserves backwards compatibility for humans who want to read the report without their shell complaining about a non-zero status.

scan_p.add_argument(
    "--exit-on-conflict",
    action="store_true",
    help="Exit with code 1 if any candidate is already claimed by a scanned app.",
)

main consults the flag after rendering the report, so the user still sees the conflict block before the process exits. _has_any_conflict is a thin any(...) over find_conflicts from step 6, which keeps the matching logic in one place — the renderer and the exit-code decision use the same source of truth.

if args.command == "scan":
    findings = scanner()
    write(render(findings))
    if getattr(args, "exit_on_conflict", False) and _has_any_conflict(candidates, findings):
        return 1
    return 0

The smoke test is where the end-to-end story lands. Each case builds a tmp_path/appdata directory, drops a Microsoft/Teams/desktop-config.json with a known ptt hotkey, points the APPDATA environment variable at it via monkeypatch.setenv, then calls main(["scan", "--candidate", _PTT_COMBO, "--exit-on-conflict"], write_fn=written.append). The real scanner walks the real (synthetic) filesystem, the real renderer formats the report, and the real exit-code decision fires — the only thing stubbed is the sink that captures output.

def test_smoke_conflicting_candidate_exits_nonzero_and_names_app(monkeypatch, tmp_path):
    appdata = _appdata_root(tmp_path)
    _stage_teams_config(appdata, {"ptt": _PTT_COMBO})
    monkeypatch.setenv("APPDATA", str(appdata))

    written = []
    exit_code = main(
        argv=["scan", "--candidate", _PTT_COMBO, "--exit-on-conflict"],
        write_fn=written.append,
    )
    output = "".join(written)

    assert exit_code == 1
    assert "== Microsoft Teams" in output
    assert "ptt: Ctrl+Space" in output
    assert "CONFLICT  Ctrl+Space" in output
    assert "claimed by Microsoft Teams for ptt" in output

Three sibling cases pin the rest of the contract. A non-conflicting candidate (Ctrl+Alt+F11) exits 0 with an OK line. A conflicting candidate without --exit-on-conflict still reports the conflict in the body but exits 0, preserving the human-friendly default. And a scan with no candidates at all still lists the Teams section, proving the staged config is being read by the real scanner and not silently dropped.

Verification

Run the suite from inside the codebase/ directory:

python3 -m pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests
collecting ... collected 87 items

tests/test_cli.py::test_find_conflicts_returns_every_app_that_claims_combo PASSED [  1%]
...
tests/test_packaging.py::test_default_spec_path_points_to_existing_spec_at_codebase_root PASSED [ 48%]
tests/test_packaging.py::test_spec_file_targets_main_entrypoint_and_console_executable PASSED [ 49%]
tests/test_packaging.py::test_spec_file_lists_every_package_module_as_hiddenimport PASSED [ 50%]
tests/test_packaging.py::test_build_command_invokes_pyinstaller_with_current_interpreter PASSED [ 51%]
tests/test_packaging.py::test_build_command_appends_distpath_and_workpath_when_provided PASSED [ 52%]
tests/test_packaging.py::test_build_command_omits_noconfirm_when_disabled PASSED [ 54%]
tests/test_packaging.py::test_build_invokes_runner_with_check_true_against_real_spec PASSED [ 55%]
tests/test_packaging.py::test_build_raises_when_spec_file_missing PASSED [ 56%]
...
tests/test_smoke.py::test_smoke_conflicting_candidate_exits_nonzero_and_names_app PASSED [ 96%]
tests/test_smoke.py::test_smoke_non_conflicting_candidate_exits_zero_with_ok_line PASSED [ 97%]
tests/test_smoke.py::test_smoke_without_exit_flag_reports_conflict_but_still_exits_zero PASSED [ 98%]
tests/test_smoke.py::test_smoke_scan_with_no_candidates_lists_teams_section PASSED [100%]

============================== 87 passed in 0.36s ==============================

The suite grows from seventy-five cases to eighty-seven. Eight new packaging tests pin the spec file's externally-visible contract (entrypoint path, executable name, hidden-imports list, console=True) and the orchestrator's behaviour (runner injection, --noconfirm defaulting, optional --distpath/--workpath, missing-spec failure). Four new smoke tests exercise the full CLI from argv through the real scanner against a staged filesystem, asserting both the human-readable output and the exit-code contract that downstream CI will rely on.

What we built

The project now produces a shippable artifact. A Windows developer with PyInstaller installed runs python -m PyInstaller ptt_conflict_detector.spec from the codebase root and gets a single dist/ptt-conflict-detector.exe that bundles the interpreter, the stdlib, and every module the engine reaches. An end user double-clicks the binary, sees the console window, and gets the same OK/CONFLICT report a developer would see from python -m ptt_conflict_detector.

The build pipeline itself is testable. packaging.build_command is a pure function over the spec path, so its eight test cases assert on argv strings rather than on a real PyInstaller invocation. packaging.build accepts an injectable runner, so the orchestrator can be exercised against a list-appending stub instead of a subprocess that takes seconds and needs PyInstaller on the host — the suite stays under half a second and works on a developer machine that has never seen the build tool.

The --exit-on-conflict flag completes the CI story. A team that wants their commit hook to reject a pull request when the chosen push-to-talk chord is already claimed by Discord or Teams pipes a single command into their workflow — ptt-conflict-detector scan --candidate "Ctrl+Space" --exit-on-conflict — and the non-zero exit fails the job. The renderer still prints the full report first, so a human reading the CI logs immediately sees which app stole the chord and why.

The four-case smoke suite is the regression net underneath all of it. By staging a fake Teams config under a temp APPDATA and driving the real main through the real scanner, we pin every layer from argv parsing to filesystem walking to combo equality to exit-code mapping — the same end-to-end pipeline the packaged binary will run on a user's machine, exercised on every test run without ever touching Windows.

Repository

The companion code for this article: https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32

The state of the code after this step: 3f4aa9c

Key commits to step through:

  • fdfaa49 — step 1: project scaffold and Win32 message pump skeleton
  • ce87746 — step 2: hotkey registrar and WM_HOTKEY decoder
  • 7d77a01 — step 3: hotkey probe + owned/taken/free snapshot
  • bc387de — step 4: HotkeyCombo + parse/format + resolve_first_available
  • a92bd93 — step 5: scanner for Discord/OBS/Steam/Teams hotkey configs and running processes
  • ef67d77 — step 6: argparse CLI with scan/watch subcommands, candidate conflict report, and emit-on-change polling
  • 3f4aa9c — step 7: PyInstaller spec + packaging helper, --exit-on-conflict flag, and an offline end-to-end smoke test against a staged APPDATA

Repository

Full source at https://github.com/vytharion/push-to-talk-hotkey-conflict-detector-pywin32.

Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.