diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f73907ae6..f091ee71a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,6 +231,17 @@ jobs: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] timeout-minutes: 15 + # Without this the matrix is decorative. `make dev` runs a bare `uv sync`, + # and uv resolves its interpreter from .python-version (pinned to 3.11) + # rather than from whatever actions/setup-python just installed -- so every + # leg built a 3.11 venv and all four ran the same interpreter. UV_PYTHON + # takes precedence over the pin file, which restores real version coverage. + # + # Set at job level, not on the install step: `make ci-quality-github` runs + # `uv run pytest`, which resolves the interpreter again. + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/tests/unit/cli/commands/test_run_server_helpers.py b/tests/unit/cli/commands/test_run_server_helpers.py index 60ae9a14d..80a3feb60 100644 --- a/tests/unit/cli/commands/test_run_server_helpers.py +++ b/tests/unit/cli/commands/test_run_server_helpers.py @@ -336,12 +336,34 @@ async def test_allows_populated_fields(self): assert result == {"echo": "hello"} func.assert_called_once_with(msg="hello") + # These two wrap the mock in a real async def instead of passing the mock + # straight to call_with_body. + # + # call_with_body -> _map_body_to_params calls inspect.signature(func), and + # signature() of a Mock is version-dependent: on 3.10 it raises + # `TypeError: 'Mock' object is not subscriptable` (Mock auto-creates a + # __signature__ child, which inspect then tries to use), while on 3.11+ it + # reports (*args, **kwargs). Passing spec= does not help -- the failure is + # in signature(), not in spec resolution. + # + # call_with_body catches Exception and converts it into a 500 JSONResponse, + # so on 3.10 these failed as `assert == {'ok': True}`, + # with the real TypeError visible only inside the response body. + # + # The wrapper declares (*args, **kwargs), the same signature 3.11+ inferred + # from the Mock, so both branches of _map_body_to_params behave exactly as + # before -- and delegating to the mock keeps the call assertions. + @pytest.mark.asyncio async def test_allows_plain_dict_body(self): """Non-empty plain dict passes through unchanged (no model_fields_set).""" body = {"key": "value"} - func = AsyncMock(return_value={"ok": True}) + mock = AsyncMock(return_value={"ok": True}) + + async def func(*args, **kwargs): + return await mock(*args, **kwargs) + result = await call_with_body(func, body) assert result == {"ok": True} @@ -354,8 +376,12 @@ async def test_allows_empty_plain_dict_body(self): """ body = {} - func = AsyncMock(return_value={"ok": True}) + mock = AsyncMock(return_value={"ok": True}) + + async def func(*args, **kwargs): + return await mock(*args, **kwargs) + result = await call_with_body(func, body) assert result == {"ok": True} - func.assert_called_once_with() + mock.assert_called_once_with() diff --git a/tests/unit/core/resources/test_resource_manager_extended.py b/tests/unit/core/resources/test_resource_manager_extended.py index 06f2ac1eb..fe24a8f85 100644 --- a/tests/unit/core/resources/test_resource_manager_extended.py +++ b/tests/unit/core/resources/test_resource_manager_extended.py @@ -9,6 +9,17 @@ ) +class _LegacyResource: + """Minimal picklable stand-in for a resource in the pre-1.12 state file. + + Module-level, not defined inside a test: cloudpickle serialises a class + defined in a function body by value, which is the thing being avoided here. + """ + + def __init__(self, config_hash: str): + self.config_hash = config_hash + + @pytest.fixture(autouse=True) def reset_manager(isolate_resource_state_file, reset_singletons): """Reset singleton and state file between tests.""" @@ -56,7 +67,13 @@ def test_loads_legacy_dict_format(self, tmp_path): state_file = tmp_path / ".runpod" / "resources.pkl" state_file.parent.mkdir(parents=True) - resources = {"key1": MagicMock(config_hash="hash1")} + # A plain object rather than MagicMock: whether cloudpickle can pickle a + # Mock is version-dependent, and on 3.10 this failed with + # "Could not pickle object as excessively deep recursion required". + # Only .config_hash is read here (via _refresh_config_hashes), and the + # absence of get_resource_key is what keeps the legacy key un-migrated, + # which is exactly what this test is asserting. + resources = {"key1": _LegacyResource("hash1")} with open(state_file, "wb") as f: cloudpickle.dump(resources, f)