Skip to content

Commit 679eb6d

Browse files
Korijnclaude
andauthored
Remember which keyring backend was found, instead of searching every run (#37)
Left to itself, keyring works out which backend to use by loading every backend that every installed package registers and keeping the best of them. That search runs again on every invocation and is the single most expensive thing a keycmd run does: measured here it is the difference between 0.156s and 0.085s, and it grows with the number of packages installed and with any backend that takes its time deciding it is not viable. The answer, though, is the same every time until the packages on the machine change. So the new backend.py writes it down the first time a run needs a credential and loads that backend by name afterwards, which is the shortcut PYTHON_KEYRING_BACKEND buys without anyone having to know the variable exists. Nothing to read, nothing to set: the second run is simply faster than the first. The note goes where the platform keeps files a program can afford to lose -- %LOCALAPPDATA% on windows, ~/Library/Caches on macOS, $XDG_CACHE_HOME on linux -- and holds one line, and is trusted only as far as it can be checked: - is_backend_name keeps anything that is not a dotted class name from reaching an import, so a file that has been truncated or scribbled in is worth no more than a search - a name that no longer loads sends the run back to searching and is replaced. load_keyring asks the class for its priority on the way, which is how keyring itself decides a backend is viable, so an uninstalled backend and one whose daemon stopped are both caught - backend_name looks through the chainer, which is not a backend but the search wearing one's clothes; writing that down would leave the search in place, so the backend it would have reached first is written instead - PYTHON_KEYRING_BACKEND outranks the note and is never written over - a search that found nothing is not an answer, and is not remembered What keycmd cannot notice by itself is a backend that still loads but is no longer the one you want, so the two steps are also available on purpose: keycmd --detect-backend # search now, and remember what turns up keycmd --reset-backend # forget it, so the next run searches again Two ways of ending up with no backend used to reach the user as a traceback and are now errors that say what to do: keyring settling on fail.Keyring, which raises on the first lookup and which inside a distro points at the WSL section of the README, and a PYTHON_KEYRING_BACKEND that cannot be loaded. logs.error grew the hint lines those need. keyring stays out of the import path of a run that looks up no credential: backend.py reaches for it inside the functions that need it, the way creds.py used to, and its own name only appears at module level under TYPE_CHECKING. Claude-Session: https://claude.ai/code/session_01CRYugGcVh3wvC5a5pzwKU3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 479415e commit 679eb6d

10 files changed

Lines changed: 768 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,19 @@ Things that bite in this suite:
2929

3030
- **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.
3131
- **`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+
- **The remembered backend is redirected, always.** The autouse `cache_home` fixture in `tests/conftest.py` points `backend.CACHE_HOME` at a folder under `tmp_path`, so that a test run neither reads nor writes the note the machine it runs on is using, and every test starts with nothing remembered.
33+
- **Do not assume the suite runs unpinned.** `PYTHON_KEYRING_BACKEND` is how the README suggests running the suite without an OS keyring, and it outranks everything `backend.py` does, so a test about remembering has to `delenv` it first or it will be testing the path that deliberately remembers nothing.
3234
- Warnings are errors (`filterwarnings` in `pyproject.toml`), so a deprecation in a new Python release fails the suite rather than scrolling past.
3335

3436
## Architecture
3537

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.
38+
`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. `--detect-backend` and `--reset-backend` return before any of it, since neither has a use for a configuration or a command. Errors reach the user through `logs.error`, which exits with status 1 and takes the hint lines that go under the error with it; `logs.vlog` output only appears under `--verbose` and is the first thing to reach for when debugging a configuration.
3739

3840
**`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. Both searches cover the same ground, so `load_conf` collects them in a single pass over `walk_up`, which 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.
3941

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.
42+
**`creds.py` — configuration to environment.** `get_env` copies `os.environ` and adds a variable per entry of `[keys]`, looking each credential up in the backend `backend.load_backend` hands it; `[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.
43+
44+
**`backend.py` — which keyring backend, and remembering the answer.** Left to itself keyring finds its backend by loading every backend every installed package registers, the single most expensive thing a run does. The answer only changes when the machine does, so `load_backend` writes it to `cache_path` — the platform's cache folder, `CACHE_HOME` being the module attribute tests redirect — and afterwards loads it with `load_keyring`, which is the same shortcut `PYTHON_KEYRING_BACKEND` buys without anyone having to know the variable exists. Everything about the note is treated as untrusted: `is_backend_name` keeps anything that is not a dotted class name from reaching an import, and a name that no longer loads (uninstalled, or a daemon that is no longer running, which `load_keyring` catches alike because it asks the class for its `priority`) sends the run back to searching. `backend_name` looks *through* the chainer, which is not a backend but the search wearing one's clothes, so writing it down would leave the search in place. `PYTHON_KEYRING_BACKEND` outranks the note and is never written over. Nothing is remembered when the search finds nothing: that and a `PYTHON_KEYRING_BACKEND` that cannot be loaded are reported as user errors with advice, rather than as the traceback that reaches the user otherwise. `detect_backend` and `reset_backend` are the deliberate versions of the two steps, for a machine that changed in a way that leaves the note valid but wrong.
4145

4246
**`wsl.py` — the boundary between WSL and Windows.** WSL users install keycmd on Windows, which leaves it a Windows process with a Windows idea of a shell. `from_wsl` decides whether it was called from a distro — a `wsl.exe`/`wslhost.exe` ancestor decides it, a Windows shell found first decides against it, and a UNC working directory settles the rest — after which `run_shell`/`run_cmd` hand the work to `wsl.exe` rather than to a Windows shell. A UNC working directory also names the distro, which `wsl_argv` passes as `--distribution` so that a second distro does not send the command to the default one. `KEYCMD_WSL` overrides that decision in either direction. Neither side of the boundary inherits the other's environment, so `share_env` lists the exposed variables in `WSLENV`, which is the only thing that crosses.
4347

README.md

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,22 @@ The CLI has the following options:
157157

158158
```
159159
❯ keycmd --help
160-
usage: keycmd [-h] [-v] [--version] [--shell] ...
160+
usage: keycmd [-h] [-v] [--version] [--detect-backend] [--reset-backend]
161+
[--shell]
162+
...
161163
162164
positional arguments:
163-
command command to run
164-
165-
optional arguments:
166-
-h, --help show this help message and exit
167-
-v, --verbose enable verbose output, useful for configuration debugging
168-
--version print version info
169-
--shell spawn a subshell instead of running a command
165+
command command to run
166+
167+
options:
168+
-h, --help show this help message and exit
169+
-v, --verbose enable verbose output, useful for configuration debugging
170+
--version print version info
171+
--detect-backend search for the keyring backend now and remember it for
172+
later runs
173+
--reset-backend forget the remembered keyring backend, so the next run
174+
searches again
175+
--shell spawn a subshell instead of running a command
170176
```
171177

172178
There are two main ways to use the CLI:
@@ -401,7 +407,7 @@ keycmd: merged config:
401407
'ARTIFACTS_TOKEN_B64': {'b64': True,
402408
'credential': 'korijn@poetry-repository-main',
403409
'username': 'korijn'}}}
404-
keycmd: keyring backend: <keyring.backends.Windows.WinVaultKeyring object at 0x000001F8C2A1B4D0>
410+
keycmd: keyring backend: <keyring.backends.Windows.WinVaultKeyring object at 0x000001F8C2A1B4D0> (remembered)
405411
keycmd: exposing credential korijn@poetry-repository-main with user korijn as environment variable ARTIFACTS_TOKEN (b64: False, format: None)
406412
keycmd: exposing credential korijn@poetry-repository-main with user korijn as environment variable ARTIFACTS_TOKEN_B64 (b64: True, format: None)
407413
keycmd: detected shell: C:\Windows\System32\cmd.exe
@@ -419,14 +425,50 @@ See the [third party backends](https://github.com/jaraco/keyring/#third-party-ba
419425

420426
Left to itself, keyring works out which backend to use by loading every backend registered by every installed package and picking the most suitable one. That search runs on each `keycmd` invocation and, on a machine with a few packages installed, costs more time than the whole of the rest of a `keycmd` run put together.
421427

422-
If that shows up in your shell, name the backend you already know you want, and keyring will load that one instead of going looking:
428+
The answer, though, is the same every time until the packages on your machine change. So keycmd writes it down the first time it needs a credential, and loads that backend by name on every run after, which on the machine this was measured on takes a run from 0.156s to 0.085s. There is nothing to configure and nothing to read; it just gets faster after the first run.
429+
430+
You can watch it happen with `--verbose`, which says where the backend came from:
431+
432+
```
433+
keycmd: keyring backend: keyring.backends.SecretService.Keyring (found in 0.12s) # the first run
434+
keycmd: keyring backend: keyring.backends.SecretService.Keyring (remembered) # every run after
435+
```
436+
437+
The note lives with the rest of your cached files — `%LOCALAPPDATA%\keycmd\backend` on Windows, `~/Library/Caches/keycmd/backend` on macOS, and `$XDG_CACHE_HOME/keycmd/backend` (usually `~/.cache`) on Linux — and deleting it costs you nothing but one slow run.
438+
439+
keycmd only trusts the note as far as it can check it. If the backend it names has been uninstalled, or is no longer usable because the daemon behind it is not running, the run searches again and writes down what it finds instead. What it cannot notice by itself is a backend that still loads but is no longer the one you want — you installed a better one, or removed a package and want the runner-up. That is what these two are for:
440+
441+
```bash
442+
keycmd --detect-backend # search now, and remember what turns up
443+
keycmd --reset-backend # forget it, so the next run searches again
444+
```
445+
446+
```
447+
❯ keycmd --detect-backend
448+
keycmd: remembered keyring backend keyring.backends.SecretService.Keyring, found in 0.12s
449+
```
450+
451+
If you would rather take the whole thing into your own hands, keyring's own `PYTHON_KEYRING_BACKEND` still works and outranks anything keycmd remembers:
423452
424453
```bash
425454
# in your shell profile; use the backend your platform actually uses
426455
export PYTHON_KEYRING_BACKEND=keyring.backends.SecretService.Keyring
427456
```
428457

429-
`keyring --list-backends` prints the names to choose from, and `keycmd --verbose` will tell you which one ends up being used. The setting is keyring's own, so it applies to everything else using keyring too.
458+
`keyring --list-backends` prints the names to choose from. The setting is keyring's own, so it applies to everything else using keyring too, and with it set keycmd has nothing to remember and says so if you ask it to.
459+
460+
### No backend at all
461+
462+
If keyring finds no backend it can use, there is nowhere for keycmd to read credentials from, and it says so rather than failing on the first lookup:
463+
464+
```
465+
❯ keycmd 'npm install'
466+
keycmd: error: keyring has no backend to read credentials from
467+
keycmd: hint: install one for this platform, or name one you have with PYTHON_KEYRING_BACKEND
468+
keycmd: hint: see https://github.com/jaraco/keyring#third-party-backends
469+
```
470+
471+
Inside a WSL distribution this usually means the distro's keyring daemon is not running, which is what the [WSL installation](#wsl-installation) instructions above are for; keycmd points you there when it notices it is running in one. Nothing is written down in this case, so there is nothing to reset once you have fixed it.
430472

431473
## Development
432474

0 commit comments

Comments
 (0)