You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Cover macOS, Linux and WSL in the test suite and CI matrix (#32)
* Cover macOS and Linux in the test suite and CI matrix
The test suite needs an OS keyring that unlocks without user interaction,
which used to limit CI to Windows runners. Both other platforms can
provide one too:
- macOS: create a throwaway keychain and make it the default
- Linux: run the tests in a d-bus session with an unlocked gnome-keyring
CI now tests Linux, macOS and Windows on every supported Python version.
The tests that need a keyring skip themselves with a message when none is
available, so contributors are not blocked; KEYCMD_REQUIRE_OS_KEYRING
turns those skips into failures, and CI sets it so that a broken keyring
setup cannot quietly gut a run.
Everything platform specific now lives in a shared conftest, which also
removes the duplicated fixtures. The suite exercises every shell of the
platform that is installed instead of only the one that happens to have
invoked pytest, and the shells that are not installed are covered by
asserting on the command line keycmd builds for them. New tests cover the
posix process replacement path, the shell detection fallbacks, the
verbose logging, and the error paths of the cli and of get_env, taking
coverage to 100% (CI gates at 95%).
Also stop test_find_file from creating a file in the real home folder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
* Explain how the test suite relates to WSL
WSL needs no job of its own: inside WSL keycmd is a posix process the
Linux job already covers, and calling the Windows install from a WSL
shell runs keycmd as a Windows process the Windows job already covers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
* Test reaching the credential manager from a WSL shell
The half of the WSL story that can actually break is the interop
boundary: keycmd installed on windows, invoked from a shell inside WSL,
reading the windows credential manager. Cover it end to end with a
credential in the credential manager, a shell inside WSL, and the windows
install of keycmd in between, in a CI job that installs WSL.
The tests are opt in through KEYCMD_TEST_WSL, since installing WSL is too
expensive to put in front of every run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
* Match the wrapped pformat output when asserting on the command log
run_cmd logs the command through pformat, which wraps the list over
several lines when the shell path is long, so a plain repr of the list is
not in the output. Caught by powershell.EXE on the windows runners, whose
path is long enough to wrap where pwsh's is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
* Keep windows paths intact on their way through wsl.exe
Backslashes do not survive wsl.exe's command line, which turned the path
of the console script into D:akeycmdkeycmd.venvScriptskeycmd.EXE before
wslpath ever saw it. Hand wslpath forward slashes instead, and keep the
script one line so that it crosses the same boundary unharmed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
* Provision WSL without a virtual machine or a store distribution
The install step took 44 seconds on one run and was still going twenty
minutes later on the next, so drop what makes it heavy: WSLv1 needs no
virtual machine, and the Alpine root file system is a fraction of the
Ubuntu one from the store.
That root file system ships neither bash nor wslpath, so run the scripts
with plain sh and translate windows paths in the test itself, which is
also one less thing to lose backslashes on the way through wsl.exe. The
smoke test now reads a file it created on the windows side, so that the
translation is verified before the tests that depend on it run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
* Print the environment with cmd instead of a shell dialect
wsl.exe strips quotes from its command line, so sh expanded the $env:
prefix of the powershell spelling as an unset variable, and keycmd echoed
":KEYCMD_TEST". Ask cmd to print the environment instead: it works
whichever shell keycmd detects on the windows side, needs no quoting, and
lets the test assert on the value of the variable rather than on whatever
the shell echoed.
Also assert paths translated for WSL have no spaces, since quoting them
is not an option.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
* Stop constructing TOMLDecodeError the deprecated way
Python 3.14 deprecated the single argument form of TOMLDecodeError, and
the structured one it wants instead does not exist before 3.14, so name
the offending file by rewriting the message of the original error rather
than raising a new one. That keeps the type, the message and the
traceback the cli and the tests already rely on.
Turn warnings into errors while here, so the next deprecation fails the
suite instead of scrolling past in the log.
Trim the matrix to the latest python on all three platforms plus a single
job on the oldest supported one, which is what the type checker is
already pinned to.
Add a CLAUDE.md covering the commands, the shape of the configuration,
credential and shell layers, and the platform traps in the test suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYvtsyJgsswPf278ytw2Zd
---------
Co-authored-by: Claude <noreply@anthropic.com>
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+
`keycmd` runs a command with secrets from the OS keyring exposed as environment variables, so that credentials never have to live in a `.env` file. It supports Windows, macOS and Linux.
6
+
7
+
## Commands
8
+
9
+
```bash
10
+
uv sync # create the virtual environment
11
+
uv run pre-commit install # the hooks run everything below on commit
12
+
13
+
uv run ruff check --fix
14
+
uv run ruff format
15
+
uv run ty check --error-on-warning
16
+
uv run pytest tests
17
+
18
+
uv run pytest tests/test_conf.py::test_load_conf # a single test
19
+
uv run pytest -k "run_cmd and bash"# a single shell's parameters
20
+
```
21
+
22
+
## Testing
23
+
24
+
The tests that read and write credentials need an OS keyring that unlocks without user interaction. They skip themselves with a message when there is none, so the rest of the suite still runs; `KEYCMD_REQUIRE_OS_KEYRING=1` turns those skips into failures, and CI sets it. Windows needs no setup, macOS needs an unlocked keychain, and Linux needs the tests to run inside a d-bus session with `gnome-keyring` unlocked (see the Testing section of the README for the exact commands). `PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring` with `uv run --with keyrings.alt` avoids the OS keyring entirely.
25
+
26
+
`tests/test_wsl.py` covers calling the Windows install of keycmd from a shell inside WSL, and is opt in through `KEYCMD_TEST_WSL=1` on a Windows machine with WSL installed.
27
+
28
+
Things that bite in this suite:
29
+
30
+
-**Never assume a shell.** The `shell` fixture in `tests/conftest.py` parametrizes over every shell of the platform that is installed, so a test using it runs three times. Ask the `Shell` object for the dialect (`env_var`, `unset_env_var`, `command_not_found_statuses`) instead of branching on the platform. Shells that are not installed locally are covered by asserting on the command line keycmd builds for them.
31
+
-**`wsl.exe` mangles its command line**: backslashes disappear and quotes are stripped before the distribution sees them. Pass paths translated to `/mnt/...` by `wsl_path`, unquoted and free of spaces, and keep remote scripts on one line.
32
+
- Warnings are errors (`filterwarnings` in `pyproject.toml`), so a deprecation in a new Python release fails the suite rather than scrolling past.
33
+
34
+
## Architecture
35
+
36
+
`cli.main` wires the three halves together: `load_conf` produces the configuration, `get_env` turns it into an environment, and `run_cmd`/`run_shell` hand that environment to a shell. Errors reach the user through `logs.error`, which exits with status 1; `logs.vlog` output only appears under `--verbose` and is the first thing to reach for when debugging a configuration.
37
+
38
+
**`conf.py` — where the configuration comes from.** Later sources win, merged deeply by `merge_conf`: defaults, then `~/.keycmd`, then every `.keycmd` found walking up from the working directory (outermost first), then the first `pyproject.toml` found walking up, whose `[tool.keycmd]` table is used. `find_file` stops at a `.git` directory, at the home folder, and at the root of the file system, so the walk never escapes a repository. `USERPROFILE` is a module attribute so tests can point the user config elsewhere. The merged result is `cast` to `Conf` rather than validated: it is user authored, and `get_env` reports violations as user errors.
39
+
40
+
**`creds.py` — configuration to environment.**`get_env` copies `os.environ` and adds a variable per entry of `[keys]`, looking each credential up in the keyring; `[aliases]` re-expose an existing key under another name with different `b64`/`format` options, without a second keyring lookup. `expose` applies `format` first and `b64` second, which is what makes `{username}:{password}` basic auth work.
41
+
42
+
**`shell.py` — where the platform differences live.**`get_shell` asks shellingham which shell invoked the process and falls back to `$SHELL` or `%COMSPEC%`. `cmd` takes `/C` and keeps the command's arguments separate; every other shell takes `-c` and a single joined string. `exec` replaces the process with `execvpe` on posix, but runs a subprocess on Windows, which has no equivalent; `USE_SUBPROCESS` and the `IS_WINDOWS`/`IS_POSIX` flags are module attributes so tests can drive both paths on either platform.
43
+
44
+
## Conventions
45
+
46
+
The package is fully annotated and ships a `py.typed` marker, so `ANN` rules apply to `keycmd/` while the test suite is exempt. `ty` is configured with `python-version = "3.13"`, the oldest supported release, so it catches typing features that are newer than `requires-python` allows. CI matches that split: the latest Python on all three platforms, plus a single job on the oldest.
Copy file name to clipboardExpand all lines: README.md
+38-1Lines changed: 38 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -417,7 +417,44 @@ uv run ty check
417
417
uv run pytest tests
418
418
```
419
419
420
-
Note that the test suite exercises a real OS keyring, so it needs a keyring backend that can be unlocked without user interaction. On Windows that works out of the box, which is why CI runs the tests there. On other platforms you can point keyring at a file-based backend instead:
420
+
### Testing
421
+
422
+
CI runs the test suite on Windows, macOS and Linux on the latest Python, plus one job on the oldest supported Python to catch anything newer than it allows. The suite adapts to the platform it runs on: it exercises every shell of the platform that is installed (`sh`, `bash` and `zsh` on posix, `cmd`, `powershell` and `pwsh` on Windows), and it skips the process replacement tests on Windows, which has no `execvpe`.
423
+
424
+
The tests that read and write credentials need a real OS keyring that can be unlocked without user interaction. They are skipped with a message if there is no such keyring, so the rest of the suite still runs. Set `KEYCMD_REQUIRE_OS_KEYRING=1` to turn those skips into failures instead; CI sets it so that a broken keyring setup can't quietly reduce the coverage of a run.
425
+
426
+
* **Windows**: the credential manager is available to your session out of the box, no setup needed.
427
+
* **macOS**: your login keychain works as long as it is unlocked. CI instead creates a throwaway keychain and makes it the default:
security list-keychains -d user -s keycmd-test.keychain login.keychain
434
+
security default-keychain -s keycmd-test.keychain
435
+
```
436
+
437
+
* **Linux**: the secret service is bound to a d-bus session, so the tests have to run inside one, with an unlocked keyring daemon (install `gnome-keyring` and `dbus-x11` first):
The [WSL setup](#wsl-installation) has two halves. Working *inside* WSL, keycmd is a posix process like any other, talking to whichever keyring backend the distro provides; that is the Linux job above, keyring daemon and all. The other half, calling the Windows install of keycmd from a WSL shell to reach the Windows credential manager, crosses the interop boundary, and that is what `tests/test_wsl.py` covers: a credential in the credential manager, a shell inside WSL, and the Windows install of keycmd in between.
449
+
450
+
Those tests are opt in, because installing WSL takes a CI job of its own. On a Windows machine that has WSL installed:
451
+
452
+
```powershell
453
+
$env:KEYCMD_TEST_WSL = 1
454
+
uv run pytest tests/test_wsl.py
455
+
```
456
+
457
+
If you would rather not involve your OS keyring at all, point keyring at a file-based backend:
0 commit comments