Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@ Things that bite in this suite:

- **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.
- **`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.
- **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.
- **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.
- Warnings are errors (`filterwarnings` in `pyproject.toml`), so a deprecation in a new Python release fails the suite rather than scrolling past.

## Architecture

`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.
`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.

**`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.

**`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.
**`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.

**`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.

**`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.

Expand Down
64 changes: 53 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,22 @@ The CLI has the following options:

```
❯ keycmd --help
usage: keycmd [-h] [-v] [--version] [--shell] ...
usage: keycmd [-h] [-v] [--version] [--detect-backend] [--reset-backend]
[--shell]
...

positional arguments:
command command to run

optional arguments:
-h, --help show this help message and exit
-v, --verbose enable verbose output, useful for configuration debugging
--version print version info
--shell spawn a subshell instead of running a command
command command to run

options:
-h, --help show this help message and exit
-v, --verbose enable verbose output, useful for configuration debugging
--version print version info
--detect-backend search for the keyring backend now and remember it for
later runs
--reset-backend forget the remembered keyring backend, so the next run
searches again
--shell spawn a subshell instead of running a command
```

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

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.

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:
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.

You can watch it happen with `--verbose`, which says where the backend came from:

```
keycmd: keyring backend: keyring.backends.SecretService.Keyring (found in 0.12s) # the first run
keycmd: keyring backend: keyring.backends.SecretService.Keyring (remembered) # every run after
```

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.

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:

```bash
keycmd --detect-backend # search now, and remember what turns up
keycmd --reset-backend # forget it, so the next run searches again
```

```
❯ keycmd --detect-backend
keycmd: remembered keyring backend keyring.backends.SecretService.Keyring, found in 0.12s
```

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:

```bash
# in your shell profile; use the backend your platform actually uses
export PYTHON_KEYRING_BACKEND=keyring.backends.SecretService.Keyring
```

`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.
`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.

### No backend at all

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:

```
❯ keycmd 'npm install'
keycmd: error: keyring has no backend to read credentials from
keycmd: hint: install one for this platform, or name one you have with PYTHON_KEYRING_BACKEND
keycmd: hint: see https://github.com/jaraco/keyring#third-party-backends
```

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.

## Development

Expand Down
Loading