Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.13"]
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.13) 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.13
- **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.13 (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
14 changes: 4 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ 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",
"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 +41,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 +69,7 @@ packages = ["src/promptfoo"]

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

[tool.ruff.lint]
extend-select = [
Expand All @@ -97,7 +91,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 +115,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