From b9a05f71a0fd5b5079e31b6adaa7e55a2afff1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 18 Jun 2026 01:06:46 -0700 Subject: [PATCH 1/5] feat: support Python 3.10-3.14, drop EOL 3.8/3.9 Python 3.8 (EOL 2024-10) and 3.9 (EOL 2025-10) are end-of-life; advertising support for them was a false security claim. Raise the supported floor to 3.10 and declare/test through 3.14. - requires-python / python_requires: >=3.10 - classifiers: 3.10-3.14 - CI test matrix: 3.10, 3.11, 3.12, 3.13, 3.14 - README / ARCHITECTURE: reflect new support range Floor stays at 3.10 (not 3.11) because GPU/ML base images still ship 3.10/3.11; plan a 3.10->3.11 bump after 3.10 EOL (2026-10). Refs SLS-265 --- .github/workflows/ci.yml | 2 +- ARCHITECTURE.md | 2 +- README.md | 2 +- pyproject.toml | 7 ++++--- setup.py | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 167730c0..a9f64528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 16bfffa1..9daad84f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ **Last Updated**: 2025-12-13 **Module**: `runpod/serverless/` -**Python Support**: 3.8-3.11 +**Python Support**: 3.10-3.14 --- diff --git a/README.md b/README.md index f8911942..b34014b1 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ cd runpod-python pip install -e . ``` -*Python 3.8 or higher is required to use the latest version of this package.* +*Python 3.10 or higher is required to use the latest version of this package.* ## ⚡ | Serverless Worker (SDK) diff --git a/pyproject.toml b/pyproject.toml index c88c8ec2..58b25347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "runpod" dynamic = ["version", "dependencies"] description = "🐍 | Python library for Runpod API and serverless worker SDK." readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.8" +requires-python = ">=3.10" license = { text = "MIT License" } authors = [ { name = "Runpod", email = "engineer@runpod.io" }, @@ -25,10 +25,11 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ] diff --git a/setup.py b/setup.py index ebd4c2d1..6fbfa776 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires=install_requires, extras_require=extras_require, packages=find_packages(), - python_requires=">=3.8", + python_requires=">=3.10", description="🐍 | Python library for Runpod API and serverless worker SDK.", long_description=long_description, long_description_content_type="text/markdown", From f950a4ba8ddff3942cb7c2f3c2d676c50ef60558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 18 Jun 2026 13:28:13 -0700 Subject: [PATCH 2/5] test: don't fail on nest_asyncio deprecation under Python 3.14 Adding 3.14 to the CI matrix surfaced a collection error: nest_asyncio (a test-only dependency) calls the deprecated asyncio.get_event_loop_policy, and pytest's -W error promoted the DeprecationWarning to a hard error, interrupting collection of test_worker.py. The SDK runtime does not use that API, so this is purely test infrastructure. Move the warnings config from addopts (-W error) into a filterwarnings list so an ignore for this specific third-party message takes precedence while all other warnings remain errors. Refs SLS-265 --- pytest.ini | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index 165c6b91..f8d14290 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,10 @@ [pytest] -addopts = --durations=10 --cov-config=.coveragerc --timeout=120 --timeout_method=thread --cov=runpod --cov-report=xml --cov-report=term-missing --cov-fail-under=90 -W error -p no:cacheprovider -p no:unraisableexception +addopts = --durations=10 --cov-config=.coveragerc --timeout=120 --timeout_method=thread --cov=runpod --cov-report=xml --cov-report=term-missing --cov-fail-under=90 -p no:cacheprovider -p no:unraisableexception +filterwarnings = + error + # nest_asyncio (test-only dep) calls the deprecated asyncio.get_event_loop_policy + # on Python 3.14; the SDK itself does not. Don't fail collection on its warning. + ignore:'asyncio\.get_event_loop_policy' is deprecated:DeprecationWarning python_files = tests.py test_*.py *_test.py norecursedirs = venv *.egg-info .git build tests/e2e asyncio_mode = auto From 14a7ee7782c7d4ce7229ac42335c39fc77777589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 18 Jun 2026 14:43:39 -0700 Subject: [PATCH 3/5] test: modernize async worker tests for Python 3.14 nest_asyncio.apply() ran at import and globally patched asyncio; on Python 3.14 the patched loop breaks current_task(), so asyncio.timeout (used by asyncio.wait_for in the GPU fitness check) raised "Timeout should be used inside a task" and failed collection/run for any test touching it. The SDK runtime itself is 3.14-clean (plain asyncio.run provides the task context). - Remove nest_asyncio entirely and drop it from test deps. Convert the worker test classes from IsolatedAsyncioTestCase to TestCase: their bodies call runpod.serverless.start() synchronously (as production does), so run_worker's internal asyncio.run() no longer nests inside a running loop and needs no patching. - Stub run_fitness_checks in TestRunWorker.setUp so unit tests don't run real GPU/memory probes (covered by test_modules/test_fitness/). - test_download_files_from_urls: assert the set of requested URLs, not positional call order, since downloads run in parallel threads. Refs SLS-265 --- pyproject.toml | 1 - .../test_utils/test_download.py | 12 +++-- tests/test_serverless/test_worker.py | 46 +++++++++++-------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58b25347..d4639a15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,6 @@ dev = [ ] test = [ "asynctest", - "nest_asyncio", "faker", "pytest-asyncio", "pytest-cov", diff --git a/tests/test_serverless/test_utils/test_download.py b/tests/test_serverless/test_utils/test_download.py index bc04db95..b7533f5c 100644 --- a/tests/test_serverless/test_utils/test_download.py +++ b/tests/test_serverless/test_utils/test_download.py @@ -95,10 +95,14 @@ def test_download_files_from_urls(self, mock_open_file, mock_get, mock_makedirs) self.assertEqual(len(downloaded_files), len(urls)) - for index, url in enumerate(urls): - # Check that the url was called with SyncClientSession.get - self.assertIn(url, mock_get.call_args_list[index][0]) - + # Downloads run in parallel threads, so the order of get() calls is + # non-deterministic; assert the set of requested URLs instead of order. + requested_urls = {call.args[0] for call in mock_get.call_args_list} + self.assertEqual(requested_urls, set(urls)) + + # executor.map preserves input order in results, so downloaded_files + # still aligns positionally with urls. + for index in range(len(urls)): # Check that the file has the correct extension self.assertTrue(downloaded_files[index].endswith(".jpg")) diff --git a/tests/test_serverless/test_worker.py b/tests/test_serverless/test_worker.py index e1fd743f..88f969ba 100644 --- a/tests/test_serverless/test_worker.py +++ b/tests/test_serverless/test_worker.py @@ -6,23 +6,19 @@ import os import sys from unittest import mock -from unittest.mock import patch, mock_open, Mock, MagicMock - -from unittest import IsolatedAsyncioTestCase -import nest_asyncio +from unittest import TestCase +from unittest.mock import patch, mock_open, Mock, MagicMock, AsyncMock import runpod from runpod.serverless.modules.rp_logger import RunPodLogger from runpod.serverless.modules.rp_scale import _handle_uncaught_exception from runpod.serverless import _signal_handler -nest_asyncio.apply() - -class TestWorker(IsolatedAsyncioTestCase): +class TestWorker(TestCase): """Tests for Runpod serverless worker.""" - async def asyncSetUp(self): + def setUp(self): self.mock_handler = mock.Mock(return_value="test") self.mock_config = { "handler": self.mock_handler, @@ -105,10 +101,10 @@ def test_signal_handler(self, mock_exit, mock_logger): assert mock_logger.info.called -class TestWorkerTestInput(IsolatedAsyncioTestCase): +class TestWorkerTestInput(TestCase): """Tests for runpod | serverless| worker""" - async def asyncSetUp(self): + def setUp(self): self.mock_handler = Mock() self.mock_handler.return_value = {} @@ -176,12 +172,22 @@ def test_generator_handler_exception(): assert True, "Exception was caught as expected" -class TestRunWorker(IsolatedAsyncioTestCase): +class TestRunWorker(TestCase): """Tests for runpod | serverless| worker""" - async def asyncSetUp(self): + def setUp(self): os.environ["RUNPOD_WEBHOOK_GET_JOB"] = "https://test.com" + # run_worker() runs real fitness checks (GPU/memory probes) that exit the + # process when unmet; they have dedicated coverage in + # test_modules/test_fitness/. Stub them so these tests are deterministic + # regardless of host state. + fitness_patcher = patch( + "runpod.serverless.worker.run_fitness_checks", new=AsyncMock() + ) + fitness_patcher.start() + self.addCleanup(fitness_patcher.stop) + # Set up the config self.config = { "handler": MagicMock(), @@ -189,7 +195,7 @@ async def asyncSetUp(self): "rp_args": {"rp_debugger": True, "rp_log_level": "DEBUG"}, } - async def asyncTearDown(self): + def tearDown(self): sys.excepthook = sys.__excepthook__ @patch("runpod.serverless.modules.rp_scale.AsyncClientSession") @@ -197,7 +203,7 @@ async def asyncTearDown(self): @patch("runpod.serverless.modules.rp_job.run_job") @patch("runpod.serverless.modules.rp_job.stream_result") @patch("runpod.serverless.modules.rp_job.send_result") - async def test_run_worker( + def test_run_worker( self, mock_send_result, mock_stream_result, @@ -228,7 +234,7 @@ async def test_run_worker( @patch("runpod.serverless.modules.rp_job.run_job") @patch("runpod.serverless.modules.rp_job.stream_result") @patch("runpod.serverless.modules.rp_job.send_result") - async def test_run_worker_generator_handler( + def test_run_worker_generator_handler( self, mock_send_result, mock_stream_result, mock_run_job, mock_get_job ): """ @@ -258,7 +264,7 @@ async def test_run_worker_generator_handler( @patch("runpod.serverless.modules.rp_job.run_job") @patch("runpod.serverless.modules.rp_job.stream_result") @patch("runpod.serverless.modules.rp_job.send_result") - async def test_run_worker_generator_handler_exception( + def test_run_worker_generator_handler_exception( self, mock_send_result, mock_stream_result, mock_run_job, mock_get_job ): """ @@ -303,7 +309,7 @@ async def test_run_worker_generator_handler_exception( @patch("runpod.serverless.modules.rp_job.run_job") @patch("runpod.serverless.modules.rp_job.stream_result") @patch("runpod.serverless.modules.rp_job.send_result") - async def test_run_worker_generator_aggregate_handler( + def test_run_worker_generator_aggregate_handler( self, mock_send_result, mock_stream_result, mock_run_job, mock_get_job ): """ @@ -343,7 +349,7 @@ async def test_run_worker_generator_aggregate_handler( @patch("runpod.serverless.modules.rp_job.run_job") @patch("runpod.serverless.modules.rp_job.stream_result") @patch("runpod.serverless.modules.rp_job.send_result") - async def test_run_worker_concurrency( + def test_run_worker_concurrency( self, mock_send_result, mock_stream_result, @@ -420,7 +426,7 @@ def concurrency_modifier(current_concurrency): @patch("runpod.serverless.modules.rp_job.run_job") @patch("runpod.serverless.modules.rp_job.stream_result") @patch("runpod.serverless.modules.rp_job.send_result") - async def test_run_worker_multi_processing( + def test_run_worker_multi_processing( self, mock_send_result, mock_stream_result, @@ -480,7 +486,7 @@ async def test_run_worker_multi_processing( @patch("runpod.serverless.modules.rp_scale.get_job") @patch("runpod.serverless.modules.rp_job.run_job") - async def test_run_worker_multi_processing_scaling_up( + def test_run_worker_multi_processing_scaling_up( self, mock_run_job, mock_get_job ): """ From 2b3e628e4f2c8c7a61b16a4e25a71a257d8d58af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 18 Jun 2026 14:50:06 -0700 Subject: [PATCH 4/5] test: ignore backoff's asyncio.iscoroutinefunction warning on 3.14 The backoff dependency calls asyncio.iscoroutinefunction in its on_exception decorator; Python 3.14 deprecates it (removal in 3.16). The SDK's own code uses inspect.iscoroutinefunction, and at runtime this is only a warning. Filter it (alongside the existing get_event_loop_policy ignore) so warnings-as-errors doesn't fail the suite until backoff updates. Refs SLS-265 --- pytest.ini | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index f8d14290..1c2c135e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,9 +2,11 @@ addopts = --durations=10 --cov-config=.coveragerc --timeout=120 --timeout_method=thread --cov=runpod --cov-report=xml --cov-report=term-missing --cov-fail-under=90 -p no:cacheprovider -p no:unraisableexception filterwarnings = error - # nest_asyncio (test-only dep) calls the deprecated asyncio.get_event_loop_policy - # on Python 3.14; the SDK itself does not. Don't fail collection on its warning. + # Third-party deps (e.g. backoff) still call asyncio APIs that 3.14 deprecates + # and slates for removal in 3.16. The SDK's own code does not; at runtime these + # only warn. Don't fail the suite on them until the deps update. ignore:'asyncio\.get_event_loop_policy' is deprecated:DeprecationWarning + ignore:'asyncio\.iscoroutinefunction' is deprecated:DeprecationWarning python_files = tests.py test_*.py *_test.py norecursedirs = venv *.egg-info .git build tests/e2e asyncio_mode = auto From b52a6213a616a73c3499fd098d675bdfb5ad9eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Thu, 18 Jun 2026 16:02:36 -0700 Subject: [PATCH 5/5] docs: bump ARCHITECTURE.md Last Updated for Python support change Addresses Copilot review: the Python support range was changed without updating the document's Last Updated header. Refs SLS-265 --- ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9daad84f..2dbca2ef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Runpod Serverless Module Architecture -**Last Updated**: 2025-12-13 +**Last Updated**: 2026-06-18 **Module**: `runpod/serverless/` **Python Support**: 3.10-3.14 @@ -1467,4 +1467,4 @@ stateDiagram-v2 --- **Document Version**: 1.0 -**Last Updated**: 2025-12-13 +**Last Updated**: 2026-06-18