Skip to content

Commit 9750814

Browse files
chore: drop Python 3.9 support (#49)
* chore: drop Python 3.9 support * chore: test against Python 3.14
1 parent 6ea1d96 commit 9750814

9 files changed

Lines changed: 414 additions & 482 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ jobs:
7979
# causing BlockingIOError [Errno 35] when spawning subprocess
8080
os: [ubuntu-latest, windows-latest]
8181
# Test only min and max supported Python versions for efficiency
82-
python-version: ["3.9", "3.13"]
82+
python-version: ["3.10", "3.14"]
8383
steps:
8484
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
8585

AGENTS.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This document provides comprehensive guidance for AI agents and developers worki
88

99
- **Primary Purpose**: Enable pip-based installation of promptfoo for Python-centric environments
1010
- **Implementation**: Thin wrapper that delegates to the official TypeScript promptfoo package
11-
- **Requirements**: Python 3.9+ and Node.js 20+
11+
- **Requirements**: Python 3.10+ and Node.js 20+
1212

1313
### How It Works
1414

@@ -141,7 +141,7 @@ Runs on every PR and push to main:
141141
- **Smoke Tests**: Integration tests against real CLI (`uv run pytest tests/smoke/`)
142142
- **Build**: Package build validation
143143

144-
Tests run on multiple Python versions (3.9, 3.13) and OSes (Ubuntu, Windows).
144+
Tests run on multiple Python versions (3.10, 3.14) and OSes (Ubuntu, Windows).
145145

146146
### Release Workflow (`.github/workflows/release-please.yml`)
147147

@@ -174,9 +174,9 @@ We use **OpenID Connect (OIDC)** for secure, credential-free PyPI publishing:
174174

175175
### Python Version Support
176176

177-
- **Minimum**: Python 3.9
178-
- **Tested**: Python 3.9 and 3.13
179-
- **Target**: `py39` for Ruff and mypy
177+
- **Minimum**: Python 3.10
178+
- **Tested**: Python 3.10 and 3.14
179+
- **Target**: `py310` for Ruff and mypy
180180

181181
### Code Quality Tools
182182

@@ -264,7 +264,7 @@ tests/
264264

265265
CI tests across:
266266
- **Operating Systems**: Ubuntu, Windows (macOS temporarily excluded due to runner constraints)
267-
- **Python Versions**: 3.9 (min), 3.13 (max)
267+
- **Python Versions**: 3.10 (min), 3.14 (max)
268268
- **Scenarios**: Global promptfoo install vs. npx fallback
269269

270270
### Running Tests

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This repository is only the thin Python shim that lets people install promptfoo
1616

1717
### Setup
1818

19-
Requires Python 3.9+, Node.js 20+, and [uv](https://github.com/astral-sh/uv).
19+
Requires Python 3.10+, Node.js 20+, and [uv](https://github.com/astral-sh/uv).
2020

2121
```bash
2222
git clone https://github.com/promptfoo/promptfoo-python.git

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
4848
### Requirements
4949
50-
- **Python 3.9+** (for this wrapper)
50+
- **Python 3.10+** (for this wrapper)
5151
- **Node.js 20+** (required to run promptfoo)
5252
5353
### Install from PyPI

pyproject.toml

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ classifiers = [
1919
"Topic :: Software Development :: Testing",
2020
"Topic :: Scientific/Engineering :: Artificial Intelligence",
2121
"Programming Language :: Python :: 3",
22-
"Programming Language :: Python :: 3.9",
2322
"Programming Language :: Python :: 3.10",
2423
"Programming Language :: Python :: 3.11",
2524
"Programming Language :: Python :: 3.12",
2625
"Programming Language :: Python :: 3.13",
26+
"Programming Language :: Python :: 3.14",
2727
"License :: OSI Approved :: MIT License",
2828
"Operating System :: OS Independent",
2929
]
30-
requires-python = ">=3.9"
30+
requires-python = ">=3.10"
3131
dependencies = [
3232
"posthog>=3.0.0",
3333
"pyyaml>=6.0.0",
@@ -42,11 +42,6 @@ dev = [
4242
"types-pyyaml>=6.0.0",
4343
]
4444

45-
[tool.uv]
46-
constraint-dependencies = [
47-
"urllib3<2.7; python_full_version < '3.10'",
48-
]
49-
5045
[project.scripts]
5146
promptfoo = "promptfoo.cli:main"
5247

@@ -75,7 +70,7 @@ packages = ["src/promptfoo"]
7570

7671
[tool.ruff]
7772
line-length = 120
78-
target-version = "py39"
73+
target-version = "py310"
7974

8075
[tool.ruff.lint]
8176
extend-select = [
@@ -97,7 +92,7 @@ ignore = [
9792
quote-style = "double"
9893

9994
[tool.mypy]
100-
python_version = "3.9"
95+
python_version = "3.10"
10196
# Enable strict mode for comprehensive type checking
10297
strict = true
10398
# Additional strictness beyond --strict
@@ -121,7 +116,7 @@ module = "posthog.*"
121116
ignore_missing_imports = true
122117

123118
[tool.pyright]
124-
pythonVersion = "3.9"
119+
pythonVersion = "3.10"
125120
pythonPlatform = "All"
126121
typeCheckingMode = "strict"
127122
include = ["src/promptfoo"]

src/promptfoo/cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import shutil
1010
import subprocess
1111
import sys
12-
from typing import NoReturn, Optional
12+
from typing import NoReturn
1313

1414
from .telemetry import record_wrapper_used
1515

@@ -61,7 +61,7 @@ def _split_path(path_value: str) -> list[str]:
6161
return entries
6262

6363

64-
def _resolve_argv0() -> Optional[str]:
64+
def _resolve_argv0() -> str | None:
6565
"""Resolve the absolute path of the current script (argv[0])."""
6666
if not sys.argv:
6767
return None
@@ -76,7 +76,7 @@ def _resolve_argv0() -> Optional[str]:
7676
return None
7777

7878

79-
def _find_windows_promptfoo() -> Optional[str]:
79+
def _find_windows_promptfoo() -> str | None:
8080
"""
8181
Search for promptfoo in standard Windows installation locations.
8282
Useful when not in PATH.
@@ -127,15 +127,15 @@ def _is_executing_wrapper(found_path: str) -> bool:
127127
)
128128

129129

130-
def _search_path_excluding(exclude_dir: str) -> Optional[str]:
130+
def _search_path_excluding(exclude_dir: str) -> str | None:
131131
"""Search PATH for promptfoo, excluding the specified directory."""
132132
path_entries = [entry for entry in _split_path(os.environ.get("PATH", "")) if _normalize_path(entry) != exclude_dir]
133133
if not path_entries:
134134
return None
135135
return shutil.which("promptfoo", path=os.pathsep.join(path_entries))
136136

137137

138-
def _find_external_promptfoo() -> Optional[str]:
138+
def _find_external_promptfoo() -> str | None:
139139
"""Find the external promptfoo executable, avoiding the wrapper itself."""
140140
# 1. First naive search
141141
candidate = shutil.which("promptfoo")
@@ -167,7 +167,7 @@ def _requires_shell(executable: str) -> bool:
167167
return ext.lower() in _WINDOWS_SHELL_EXTENSIONS
168168

169169

170-
def _run_command(cmd: list[str], env: Optional[dict[str, str]] = None) -> subprocess.CompletedProcess[bytes]:
170+
def _run_command(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[bytes]:
171171
"""Execute a command, handling shell requirements on Windows."""
172172
if _requires_shell(cmd[0]):
173173
return subprocess.run(subprocess.list2cmdline(cmd), shell=True, env=env)

src/promptfoo/environment.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,29 @@
1010
import sys
1111
from dataclasses import dataclass
1212
from pathlib import Path
13-
from typing import Optional
1413

1514

1615
@dataclass
1716
class Environment:
1817
"""Information about the current execution environment."""
1918

2019
os_type: str # "linux", "darwin", "windows"
21-
linux_distro: Optional[str] = None # "ubuntu", "debian", "rhel", "fedora", "alpine", "arch", etc.
22-
linux_distro_version: Optional[str] = None # e.g., "22.04", "11", "9"
23-
cloud_provider: Optional[str] = None # "aws", "gcp", "azure"
20+
linux_distro: str | None = None # "ubuntu", "debian", "rhel", "fedora", "alpine", "arch", etc.
21+
linux_distro_version: str | None = None # e.g., "22.04", "11", "9"
22+
cloud_provider: str | None = None # "aws", "gcp", "azure"
2423
is_lambda: bool = False # AWS Lambda
2524
is_cloud_function: bool = False # GCP Cloud Functions or Azure Functions
2625
is_docker: bool = False
2726
is_kubernetes: bool = False
2827
is_wsl: bool = False # Windows Subsystem for Linux
2928
is_ci: bool = False
30-
ci_platform: Optional[str] = None # "github", "gitlab", "circleci", "jenkins", etc.
29+
ci_platform: str | None = None # "github", "gitlab", "circleci", "jenkins", etc.
3130
is_venv: bool = False
3231
is_conda: bool = False
3332
has_sudo: bool = False # Best guess if user has sudo access
3433

3534

36-
def _read_probe_file(path: Path) -> Optional[str]:
35+
def _read_probe_file(path: Path) -> str | None:
3736
"""
3837
Read an optional environment probe file.
3938
@@ -53,7 +52,7 @@ def _read_probe_file(path: Path) -> Optional[str]:
5352
return None
5453

5554

56-
def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
55+
def _detect_linux_distro() -> tuple[str | None, str | None]:
5756
"""
5857
Detect Linux distribution and version.
5958
@@ -122,7 +121,7 @@ def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
122121
return None, None
123122

124123

125-
def _detect_cloud_provider() -> Optional[str]:
124+
def _detect_cloud_provider() -> str | None:
126125
"""
127126
Detect if running on a cloud provider.
128127
@@ -213,7 +212,7 @@ def _detect_wsl() -> bool:
213212
return Path("/mnt/c").exists() and Path("/proc/version").exists()
214213

215214

216-
def _detect_ci() -> tuple[bool, Optional[str]]:
215+
def _detect_ci() -> tuple[bool, str | None]:
217216
"""
218217
Detect if running in a CI/CD environment.
219218

src/promptfoo/telemetry.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import sys
1212
import uuid
1313
from pathlib import Path
14-
from typing import Any, Optional
14+
from typing import Any
1515

1616
import yaml
1717
from posthog import Posthog
@@ -95,7 +95,7 @@ def _get_user_id() -> str:
9595
return user_id
9696

9797

98-
def _get_user_email() -> Optional[str]:
98+
def _get_user_email() -> str | None:
9999
"""Get the user email from the global config if set."""
100100
config = _read_global_config()
101101
account = config.get("account", {})
@@ -106,9 +106,9 @@ class _Telemetry:
106106
"""Internal telemetry client for the promptfoo Python wrapper."""
107107

108108
def __init__(self) -> None:
109-
self._client: Optional[Posthog] = None
110-
self._user_id: Optional[str] = None
111-
self._email: Optional[str] = None
109+
self._client: Posthog | None = None
110+
self._user_id: str | None = None
111+
self._email: str | None = None
112112
self._initialized = False
113113

114114
@property
@@ -136,7 +136,7 @@ def _ensure_initialized(self) -> None:
136136
except Exception:
137137
self._client = None # Silently fail
138138

139-
def record(self, event_name: str, properties: Optional[dict[str, Any]] = None) -> None:
139+
def record(self, event_name: str, properties: dict[str, Any] | None = None) -> None:
140140
"""Record a telemetry event."""
141141
if self._disabled:
142142
return
@@ -181,7 +181,7 @@ def shutdown(self) -> None:
181181

182182

183183
# Global singleton instance
184-
_telemetry: Optional[_Telemetry] = None
184+
_telemetry: _Telemetry | None = None
185185

186186

187187
def _get_telemetry() -> _Telemetry:

0 commit comments

Comments
 (0)