Skip to content

Commit 6495ff3

Browse files
committed
Add Ruff linting for the Python scripts
The _scripts/ helpers had no linter, and the pre-commit hook's Ruff stage called a bare `ruff` — which is not on PATH here, since nothing Python is installed globally. That stage has been exiting 127 and blocking every commit that touches a .py file. It now goes through uv. Markdown and files/ are excluded: Ruff 0.16 formats Python code fences inside .md, and post code blocks are published prose set deliberately at two-space indent. The notebooks under files/ are frozen artifacts of old posts and produce hundreds of findings nobody will act on. The B023 fix in publish.py is a real bug: the listRecords retry lambda captured the loop variable by reference, so a retry on any page but the last would have re-requested with the final page's cursor. Changes: - Add Ruff to the _scripts dev group with the shared rule set. - Add make lint-scripts and format-scripts; call the former from CI. - Point the CI test step at make test-scripts rather than restating it. - Fix the hook to run Ruff via uv run --project _scripts. - Convert html_diff.py to pathlib throughout; verified end to end against synthetic trees. - Bind the listRecords params per iteration. - Drop ten unused pytest.raises bindings and assorted dead locals. - Bump setup-uv to v8.
1 parent 33974ad commit 6495ff3

32 files changed

Lines changed: 867 additions & 362 deletions

.github/workflows/jekyll.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,13 @@ jobs:
8383
python-version: '3.12'
8484

8585
- name: Install uv
86-
uses: astral-sh/setup-uv@v7
86+
uses: astral-sh/setup-uv@v8
87+
88+
- name: Lint Python scripts
89+
run: make lint-scripts
8790

8891
- name: Run script tests
89-
working-directory: _scripts
90-
run: uv run pytest
92+
run: make test-scripts
9193

9294
# Fast, credential-free subset of the build job's --site-dir
9395
# cross-check (keep the two invocations' flags in sync).
@@ -123,7 +125,7 @@ jobs:
123125
uses: actions/configure-pages@v6
124126

125127
- name: Install uv
126-
uses: astral-sh/setup-uv@v7
128+
uses: astral-sh/setup-uv@v8
127129

128130
- name: Build with Jekyll
129131
run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Jekyll-based static site (alexgude.com) running in Docker.
1515
- **Build:** `make build` (Production build to `_site/`).
1616
- **Deps:** `make lock` (Update Gemfile.lock via Docker).
1717
- **Lint:** `make lint` / `make format-all`.
18+
- **Lint Python:** `make lint-scripts` (Ruff check + format check) /
19+
`make format-scripts` (Ruff autofix + format).
1820
- **Format MD:** `make format-md` (Run Prettier on all Markdown files).
1921
- **Scripts:** `make scripts` (List available Python scripts with descriptions).
2022
- **Test Scripts:** `make test-scripts` (Run Python script tests via pytest).

Makefile

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ DOCKER_RUN := docker run --rm $(DOCKER_RUN_OPTS) -v $(PWD):$(MOUNT) -w $(MOUNT)
3333
TEST ?= $(shell find _tests -type f -name 'test_*.rb' -not -name 'test_helper.rb')
3434

3535
# Tier 1: Daily drivers
36-
.PHONY: serve build test test-scripts lint clean debug scripts
36+
.PHONY: serve build test test-scripts lint lint-scripts format-scripts clean debug scripts
3737

3838
# Tier 2: Command variants
3939
.PHONY: serve-drafts serve-profile test-cov test-summary lint-fix check-links check-liquid doc-index doc-show
@@ -209,6 +209,20 @@ lint: image-build
209209
@echo "Running linter..."
210210
@$(DOCKER_RUN) bundle exec rubocop
211211

212+
# Run Ruff (lint + format check) on all Python scripts.
213+
# Runs from _scripts/ so its pyproject.toml supplies the config and the
214+
# relative excludes resolve against the repo root.
215+
lint-scripts:
216+
@echo "Running Ruff on Python scripts..."
217+
@cd _scripts && uv run ruff check ..
218+
@cd _scripts && uv run ruff format --check ..
219+
220+
# Apply Ruff formatting and safe fixes to all Python scripts.
221+
format-scripts:
222+
@echo "Formatting Python scripts with Ruff..."
223+
@cd _scripts && uv run ruff format ..
224+
@cd _scripts && uv run ruff check --fix ..
225+
212226
# Build the site and check for broken links/HTML issues.
213227
check-links: build
214228
@echo "Checking generated site for broken links and HTML issues..."
@@ -244,7 +258,8 @@ hooks-install: image-build prettier-image-build _bin/pre-commit.sh
244258
@cp _bin/pre-commit.sh .git/hooks/pre-commit
245259
@chmod +x .git/hooks/pre-commit
246260
@echo "Pre-commit hook installed at .git/hooks/pre-commit."
247-
@echo "It will run RuboCop on staged .rb files and Prettier on staged .md files."
261+
@echo "It will run RuboCop on staged .rb files, Prettier on staged .md files,"
262+
@echo "and Ruff on staged .py files."
248263

249264
# Run RuboCop --autocorrect on all Ruby files to establish a clean formatting baseline.
250265
# This target modifies files on the host via the volume mount.

_bin/pre-commit.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,12 @@ if [ ${#STAGED_PY_FILES[@]} -gt 0 ]; then
116116
printf '%s\n' "${STAGED_PY_FILES[@]}"
117117
echo "---"
118118

119-
ruff check --fix "${STAGED_PY_FILES[@]}"
119+
# Ruff lives in the _scripts uv project; --project makes it resolvable from
120+
# the repo root, and its pyproject.toml supplies the config for every path.
121+
uv run --project _scripts ruff check --fix "${STAGED_PY_FILES[@]}"
120122
RUFF_CHECK_EXIT=$?
121123

122-
ruff format "${STAGED_PY_FILES[@]}"
124+
uv run --project _scripts ruff format "${STAGED_PY_FILES[@]}"
123125
RUFF_FORMAT_EXIT=$?
124126

125127
git add -- "${STAGED_PY_FILES[@]}"

_scripts/atproto/publish.py

Lines changed: 31 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@
2929
import re
3030
import sys
3131
import time
32-
from datetime import datetime, timezone
32+
from collections.abc import Callable
33+
from datetime import UTC, datetime
3334
from pathlib import Path
35+
from typing import Any
3436
from zoneinfo import ZoneInfo
35-
from typing import Any, Callable
3637

3738
import requests
3839
import yaml
@@ -53,13 +54,13 @@
5354

5455
class PublishError(Exception):
5556
"""Fatal pipeline error; main() converts it to a message and exit 1."""
57+
58+
5659
_CONFIG_FILE = Path(__file__).parent.parent.parent / "_config.yml"
5760

5861
POST_FILENAME_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-(.+)\.md$")
5962

60-
MANAGED_FIELDS = frozenset(
61-
{"$type", "site", "path", "title", "description", "tags", "publishedAt"}
62-
)
63+
MANAGED_FIELDS = frozenset({"$type", "site", "path", "title", "description", "tags", "publishedAt"})
6364

6465
# Publication fields the pipeline manages; values come from _config.yml so
6566
# site metadata has a single home (title → name, description, url).
@@ -78,13 +79,14 @@ def desired_publication_record(config: dict) -> dict[str, Any]:
7879
"preferences": {"showInDiscover": True},
7980
}
8081

82+
8183
# ---------------------------------------------------------------------------
8284
# Config helpers
8385
# ---------------------------------------------------------------------------
8486

8587

8688
def load_config(config_path: Path = _CONFIG_FILE) -> dict:
87-
with open(config_path, encoding="utf-8") as fh:
89+
with config_path.open(encoding="utf-8") as fh:
8890
return yaml.safe_load(fh) or {}
8991

9092

@@ -199,9 +201,7 @@ def _extract_frontmatter_strict(text: str) -> dict:
199201
return {}
200202
fm = yaml.safe_load(m.group(1)) or {}
201203
if not isinstance(fm, dict):
202-
raise ValueError(
203-
f"front matter is not a YAML mapping (got {type(fm).__name__})"
204-
)
204+
raise ValueError(f"front matter is not a YAML mapping (got {type(fm).__name__})")
205205
return fm
206206

207207

@@ -336,7 +336,8 @@ def list_records(self, collection: str) -> list[dict]:
336336
if cursor:
337337
params["cursor"] = cursor
338338
resp = self._retry_request(
339-
lambda: self._http.get(
339+
# Bind params per iteration; the loop rebinds it each page.
340+
lambda params=params: self._http.get(
340341
f"{self._pds}/xrpc/com.atproto.repo.listRecords",
341342
params=params,
342343
headers=self._auth(),
@@ -438,14 +439,11 @@ def _verify_publication(client: AtprotoClient, publication_uri: str) -> dict:
438439
uri_did, rkey = m.group(1), m.group(2)
439440
if uri_did != client.did:
440441
raise PublishError(
441-
f"publication_uri belongs to {uri_did} but the session is "
442-
f"authenticated as {client.did}"
442+
f"publication_uri belongs to {uri_did} but the session is authenticated as {client.did}"
443443
)
444444
remote = client.get_record(client.did, "site.standard.publication", rkey)
445445
if remote is None:
446-
raise PublishError(
447-
f"publication record does not exist on the PDS: {publication_uri}"
448-
)
446+
raise PublishError(f"publication record does not exist on the PDS: {publication_uri}")
449447
return remote
450448

451449

@@ -460,9 +458,7 @@ def _records_differ(local: dict, remote: dict) -> bool:
460458
def _document_sources(
461459
posts_dir: Path, books_dir: Path | None
462460
) -> list[tuple[Path, Callable[[Path], dict | None]]]:
463-
sources: list[tuple[Path, Callable[[Path], dict | None]]] = [
464-
(posts_dir, parse_post)
465-
]
461+
sources: list[tuple[Path, Callable[[Path], dict | None]]] = [(posts_dir, parse_post)]
466462
if books_dir is not None:
467463
bd = books_dir
468464
sources.append((bd, lambda f: parse_book(f, bd)))
@@ -474,6 +470,7 @@ def _source_files(src_dir: Path) -> list[Path]:
474470
All candidate markdown files under src_dir, recursively, mirroring
475471
Jekyll: directories starting with '_' (templates) are not read.
476472
"""
473+
477474
def included(f: Path) -> bool:
478475
rel = f.relative_to(src_dir)
479476
if any(part.startswith("_") for part in rel.parts[:-1]):
@@ -519,10 +516,7 @@ def _collect_documents(posts_dir: Path, books_dir: Path | None):
519516
if not rec["title"].strip():
520517
errors.append("missing or empty 'title'")
521518
if not rec.get("publishedAt"):
522-
errors.append(
523-
"cannot derive publishedAt from the 'date' front matter "
524-
"or filename"
525-
)
519+
errors.append("cannot derive publishedAt from the 'date' front matter or filename")
526520
if rec["path"] in seen_paths:
527521
errors.append(
528522
f"duplicate path {rec['path']!r} "
@@ -557,9 +551,7 @@ def sync_documents(
557551
# --- Keep the publication record itself in sync with _config.yml ---
558552
desired_pub = desired_publication_record(config)
559553
remote_pub_value = remote_publication["value"]
560-
pub_subset = {
561-
k: v for k, v in remote_pub_value.items() if k in PUBLICATION_MANAGED_FIELDS
562-
}
554+
pub_subset = {k: v for k, v in remote_pub_value.items() if k in PUBLICATION_MANAGED_FIELDS}
563555
if pub_subset != desired_pub:
564556
merged_pub = dict(remote_pub_value)
565557
merged_pub.update(desired_pub)
@@ -580,9 +572,7 @@ def sync_documents(
580572
local: dict[str, tuple[Path, dict]] = {}
581573
for doc_file, rec, errors in _collect_documents(posts_dir, books_dir):
582574
if errors:
583-
raise PublishError(
584-
"; ".join(f"{doc_file.name}: {msg}" for msg in errors)
585-
)
575+
raise PublishError("; ".join(f"{doc_file.name}: {msg}" for msg in errors))
586576
if rec is None:
587577
raise AssertionError("collector yielded no record and no errors")
588578
rec["site"] = publication_uri
@@ -610,9 +600,7 @@ def sync_documents(
610600
data_map: dict[str, str] = {}
611601
for path_key, (rkey, _, _) in remote.items():
612602
if path_key in local:
613-
data_map[path_key] = (
614-
f"at://{client.did}/site.standard.document/{rkey}"
615-
)
603+
data_map[path_key] = f"at://{client.did}/site.standard.document/{rkey}"
616604

617605
# --- Diff and sync ---
618606
created = updated = unchanged = orphaned = 0
@@ -640,15 +628,11 @@ def sync_documents(
640628
merged[key] = local_rec[key]
641629
elif key in merged:
642630
del merged[key]
643-
merged["updatedAt"] = datetime.now(timezone.utc).strftime(
644-
"%Y-%m-%dT%H:%M:%SZ"
645-
)
631+
merged["updatedAt"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
646632
if dry_run:
647633
print(f"[dry-run] Would update: {path_key}")
648634
else:
649-
client.put_record(
650-
"site.standard.document", rkey, merged, swap_cid=cid
651-
)
635+
client.put_record("site.standard.document", rkey, merged, swap_cid=cid)
652636
print(f"Updated: {path_key}")
653637
updated += 1
654638
else:
@@ -670,13 +654,13 @@ def sync_documents(
670654
if dry_run:
671655
print(f"[dry-run] Would write {len(data_map)} entries to {data_out}")
672656
else:
673-
output = {path_key: data_map[path_key] for path_key in sorted(local) if path_key in data_map}
657+
output = {
658+
path_key: data_map[path_key] for path_key in sorted(local) if path_key in data_map
659+
}
674660
data_out.parent.mkdir(parents=True, exist_ok=True)
675661
data_out.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
676662

677-
print(
678-
f"created={created} updated={updated} unchanged={unchanged} orphaned={orphaned}"
679-
)
663+
print(f"created={created} updated={updated} unchanged={unchanged} orphaned={orphaned}")
680664

681665

682666
# ---------------------------------------------------------------------------
@@ -790,8 +774,7 @@ def validate_documents(
790774
error_count += 1
791775
elif wk.read_text(encoding="utf-8") != expected_publication_uri:
792776
print(
793-
f"ERROR: {wk} content does not match "
794-
"standard_site.publication_uri",
777+
f"ERROR: {wk} content does not match standard_site.publication_uri",
795778
file=sys.stderr,
796779
)
797780
error_count += 1
@@ -821,9 +804,7 @@ def delete_orphans(
821804
local_paths: set[str] = set()
822805
for doc_file, rec, errors in _collect_documents(posts_dir, books_dir):
823806
if errors:
824-
raise PublishError(
825-
"; ".join(f"{doc_file.name}: {msg}" for msg in errors)
826-
)
807+
raise PublishError("; ".join(f"{doc_file.name}: {msg}" for msg in errors))
827808
if rec is None:
828809
raise AssertionError("collector yielded no record and no errors")
829810
local_paths.add(rec["path"])
@@ -868,9 +849,7 @@ def delete_orphans(
868849
def init_publication(client: AtprotoClient, config: dict) -> None:
869850
existing = get_publication_uri(config)
870851
if existing:
871-
raise PublishError(
872-
f"publication_uri is already set in _config.yml: {existing!r}"
873-
)
852+
raise PublishError(f"publication_uri is already set in _config.yml: {existing!r}")
874853
# The config guard is local-only; also check the PDS so running twice
875854
# before committing cannot create two publication records.
876855
remote_pubs = client.list_records("site.standard.publication")
@@ -880,9 +859,7 @@ def init_publication(client: AtprotoClient, config: dict) -> None:
880859
f"publication record(s) already exist on the PDS: {uris} — "
881860
"put the URI in _config.yml instead of creating another"
882861
)
883-
at_uri = client.create_record(
884-
"site.standard.publication", desired_publication_record(config)
885-
)
862+
at_uri = client.create_record("site.standard.publication", desired_publication_record(config))
886863
print(f"Publication record created: {at_uri}")
887864
print("Add this URI to _config.yml → standard_site.publication_uri and commit.")
888865

@@ -911,9 +888,7 @@ def _dispatch(argv: list[str] | None = None) -> None:
911888
parser = argparse.ArgumentParser(description=__doc__)
912889
sub = parser.add_subparsers(dest="cmd", required=True)
913890

914-
pub_p = sub.add_parser(
915-
"publish", help="Sync posts and books to PDS document records"
916-
)
891+
pub_p = sub.add_parser("publish", help="Sync posts and books to PDS document records")
917892
pub_p.add_argument("--posts-dir", required=True, type=Path)
918893
pub_p.add_argument("--books-dir", required=True, type=Path)
919894
pub_p.add_argument("--data-out", required=True, type=Path)
@@ -923,8 +898,7 @@ def _dispatch(argv: list[str] | None = None) -> None:
923898

924899
val_p = sub.add_parser(
925900
"validate",
926-
help="Validate posts and books for AT Protocol compatibility "
927-
"(no network, no credentials)",
901+
help="Validate posts and books for AT Protocol compatibility (no network, no credentials)",
928902
)
929903
val_p.add_argument("--posts-dir", required=True, type=Path)
930904
val_p.add_argument("--books-dir", required=True, type=Path)

_scripts/content/backdate_rating.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import subprocess
2525
import sys
2626
from datetime import datetime
27-
27+
from pathlib import Path
2828

2929
RATING_RE = re.compile(r"^rating:\s*(.+)$", re.MULTILINE)
3030
DATE_RE = re.compile(r"^date:\s*(.+)$", re.MULTILINE)
@@ -49,7 +49,7 @@ def get_repo_root() -> str:
4949

5050
def repo_relative_path(filepath: str) -> str:
5151
"""Convert any path to a repo-relative path for consistent git operations."""
52-
abs_path = os.path.abspath(filepath)
52+
abs_path = Path(filepath).resolve()
5353
return os.path.relpath(abs_path, REPO_ROOT)
5454

5555

@@ -196,7 +196,7 @@ def format_datetime(iso_str: str) -> str:
196196

197197
def update_date(filepath: str, new_date: str, dry_run: bool) -> bool:
198198
"""Update the date field in the file's front matter. Returns True if changed."""
199-
with open(filepath, "r") as f:
199+
with Path(filepath).open() as f:
200200
content = f.read()
201201

202202
date_match = DATE_RE.search(content)
@@ -209,15 +209,13 @@ def update_date(filepath: str, new_date: str, dry_run: bool) -> bool:
209209
print(f" SKIP (already correct): {filepath}")
210210
return False
211211

212-
new_content = (
213-
content[: date_match.start(1)] + new_date + content[date_match.end(1) :]
214-
)
212+
new_content = content[: date_match.start(1)] + new_date + content[date_match.end(1) :]
215213

216214
if dry_run:
217215
print(f" DRY RUN: {filepath}")
218216
print(f" {old_date} -> {new_date}")
219217
else:
220-
with open(filepath, "w") as f:
218+
with Path(filepath).open("w") as f:
221219
f.write(new_content)
222220
print(f" UPDATED: {filepath}")
223221
print(f" {old_date} -> {new_date}")
@@ -279,7 +277,7 @@ def main():
279277

280278
# Skip files that already have a full timestamp (not just a bare date)
281279
if not args.force:
282-
with open(filepath, "r") as f:
280+
with Path(filepath).open() as f:
283281
content = f.read()
284282
date_match = DATE_RE.search(content)
285283
if date_match and not BARE_DATE_RE.match(date_match.group(1).strip()):

0 commit comments

Comments
 (0)