diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4cf4db..7a18793 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,9 @@ jobs: - name: Check code style with ruff run: make lint + - name: Reject git-ref dependencies + run: make check-deps + docker-test: runs-on: ubuntu-latest if: github.event_name != 'pull_request' || github.head_ref != 'release-please--branches--main' diff --git a/Makefile b/Makefile index 250536c..9a641a6 100644 --- a/Makefile +++ b/Makefile @@ -330,8 +330,11 @@ format-check: # Check code formatting typecheck: # Check types with mypy uv run mypy src/ +check-deps: # Reject git-ref dependencies in pyproject.toml + uv run python scripts/check_git_deps.py + # Quality gates (used in CI) -quality-check: format-check lint typecheck test-coverage test-handler +quality-check: format-check lint typecheck check-deps test-coverage test-handler # Code intelligence commands index: # Generate code intelligence index diff --git a/pyproject.toml b/pyproject.toml index 692ebe1..b06a30a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,8 @@ dependencies = [ "huggingface_hub>=0.32.0", "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", - # TEMPORARY (SLS-360 coordinated release): this worker uses - # runpod_flash.runtime.module_loader, which is not in a published runpod-flash - # yet. Pin to the flash branch until runpod/flash#352 is merged and released, - # then REVERT to a released constraint (e.g. "runpod-flash>=1.18.0"). - "runpod-flash @ git+https://github.com/runpod/flash.git@deanquinanola/sls-360-local-module-bundling", + # 1.19.0 is the first release containing runpod_flash.runtime.module_loader. + "runpod-flash>=1.19.0", ] [dependency-groups] diff --git a/scripts/check_git_deps.py b/scripts/check_git_deps.py new file mode 100644 index 0000000..e97d642 --- /dev/null +++ b/scripts/check_git_deps.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Fail if pyproject.toml declares any dependency from a git ref. + +Why this exists: a git-ref dependency carries no version, so the Bump Runtime +Dependencies workflow has nothing to bump and silently stops tracking that +package. Worse, the ref is not immutable -- when the referenced branch is +deleted, every `uv lock` and `uv sync` on main hard-fails with +"couldn't find remote ref". That happened for real: a temporary pin to a flash +feature branch broke main for two weeks (PR #103). + +Usage: + python scripts/check_git_deps.py [pyproject.toml] + +Exits 0 when clean, 1 when a git dependency is found or the file is unreadable. + +Temporary pins during a coordinated cross-repo release are the legitimate use +case this blocks. When you need one, land it behind an explicit, time-boxed +exception rather than deleting this check -- and revert it the moment the +depended-on version is published. +""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path +from typing import Any + +GIT_MARKERS = ("git+", "git://") +DEFAULT_PYPROJECT = "pyproject.toml" + + +def _is_git_requirement(requirement: str) -> bool: + """True if a PEP 508 requirement string points at a git repository.""" + return any(marker in requirement for marker in GIT_MARKERS) + + +def _scan_requirements(requirements: Any, label: str) -> list[str]: + """Collect offending requirement strings from one dependency list.""" + if not isinstance(requirements, list): + return [] + return [ + f"{label}: {req}" + for req in requirements + if isinstance(req, str) and _is_git_requirement(req) + ] + + +def find_git_dependencies(pyproject_text: str) -> list[str]: + """Return a labelled description of every git-sourced dependency. + + Covers the four places a git ref can enter a uv project: project + dependencies, optional-dependency extras, PEP 735 dependency groups, and + `[tool.uv.sources]`. + + Returns an empty list when the file declares no git dependencies. + """ + data = tomllib.loads(pyproject_text) + offenders: list[str] = [] + + project = data.get("project", {}) + offenders += _scan_requirements(project.get("dependencies"), "project.dependencies") + + for extra, requirements in (project.get("optional-dependencies") or {}).items(): + offenders += _scan_requirements(requirements, f"project.optional-dependencies.{extra}") + + for group, requirements in (data.get("dependency-groups") or {}).items(): + offenders += _scan_requirements(requirements, f"dependency-groups.{group}") + + sources = (data.get("tool", {}).get("uv", {}) or {}).get("sources") or {} + for name, source in sources.items(): + if isinstance(source, dict) and "git" in source: + offenders.append(f"tool.uv.sources.{name}: git = {source['git']}") + + return offenders + + +def main(argv: list[str] | None = None) -> int: + """Check a pyproject.toml and report offenders. Returns a process exit code.""" + args = argv if argv is not None else sys.argv[1:] + target = Path(args[0]) if args else Path(DEFAULT_PYPROJECT) + + try: + text = target.read_text() + except OSError as e: + print(f"error: cannot read {target}: {e}") + return 1 + + try: + offenders = find_git_dependencies(text) + except tomllib.TOMLDecodeError as e: + print(f"error: cannot parse {target}: {e}") + return 1 + + if not offenders: + print(f"{target}: no git dependencies") + return 0 + + print(f"{target}: git dependencies are not allowed on main:") + for offender in offenders: + print(f" {offender}") + print( + "\nA git ref has no version for the bump workflow to track, and breaks " + "every uv lock once the branch is deleted.\n" + "Depend on a published release instead." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_check_git_deps.py b/tests/unit/test_check_git_deps.py new file mode 100644 index 0000000..6afab35 --- /dev/null +++ b/tests/unit/test_check_git_deps.py @@ -0,0 +1,153 @@ +"""Tests for the CI guard that rejects git dependencies in pyproject.toml. + +A git-ref dependency has no version for the Bump Runtime Dependencies workflow +to bump, so it silently defeats that workflow -- and it hard-fails every +`uv lock` once the referenced branch is deleted. See PR #103. +""" + +import importlib.util +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_checker(): + """Load scripts/check_git_deps.py, which is not on the package path.""" + path = REPO_ROOT / "scripts" / "check_git_deps.py" + spec = importlib.util.spec_from_file_location("check_git_deps", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +check_git_deps = _load_checker() + + +class TestFindGitDependencies: + def test_flags_direct_url_git_dependency(self): + pyproject = """ +[project] +name = "worker-flash" +dependencies = [ + "requests>=2.25.0", + "runpod-flash @ git+https://github.com/runpod/flash.git@some-branch", +] +""" + + offenders = check_git_deps.find_git_dependencies(pyproject) + + assert offenders == [ + "project.dependencies: runpod-flash @ git+https://github.com/runpod/flash.git@some-branch" + ] + + def test_accepts_released_version_constraints(self): + pyproject = """ +[project] +name = "worker-flash" +dependencies = [ + "requests>=2.25.0", + "runpod-flash>=1.19.0", +] +""" + + assert check_git_deps.find_git_dependencies(pyproject) == [] + + def test_flags_git_dependency_in_dependency_group(self): + pyproject = """ +[project] +name = "worker-flash" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=8.3.5", + "some-tool @ git+https://github.com/example/tool.git", +] +""" + + offenders = check_git_deps.find_git_dependencies(pyproject) + + assert offenders == [ + "dependency-groups.dev: some-tool @ git+https://github.com/example/tool.git" + ] + + def test_flags_optional_dependency_git_ref(self): + pyproject = """ +[project] +name = "worker-flash" +dependencies = [] + +[project.optional-dependencies] +extra = ["thing @ git+ssh://git@github.com/example/thing.git"] +""" + + offenders = check_git_deps.find_git_dependencies(pyproject) + + assert offenders == [ + "project.optional-dependencies.extra: thing @ git+ssh://git@github.com/example/thing.git" + ] + + def test_flags_tool_uv_sources_git_entry(self): + pyproject = """ +[project] +name = "worker-flash" +dependencies = ["runpod"] + +[tool.uv.sources] +runpod = { git = "https://github.com/runpod/runpod-python", rev = "main" } +""" + + offenders = check_git_deps.find_git_dependencies(pyproject) + + assert offenders == [ + "tool.uv.sources.runpod: git = https://github.com/runpod/runpod-python" + ] + + def test_reports_every_offender(self): + pyproject = """ +[project] +name = "worker-flash" +dependencies = [ + "a @ git+https://github.com/example/a.git", + "b @ git+https://github.com/example/b.git", +] +""" + + assert len(check_git_deps.find_git_dependencies(pyproject)) == 2 + + +class TestMain: + def test_exits_nonzero_and_names_offender_when_git_dep_present(self, tmp_path, capsys): + target = tmp_path / "pyproject.toml" + target.write_text( + '[project]\nname = "x"\ndependencies = ["p @ git+https://github.com/e/p.git"]\n' + ) + + exit_code = check_git_deps.main([str(target)]) + + assert exit_code == 1 + assert "git+https://github.com/e/p.git" in capsys.readouterr().out + + def test_exits_zero_for_clean_file(self, tmp_path): + target = tmp_path / "pyproject.toml" + target.write_text('[project]\nname = "x"\ndependencies = ["requests>=2.25.0"]\n') + + assert check_git_deps.main([str(target)]) == 0 + + def test_exits_nonzero_when_file_missing(self, tmp_path): + assert check_git_deps.main([str(tmp_path / "nope.toml")]) == 1 + + +class TestThisRepository: + """The guard must pass against the real pyproject.toml it protects.""" + + def test_repo_pyproject_has_no_git_dependencies(self): + pyproject = (REPO_ROOT / "pyproject.toml").read_text() + + assert check_git_deps.find_git_dependencies(pyproject) == [] + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/uv.lock b/uv.lock index 0f213e5..0cecbd5 100644 --- a/uv.lock +++ b/uv.lock @@ -2557,8 +2557,8 @@ wheels = [ [[package]] name = "runpod" -version = "1.10.2.dev1" -source = { git = "https://github.com/runpod/runpod-python?rev=main#08e07cb5dded275b1bab0451940433fe2a1c8244" } +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", extra = ["speedups"] }, { name = "aiohttp-retry" }, @@ -2580,11 +2580,15 @@ dependencies = [ { name = "urllib3" }, { name = "watchdog" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/69/fd/62538e5497c4bc800bd62b0270de43f1d3d054eb58b9696ca548850da166/runpod-1.10.1.tar.gz", hash = "sha256:6d0ad5cdc36f5b7375968861f6973be82c923eb6e8c20bc658c48f398304c7b8", size = 580786 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/38f6ad5d15b0d5d843a1a87d89364c07b10c98a3542e604bf5e6259ace8a/runpod-1.10.1-py3-none-any.whl", hash = "sha256:1c12e32107cfd9f38c5d9c3d3f9c7f68fa7969bf99ea35f2fdc1fc03b174b90f", size = 334368 }, +] [[package]] name = "runpod-flash" -version = "1.18.0" -source = { git = "https://github.com/runpod/flash.git?rev=deanquinanola%2Fsls-360-local-module-bundling#0c8c90cefcddba3324a96a8474eb9986b174837d" } +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, { name = "pathspec" }, @@ -2596,6 +2600,10 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typer" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/a7/15/12b46f2f45391095683f070cab73a8ff0f49aa015b2d9466d3d50487926f/runpod_flash-1.19.0.tar.gz", hash = "sha256:70344dd7e2939a901a3c1c6eefe1a678a2f5533cb17f4b1239692bc278148952", size = 211122 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/7e/00665d3b0c47f52ffd43499a8ba7d5cd3adb5f0d74a04e8b094b84abe51d/runpod_flash-1.19.0-py3-none-any.whl", hash = "sha256:d7fc371e368be5504476a7ad915c0da965f0a0905c4a100017160168108020f4", size = 248661 }, +] [[package]] name = "s3transfer" @@ -3062,7 +3070,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.4" }, { name = "requests", specifier = ">=2.25.0" }, { name = "runpod" }, - { name = "runpod-flash", git = "https://github.com/runpod/flash.git?rev=deanquinanola%2Fsls-360-local-module-bundling" }, + { name = "runpod-flash", specifier = ">=1.19.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, ]