Skip to content

Commit f5857cf

Browse files
Korijnclaude
andauthored
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>
1 parent 88146fe commit f5857cf

11 files changed

Lines changed: 830 additions & 161 deletions

File tree

.github/workflows/ci.yml

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,31 @@ jobs:
5454
test:
5555
name: Test on ${{ matrix.name }}
5656
runs-on: ${{ matrix.os }}
57+
timeout-minutes: 15
5758
strategy:
5859
fail-fast: false
5960
matrix:
6061
include:
61-
# the test suite exercises a real OS keyring, which is only
62-
# available unattended on Windows runners
63-
- name: Windows py313
64-
os: windows-latest
65-
pyversion: "3.13"
62+
# every platform on the latest python, plus a single job on the
63+
# oldest supported one to catch anything newer than it allows
64+
- name: Linux py314
65+
os: ubuntu-latest
66+
pyversion: "3.14"
67+
- name: macOS py314
68+
os: macos-latest
69+
pyversion: "3.14"
6670
- name: Windows py314
6771
os: windows-latest
6872
pyversion: "3.14"
73+
- name: Linux py313
74+
os: ubuntu-latest
75+
pyversion: "3.13"
76+
env:
77+
# the test suite exercises a real OS keyring; this makes it fail
78+
# instead of skipping those tests if the setup below breaks
79+
KEYCMD_REQUIRE_OS_KEYRING: "1"
80+
# the keyrings created below only ever hold test credentials
81+
KEYCMD_KEYRING_PASSWORD: keycmd-test
6982
steps:
7083
- uses: actions/checkout@v7
7184
- name: Install uv and Python ${{ matrix.pyversion }}
@@ -77,8 +90,73 @@ jobs:
7790
cache-suffix: py${{ matrix.pyversion }}
7891
- name: Install dependencies
7992
run: uv sync
93+
# windows runners expose the credential manager to the session out of
94+
# the box; the other two need a keyring that unlocks without a prompt
95+
- name: Install gnome-keyring
96+
if: runner.os == 'Linux'
97+
run: |
98+
sudo apt-get update
99+
sudo apt-get install --yes --no-install-recommends gnome-keyring dbus-x11
100+
- name: Create a keychain
101+
if: runner.os == 'macOS'
102+
run: |
103+
security create-keychain -p "$KEYCMD_KEYRING_PASSWORD" "$KEYCHAIN"
104+
# no auto lock, neither on a timeout nor on sleep
105+
security set-keychain-settings "$KEYCHAIN"
106+
security unlock-keychain -p "$KEYCMD_KEYRING_PASSWORD" "$KEYCHAIN"
107+
# the keyring package talks to the default keychain
108+
security list-keychains -d user -s "$KEYCHAIN" login.keychain
109+
security default-keychain -s "$KEYCHAIN"
110+
env:
111+
KEYCHAIN: keycmd-test.keychain
112+
# the secret service is bound to a d-bus session, so the tests have to
113+
# run inside one, with the keyring daemon unlocked and running in it
114+
- name: Test in a d-bus session
115+
if: runner.os == 'Linux'
116+
run: |
117+
dbus-run-session -- bash -c '
118+
printf "%s" "$KEYCMD_KEYRING_PASSWORD" |
119+
gnome-keyring-daemon --unlock --components=secrets
120+
uv run --no-sync pytest -v --cov=keycmd --cov-report=term-missing \
121+
--cov-fail-under=95 tests
122+
'
123+
- name: Test
124+
if: runner.os != 'Linux'
125+
run: >
126+
uv run --no-sync pytest -v --cov=keycmd --cov-report=term-missing
127+
--cov-fail-under=95 tests
128+
129+
test-wsl:
130+
name: Test on WSL
131+
runs-on: windows-latest
132+
timeout-minutes: 20
133+
env:
134+
KEYCMD_REQUIRE_OS_KEYRING: "1"
135+
KEYCMD_TEST_WSL: "1"
136+
steps:
137+
- uses: actions/checkout@v7
138+
- name: Install uv and Python 3.14
139+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
140+
with:
141+
python-version: "3.14"
142+
enable-cache: true
143+
cache-dependency-glob: "pyproject.toml"
144+
cache-suffix: py3.14
145+
- name: Install dependencies
146+
run: uv sync
147+
# WSL only provides the shell keycmd is invoked from; keycmd itself
148+
# runs as a windows process, reading the windows credential manager
149+
- name: Install WSL
150+
uses: Vampire/setup-wsl@d1da7f2c0322a5ee4f24975344f67fc0f5baf364 # v7.0.0
151+
with:
152+
# the smallest distribution, on the WSL version that needs no
153+
# virtual machine: the tests only need a shell on the other side
154+
# of the interop boundary, and provisioning WSLv2 with a store
155+
# distribution has taken anywhere from one to twenty minutes
156+
distribution: Alpine-3.23
157+
wsl-version: 1
80158
- name: Test
81-
run: uv run --no-sync pytest -v --cov=keycmd --cov-report=term-missing tests
159+
run: uv run --no-sync pytest -v tests/test_wsl.py
82160

83161
build:
84162
name: Build and test wheel
@@ -100,7 +178,7 @@ jobs:
100178
publish:
101179
name: Publish to Github and Pypi
102180
runs-on: ubuntu-latest
103-
needs: [lint, typecheck, test, build]
181+
needs: [lint, typecheck, test, test-wsl, build]
104182
if: success() && startsWith(github.ref, 'refs/tags/v')
105183
permissions:
106184
contents: write

CLAUDE.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# CLAUDE.md
2+
3+
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.

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,44 @@ uv run ty check
417417
uv run pytest tests
418418
```
419419

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:
428+
429+
```bash
430+
security create-keychain -p keycmd-test keycmd-test.keychain
431+
security set-keychain-settings keycmd-test.keychain
432+
security unlock-keychain -p keycmd-test keycmd-test.keychain
433+
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):
438+
439+
```bash
440+
dbus-run-session -- bash -c '
441+
printf "%s" keycmd-test | gnome-keyring-daemon --unlock --components=secrets
442+
uv run pytest tests
443+
'
444+
```
445+
446+
### Testing WSL
447+
448+
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:
421458

422459
```bash
423460
uv run --with keyrings.alt pytest tests

keycmd/conf.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ def load_toml(path: Path) -> dict[str, Any]:
4040
try:
4141
return tomllib.load(fh)
4242
except tomllib.TOMLDecodeError as err:
43-
raise tomllib.TOMLDecodeError(f"invalid TOML in {path}:\n{err}") from err
43+
# name the offending file in the error, by rewriting the message
44+
# of the original rather than raising a new one: the single
45+
# argument constructor is deprecated as of python 3.14, and the
46+
# structured one that replaces it does not exist before it
47+
err.args = (f"invalid TOML in {path}:\n{err}",)
48+
raise
4449

4550

4651
def load_pyproj(path: Path) -> dict[str, Any]:

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ python-version = "3.13"
8484

8585
[tool.pytest.ini_options]
8686
testpaths = ["tests"]
87+
# deprecations are the way python announces what will break in a release
88+
# or two, so treat them as the failures they are about to become
89+
filterwarnings = ["error"]
8790

8891
# example config for development and testing
8992
[tool.keycmd.keys]

0 commit comments

Comments
 (0)