title: Look into using uv for linting state: OPEN author: dboreham labels: comments: 0 assignees: projects: milestone: number: 41 -- I read somewhere that the uv tool has its own linter. We should look into whether it would be appropriate to use that rather than flake8, since the migration to uv for the build and packaging process.
1→[flake8]
2→extend-ignore = E203
3→exclude = .git,__pycache__,old,build,dist,dev,.venv
4→max-complexity = 25
5→max-line-length = 132
6→per-file-ignores = ./machine/cloud_config.py:E501
7→ 1→#!/bin/bash
2→
3→if [[ "$1" == "--fix" ]]; then
4→ LINE_LENGTH=$(grep 'max-line-length' .flake8 | cut -d'=' -f2 | awk '{ print $1 }')
5→ uv run black -l ${LINE_LENGTH:-132} machine/
6→fi
7→
8→uv run flake8
9→ 1→[project]
2→name = "machine"
3→version = "1.0.0"
4→description = "Utility for creating and managing VMs"
5→readme = "README.md"
6→license = "AGPL-3.0-only"
7→requires-python = ">=3.8"
8→authors = [
9→ { name = "Stirlingbridge", email = "info@stirlingbridge.website" },
10→]
11→classifiers = [
12→ "Programming Language :: Python :: 3.8",
13→ "Operating System :: OS Independent",
14→]
15→dependencies = [
16→ "click==8.1.7",
17→ "python-digitalocean==1.17.0",
18→ "ruamel.yaml>=0.17.32",
19→ "requests==2.32.3",
20→ "expandvars==1.0.0",
21→]
22→
23→[project.scripts]
24→machine = "machine.main:main"
25→
26→[project.urls]
27→Homepage = "https://github.com/stirlingbridge/machine"
28→
29→[build-system]
30→requires = ["hatchling"]
31→build-backend = "hatchling.build"
32→
33→[tool.pytest.ini_options]
34→markers = [
35→ "e2e: end-to-end tests that create real resources on DigitalOcean (require E2E_* env vars)",
36→]
37→addopts = "-m 'not e2e'"
38→
39→[dependency-groups]
40→dev = [
41→ "flake8",
42→ "black",
43→ "pytest",
44→]
45→Here's my assessment of issue #41:
What uv actually offers: uv itself doesn't include a linter. The confusion likely comes from the fact that uv is made by Astral, the same company that makes Ruff — a very fast Python linter and formatter written in Rust. Ruff is a natural companion to uv but is a separate tool.
Current setup: The project uses flake8 (linting) + black (formatting), both run via uv run. These are two separate dev dependencies.
Why Ruff would be a good replacement:
uv run ruff check and uv run ruff format work seamlessly.pyproject.toml — eliminates the separate .flake8 file.Migration would look like:
- Replace flake8 and black with ruff in [dependency-groups] dev
- Add a [tool.ruff] section in pyproject.toml mapping the current flake8/black settings
- Update sh/lint.sh to use uv run ruff check and uv run ruff format
- Delete .flake8
Want me to go ahead and implement this migration?
Ok that sounds good please go ahead but first please add the notes you made above to the bug report.
gh issue comment 41 --body "$(cat <<'EOF' ## Investigation Notes **What uv actually offers:** uv itself doesn't include a linter. The confusion likely comes from the fact that uv is made by **Astral**, the same company that makes **Ruff** — a very fast Python linter and formatter written in Rust. Ruff is a natural companion to uv but is a separate tool. **Current setup:** The project uses **flake8** (linting) + **black** (formatting), both run via `uv run`. These are two separate dev dependencies. **Why Ruff would be a good replacement:** 1. **Ruff replaces both flake8 AND black** — it's a linter and formatter in one tool. This would cut two dev dependencies down to one. 2. **Direct uv integration** — `uv run ruff check` and `uv run ruff format` work seamlessly. 3. **Drop-in migration** — Ruff supports all the flake8 rules currently configured (E203 ignore, E501 per-file ignore, max-line-length 132, complexity checks). 4. **Much faster** — 10-100x faster than flake8, though that barely matters for a project this size. 5. **Config lives in `pyproject.toml`** — eliminates the separate `.flake8` file. **Migration plan:** - Replace `flake8` and `black` with `ruff` in `[dependency-groups] dev` - Add a `[tool.ruff]` section in `pyproject.toml` mapping the current flake8/black settings - Update `sh/lint.sh` to use `uv run ruff check` and `uv run ruff format` - Delete `.flake8` EOF )"
[dependency-groups]
dev = [
"flake8",
"black",
"pytest",
][dependency-groups]
dev = [
"ruff",
"pytest",
]
[tool.ruff]
line-length = 132
[tool.ruff.lint]
select = ["E", "F", "W", "C90"]
ignore = ["E203"]
[tool.ruff.lint.mccabe]
max-complexity = 25
[tool.ruff.lint.per-file-ignores]
"machine/cloud_config.py" = ["E501"]The file /home/david/projects/stirlingbridge/machine/pyproject.toml has been updated successfully.
#!/bin/bash if [[ "$1" == "--fix" ]]; then uv run ruff format machine/ uv run ruff check --fix machine/ else uv run ruff format --check machine/ uv run ruff check machine/ fi
The file /home/david/projects/stirlingbridge/machine/sh/lint.sh has been updated successfully.
Resolved 28 packages in 1.16s
Building machine @ file:///home/david/projects/stirlingbridge/machine
Downloading ruff (10.8MiB)
Built machine @ file:///home/david/projects/stirlingbridge/machine
Downloaded ruff
Prepared 2 packages in 2.23s
Uninstalled 10 packages in 11ms
Installed 2 packages in 2ms
- black==26.1.0
- flake8==7.3.0
~ machine==1.0.0 (from file:///home/david/projects/stirlingbridge/machine)
- mccabe==0.7.0
- mypy-extensions==1.1.0
- pathspec==1.0.4
- platformdirs==4.9.4
- pycodestyle==2.14.0
- pyflakes==3.4.0
- pytokens==0.4.1
+ ruff==0.15.6uv run ruff check machine/ && uv run ruff format --check machine/
Exit code 1 All checks passed! Would reformat: machine/cloud_config.py 1 file would be reformatted, 20 files already formatted
uv run ruff format --diff machine/cloud_config.py
Exit code 1
--- machine/cloud_config.py
+++ machine/cloud_config.py
@@ -42,6 +42,6 @@
- mkdir -p {machine_config.script_dir}
- curl -L {machine_config.script_url} -o {machine_config.script_path}
- chmod +x {machine_config.script_path}
- - [su, -c, "env {' '.join([f"{k}='{v}'" for k, v in cloud_env.items()])} {machine_config.script_path} {escaped_args}", -, {machine_config.new_user_name}]
+ - [su, -c, "env {" ".join([f"{k}='{v}'" for k, v in cloud_env.items()])} {machine_config.script_path} {escaped_args}", -, {machine_config.new_user_name}]
"""
return cloud_config
1 file would be reformattedMinor difference — ruff prefers " " over ' ' inside the f-string. This is a trivial style change. Let me apply it.
uv run ruff check machine/ && uv run ruff format --check machine/
============================= test session starts ============================== platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 rootdir: /home/david/projects/stirlingbridge/machine configfile: pyproject.toml collected 35 items / 6 deselected / 29 selected tests/test_cli_integration.py ........ [ 27%] tests/test_config.py ............. [ 72%] tests/test_input_validation.py ........ [100%] ======================= 29 passed, 6 deselected in 4.18s =======================
Everything passes. Now I need to update the CI workflow and CLAUDE.md references to flake8/black.
1→# Setup dev environment
2→dev:
3→ uv sync
4→
5→build:
6→ ./sh/build-package.sh
7→
8→test:
9→ uv run pytest tests/ -v
10→
11→test-e2e:
12→ uv run pytest tests/test_e2e.py -v -m e2e
13→
14→lint:
15→ uv run flake8
16→ 1→# machine
2→CLI utility to create and manage VMs
3→
4→Initially supports only DigitalOcean using the [python-digitalocean](https://github.com/koalalorenzo/python-digitalocean) module.
5→
6→## Prerequisites
7→
8→This project uses [uv](https://docs.astral.sh/uv/) for dependency management and builds.
9→
10→Install uv:
11→```
12→curl -LsSf https://astral.sh/uv/install.sh | sh
13→```
14→
15→## Development Setup
16→
17→```
18→uv sync
19→```
20→
21→This creates a `.venv` virtual environment and installs all dependencies (including dev tools like flake8 and black).
22→
23→Run the CLI during development:
24→```
25→uv run machine --help
26→```
27→
28→Run the linter:
29→```
30→uv run flake8
31→```
32→
33→## Build
34→
35→Build a self-contained executable using [shiv](https://github.com/linkedin/shiv):
36→```
37→./sh/build-package.sh
38→```
39→
40→This produces `build/machine`, a single-file Python zipapp.
41→
42→## Install
43→
44→Install directly from the GitHub repository using uv:
45→```
46→uv tool install git+https://github.com/stirlingbridge/machine.git
47→```
48→
49→Alternatively, download the `machine` binary from the [releases page](https://github.com/stirlingbridge/machine/releases), make it executable, and place it on your PATH:
50→```
51→chmod +x machine
52→sudo mv machine /usr/local/bin/
53→```
54→
55→## Usage
56→
57→### Config File
58→Access token and other settings configured in the file `~/.machine/config.yml` :
59→```yaml
60→digital-ocean:
61→ access-token: dop_v1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
62→ ssh-key: my-ssh-key-name
63→ dns-zone: example.com
64→ machine-size: s-4vcpu-8gb
65→ image: ubuntu-22-04-x64
66→ region: nyc3
67→ project: Infrastructure
68→
69→machines:
70→ example:
71→ new-user-name: alice
72→ script-dir: /opt/setup-scripts
73→ script-url: https://raw.githubusercontent.com/example/setup-machine.sh
74→ script-path: /opt/setup-scripts/setup-machine.sh
75→ script-args: "-y"
76→```
77→
78→#### Config Reference
79→
80→**digital-ocean section:**
81→
82→| Key | Required | Description |
83→|-----|----------|-------------|
84→| `access-token` | Yes | DigitalOcean API access token |
85→| `ssh-key` | Yes | Name of the SSH key in your DigitalOcean account to use for new machines |
86→| `dns-zone` | No | DNS zone for automatic DNS record creation/deletion |
87→| `machine-size` | Yes | Default machine size slug (e.g. `s-4vcpu-8gb`) |
88→| `image` | Yes | Default image name (e.g. `ubuntu-22-04-x64`) |
89→| `region` | Yes | Default region code (e.g. `nyc3`) |
90→| `project` | No | DigitalOcean project name to assign new machines to |
91→
92→**machines section:**
93→
94→Each entry under `machines:` defines a machine type that can be referenced with `create --type`:
95→
96→| Key | Required | Description |
97→|-----|----------|-------------|
98→| `new-user-name` | Yes | Username for the non-root user created on the machine |
99→| `script-url` | No | URL to download an initialization script from |
100→| `script-dir` | No | Directory to store the initialization script |
101→| `script-path` | No | Full path for the initialization script |
102→| `script-args` | No | Arguments passed to the initialization script (supports variable expansion) |
103→
104→If `script-url`, `script-dir`, and `script-path` are all provided, the script is downloaded and executed as the new user during cloud-init. The following variables are available for expansion in `script-args`:
105→
106→- `$MACHINE_SCRIPT_URL` — URL of the initialization script
107→- `$MACHINE_SCRIPT_DIR` — directory path for the script
108→- `$MACHINE_FQDN` — fully qualified domain name of the machine (if DNS is configured)
109→
110→#### Environment Variable Substitution
111→
112→Config values support environment variable substitution using `${VAR}` syntax, similar to Docker Compose files. This is useful for keeping secrets like API tokens out of the config file.
113→
114→Supported forms:
115→- `${VAR}` — substitutes the value of `VAR`; errors if the variable is not set
116→- `${VAR:-default}` — substitutes the value of `VAR`, or `default` if the variable is not set
117→
118→Example:
119→```yaml
120→digital-ocean:
121→ access-token: ${DO_API_TOKEN}
122→ ssh-key: ${SSH_KEY_NAME:-my-ssh-key}
123→ dns-zone: example.com
124→ machine-size: ${MACHINE_SIZE:-s-4vcpu-8gb}
125→ image: ubuntu-22-04-x64
126→ region: ${DO_REGION:-nyc3}
127→ project: Infrastructure
128→```
129→
130→Substitution is applied to all string values throughout the config file, including the `machines` section.
131→
132→#### Advanced Machine Setup
133→
134→Examples of advanced machine setup scripts can be found in [the machine-provisioning repository](https://github.com/bozemanpass/machine-provisioning).
135→
136→### Session Management
137→
138→Each invocation of `machine` uses a session ID (auto-generated and stored in `~/.machine/session-id.yml`). Machines are tagged with their session ID on creation. By default, `list`, `status`, `list-domain`, and `destroy` only operate on machines from the current session. Use the `--all` flag to include machines from other sessions or machines not created by this tool.
139→
140→The session ID can be overridden with the global `--session-id` option.
141→
142→### Automatic Tagging
143→
144→Machines created by this tool are automatically tagged with:
145→
146→- `machine:created` — identifies the machine as created by this tool
147→- `machine:type:<type-name>` — the machine type from the config
148→- `machine:session:<session-id>` — the session that created the machine
149→
150→### Global Options
151→```
152→$ machine --help
153→Usage: machine [OPTIONS] COMMAND [ARGS]...
154→
155→Options:
156→ --debug Enable debug output
157→ --quiet Suppress all non-essential output
158→ --verbose Enable verbose output
159→ --dry-run Run but do not do anything
160→ --config-file <PATH> Specify the config file (default
161→ ~/.machine/config.yml)
162→ --session-id <ID> Override the default session ID
163→ -h, --help Show this message and exit.
164→
165→Commands:
166→ create Create a machine
167→ destroy Destroy one or more machines
168→ domains List dns domains
169→ list List machines
170→ list-domain List domain records
171→ projects List projects
172→ ssh-keys List ssh keys
173→ status Machine status
174→ types List configured machine types
175→ version Display version
176→```
177→
178→### Commands
179→
180→#### create
181→
182→Create a new machine on DigitalOcean. By default, the machine is initialized with cloud-init (using the specified `--type` from config) and a DNS A record is created.
183→
184→```
185→$ machine create --help
186→Usage: machine create [OPTIONS]
187→
188→ Create a machine
189→
190→Options:
191→ -n, --name <MACHINE-NAME> Name for new machine [required]
192→ -t, --tag <TAG-TEXT> Tag to be applied to new machine
193→ -m, --type <MACHINE-TYPE> Machine type from config (required if --initialize)
194→ -r, --region <REGION-CODE> Region (overrides config default)
195→ -s, --machine-size <MACHINE-SLUG>
196→ Machine size (overrides config default)
197→ -s, --image <IMAGE-NAME> Image (overrides config default)
198→ --wait-for-ip / --no-wait-for-ip Wait for IP address assignment (default: off)
199→ --update-dns / --no-update-dns Create DNS A record (default: on)
200→ --initialize / --no-initialize Initialize with cloud-init (default: on)
201→ -h, --help Show this message and exit.
202→```
203→
204→Supported regions: `NYC1`, `NYC3`, `AMS3`, `SFO2`, `SFO3`, `SGP1`, `LON1`, `FRA1`, `TOR1`, `BLR1`, `SYD1`
205→
206→When `--update-dns` is enabled (the default), the command waits for the droplet's IP address and creates an A record in the configured `dns-zone` with a 5-minute TTL.
207→
208→When `--initialize` is enabled (the default), a cloud-config user-data payload is generated that creates a non-root user with sudo access, installs the SSH key, and optionally downloads and runs an initialization script.
209→
210→If a `project` is configured, the machine is automatically assigned to that DigitalOcean project.
211→
212→#### destroy
213→
214→Destroy one or more machines by droplet ID. By default, requires confirmation and deletes associated DNS records.
215→
216→```
217→$ machine destroy --help
218→Usage: machine destroy [OPTIONS] [DROPLET-IDS]...
219→
220→ Destroy one or more machines
221→
222→Options:
223→ --confirm / --no-confirm Require confirmation (default: on)
224→ --delete-dns / --no-delete-dns Delete associated DNS records (default: on)
225→ --all Include machines not created by this tool
226→ or by other sessions
227→ -h, --help Show this message and exit.
228→```
229→
230→Confirmation requires typing exactly `YES` (not "y", "yes", or "Yes"). Use `--no-confirm` to skip.
231→
232→Safety checks prevent destroying machines that were not created by this tool or that belong to a different session, unless `--all` is specified.
233→
234→#### list
235→
236→List machines with optional filtering.
237→
238→```
239→$ machine list --help
240→Usage: machine list [OPTIONS]
241→
242→ List machines
243→
244→Options:
245→ --id <MACHINE-ID> Filter by id
246→ -n, --name <MACHINE-NAME> Filter by name
247→ -t, --tag <TAG-TEXT> Filter by tag
248→ -m, --type <MACHINE-TYPE> Filter by type
249→ -r, --region <REGION> Filter by region
250→ -o, --output <FORMAT> Output format (json)
251→ -q, --quiet Only display machine IDs
252→ --unique Return an error if more than one match
253→ --all Include all machines from all sessions
254→ -h, --help Show this message and exit.
255→```
256→
257→Output formats:
258→- Default: `name (id, region, type): ip_address`
259→- `--quiet`: droplet IDs only
260→- `--output json`: JSON array with id, name, tags, region, ip, type
261→
262→#### status
263→
264→Check the status of machines, including querying a custom status endpoint.
265→
266→```
267→$ machine status --help
268→Usage: machine status [OPTIONS]
269→
270→ Machine status
271→
272→Options:
273→ --id <MACHINE-ID> Filter by id
274→ -n, --name <MACHINE-NAME> Filter by name
275→ -t, --tag <TAG-TEXT> Filter by tag
276→ -m, --type <MACHINE-TYPE> Filter by type
277→ -r, --region <REGION> Filter by region
278→ -o, --output <FORMAT> Output format (json)
279→ --status-check <CHECK> Status check to perform (default: cloud-init-status)
280→ -q, --quiet Only display machine IDs
281→ --all Include all machines from all sessions
282→ -h, --help Show this message and exit.
283→```
284→
285→In addition to the DigitalOcean droplet status, this command queries each machine at `http://<ip>:4242/cgi-bin/<status-check>` (default: `cloud-init-status`) for custom status information. If the endpoint is unreachable, the status is reported as `UNKNOWN`.
286→
287→#### list-domain
288→
289→List DNS records within a domain zone.
290→
291→```
292→$ machine list-domain --help
293→Usage: machine list-domain [OPTIONS] [ZONE]
294→
295→ List domain records
296→
297→Options:
298→ -n, --name <RECORD-NAME> Filter by record name
299→ -m, --type <RECORD-TYPE> Filter by record type (default: A and AAAA, use * for all)
300→ -o, --output <FORMAT> Output format (json)
301→ -q, --quiet Only display record names
302→ --all Include all records from all sessions
303→ -h, --help Show this message and exit.
304→```
305→
306→If `ZONE` is omitted, uses the `dns-zone` from config. By default, only shows A and AAAA records associated with machines from the current session.
307→
308→Output formats:
309→- Default: `name\ttype\tdata`
310→- `--quiet`: record names only
311→- `--output json`: JSON array with id, droplet info, name, fqdn, zone, data, ttl, type
312→
313→#### domains
314→
315→List all DNS domains in your DigitalOcean account. Takes no options.
316→
317→#### ssh-keys
318→
319→List SSH keys in your DigitalOcean account. Output format: `id: name (fingerprint)`
320→
321→#### projects
322→
323→List DigitalOcean project names. Takes no options.
324→
325→#### types
326→
327→List all machine types defined in the config file (from the `machines` section). Takes no options.
328→lint: uv run flake8
lint: uv run ruff check machine/ uv run ruff format --check machine/
This creates a `.venv` virtual environment and installs all dependencies (including dev tools like flake8 and black).
This creates a `.venv` virtual environment and installs all dependencies (including dev tools like ruff).
Run the linter: ``` uv run flake8 ```
Run the linter: ``` uv run ruff check machine/ ```
- **Formatter**: black (line length 132) - **Linter**: flake8 (max line length 132, max complexity 25, E203 ignored) - Config in `.flake8`
- **Linter & Formatter**: ruff (line length 132, max complexity 25, E203 ignored) - Config in `pyproject.toml` under `[tool.ruff]`
uv run flake8 # Lint ./sh/lint.sh --fix # Auto-format with black, then lint
uv run ruff check machine/ # Lint ./sh/lint.sh --fix # Auto-format with ruff, then lint
make lint # Alias for uv run flake8
make lint # Alias for ruff check + format check
The file /home/david/projects/stirlingbridge/machine/Makefile has been updated successfully.
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully.
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully.
The file /home/david/projects/stirlingbridge/machine/CLAUDE.md has been updated successfully.
The file /home/david/projects/stirlingbridge/machine/CLAUDE.md has been updated successfully.
The file /home/david/projects/stirlingbridge/machine/CLAUDE.md has been updated successfully.
{
"pattern": "flake8|black",
"path": "/home/david/projects/stirlingbridge/machine/.github",
"output_mode": "files_with_matches"
}uv run ruff check machine/ All checks passed! uv run ruff format --check machine/ 21 files already formatted ============================= test session starts ============================== platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 rootdir: /home/david/projects/stirlingbridge/machine configfile: pyproject.toml collected 35 items / 6 deselected / 29 selected tests/test_cli_integration.py ........ [ 27%] tests/test_config.py ............. [ 72%] tests/test_input_validation.py ........ [100%] ======================= 29 passed, 6 deselected in 5.57s =======================
All passing. Here's a summary of the changes:
Migration from flake8+black to ruff:
- pyproject.toml — replaced flake8 and black dev deps with ruff; added [tool.ruff] config sections matching the previous flake8/black settings
- sh/lint.sh — updated to use ruff check and ruff format
- .flake8 — deleted (config now in pyproject.toml)
- Makefile — updated lint target
- README.md and CLAUDE.md — updated references
One minor formatting change was applied by ruff to cloud_config.py (quote style inside an f-string). All lint checks and tests pass. Would you like me to commit this?