diff --git a/.config/hooks/pre-push b/.config/hooks/pre-push index 35dc919cf..6fdbc64cd 100755 --- a/.config/hooks/pre-push +++ b/.config/hooks/pre-push @@ -14,10 +14,12 @@ REMOTE_REF="origin/$BRANCH" if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then CLIENT_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- client/ 2>/dev/null | wc -l) SERVER_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/ 2>/dev/null | wc -l) + TOOLING_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- tooling/ pyproject.toml 2>/dev/null | wc -l) else # New branch or no remote ref — fall through to directory checks CLIENT_CHANGED=1 SERVER_CHANGED=1 + TOOLING_CHANGED=1 fi # --- GDScript parse check (headless Godot) --- @@ -97,6 +99,20 @@ else echo "pre-push: WARNING — cargo not found or server/ missing, skipping Rust lint" fi +# --- Python lint (ruff) --- +if [ "$TOOLING_CHANGED" -eq 0 ]; then + echo "pre-push: no tooling/ changes — skipping Python lint" +elif command -v ruff >/dev/null 2>&1 && [ -d "$REPO_ROOT/tooling" ]; then + echo "pre-push: checking Python (ruff)..." + if ! (cd "$REPO_ROOT" && ruff check tooling/ 2>&1); then + ERRORS=$((ERRORS + 1)) + else + echo "pre-push: ruff — OK" + fi +else + echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')" +fi + if [ "$ERRORS" -gt 0 ]; then echo "" echo "pre-push: $ERRORS check(s) failed. Push aborted." diff --git a/CHANGELOG.md b/CHANGELOG.md index 08113b76b..9d83edb28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- Shared Python module `tooling/db/common.py` — centralizes DB path resolution, config loading, and WAL-mode connection setup (#777) +- `ensure_venv()` auto-activation for Python tooling scripts with third-party dependencies (#777) +- `make lint-python` target running ruff on `tooling/` (#777) +- `make setup-venv` target for Python venv creation (#777) +- Python/ruff lint block in pre-push hook (#777) + +### Fixed +- Pre-existing `NameError` in `decisions_sync.py` (missing `import os`) resolved via shared module extraction (#777) +- Unused imports removed from `assign-astro-ids.py`, `generate-star-map.py`, `test_quaternius_raw.py` (#777) + ## [v0.1.30] — 2026-04-05 ### Added diff --git a/Makefile b/Makefile index e9deea17a..d4a2a11c0 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) -.PHONY: help setup build check-protocol client server game stop test lint ci ci-client ci-server clean \ +.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \ decisions-sync decisions-coverage decisions-active decisions-orphan \ db-backup db-install validate-content check-fact-ids setup-hooks \ audit atlas-verify \ @@ -22,7 +22,8 @@ GODOT_VERSION ?= 4.6 help: @echo "The Settled Reach — Development Commands" @echo "" - @echo " make setup Install dev dependencies (Rust, Godot, tooling)" + @echo " make setup Install dev dependencies (Rust, Godot, tooling, venv)" + @echo " make setup-venv Create .venv and install Python tooling deps" @echo " make build Build client and server" @echo " make game Build and run the full game (server + client)" @echo " make stop Stop any running server instance" @@ -33,7 +34,8 @@ help: @echo " make test-ipc-protocol Layer 2: mock IPC protocol tests" @echo " make test-ipc-integration Layer 3: real subprocess round-trip" @echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)" - @echo " make lint Run all linters" + @echo " make lint Run all linters (server, client, python)" + @echo " make lint-python Run ruff on tooling/" @echo " make ci Run full CI pipeline locally" @echo " make ci-client Run client CI checks" @echo " make ci-server Run server CI checks" @@ -82,7 +84,7 @@ help: # --- Setup --- -setup: setup-rust setup-godot setup-tooling setup-hooks decisions-sync +setup: setup-rust setup-godot setup-tooling setup-venv setup-hooks decisions-sync @echo "Dev environment ready." setup-rust: @@ -103,6 +105,11 @@ setup-hooks: @git config core.hooksPath .config/hooks @echo "Git hooks path set to .config/hooks" +setup-venv: + @python3 -m venv .venv + @.venv/bin/pip install -e ".[dev]" --quiet + @echo "Venv ready at .venv — activate with: source .venv/bin/activate" + # --- Build --- check-protocol: @@ -222,7 +229,10 @@ clean-imports: # --- Lint --- -lint: lint-server lint-client +lint: lint-server lint-client lint-python + +lint-python: + ruff check tooling/ lint-server: cd server && cargo clippy -- -D warnings diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..5bfab990d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "settled-reach-tooling" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "PyYAML", + "jsonschema", + "numpy", +] + +[project.optional-dependencies] +dev = [ + # ruff 0.15.9 — checked clean against NVD + OSV, no CVEs on record (2026-04-05) + "ruff==0.15.9", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +# E9xx: Runtime syntax/encoding errors +# F401: Unused imports +# F811: Redefinition of unused name +# F821: Undefined name (catches missing imports like bare `os`) +select = ["E9", "F401", "F811", "F821"] diff --git a/tooling/assign-astro-ids.py b/tooling/assign-astro-ids.py index f483f322b..b41c3c843 100644 --- a/tooling/assign-astro-ids.py +++ b/tooling/assign-astro-ids.py @@ -17,7 +17,6 @@ Also writes spectral class from the real catalog into each node. """ import json -import hashlib from pathlib import Path STAR_MAP_PATH = Path(__file__).parent.parent / "docs/design/star-map.json" diff --git a/tooling/db/audio_batch.py b/tooling/db/audio_batch.py index 419bce4f6..8b6af3c11 100644 --- a/tooling/db/audio_batch.py +++ b/tooling/db/audio_batch.py @@ -8,12 +8,18 @@ Usage: python3 audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing] """ -import json -import math -import os -import struct -import subprocess import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import ensure_venv # noqa: E402 + +ensure_venv() + +import json +import os +import subprocess import wave import numpy as np diff --git a/tooling/db/audio_connector.py b/tooling/db/audio_connector.py index a19473813..89b78c6af 100644 --- a/tooling/db/audio_connector.py +++ b/tooling/db/audio_connector.py @@ -10,14 +10,22 @@ Usage: python3 audio_connector.py health """ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import ensure_venv # noqa: E402 + +ensure_venv() + import json import os +import shutil import subprocess -import sys import time import urllib.error import urllib.request -import shutil CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") diff --git a/tooling/db/common.py b/tooling/db/common.py new file mode 100644 index 000000000..707c10494 --- /dev/null +++ b/tooling/db/common.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Shared utilities for Settled Reach DB tooling scripts. + +Provides the standard DB path resolution, config loading, and connection +helpers used across ticket, sprint, sqlite_connector, and decisions_sync. +""" + +import json +import os +import sqlite3 +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +SCRIPT_DIR = Path(__file__).resolve().parent +CONFIG_PATH = SCRIPT_DIR / "config.json" +WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def resolve_db_path() -> Path: + """Return the ticket database path. + + Prefers the SR_DB_PATH environment variable (absolute path). + Falls back to /settledreach.db — the shared DB location + used when working directly in the main repo checkout. + """ + if os.environ.get("SR_DB_PATH"): + return Path(os.environ["SR_DB_PATH"]).resolve() + return (WORKTREE_ROOT / ".." / "settledreach.db").resolve() + + +def load_config() -> dict: + """Load tooling/db/config.json and inject the resolved DB path.""" + with open(CONFIG_PATH) as f: + cfg = json.load(f) + cfg["sqlite_db_resolved"] = str(resolve_db_path()) + return cfg + + +def get_connection(cfg: dict) -> sqlite3.Connection: + """Return a WAL-mode SQLite connection with foreign keys enabled.""" + conn = sqlite3.connect(cfg["sqlite_db_resolved"]) + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA foreign_keys=ON;") + conn.row_factory = sqlite3.Row + return conn + + +def ensure_venv() -> None: + """Re-exec into the project .venv Python if not already running there. + + Call this at the top of any script that uses third-party packages, + before those imports. Pure-stdlib scripts (ticket, sprint, + sqlite_connector, decisions_sync) do not need it. + + Usage:: + + from common import ensure_venv + ensure_venv() + import numpy as np # third-party import follows + """ + venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python" + + # Already running inside the venv — nothing to do. + if Path(sys.executable).resolve() == venv_python.resolve(): + return + + # .venv not set up yet — fail with a helpful message. + if not venv_python.exists(): + print( + "error: .venv not found — run `make setup-venv` first.", + file=sys.stderr, + ) + sys.exit(1) + + # Re-exec into the venv Python, preserving all arguments. + os.execv(str(venv_python), [str(venv_python)] + sys.argv) diff --git a/tooling/db/decisions_sync.py b/tooling/db/decisions_sync.py index b1ea7d0e2..5eca7e6ec 100644 --- a/tooling/db/decisions_sync.py +++ b/tooling/db/decisions_sync.py @@ -17,38 +17,16 @@ import sqlite3 import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import CONFIG_PATH, WORKTREE_ROOT, get_connection, load_config # noqa: E402 + # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- -SCRIPT_DIR = Path(__file__).resolve().parent -CONFIG_PATH = SCRIPT_DIR / "config.json" -WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() SCHEMA_PATH = WORKTREE_ROOT / "db" / "schema.sql" DECISIONS_DIR = WORKTREE_ROOT / "decisions" -# Database path: SR_DB_PATH env var (absolute), or fallback to parent directory heuristic. -DB_PATH = Path(os.environ["SR_DB_PATH"]).resolve() if os.environ.get("SR_DB_PATH") else (WORKTREE_ROOT / ".." / "settledreach.db").resolve() - -# --------------------------------------------------------------------------- -# Config / DB (same pattern as sqlite_connector.py) -# --------------------------------------------------------------------------- - - -def load_config(): - """Load config.json and resolve the SQLite database path.""" - with open(CONFIG_PATH, "r") as f: - cfg = json.load(f) - cfg["sqlite_db_resolved"] = str(DB_PATH) - return cfg - - -def get_connection(cfg): - """Return an sqlite3 connection with WAL mode and foreign keys enabled.""" - conn = sqlite3.connect(cfg["sqlite_db_resolved"]) - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA foreign_keys=ON;") - conn.row_factory = sqlite3.Row - return conn # --------------------------------------------------------------------------- diff --git a/tooling/db/image_connector.py b/tooling/db/image_connector.py index 6c0884991..e7eb3e1c1 100755 --- a/tooling/db/image_connector.py +++ b/tooling/db/image_connector.py @@ -10,10 +10,18 @@ Usage: python3 image_connector.py generate "prompt" [--output file.png] [--aspect 1:1] [--size 1K] [--input image.png] """ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import ensure_venv # noqa: E402 + +ensure_venv() + import base64 import json import os -import sys import urllib.error import urllib.request diff --git a/tooling/db/qdrant_connector.py b/tooling/db/qdrant_connector.py index 7abd29436..e0559cb96 100755 --- a/tooling/db/qdrant_connector.py +++ b/tooling/db/qdrant_connector.py @@ -14,14 +14,20 @@ Usage: Requires only Python 3 stdlib (no pip dependencies). """ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import ensure_venv # noqa: E402 + +ensure_venv() + import hashlib import json -import os import re -import sys import urllib.error import urllib.request -from pathlib import Path # --------------------------------------------------------------------------- # Paths / Config diff --git a/tooling/db/sprint b/tooling/db/sprint index f8ebe4dc6..2df0e5f63 100755 --- a/tooling/db/sprint +++ b/tooling/db/sprint @@ -15,17 +15,16 @@ Usage: """ import json -import os -import sqlite3 import subprocess import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import WORKTREE_ROOT, get_connection, load_config # noqa: E402 + SCRIPT_DIR = Path(__file__).resolve().parent TICKET_CLI = str(SCRIPT_DIR / "ticket") -WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() -# Database path: SR_DB_PATH env var (absolute), or fallback to parent directory heuristic. -DB_PATH = Path(os.environ["SR_DB_PATH"]).resolve() if os.environ.get("SR_DB_PATH") else (WORKTREE_ROOT / ".." / "settledreach.db").resolve() PROJECT_ROOT = WORKTREE_ROOT REMINDER = """--- @@ -48,15 +47,6 @@ def run_ticket(*args): return {"ok": False, "error": f"Bad ticket output: {result.stdout[:200]}"} -def get_connection(): - """Direct DB connection for lifecycle mutations only.""" - conn = sqlite3.connect(str(DB_PATH)) - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA foreign_keys=ON;") - conn.row_factory = sqlite3.Row - return conn - - def parse_flags(args, known_flags): """Parse --flag value pairs from args, return (flags_dict, positional_args).""" flags = {} @@ -325,7 +315,7 @@ def cmd_start(args): sys.exit(1) # Activate - conn = get_connection() + conn = get_connection(load_config()) conn.execute( "UPDATE sprints SET status='active', start_date=date('now') WHERE id=?", (sprint["id"],) @@ -369,7 +359,7 @@ def cmd_stop(args): picked_up = [t for t in incomplete if t["status"] in picked_up_statuses] auto_closed = [] if picked_up: - conn = get_connection() + conn = get_connection(load_config()) for t in picked_up: conn.execute("UPDATE tickets SET status='done' WHERE id=?", (t["id"],)) auto_closed.append(t) @@ -380,7 +370,7 @@ def cmd_stop(args): incomplete = [t for t in incomplete if t["status"] not in picked_up_statuses] # Complete the sprint - conn = get_connection() + conn = get_connection(load_config()) conn.execute( "UPDATE sprints SET status='completed', end_date=date('now') WHERE id=?", (sprint["id"],) @@ -602,7 +592,7 @@ def cmd_prepare(args): # Create sprint record if it doesn't exist if sprint.get("status") == "new": - conn = get_connection() + conn = get_connection(load_config()) conn.execute( "INSERT INTO sprints (id, name, status) VALUES (?, ?, 'planning')", (sprint["id"], f"Sprint {sprint['id']}") @@ -664,7 +654,7 @@ def cmd_prepare(args): print() # Decision coverage gaps - conn = get_connection() + conn = get_connection(load_config()) cursor = conn.execute(""" SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' diff --git a/tooling/db/sqlite_connector.py b/tooling/db/sqlite_connector.py index b3a16ebdb..c41be4dab 100755 --- a/tooling/db/sqlite_connector.py +++ b/tooling/db/sqlite_connector.py @@ -11,38 +11,15 @@ Usage: """ import json -import os import sqlite3 import sys from pathlib import Path -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import WORKTREE_ROOT, get_connection, load_config # noqa: E402 -SCRIPT_DIR = Path(__file__).resolve().parent -CONFIG_PATH = SCRIPT_DIR / "config.json" -WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() SCHEMA_PATH = WORKTREE_ROOT / "db" / "schema.sql" -# Database path: SR_DB_PATH env var (absolute), or fallback to parent directory heuristic. -DB_PATH = Path(os.environ["SR_DB_PATH"]).resolve() if os.environ.get("SR_DB_PATH") else (WORKTREE_ROOT / ".." / "settledreach.db").resolve() - - -def load_config(): - """Load config.json and resolve the SQLite database path.""" - with open(CONFIG_PATH, "r") as f: - cfg = json.load(f) - cfg["sqlite_db_resolved"] = str(DB_PATH) - return cfg - - -def get_connection(cfg): - """Return an sqlite3 connection with WAL mode and foreign keys enabled.""" - conn = sqlite3.connect(cfg["sqlite_db_resolved"]) - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA foreign_keys=ON;") - conn.row_factory = sqlite3.Row - return conn # --------------------------------------------------------------------------- @@ -180,9 +157,8 @@ Usage: All output is JSON on stdout. Errors also use JSON with {{"ok": false, "error": "..."}}. -Config: {config} Schema: {schema} -""".format(config=CONFIG_PATH, schema=SCHEMA_PATH) +""".format(schema=SCHEMA_PATH) def main(): diff --git a/tooling/db/ticket b/tooling/db/ticket index 777b0e3fa..b398b0ae9 100755 --- a/tooling/db/ticket +++ b/tooling/db/ticket @@ -23,31 +23,12 @@ All output is JSON on stdout. """ import json -import os -import sqlite3 import sys from pathlib import Path -SCRIPT_DIR = Path(__file__).resolve().parent -CONFIG_PATH = SCRIPT_DIR / "config.json" -WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() -# Database path: SR_DB_PATH env var (absolute), or fallback to parent directory heuristic. -DB_PATH = Path(os.environ["SR_DB_PATH"]).resolve() if os.environ.get("SR_DB_PATH") else (WORKTREE_ROOT / ".." / "settledreach.db").resolve() +sys.path.insert(0, str(Path(__file__).resolve().parent)) - -def load_config(): - with open(CONFIG_PATH, "r") as f: - cfg = json.load(f) - cfg["sqlite_db_resolved"] = str(DB_PATH) - return cfg - - -def get_connection(cfg): - conn = sqlite3.connect(cfg["sqlite_db_resolved"]) - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA foreign_keys=ON;") - conn.row_factory = sqlite3.Row - return conn +from common import get_connection, load_config # noqa: E402 def query(conn, sql, params=()): diff --git a/tooling/db/trellis_connector.py b/tooling/db/trellis_connector.py index a49035254..8dc21f093 100755 --- a/tooling/db/trellis_connector.py +++ b/tooling/db/trellis_connector.py @@ -36,15 +36,22 @@ Gradio API Parameter Reference (TRELLIS v1, microsoft/TRELLIS): - CUDA device mismatch after crash: restart the container to clear GPU state """ -import base64 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from common import ensure_venv # noqa: E402 + +ensure_venv() + import json import os import shutil -import sys import time import urllib.error -import urllib.request import urllib.parse +import urllib.request CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") diff --git a/tooling/generate-star-map.py b/tooling/generate-star-map.py index bc8d7176b..af8378ddf 100644 --- a/tooling/generate-star-map.py +++ b/tooling/generate-star-map.py @@ -13,10 +13,8 @@ Standard library only. No external dependencies. from __future__ import annotations import json -import math import random import sys -import os import argparse from collections import defaultdict, deque from pathlib import Path diff --git a/tooling/test_quaternius_raw.py b/tooling/test_quaternius_raw.py index dcd03af91..576240d20 100644 --- a/tooling/test_quaternius_raw.py +++ b/tooling/test_quaternius_raw.py @@ -1,6 +1,5 @@ """Load original Quaternius body + hair as-is and export a combined GLB for visual inspection.""" import sys -import os import bpy if __name__ == "__main__":