diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 590b1f5..0e1a544 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,11 +5,6 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 5 - - package-ecosystem: "pip" - directory: "/backend" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - package-ecosystem: "pip" directory: "/" schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28f72d7..0641a59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,32 +148,10 @@ jobs: - name: Install uv run: pip install uv - - name: uv lock --check (root PEP 621 manifest) + - name: Synchronize manifests and regenerate lockfiles for Dependabot + if: env.IS_DEPENDABOT == 'true' run: | - if ! uv lock --check; then - if [ "$IS_DEPENDABOT" = "true" ]; then - echo "::notice::root uv.lock out of sync with pyproject.toml (expected on Dependabot PRs); regenerating." - uv lock - else - echo "::error::root uv.lock is out of sync with pyproject.toml." - echo "Regenerate it with: uv lock" - exit 1 - fi - fi - - - name: uv lock --check (backend) - working-directory: backend - run: | - if ! uv lock --check; then - if [ "$IS_DEPENDABOT" = "true" ]; then - echo "::notice::backend/uv.lock out of sync (expected on Dependabot PRs); regenerating." - uv lock - else - echo "::error::backend/uv.lock does not match backend/pyproject.toml." - echo "Regenerate it with: cd backend && uv lock" - exit 1 - fi - fi + python scripts/sync_dependencies.py - name: Commit regenerated lockfiles to the Dependabot branch if: >- @@ -183,7 +161,10 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add uv.lock backend/uv.lock backend/requirements.lock + git add pyproject.toml backend/pyproject.toml setup.cfg backend/setup.cfg \ + backend/requirements-runtime.txt backend/requirements-dev.txt \ + backend/requirements.lock uv.lock backend/uv.lock \ + frontend/package.json frontend/package-lock.json if git diff --cached --quiet; then echo "Lockfiles already in sync; nothing to commit." else @@ -191,6 +172,23 @@ jobs: git push || echo "::warning::Could not push regenerated lockfiles; continuing verification against the regenerated files." fi + - name: uv lock --check (root PEP 621 manifest) + run: | + if ! uv lock --check; then + echo "::error::root uv.lock is out of sync with pyproject.toml." + echo "Regenerate it with: uv lock" + exit 1 + fi + + - name: uv lock --check (backend) + working-directory: backend + run: | + if ! uv lock --check; then + echo "::error::backend/uv.lock does not match backend/pyproject.toml." + echo "Regenerate it with: cd backend && uv lock" + exit 1 + fi + - name: Verify dependency manifests and lockfiles agree run: python scripts/check_dependency_sync.py diff --git a/Makefile b/Makefile index 8fb1273..45b321a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Oyster360 developer shortcuts. The same commands live in README.md; run # `make help` to list targets. -.PHONY: help ci-local deps-check setup bootstrap fresh-start up down logs ps migrate seed verify quality test test-unit test-backend test-frontend test-integration test-e2e lint +.PHONY: help ci-local deps-check deps-sync sync-deps setup bootstrap fresh-start up down logs ps migrate seed verify quality test test-unit test-backend test-frontend test-integration test-e2e lint setup: ## Prepare a fresh clone (creates .env and starts the Docker stack) ./scripts/bootstrap.sh @@ -49,6 +49,11 @@ ci-local: ## Reproduce every CI gate from a fresh clone (installs deps, no Docke deps-check: ## Verify dependency manifests and lockfiles agree python3 scripts/check_dependency_sync.py +deps-sync: ## Synchronize dependency manifests and regenerate lockfiles + python3 scripts/sync_dependencies.py + +sync-deps: deps-sync ## Alias for deps-sync + verify: ## Run the local checks CI enforces (assumes deps are already installed) python3 scripts/check_dependency_sync.py cd backend && flake8 app tests --count --show-source --statistics --exclude=.venv,__pycache__,alembic/versions diff --git a/README.md b/README.md index 3971af3..3a75b75 100644 --- a/README.md +++ b/README.md @@ -837,6 +837,11 @@ CI additionally runs `uv lock --check` on both `uv.lock` files, `pip install --d ### Refreshing the locks ```bash +# Automatic: synchronize manifests across the repo and regenerate all lockfiles +make deps-sync +make deps-check + +# Or manually: # Python: after editing pyproject.toml dependency pins uv pip compile pyproject.toml --extra dev --output-file backend/requirements.lock uv lock && (cd backend && uv lock) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3070858..4a09e8e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "pyotp==2.10.0", "qrcode[pil]==8.2", "requests==2.34.2", - "sentry-sdk==2.68.0", + "sentry-sdk==2.68.1", "python-json-logger==4.2.0", ] diff --git a/backend/requirements-runtime.txt b/backend/requirements-runtime.txt index 0427444..fc7ab0e 100644 --- a/backend/requirements-runtime.txt +++ b/backend/requirements-runtime.txt @@ -15,5 +15,5 @@ stripe==15.5.1 pyotp==2.10.0 qrcode[pil]==8.2 requests==2.34.2 -sentry-sdk==2.68.0 +sentry-sdk==2.68.1 python-json-logger==4.2.0 diff --git a/backend/requirements.lock b/backend/requirements.lock index dd472a2..8565f11 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -177,7 +177,7 @@ requests==2.34.2 # via # oyster360 (pyproject.toml) # stripe -sentry-sdk==2.68.0 +sentry-sdk==2.68.1 # via oyster360 (pyproject.toml) six==1.17.0 # via python-dateutil diff --git a/backend/setup.cfg b/backend/setup.cfg index cf2c34a..980e367 100644 --- a/backend/setup.cfg +++ b/backend/setup.cfg @@ -25,7 +25,7 @@ install_requires = pyotp==2.10.0 qrcode[pil]==8.2 requests==2.34.2 - sentry-sdk==2.68.0 + sentry-sdk==2.68.1 python-json-logger==4.2.0 [flake8] diff --git a/backend/tests/test_dependency_sync.py b/backend/tests/test_dependency_sync.py new file mode 100644 index 0000000..6dab159 --- /dev/null +++ b/backend/tests/test_dependency_sync.py @@ -0,0 +1,139 @@ +"""Tests for dependency synchronisation and manifest consistency checks.""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +if str(ROOT / "scripts") not in sys.path: + sys.path.insert(0, str(ROOT / "scripts")) + +from check_dependency_sync import ( # noqa: E402 + canonical, + check_node_manifests, + check_python_manifests, + errors as check_errors, + parse_requirements, +) +from sync_dependencies import ( # noqa: E402 + format_toml_array, + merge_dependency_lists, + parse_dep, + parse_version, + update_setup_cfg, + update_toml_dependencies, +) + + +def test_canonical_names(): + assert canonical("sentry-sdk") == "sentry-sdk" + assert canonical("sentry_sdk") == "sentry-sdk" + assert canonical("PyYAML") == "pyyaml" + assert canonical("argon2-cffi-bindings") == "argon2-cffi-bindings" + + +def test_parse_version(): + assert parse_version("2.68.1") > parse_version("2.68.0") + assert parse_version("1.0.0") > parse_version("0.9.9") + assert parse_version("2.9.0.post0") > parse_version("2.9.0") + assert parse_version("2.0.0b1") < parse_version("2.0.0") + assert parse_version("==2.68.1") == parse_version("2.68.1") + + +def test_parse_dep(): + canon, raw, op, ver = parse_dep("sentry-sdk==2.68.1") + assert canon == "sentry-sdk" + assert raw == "sentry-sdk" + assert op == "==" + assert ver == "2.68.1" + + canon, raw, op, ver = parse_dep("qrcode[pil]==8.2") + assert canon == "qrcode" + assert raw == "qrcode[pil]" + assert op == "==" + assert ver == "8.2" + + canon, raw, op, ver = parse_dep("starlette>=1.0.1") + assert canon == "starlette" + assert raw == "starlette" + assert op == ">=" + assert ver == "1.0.1" + + +def test_merge_dependency_lists(): + list_root = ["fastapi==0.141.1", "sentry-sdk==2.68.1", "qrcode[pil]==8.2"] + list_backend = ["fastapi==0.141.1", "sentry-sdk==2.68.0", "qrcode==8.2"] + + merged = merge_dependency_lists(list_root, list_backend) + assert "sentry-sdk==2.68.1" in merged + assert "qrcode[pil]==8.2" in merged + assert "fastapi==0.141.1" in merged + + # Test reverse precedence — higher version wins regardless of argument position + merged_reverse = merge_dependency_lists(list_backend, list_root) + assert "sentry-sdk==2.68.1" in merged_reverse + assert "qrcode[pil]==8.2" in merged_reverse + + +def test_update_toml_dependencies(): + content = """[project] +name = "oyster360" +dependencies = [ + "fastapi==0.141.1", + "sentry-sdk==2.68.0", +] + +[project.optional-dependencies] +dev = [ + "flake8==7.3.0", +] +""" + updated = update_toml_dependencies( + content, + ["fastapi==0.141.1", "sentry-sdk==2.68.1"], + ["flake8==7.3.0", "pytest==9.1.1"], + ) + assert '"sentry-sdk==2.68.1",' in updated + assert '"pytest==9.1.1",' in updated + + +def test_update_setup_cfg(): + content = """[metadata] +name = oyster360 + +[options] +python_requires = >=3.11 +install_requires = + fastapi==0.141.1 + sentry-sdk==2.68.0 + +[flake8] +select = E9 +""" + updated = update_setup_cfg( + content, + ["fastapi==0.141.1", "sentry-sdk==2.68.1"], + ) + assert "sentry-sdk==2.68.1" in updated + assert "[flake8]" in updated + + +def test_parse_requirements(): + lines = [ + "# A comment", + "fastapi==0.141.1", + "qrcode[pil]==8.2", + "-r requirements-runtime.txt", + "", + ] + parsed = parse_requirements(lines) + assert parsed["fastapi"] == "0.141.1" + assert parsed["qrcode"] == "8.2" + assert "requirements-runtime.txt" not in parsed + + +def test_current_manifests_pass_checks(): + check_errors.clear() + check_python_manifests() + check_node_manifests() + assert check_errors == [], f"Unexpected dependency sync errors: {check_errors}" diff --git a/backend/uv.lock b/backend/uv.lock index ea764f7..c857a5e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1267,7 +1267,7 @@ requires-dist = [ { name = "qrcode", extras = ["pil"], specifier = "==8.2" }, { name = "redis", specifier = "==8.1.0" }, { name = "requests", specifier = "==2.34.2" }, - { name = "sentry-sdk", specifier = "==2.68.0" }, + { name = "sentry-sdk", specifier = "==2.68.1" }, { name = "sqlalchemy", specifier = "==2.0.52" }, { name = "starlette", specifier = ">=1.0.1" }, { name = "stripe", specifier = "==15.5.1" }, @@ -1865,15 +1865,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.68.0" +version = "2.68.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/e7/c504a4bd2d95df2e0ab73714a9161ff1cf6ff1486922685e5f46dfd9eba8/sentry_sdk-2.68.1.tar.gz", hash = "sha256:6a97895230b04bc35d4d8d2e51e3b9e21902dfb0086ccf1f131a80c15c7b997a", size = 1019262, upload-time = "2026-08-24T13:09:38.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, + { url = "https://files.pythonhosted.org/packages/2b/28/465ad9382be98f2172e691f5836cf87f936773913ad7ab85ba1ba1d6706e/sentry_sdk-2.68.1-py3-none-any.whl", hash = "sha256:775b78871783a0ffd758276ad01b3bb2b1ebcdad8f9d2f0a7723f76b73c99b65", size = 520851, upload-time = "2026-08-24T13:09:36.186Z" }, ] [[package]] diff --git a/docs/testing.md b/docs/testing.md index b0988a3..fc03db6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -86,8 +86,9 @@ test genuinely needs infrastructure, mark it `@pytest.mark.integration`. 1. **Lockfile reproducibility** — `uv lock --check` (root and backend), `scripts/check_dependency_sync.py`, `pip install --dry-run -r - requirements.lock`, and `npm ci --dry-run`. On Dependabot PRs the uv - lockfiles are regenerated and pushed back to the branch instead of failing. + requirements.lock`, and `npm ci --dry-run`. On Dependabot PRs manifests + and lockfiles are automatically synchronized, regenerated, and pushed back + to the branch instead of failing. 2. **Lint** — flake8 (backend) and `eslint --max-warnings=0` (frontend). 3. **Typecheck** — `mypy app` and `tsc --noEmit`. Both are blocking. 4. **Test** — fast offline lane, full suite with the 80% coverage gate, Vitest diff --git a/pyproject.toml b/pyproject.toml index 8c640f1..ae04eb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "pyotp==2.10.0", "qrcode[pil]==8.2", "requests==2.34.2", - "sentry-sdk==2.68.0", + "sentry-sdk==2.68.1", "python-json-logger==4.2.0", ] diff --git a/scripts/check_dependency_sync.py b/scripts/check_dependency_sync.py index d805582..564bcbb 100755 --- a/scripts/check_dependency_sync.py +++ b/scripts/check_dependency_sync.py @@ -182,6 +182,20 @@ def check_node_manifests() -> None: def main() -> int: + import argparse + parser = argparse.ArgumentParser(description="Verify that dependency manifests and lockfiles agree.") + parser.add_argument("--fix", action="store_true", help="Automatically synchronize manifests and lockfiles.") + args = parser.parse_args() + + if args.fix: + if str(ROOT / "scripts") not in sys.path: + sys.path.insert(0, str(ROOT / "scripts")) + from sync_dependencies import sync_python_manifests, recompile_lockfiles, sync_node_manifests + sync_python_manifests() + recompile_lockfiles() + sync_node_manifests() + errors.clear() + check_python_manifests() check_node_manifests() @@ -189,6 +203,7 @@ def main() -> int: print("Dependency manifests are out of sync:\n", file=sys.stderr) for item in errors: print(f" - {item}", file=sys.stderr) + print("\nFix automatically with: make deps-sync (or python scripts/sync_dependencies.py)", file=sys.stderr) return 1 print("Dependency manifests and lockfiles are consistent:") diff --git a/scripts/sync_dependencies.py b/scripts/sync_dependencies.py new file mode 100755 index 0000000..f022e4e --- /dev/null +++ b/scripts/sync_dependencies.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Synchronize dependency manifests and lockfiles across the Oyster360 repo. + +This script keeps all Python manifests and lockfiles consistent: + - pyproject.toml (root PEP 621 manifest) + - backend/pyproject.toml + - setup.cfg + - backend/setup.cfg + - backend/requirements-runtime.txt + - backend/requirements-dev.txt + - backend/requirements.lock (via `uv pip compile`) + - uv.lock (via `uv lock`) + - backend/uv.lock (via `cd backend && uv lock`) + - frontend/package-lock.json (via `npm install --package-lock-only` if needed) + +When dependency versions differ across manifests (e.g. after Dependabot bumps a +package in root pyproject.toml or backend/pyproject.toml), this script resolves +the conflict by adopting the updated/higher version, updating all manifests, +and recompiling the lockfiles. + +Usage: + python scripts/sync_dependencies.py [--no-compile] [--dry-run] +""" +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover + print("Python 3.11+ is required to run the dependency synchronizer.", file=sys.stderr) + raise SystemExit(1) + +ROOT = Path(__file__).resolve().parent.parent +SPEC_RE = re.compile(r"^\s*([A-Za-z0-9._-]+(?:\[[^\]]+\])?)\s*([<>=~!^].*)?$") + + +def canonical(name: str) -> str: + """PEP 503 normalised distribution name.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def parse_dep(dep_str: str) -> tuple[str, str, str, str]: + """Parse a requirement string into (canonical_base, raw_name, operator, version).""" + dep_str = dep_str.strip() + match = SPEC_RE.match(dep_str) + if not match: + return (canonical(dep_str), dep_str, "", "") + raw_name = match.group(1) + base_name = canonical(re.sub(r"\[.*\]", "", raw_name)) + spec = (match.group(2) or "").strip() + op_match = re.match(r"^([<>=~!^]+)\s*(.*)$", spec) + if op_match: + return (base_name, raw_name, op_match.group(1), op_match.group(2).strip()) + return (base_name, raw_name, "", "") + + +def parse_version(v: str) -> tuple: + """Parse a version string for comparison (supports PEP 440 basic & pre-release formats).""" + v = re.sub(r"^[<>=~!^ ]+", "", v).strip() + match = re.match(r"^(\d+(?:\.\d+)*)(?:[-._]?(a|b|rc|alpha|beta|dev|post|preview)(\d*))?", v) + if not match: + return (v,) + base_nums = tuple(int(x) for x in match.group(1).split(".")) + tag = match.group(2) + tag_num = int(match.group(3)) if match.group(3) else 0 + if not tag: + return base_nums + (0,) # final release + if tag == "post": + return base_nums + (1, tag_num) + return base_nums + (-1, tag, tag_num) # pre-release + + +def load_pyproject(path: Path) -> dict: + with path.open("rb") as handle: + return tomllib.load(handle) + + +def merge_dependency_lists(*dep_lists: list[str]) -> list[str]: + """Merge lists of dependency strings, picking the highest / most specific version.""" + merged: dict[str, tuple[str, str, str]] = {} + order: list[str] = [] + + for d_list in dep_lists: + for dep in d_list: + stripped = dep.strip() + if not stripped or stripped.startswith(("#", "-")): + continue + canon, raw_name, op, ver = parse_dep(stripped) + if canon not in merged: + merged[canon] = (raw_name, op, ver) + order.append(canon) + else: + curr_raw, curr_op, curr_ver = merged[canon] + chosen_raw = raw_name if "[" in raw_name else curr_raw + chosen_op = op or curr_op + chosen_ver = curr_ver + if ver and curr_ver: + if parse_version(ver) > parse_version(curr_ver): + chosen_ver = ver + chosen_op = op or curr_op + chosen_raw = raw_name + elif ver and not curr_ver: + chosen_ver = ver + chosen_op = op or curr_op + chosen_raw = raw_name + + merged[canon] = (chosen_raw, chosen_op, chosen_ver) + + result = [] + for canon in order: + raw_name, op, ver = merged[canon] + if op and ver: + result.append(f"{raw_name}{op}{ver}") + elif ver: + result.append(f"{raw_name}=={ver}") + else: + result.append(raw_name) + return result + + +def format_toml_array(name: str, items: list[str], indent: str = " ") -> str: + lines = [f"{name} = ["] + for item in items: + lines.append(f'{indent}"{item}",') + lines.append("]") + return "\n".join(lines) + + +def update_toml_dependencies( + content: str, runtime_deps: list[str], dev_deps: list[str] | None = None +) -> str: + deps_block = format_toml_array("dependencies", runtime_deps) + content = re.sub(r"dependencies\s*=\s*\[[\s\S]*?\n\]", deps_block, content) + if dev_deps is not None: + dev_block = format_toml_array("dev", dev_deps) + content = re.sub(r"dev\s*=\s*\[[\s\S]*?\n\]", dev_block, content) + return content + + +def update_setup_cfg(content: str, runtime_deps: list[str]) -> str: + lines = ["install_requires ="] + for dep in runtime_deps: + lines.append(f" {dep}") + req_block = "\n".join(lines) + return re.sub(r"install_requires\s*=(?:\n[ \t]+[^\n]+)+", req_block, content) + + +def sync_python_manifests(dry_run: bool = False) -> tuple[list[str], list[str]]: + root_pyproject_path = ROOT / "pyproject.toml" + backend_pyproject_path = ROOT / "backend" / "pyproject.toml" + setup_cfg_path = ROOT / "setup.cfg" + backend_setup_cfg_path = ROOT / "backend" / "setup.cfg" + runtime_txt_path = ROOT / "backend" / "requirements-runtime.txt" + dev_txt_path = ROOT / "backend" / "requirements-dev.txt" + + root_proj = load_pyproject(root_pyproject_path).get("project", {}) + backend_proj = load_pyproject(backend_pyproject_path).get("project", {}) + + runtime_from_txt = [ + line.strip() + for line in runtime_txt_path.read_text().splitlines() + if line.strip() and not line.strip().startswith(("#", "-")) + ] + dev_from_txt = [ + line.strip() + for line in dev_txt_path.read_text().splitlines() + if line.strip() and not line.strip().startswith(("#", "-")) + ] + + merged_runtime = merge_dependency_lists( + root_proj.get("dependencies", []), + backend_proj.get("dependencies", []), + runtime_from_txt, + ) + + merged_dev = merge_dependency_lists( + root_proj.get("optional-dependencies", {}).get("dev", []), + backend_proj.get("optional-dependencies", {}).get("dev", []), + dev_from_txt, + ) + + print(f"Synchronizing {len(merged_runtime)} runtime and {len(merged_dev)} dev dependencies...") + + # 1. Update root pyproject.toml + root_content = root_pyproject_path.read_text() + new_root_content = update_toml_dependencies(root_content, merged_runtime, merged_dev) + if not dry_run and new_root_content != root_content: + root_pyproject_path.write_text(new_root_content) + print(" Updated pyproject.toml") + + # 2. Update backend/pyproject.toml + backend_content = backend_pyproject_path.read_text() + new_backend_content = update_toml_dependencies(backend_content, merged_runtime, merged_dev) + if not dry_run and new_backend_content != backend_content: + backend_pyproject_path.write_text(new_backend_content) + print(" Updated backend/pyproject.toml") + + # 3. Update setup.cfg + if setup_cfg_path.exists(): + setup_content = setup_cfg_path.read_text() + new_setup_content = update_setup_cfg(setup_content, merged_runtime) + if not dry_run and new_setup_content != setup_content: + setup_cfg_path.write_text(new_setup_content) + print(" Updated setup.cfg") + + # 4. Update backend/setup.cfg + if backend_setup_cfg_path.exists(): + b_setup_content = backend_setup_cfg_path.read_text() + new_b_setup_content = update_setup_cfg(b_setup_content, merged_runtime) + if not dry_run and new_b_setup_content != b_setup_content: + backend_setup_cfg_path.write_text(new_b_setup_content) + print(" Updated backend/setup.cfg") + + # 5. Update backend/requirements-runtime.txt + new_runtime_txt = "\n".join(merged_runtime) + "\n" + if not dry_run and new_runtime_txt != runtime_txt_path.read_text(): + runtime_txt_path.write_text(new_runtime_txt) + print(" Updated backend/requirements-runtime.txt") + + # 6. Update backend/requirements-dev.txt + dev_header = ( + "# Development / CI-only dependencies. Install everything needed for local\n" + "# development and tests with:\n" + "# pip install -r requirements.txt -r requirements-dev.txt\n" + "# Runtime dependencies live in requirements-runtime.txt and the fully resolved,\n" + "# reproducible set (including transitives) is pinned in requirements.lock.\n" + "-r requirements-runtime.txt\n" + ) + new_dev_txt = dev_header + "\n".join(merged_dev) + "\n" + if not dry_run and new_dev_txt != dev_txt_path.read_text(): + dev_txt_path.write_text(new_dev_txt) + print(" Updated backend/requirements-dev.txt") + + return merged_runtime, merged_dev + + +def find_uv_executable() -> str | None: + # Check PATH + uv_path = shutil.which("uv") + if uv_path: + return uv_path + # Check .venv + venv_uv = ROOT / ".venv" / "bin" / "uv" + if venv_uv.exists() and venv_uv.is_file(): + return str(venv_uv) + # Check if python -m uv works + try: + subprocess.run( + [sys.executable, "-m", "uv", "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return f"{sys.executable} -m uv" + except Exception: + pass + return None + + +def run_command(cmd: list[str] | str, cwd: Path = ROOT, shell: bool = False) -> None: + print(f" Running: {cmd if isinstance(cmd, str) else ' '.join(cmd)}") + subprocess.run(cmd, cwd=cwd, check=True, shell=shell) + + +def recompile_lockfiles(dry_run: bool = False) -> None: + if dry_run: + print("Dry run: skipping lockfile compilation.") + return + + uv_cmd = find_uv_executable() + if not uv_cmd: + print( + "Warning: `uv` not found. Skipping lockfile recompilation. " + "Install uv (`pip install uv`) and run `make deps-sync`.", + file=sys.stderr, + ) + return + + is_module = uv_cmd.startswith(f"{sys.executable} -m uv") + uv_base = [sys.executable, "-m", "uv"] if is_module else [uv_cmd] + + print("Recompiling backend/requirements.lock...") + run_command( + uv_base + + [ + "pip", + "compile", + "pyproject.toml", + "--extra", + "dev", + "--output-file", + "backend/requirements.lock", + ] + ) + + print("Regenerating root uv.lock...") + run_command(uv_base + ["lock"]) + + print("Regenerating backend/uv.lock...") + run_command(uv_base + ["lock"], cwd=ROOT / "backend") + + +def sync_node_manifests(dry_run: bool = False) -> None: + package_json = ROOT / "frontend" / "package.json" + package_lock = ROOT / "frontend" / "package-lock.json" + if not package_json.exists() or not package_lock.exists(): + return + + try: + pkg = json.loads(package_json.read_text()) + lock = json.loads(package_lock.read_text()) + except Exception: + return + + needs_sync = False + if lock.get("name") != pkg.get("name") or lock.get("version") != pkg.get("version"): + needs_sync = True + + root_entry = lock.get("packages", {}).get("", {}) + for section in ("dependencies", "devDependencies"): + if pkg.get(section, {}) != root_entry.get(section, {}): + needs_sync = True + break + + if needs_sync and not dry_run: + npm_bin = shutil.which("npm") + if npm_bin: + print("Syncing frontend/package-lock.json...") + run_command([npm_bin, "install", "--package-lock-only"], cwd=ROOT / "frontend") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Synchronize dependency manifests and lockfiles.") + parser.add_argument( + "--no-compile", action="store_true", help="Skip recompiling lockfiles with uv/npm" + ) + parser.add_argument( + "--dry-run", action="store_true", help="Check and print actions without modifying files" + ) + args = parser.parse_args() + + sync_python_manifests(dry_run=args.dry_run) + + if not args.no_compile: + recompile_lockfiles(dry_run=args.dry_run) + sync_node_manifests(dry_run=args.dry_run) + + print("\nDependency synchronization complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.cfg b/setup.cfg index 10c93a4..8f63f58 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ install_requires = pyotp==2.10.0 qrcode[pil]==8.2 requests==2.34.2 - sentry-sdk==2.68.0 + sentry-sdk==2.68.1 python-json-logger==4.2.0 [flake8] diff --git a/uv.lock b/uv.lock index 7f1cf91..19483f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1267,7 +1267,7 @@ requires-dist = [ { name = "qrcode", extras = ["pil"], specifier = "==8.2" }, { name = "redis", specifier = "==8.1.0" }, { name = "requests", specifier = "==2.34.2" }, - { name = "sentry-sdk", specifier = "==2.68.0" }, + { name = "sentry-sdk", specifier = "==2.68.1" }, { name = "sqlalchemy", specifier = "==2.0.52" }, { name = "starlette", specifier = ">=1.0.1" }, { name = "stripe", specifier = "==15.5.1" }, @@ -1865,15 +1865,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.68.0" +version = "2.68.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/e7/c504a4bd2d95df2e0ab73714a9161ff1cf6ff1486922685e5f46dfd9eba8/sentry_sdk-2.68.1.tar.gz", hash = "sha256:6a97895230b04bc35d4d8d2e51e3b9e21902dfb0086ccf1f131a80c15c7b997a", size = 1019262, upload-time = "2026-08-24T13:09:38.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, + { url = "https://files.pythonhosted.org/packages/2b/28/465ad9382be98f2172e691f5836cf87f936773913ad7ab85ba1ba1d6706e/sentry_sdk-2.68.1-py3-none-any.whl", hash = "sha256:775b78871783a0ffd758276ad01b3bb2b1ebcdad8f9d2f0a7723f76b73c99b65", size = 520851, upload-time = "2026-08-24T13:09:36.186Z" }, ] [[package]]