From cc0d1d0b7e33470bd0b736ada988d6621f573574 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Wed, 22 Apr 2026 18:38:04 +0200 Subject: [PATCH] Disallow running `dstack` on Python 3.9 Python 3.9.0 reached end-of-life on 2025-10-31. --- .github/workflows/build-artifacts.yml | 2 +- AGENTS.md | 2 +- docs/docs/concepts/backends.md | 3 --- .../plugins/example_plugin/pyproject.toml | 2 +- .../example_plugin_server/pyproject.toml | 2 +- pyproject.toml | 11 ++++---- .../_internal/server/services/plugins.py | 5 ++-- src/tests/_internal/cli/utils/test_offer.py | 27 ++++++++----------- .../core/backends/verda/test_compute.py | 5 ---- .../core/backends/verda/test_configurator.py | 6 ----- .../_internal/server/routers/test_backends.py | 8 +++--- .../server/services/test_backend_configs.py | 3 --- 12 files changed, 25 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index c8b68db67..0b3406e2c 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -64,7 +64,7 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/AGENTS.md b/AGENTS.md index 3e1f85eaf..2bf7290a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ - Frontend: from `frontend/` run `npm install`, `npm run build`, then copy `frontend/build` into `src/dstack/_internal/server/statics/`; for dev, `npm run start` with API on port 8000. ## Coding Style & Naming Conventions -- Python targets 3.9+ with 4-space indentation and max line length of 99 (see `ruff.toml`; `E501` is ignored but keep lines readable). +- Python targets 3.10+ with 4-space indentation and max line length of 99 (see `pyproject.toml`; `E501` is ignored but keep lines readable). - Imports are sorted via Ruff’s isort settings (`dstack` treated as first-party). - Keep primary/public functions before local helper functions in a module section. - Roughly keep function definitions in the order they are referenced within a file so call flow stays easy to follow. diff --git a/docs/docs/concepts/backends.md b/docs/docs/concepts/backends.md index 9a1092bc5..ee9eae223 100644 --- a/docs/docs/concepts/backends.md +++ b/docs/docs/concepts/backends.md @@ -741,9 +741,6 @@ projects: -!!! info "Python version" - Nebius is only supported if `dstack server` is running on Python 3.10 or higher. - ### Crusoe diff --git a/examples/plugins/example_plugin/pyproject.toml b/examples/plugins/example_plugin/pyproject.toml index bc83d509a..66f954a59 100644 --- a/examples/plugins/example_plugin/pyproject.toml +++ b/examples/plugins/example_plugin/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [ { name = "Victor Skvortsov", email = "victor@dstack.ai" } ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [] [build-system] diff --git a/examples/plugins/example_plugin_server/pyproject.toml b/examples/plugins/example_plugin_server/pyproject.toml index c910b172b..cc5d3b1c0 100644 --- a/examples/plugins/example_plugin_server/pyproject.toml +++ b/examples/plugins/example_plugin_server/pyproject.toml @@ -3,7 +3,7 @@ name = "dstack-plugin-server" version = "0.1.0" description = "Example plugin server" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "fastapi", "uvicorn", diff --git a/pyproject.toml b/pyproject.toml index aa92ed618..228fd3a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "dstack" dynamic = ["version", "readme"] authors = [{ name = "Andrey Cheptsov", email = "andrey@dstack.ai" }] description = "dstack is an open-source orchestration engine for running AI workloads on any cloud or on-premises." -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Topic :: Scientific/Engineering :: Artificial Intelligence", @@ -82,7 +82,7 @@ ignore-case = true dstack-plugin-server = { path = "examples/plugins/example_plugin_server", editable = true } [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 99 [tool.ruff.lint] @@ -198,7 +198,6 @@ server = [ "python-json-logger>=3.1.0", "prometheus-client", "grpcio>=1.50", - "backports.entry-points-selectable", ] aws = [ "boto3>=1.38.13", @@ -226,11 +225,11 @@ gcp = [ "dstack[server]", ] datacrunch = [ - "verda>=1.23.0; python_version >= '3.10'", + "verda>=1.23.0", "dstack[server]", ] verda = [ - "verda>=1.23.0; python_version >= '3.10'", + "verda>=1.23.0", "dstack[server]", ] kubernetes = [ @@ -251,7 +250,7 @@ oci = [ "dstack[server]", ] nebius = [ - "nebius>=0.3.4,<0.4; python_version >= '3.10'", + "nebius>=0.3.4,<0.4", "dstack[server]", ] fluentbit = [ diff --git a/src/dstack/_internal/server/services/plugins.py b/src/dstack/_internal/server/services/plugins.py index d98c32bf6..d40b84b36 100644 --- a/src/dstack/_internal/server/services/plugins.py +++ b/src/dstack/_internal/server/services/plugins.py @@ -1,9 +1,8 @@ import itertools from importlib import import_module +from importlib.metadata import entry_points from typing import Dict -from backports.entry_points_selectable import entry_points # backport for Python 3.9 - from dstack._internal.core.errors import ServerClientError from dstack._internal.utils.common import run_async from dstack._internal.utils.logging import get_logger @@ -60,7 +59,7 @@ def load_plugins(enabled_plugins: list[str]): _PLUGINS.clear() entrypoints: dict[str, PluginEntrypoint] = {} plugins_to_load = enabled_plugins.copy() - for entrypoint in entry_points(group="dstack.plugins"): # type: ignore[call-arg] + for entrypoint in entry_points(group="dstack.plugins"): if entrypoint.name not in enabled_plugins: logger.info( ("Found not enabled plugin %s. Plugin will not be loaded."), diff --git a/src/tests/_internal/cli/utils/test_offer.py b/src/tests/_internal/cli/utils/test_offer.py index 394354715..9a9a4dfb1 100644 --- a/src/tests/_internal/cli/utils/test_offer.py +++ b/src/tests/_internal/cli/utils/test_offer.py @@ -1,4 +1,4 @@ -import asyncio +import pytest from dstack._internal.cli.utils.common import console from dstack._internal.cli.utils.run import print_run_plan @@ -34,18 +34,11 @@ def _get_offer(index: int) -> InstanceOfferWithAvailability: ) -def _get_run_plan(*, offers: list[InstanceOfferWithAvailability], total_offers: int) -> RunPlan: +async def _get_run_plan( + *, offers: list[InstanceOfferWithAvailability], total_offers: int +) -> RunPlan: run_spec = get_run_spec(repo_id="test-repo") - # Keep this helper's asyncio state isolated. `asyncio.run()` clears the current event loop, - # which breaks later Python 3.9 tests that still construct asyncio primitives via - # `get_event_loop()` on the main thread. - loop = asyncio.new_event_loop() - try: - job = loop.run_until_complete( - get_jobs_from_run_spec(run_spec=run_spec, secrets={}, replica_num=0) - )[0] - finally: - loop.close() + job = (await get_jobs_from_run_spec(run_spec=run_spec, secrets={}, replica_num=0))[0] return RunPlan( project_name="test-project", user="test-user", @@ -64,8 +57,9 @@ def _get_run_plan(*, offers: list[InstanceOfferWithAvailability], total_offers: class TestPrintRunPlanOfferHint: - def test_prints_hint_before_short_offer_table(self): - run_plan = _get_run_plan(offers=[_get_offer(1), _get_offer(2)], total_offers=2) + @pytest.mark.asyncio + async def test_prints_hint_before_short_offer_table(self): + run_plan = await _get_run_plan(offers=[_get_offer(1), _get_offer(2)], total_offers=2) with console.capture() as capture: print_run_plan( @@ -78,9 +72,10 @@ def test_prints_hint_before_short_offer_table(self): assert " ".join(_OFFER_FLEET_HINT.split()) in " ".join(output.split()) assert output.index(_OFFER_FLEET_HINT_START) < output.index("1 aws (us-east-1)") - def test_prints_hint_after_truncated_offer_table(self): + @pytest.mark.asyncio + async def test_prints_hint_after_truncated_offer_table(self): offers = [_get_offer(index) for index in range(1, 4)] - run_plan = _get_run_plan(offers=offers, total_offers=10) + run_plan = await _get_run_plan(offers=offers, total_offers=10) with console.capture() as capture: print_run_plan( diff --git a/src/tests/_internal/core/backends/verda/test_compute.py b/src/tests/_internal/core/backends/verda/test_compute.py index fcde147a9..e32a42d62 100644 --- a/src/tests/_internal/core/backends/verda/test_compute.py +++ b/src/tests/_internal/core/backends/verda/test_compute.py @@ -1,12 +1,7 @@ -import sys from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest - -if sys.version_info < (3, 10): - pytest.skip("Verda requires Python 3.10", allow_module_level=True) - from verda.exceptions import APIException from dstack._internal.core.backends.verda.compute import ( diff --git a/src/tests/_internal/core/backends/verda/test_configurator.py b/src/tests/_internal/core/backends/verda/test_configurator.py index a17fd1182..a6f1106da 100644 --- a/src/tests/_internal/core/backends/verda/test_configurator.py +++ b/src/tests/_internal/core/backends/verda/test_configurator.py @@ -1,11 +1,5 @@ -import sys from unittest.mock import patch -import pytest - -if sys.version_info < (3, 10): - pytest.skip("Verda requires Python 3.10", allow_module_level=True) - from dstack._internal.core.backends.verda.configurator import ( VerdaConfigurator, ) diff --git a/src/tests/_internal/server/routers/test_backends.py b/src/tests/_internal/server/routers/test_backends.py index 104bec83e..47295748d 100644 --- a/src/tests/_internal/server/routers/test_backends.py +++ b/src/tests/_internal/server/routers/test_backends.py @@ -1,5 +1,4 @@ import json -import sys from collections.abc import Sequence from datetime import datetime, timezone from typing import Any, Optional @@ -89,17 +88,17 @@ async def test_returns_backend_types(self, client: AsyncClient): "cloudrift", "crusoe", "cudo", - *(["datacrunch"] if sys.version_info >= (3, 10) else []), + "datacrunch", "digitalocean", "gcp", "hotaisle", "kubernetes", "lambda", - *(["nebius"] if sys.version_info >= (3, 10) else []), + "nebius", "oci", "runpod", "vastai", - *(["verda"] if sys.version_info >= (3, 10) else []), + "verda", "vultr", ] @@ -221,7 +220,6 @@ async def test_creates_lambda_backend( assert len(res.scalars().all()) == 1 @pytest.mark.asyncio - @pytest.mark.skipif(sys.version_info < (3, 10), reason="Nebius requires Python 3.10") @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) class TestNebius: @pytest.fixture(autouse=True) diff --git a/src/tests/_internal/server/services/test_backend_configs.py b/src/tests/_internal/server/services/test_backend_configs.py index 96b5c998d..833dda275 100644 --- a/src/tests/_internal/server/services/test_backend_configs.py +++ b/src/tests/_internal/server/services/test_backend_configs.py @@ -1,10 +1,8 @@ import json -import sys from pathlib import Path from textwrap import dedent from unittest.mock import patch -import pytest import yaml from dstack._internal.core.backends.kubernetes.backend import KubernetesBackend @@ -57,7 +55,6 @@ def test_config_parsing(self, tmp_path: Path): assert backend_cfg.creds.secret_key == "test-secret-key" -@pytest.mark.skipif(sys.version_info < (3, 10), reason="Nebius requires Python 3.10") class TestNebiusBackendConfig: def test_with_filename(self, tmp_path: Path): creds_json = {