Skip to content

Commit 88146fe

Browse files
Korijnclaude
andauthored
Modernize tooling: uv, ruff, ty, Python 3.13+ and full typing (#31)
* Modernize tooling: uv, ruff, ty and Python 3.13+ Adopt the same project setup as the observ repo: - Require Python 3.13+ and drop the `tomli` dependency in favour of stdlib `tomllib`. Unpin `keyring` (~=24.3 -> >=25.6) and `shellingham`. - Switch the build backend from flit to hatchling and move dev dependencies from an optional-dependencies extra to PEP 735 dependency groups (dev/ruff/ty), with uv as the package manager. - Derive `__version__` from package metadata so the version is declared in one place only. - Extend the ruff lint selection (isort, flake8-debugger, flake8-print) and drop the ignores that no longer apply. - Add ty type checking and a pre-commit config running format, lint, typecheck and tests. Annotate `error()` as `NoReturn`, which is what it always was and what ty needs to see to narrow correctly. - Reorganize CI into separate lint, typecheck, test, build and publish jobs driven by uv, upgrade all actions to their latest versions and pin third-party actions by commit SHA. Tests now run on Python 3.13 and 3.14; the matrix stays on Windows because the suite needs an OS keyring that unlocks unattended. - Document the development workflow and the 3.13+ requirement in the README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nTpsbs48Pcky9GVthXnWv * Fully annotate the package and enforce it Annotate every function, parameter and module-level variable in `keycmd`, and ship a `py.typed` marker so downstream consumers get the types too. Typing the config surface required describing its shape, so the [keys] and [aliases] tables are now `TypedDict`s. `load_conf` casts to that shape at the boundary where user authored TOML enters the program, which is where the promise is actually made. Enforcement, so this does not decay: - ruff's `ANN` rules require annotations (the test suite is exempt). - ty's off-by-default rules are enabled: `missing-type-argument`, `possibly-missing-attribute`, `possibly-missing-import`, `possibly-unresolved-reference` and `division-by-zero`. Suppressions must name a rule and must be needed (`blanket-ignore-comment`, `unused-ignore-comment`). - CI and pre-commit run `ty check --error-on-warning`, so warn-level diagnostics fail rather than scroll by. Two fixes fell out of making the types honest: - `shell.exec` passed `env=None` straight to `os.execvpe`, which requires a mapping and would have raised `TypeError`. It now falls back to `os.environ`, matching what the subprocess branch already did. - `creds.get_env` bound both the loop variable and the looked up key data to the name `key`, so the two had different types under one name. They are now `key`/`data` and `src`/`alias_src`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nTpsbs48Pcky9GVthXnWv * Install runtime dependencies in the typecheck job `uv sync --only-group ty` installs ty but not the project, so ty could not resolve the `keyring` and `shellingham` imports and failed with `unresolved-import`. The pattern was copied from observ, where it is correct because that project has no runtime dependencies. keycmd has two, so the typecheck job needs the project installed as well. It still skips the rest of the dev group. The lint job keeps using `--only-group ruff`: ruff is purely syntactic and needs nothing installed to resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nTpsbs48Pcky9GVthXnWv --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b856fa7 commit 88146fe

13 files changed

Lines changed: 335 additions & 201 deletions

File tree

.github/workflows/ci.yml

Lines changed: 88 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -5,144 +5,122 @@ on:
55
branches:
66
- master
77
tags:
8-
- 'v*'
8+
- "v*"
99
pull_request:
1010
branches:
1111
- master
1212

13+
# Cancel in-flight runs when newer commits are pushed to the same
14+
# branch/PR, but never cancel a tag run mid-publish.
1315
concurrency:
14-
group: CI-${{ github.ref }}
15-
cancel-in-progress: true
16+
group: ${{ github.workflow }}-${{ github.ref }}
17+
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
1618

1719
jobs:
20+
lint:
21+
name: Lint
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v7
25+
- name: Install uv
26+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
27+
with:
28+
enable-cache: true
29+
cache-dependency-glob: "pyproject.toml"
30+
- name: Install only ruff
31+
run: uv sync --only-group ruff
32+
- name: Lint
33+
run: uv run --no-sync ruff check --output-format=github .
34+
- name: Format
35+
run: uv run --no-sync ruff format --check .
1836

19-
lint-build:
20-
name: Linting
37+
typecheck:
38+
name: Typecheck
2139
runs-on: ubuntu-latest
22-
strategy:
23-
fail-fast: false
2440
steps:
25-
- uses: actions/checkout@v4
26-
- name: Set up Python
27-
uses: actions/setup-python@v5
28-
with:
29-
python-version: 3.12
30-
- name: Install dependencies
31-
run: |
32-
python -m pip install --upgrade pip
33-
pip install ruff
34-
- name: Ruff lint
35-
run: |
36-
ruff check --output-format=github .
37-
- name: Ruff format
38-
run: |
39-
ruff format --check .
41+
- uses: actions/checkout@v7
42+
- name: Install uv
43+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
44+
with:
45+
enable-cache: true
46+
cache-dependency-glob: "pyproject.toml"
47+
# ty needs the runtime dependencies installed to resolve their
48+
# imports, so this installs the project itself, unlike the lint job
49+
- name: Install ty and runtime dependencies
50+
run: uv sync --no-default-groups --group ty
51+
- name: Typecheck
52+
run: uv run --no-sync ty check --output-format=github --error-on-warning
4053

41-
test-builds:
42-
name: ${{ matrix.name }}
54+
test:
55+
name: Test on ${{ matrix.name }}
4356
runs-on: ${{ matrix.os }}
4457
strategy:
4558
fail-fast: false
4659
matrix:
4760
include:
48-
- name: Test py39
49-
os: windows-latest
50-
pyversion: '3.9'
51-
- name: Test py310
52-
os: windows-latest
53-
pyversion: '3.10'
54-
- name: Test py311
55-
os: windows-latest
56-
pyversion: '3.11'
57-
- name: Test py312
61+
# the test suite exercises a real OS keyring, which is only
62+
# available unattended on Windows runners
63+
- name: Windows py313
5864
os: windows-latest
59-
pyversion: '3.12'
60-
- name: Test py313
65+
pyversion: "3.13"
66+
- name: Windows py314
6167
os: windows-latest
62-
pyversion: '3.13'
68+
pyversion: "3.14"
6369
steps:
64-
- uses: actions/checkout@v4
65-
- name: Set up Python ${{ matrix.pyversion }}
66-
uses: actions/setup-python@v5
67-
with:
68-
python-version: ${{ matrix.pyversion }}
69-
- name: Install package and dev dependencies
70-
run: |
71-
python -m pip install --upgrade pip
72-
pip install .[dev]
73-
rm -r keycmd
74-
- name: Unit tests
75-
run: |
76-
pytest -v tests --cov=keycmd --cov-report=term-missing
70+
- uses: actions/checkout@v7
71+
- name: Install uv and Python ${{ matrix.pyversion }}
72+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
73+
with:
74+
python-version: ${{ matrix.pyversion }}
75+
enable-cache: true
76+
cache-dependency-glob: "pyproject.toml"
77+
cache-suffix: py${{ matrix.pyversion }}
78+
- name: Install dependencies
79+
run: uv sync
80+
- name: Test
81+
run: uv run --no-sync pytest -v --cov=keycmd --cov-report=term-missing tests
7782

78-
release-build:
79-
name: Build release on ubuntu-latest
83+
build:
84+
name: Build and test wheel
8085
runs-on: ubuntu-latest
81-
strategy:
82-
fail-fast: false
8386
steps:
84-
- uses: actions/checkout@v4
85-
- name: Set up Python
86-
uses: actions/setup-python@v5
87-
with:
88-
python-version: 3.12
89-
- name: Install dev dependencies
90-
run: |
91-
python -m pip install --upgrade pip
92-
pip install -U flit build twine
93-
- name: Create source distribution
94-
run: |
95-
python -m build -n -s
96-
- name: Build wheel
97-
run: |
98-
python -m build -n -w
99-
- name: Test sdist
100-
shell: bash
101-
run: |
102-
rm -rf ./keycmd
103-
pushd $HOME
104-
pip install $GITHUB_WORKSPACE/dist/*.tar.gz
105-
python -c "import keycmd; print(keycmd.__version__)"
106-
popd
107-
# don't run tests, we just want to know if the sdist can be installed
108-
pip uninstall -y keycmd
109-
git reset --hard HEAD
87+
- uses: actions/checkout@v7
88+
- name: Install uv
89+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
90+
- name: Build sdist and wheel
91+
run: uv build
11092
- name: Twine check
111-
run: |
112-
twine check dist/*
113-
- name: Upload distributions
114-
uses: actions/upload-artifact@v4
93+
run: uvx twine check dist/*
94+
- name: Upload wheel artifact
95+
uses: actions/upload-artifact@v7
11596
with:
11697
path: dist
11798
name: dist
11899

119100
publish:
120-
name: Publish release to Github and Pypi
101+
name: Publish to Github and Pypi
121102
runs-on: ubuntu-latest
122-
needs: [test-builds, release-build]
103+
needs: [lint, typecheck, test, build]
123104
if: success() && startsWith(github.ref, 'refs/tags/v')
105+
permissions:
106+
contents: write
124107
steps:
125-
- uses: actions/checkout@v4
126-
- name: Set up Python
127-
uses: actions/setup-python@v5
128-
with:
129-
python-version: 3.12
130-
- name: Download assets
131-
uses: actions/download-artifact@v4
132-
with:
133-
name: dist
134-
path: dist
135-
- name: Release
136-
uses: softprops/action-gh-release@v2
137-
with:
138-
token: ${{ secrets.GITHUB_TOKEN }}
139-
files: |
140-
dist/*.tar.gz
141-
dist/*.whl
142-
draft: true
143-
prerelease: false
144-
- name: Publish to PyPI
145-
uses: pypa/gh-action-pypi-publish@release/v1
146-
with:
147-
user: __token__
148-
password: ${{ secrets.PYPI_PASSWORD }}
108+
- name: Download wheel artifact
109+
uses: actions/download-artifact@v8
110+
with:
111+
name: dist
112+
path: dist
113+
- name: Release to GitHub
114+
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
115+
with:
116+
token: ${{ secrets.GITHUB_TOKEN }}
117+
files: |
118+
dist/*.tar.gz
119+
dist/*.whl
120+
draft: true
121+
prerelease: false
122+
- name: Publish to PyPI
123+
uses: pypa/gh-action-pypi-publish@release/v1
124+
with:
125+
user: __token__
126+
password: ${{ secrets.PYPI_PASSWORD }}

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
.ruff_cache
1+
*.egg-info/
22
*.pyc
33
__pycache__
4+
.vscode
5+
.coverage
6+
.ruff_cache
7+
.pytest_cache
8+
.venv
49
dist
10+
uv.lock

.pre-commit-config.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
repos:
2+
- repo: local
3+
hooks:
4+
- id: ruff (format)
5+
name: Format
6+
entry: uv run ruff format
7+
language: system
8+
types: [python]
9+
require_serial: true
10+
- id: ruff (lint)
11+
name: Lint
12+
entry: uv run ruff check --fix
13+
language: system
14+
types: [python]
15+
require_serial: true
16+
- id: ty
17+
name: Typecheck
18+
entry: uv run ty check --error-on-warning
19+
language: system
20+
types: [python]
21+
pass_filenames: false
22+
require_serial: true
23+
- id: pytest
24+
name: Test
25+
entry: uv run pytest tests
26+
language: system
27+
types: [python]
28+
pass_filenames: false

README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,20 @@ The most common use case is to load credentials for package managers such as pip
1818

1919
## Installation
2020

21+
`keycmd` requires Python 3.13 or newer.
22+
2123
> **Note**
2224
> If you're intending to install `keycmd` in a WSL or pyenv environment, you'll have to skip ahead to the specific installation instructions for those environments.
2325
2426
### Global installation
2527

26-
Install `keycmd` from pypi using `pip install keycmd`, or whatever alternative python package manager you prefer.
28+
Since `keycmd` is a command line tool, the recommended way to install it is with [uv](https://docs.astral.sh/uv/):
29+
30+
```bash
31+
uv tool install keycmd
32+
```
33+
34+
This installs `keycmd` into its own isolated environment and puts the executable on your `PATH`. Alternatively, install it from pypi using `pip install keycmd`, or whatever alternative python package manager you prefer.
2735

2836
Note that the executable `keycmd` has to be installed to a folder that is on your `PATH` environment variable, or the command won't be available globally. Assuming you were able to run `pip` just now, the `keycmd` executable should end up in the exact same location and everything should be fine.
2937

@@ -42,7 +50,7 @@ Run the following commands one by one to install keycmd into its own standalone
4250

4351
```bash
4452
# run the following commands one by one
45-
pyenv virtualenv 3.9 keycmd
53+
pyenv virtualenv 3.13 keycmd
4654
pyenv activate keycmd
4755
pip install keycmd
4856
pathToKeycmd=$(python -c 'import sys; from pathlib import Path; print(Path(sys.executable).parent / "keycmd")')
@@ -388,3 +396,30 @@ aSdtIG5vdCB0aGF0IHN0dXBpZCA6KQ==
388396
Since keycmd uses keyring as its backend, you're not limited to just working with OS keyrings. 🤯 Any keyring backend will work with keycmd. No special configuration required!
389397

390398
See the [third party backends](https://github.com/jaraco/keyring/#third-party-backends) list for all options.
399+
400+
## Development
401+
402+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management, [ruff](https://docs.astral.sh/ruff/) for linting and formatting, and [ty](https://docs.astral.sh/ty/) for type checking.
403+
404+
The `keycmd` package is fully annotated and ships a `py.typed` marker, so the types are available to anything that imports it. Ruff's `ANN` rules keep it that way; the test suite is exempt.
405+
406+
```bash
407+
# create the virtual environment and install all dependencies
408+
uv sync
409+
410+
# install the git hooks that run the checks below on every commit
411+
uv run pre-commit install
412+
413+
# lint, format, typecheck and test
414+
uv run ruff check --fix
415+
uv run ruff format
416+
uv run ty check
417+
uv run pytest tests
418+
```
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:
421+
422+
```bash
423+
uv run --with keyrings.alt pytest tests
424+
# with PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring set in your environment
425+
```

keycmd/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
__version__ = "0.7.0"
1+
from importlib.metadata import version
2+
3+
__version__ = version("keycmd")

keycmd/cli.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import argparse
2-
3-
import tomli
2+
import tomllib
3+
from collections.abc import Sequence
44

55
from . import __version__
66
from .conf import load_conf
77
from .creds import get_env
88
from .logs import error, log, set_verbose
99
from .shell import run_cmd, run_shell
1010

11-
cli = argparse.ArgumentParser(
11+
cli: argparse.ArgumentParser = argparse.ArgumentParser(
1212
prog="keycmd",
1313
)
1414
cli.add_argument(
@@ -30,26 +30,26 @@
3030
cli.add_argument("command", nargs=argparse.REMAINDER, help="command to run")
3131

3232

33-
def main(args=None):
33+
def main(args: Sequence[str] | None = None) -> None:
3434
"""CLI entrypoint"""
35-
args = cli.parse_args(args=args)
35+
parsed = cli.parse_args(args=args)
3636

37-
if args.verbose:
37+
if parsed.verbose:
3838
set_verbose()
3939

40-
if args.version:
40+
if parsed.version:
4141
log(f"v{__version__}")
4242
return
4343

4444
try:
4545
conf = load_conf()
46-
except tomli.TOMLDecodeError as err:
46+
except tomllib.TOMLDecodeError as err:
4747
error(err)
4848
env = get_env(conf)
4949

50-
if args.shell:
50+
if parsed.shell:
5151
run_shell(env=env)
52-
elif args.command:
53-
run_cmd(args.command, env=env)
52+
elif parsed.command:
53+
run_cmd(parsed.command, env=env)
5454
else:
5555
error("missing command argument")

0 commit comments

Comments
 (0)