Skip to content

Commit 1f5c509

Browse files
committed
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
1 parent b856fa7 commit 1f5c509

10 files changed

Lines changed: 218 additions & 157 deletions

File tree

.github/workflows/ci.yml

Lines changed: 86 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -5,144 +5,120 @@ 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+
- name: Install only ty
48+
run: uv sync --only-group ty
49+
- name: Typecheck
50+
run: uv run --no-sync ty check
4051

41-
test-builds:
42-
name: ${{ matrix.name }}
52+
test:
53+
name: Test on ${{ matrix.name }}
4354
runs-on: ${{ matrix.os }}
4455
strategy:
4556
fail-fast: false
4657
matrix:
4758
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
59+
# the test suite exercises a real OS keyring, which is only
60+
# available unattended on Windows runners
61+
- name: Windows py313
5862
os: windows-latest
59-
pyversion: '3.12'
60-
- name: Test py313
63+
pyversion: "3.13"
64+
- name: Windows py314
6165
os: windows-latest
62-
pyversion: '3.13'
66+
pyversion: "3.14"
6367
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
68+
- uses: actions/checkout@v7
69+
- name: Install uv and Python ${{ matrix.pyversion }}
70+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
71+
with:
72+
python-version: ${{ matrix.pyversion }}
73+
enable-cache: true
74+
cache-dependency-glob: "pyproject.toml"
75+
cache-suffix: py${{ matrix.pyversion }}
76+
- name: Install dependencies
77+
run: uv sync
78+
- name: Test
79+
run: uv run --no-sync pytest -v --cov=keycmd --cov-report=term-missing tests
7780

78-
release-build:
79-
name: Build release on ubuntu-latest
81+
build:
82+
name: Build and test wheel
8083
runs-on: ubuntu-latest
81-
strategy:
82-
fail-fast: false
8384
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
85+
- uses: actions/checkout@v7
86+
- name: Install uv
87+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
88+
- name: Build sdist and wheel
89+
run: uv build
11090
- name: Twine check
111-
run: |
112-
twine check dist/*
113-
- name: Upload distributions
114-
uses: actions/upload-artifact@v4
91+
run: uvx twine check dist/*
92+
- name: Upload wheel artifact
93+
uses: actions/upload-artifact@v7
11594
with:
11695
path: dist
11796
name: dist
11897

11998
publish:
120-
name: Publish release to Github and Pypi
99+
name: Publish to Github and Pypi
121100
runs-on: ubuntu-latest
122-
needs: [test-builds, release-build]
101+
needs: [lint, typecheck, test, build]
123102
if: success() && startsWith(github.ref, 'refs/tags/v')
103+
permissions:
104+
contents: write
124105
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 }}
106+
- name: Download wheel artifact
107+
uses: actions/download-artifact@v8
108+
with:
109+
name: dist
110+
path: dist
111+
- name: Release to GitHub
112+
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
113+
with:
114+
token: ${{ secrets.GITHUB_TOKEN }}
115+
files: |
116+
dist/*.tar.gz
117+
dist/*.whl
118+
draft: true
119+
prerelease: false
120+
- name: Publish to PyPI
121+
uses: pypa/gh-action-pypi-publish@release/v1
122+
with:
123+
user: __token__
124+
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
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: 35 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,28 @@ 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+
```bash
405+
# create the virtual environment and install all dependencies
406+
uv sync
407+
408+
# install the git hooks that run the checks below on every commit
409+
uv run pre-commit install
410+
411+
# lint, format, typecheck and test
412+
uv run ruff check --fix
413+
uv run ruff format
414+
uv run ty check
415+
uv run pytest tests
416+
```
417+
418+
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:
419+
420+
```bash
421+
uv run --with keyrings.alt pytest tests
422+
# with PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring set in your environment
423+
```

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: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import argparse
2-
3-
import tomli
2+
import tomllib
43

54
from . import __version__
65
from .conf import load_conf
@@ -43,7 +42,7 @@ def main(args=None):
4342

4443
try:
4544
conf = load_conf()
46-
except tomli.TOMLDecodeError as err:
45+
except tomllib.TOMLDecodeError as err:
4746
error(err)
4847
env = get_env(conf)
4948

keycmd/conf.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1+
import tomllib
12
from pathlib import Path
23
from pprint import pformat
34

4-
import tomli
5-
65
from .logs import vlog
76

87
# exposed for testing
@@ -13,9 +12,9 @@ def load_toml(path):
1312
"""Load a toml file"""
1413
with path.open("rb") as fh:
1514
try:
16-
return tomli.load(fh)
17-
except tomli.TOMLDecodeError as err:
18-
raise tomli.TOMLDecodeError(f"invalid TOML in {path}:\n{err}") from err
15+
return tomllib.load(fh)
16+
except tomllib.TOMLDecodeError as err:
17+
raise tomllib.TOMLDecodeError(f"invalid TOML in {path}:\n{err}") from err
1918

2019

2120
def load_pyproj(path):

keycmd/logs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
from typing import NoReturn
23

34
_verbose = False
45

@@ -21,7 +22,7 @@ def vlog(msg):
2122
print(f"keycmd: {msg}")
2223

2324

24-
def error(msg):
25+
def error(msg) -> NoReturn:
2526
log(f"error: {msg}", err=True)
2627
sys.exit(1)
2728

0 commit comments

Comments
 (0)