Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
# causing BlockingIOError [Errno 35] when spawning subprocess
os: [ubuntu-latest, windows-latest]
# Test only min and max supported Python versions for efficiency
python-version: ["3.9", "3.13"]
python-version: ["3.10", "3.14"]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This document provides comprehensive guidance for AI agents and developers worki

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

### How It Works

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

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

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

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

### Python Version Support

- **Minimum**: Python 3.9
- **Tested**: Python 3.9 and 3.13
- **Target**: `py39` for Ruff and mypy
- **Minimum**: Python 3.10
- **Tested**: Python 3.10 and 3.14
- **Target**: `py310` for Ruff and mypy

### Code Quality Tools

Expand Down Expand Up @@ -264,7 +264,7 @@ tests/

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

### Running Tests
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This repository is only the thin Python shim that lets people install promptfoo

### Setup

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

```bash
git clone https://github.com/promptfoo/promptfoo-python.git
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

### Requirements

- **Python 3.9+** (for this wrapper)
- **Python 3.10+** (for this wrapper)
- **Node.js 20+** (required to run promptfoo)

### Install from PyPI
Expand Down
15 changes: 5 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ classifiers = [
"Topic :: Software Development :: Testing",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"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",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"posthog>=3.0.0",
"pyyaml>=6.0.0",
Expand All @@ -42,11 +42,6 @@ dev = [
"types-pyyaml>=6.0.0",
]

[tool.uv]
constraint-dependencies = [
"urllib3<2.7; python_full_version < '3.10'",
]

[project.scripts]
promptfoo = "promptfoo.cli:main"

Expand Down Expand Up @@ -75,7 +70,7 @@ packages = ["src/promptfoo"]

[tool.ruff]
line-length = 120
target-version = "py39"
target-version = "py310"

[tool.ruff.lint]
extend-select = [
Expand All @@ -97,7 +92,7 @@ ignore = [
quote-style = "double"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
# Enable strict mode for comprehensive type checking
strict = true
# Additional strictness beyond --strict
Expand All @@ -121,7 +116,7 @@ module = "posthog.*"
ignore_missing_imports = true

[tool.pyright]
pythonVersion = "3.9"
pythonVersion = "3.10"
pythonPlatform = "All"
typeCheckingMode = "strict"
include = ["src/promptfoo"]
Expand Down
12 changes: 6 additions & 6 deletions src/promptfoo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import shutil
import subprocess
import sys
from typing import NoReturn, Optional
from typing import NoReturn

from .telemetry import record_wrapper_used

Expand Down Expand Up @@ -61,7 +61,7 @@ def _split_path(path_value: str) -> list[str]:
return entries


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


def _find_windows_promptfoo() -> Optional[str]:
def _find_windows_promptfoo() -> str | None:
"""
Search for promptfoo in standard Windows installation locations.
Useful when not in PATH.
Expand Down Expand Up @@ -127,15 +127,15 @@ def _is_executing_wrapper(found_path: str) -> bool:
)


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


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


def _run_command(cmd: list[str], env: Optional[dict[str, str]] = None) -> subprocess.CompletedProcess[bytes]:
def _run_command(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[bytes]:
"""Execute a command, handling shell requirements on Windows."""
if _requires_shell(cmd[0]):
return subprocess.run(subprocess.list2cmdline(cmd), shell=True, env=env)
Expand Down
17 changes: 8 additions & 9 deletions src/promptfoo/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,29 @@
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional


@dataclass
class Environment:
"""Information about the current execution environment."""

os_type: str # "linux", "darwin", "windows"
linux_distro: Optional[str] = None # "ubuntu", "debian", "rhel", "fedora", "alpine", "arch", etc.
linux_distro_version: Optional[str] = None # e.g., "22.04", "11", "9"
cloud_provider: Optional[str] = None # "aws", "gcp", "azure"
linux_distro: str | None = None # "ubuntu", "debian", "rhel", "fedora", "alpine", "arch", etc.
linux_distro_version: str | None = None # e.g., "22.04", "11", "9"
cloud_provider: str | None = None # "aws", "gcp", "azure"
is_lambda: bool = False # AWS Lambda
is_cloud_function: bool = False # GCP Cloud Functions or Azure Functions
is_docker: bool = False
is_kubernetes: bool = False
is_wsl: bool = False # Windows Subsystem for Linux
is_ci: bool = False
ci_platform: Optional[str] = None # "github", "gitlab", "circleci", "jenkins", etc.
ci_platform: str | None = None # "github", "gitlab", "circleci", "jenkins", etc.
is_venv: bool = False
is_conda: bool = False
has_sudo: bool = False # Best guess if user has sudo access


def _read_probe_file(path: Path) -> Optional[str]:
def _read_probe_file(path: Path) -> str | None:
"""
Read an optional environment probe file.

Expand All @@ -53,7 +52,7 @@ def _read_probe_file(path: Path) -> Optional[str]:
return None


def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
def _detect_linux_distro() -> tuple[str | None, str | None]:
"""
Detect Linux distribution and version.

Expand Down Expand Up @@ -122,7 +121,7 @@ def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
return None, None


def _detect_cloud_provider() -> Optional[str]:
def _detect_cloud_provider() -> str | None:
"""
Detect if running on a cloud provider.

Expand Down Expand Up @@ -213,7 +212,7 @@ def _detect_wsl() -> bool:
return Path("/mnt/c").exists() and Path("/proc/version").exists()


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

Expand Down
14 changes: 7 additions & 7 deletions src/promptfoo/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import sys
import uuid
from pathlib import Path
from typing import Any, Optional
from typing import Any

import yaml
from posthog import Posthog
Expand Down Expand Up @@ -95,7 +95,7 @@ def _get_user_id() -> str:
return user_id


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

def __init__(self) -> None:
self._client: Optional[Posthog] = None
self._user_id: Optional[str] = None
self._email: Optional[str] = None
self._client: Posthog | None = None
self._user_id: str | None = None
self._email: str | None = None
self._initialized = False

@property
Expand Down Expand Up @@ -136,7 +136,7 @@ def _ensure_initialized(self) -> None:
except Exception:
self._client = None # Silently fail

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


# Global singleton instance
_telemetry: Optional[_Telemetry] = None
_telemetry: _Telemetry | None = None


def _get_telemetry() -> _Telemetry:
Expand Down
Loading
Loading