Metadata-Version: 2.5
Name: testudos
Version: 0.21.0
Summary: A testing harness for Python packages using uv's isolated environments
Project-URL: Homepage, https://github.com/martinristovski/testudos
Project-URL: Documentation, https://github.com/martinristovski/testudos/blob/main/README.md
Project-URL: Repository, https://github.com/martinristovski/testudos
Project-URL: Issues, https://github.com/martinristovski/testudos/issues
Author: Martin Ristovski
License: MIT
License-File: LICENSE
Keywords: multi-version,python,testing,uv
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Requires-Dist: httpx>=0.28.0
Requires-Dist: packaging>=25.0
Requires-Dist: rich>=14.0.0
Requires-Dist: tomli>=2.0.0; python_version < '3.11'
Requires-Dist: typer>=0.21.0
Provides-Extra: coverage
Requires-Dist: coverage>=7.13.0; extra == 'coverage'
Provides-Extra: dev
Requires-Dist: coverage>=7.13.0; extra == 'dev'
Requires-Dist: mypy>=1.19.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
Requires-Dist: pytest>=9.0.0; extra == 'dev'
Requires-Dist: ruff>=0.14.0; extra == 'dev'
Description-Content-Type: text/markdown

# testudos

[![CI](https://github.com/martinristovski/testudos/actions/workflows/ci.yml/badge.svg)](https://github.com/martinristovski/testudos/actions/workflows/ci.yml)
[![PyPI version](https://badge.fury.io/py/testudos.svg)](https://badge.fury.io/py/testudos)
[![Python versions](https://img.shields.io/pypi/pyversions/testudos.svg)](https://pypi.org/project/testudos/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A testing harness for Python packages using [uv](https://docs.astral.sh/uv/)'s isolated environments.

## Overview

Testudos runs your test suite across every Python version that is both still
supported upstream and compatible with your package. It works out which
versions those are by intersecting two sources:

1. **Currently supported Python versions** — from [endoflife.date](https://endoflife.date/python)
2. **Your package's compatibility** — the `requires-python` field in your `pyproject.toml`

Cycles that endoflife.date lists but that have no stable release yet (a
Python still in alpha, beta or rc) are left out unless you ask for them with
`--include-prerelease`.

## Quick start

```bash
# Install
uv tool install testudos

# See which versions would be tested, and why
testudos versions

# Run the suite on all of them
testudos run

# Run them concurrently
testudos run --parallel

# Show the exact commands without running anything
testudos run --dry-run
```

## Installation

```bash
uv tool install testudos    # recommended
pip install testudos
```

To use `testudos coverage combine` and `testudos coverage report`, which shell
out to coverage.py on your machine, install the extra:

```bash
uv tool install 'testudos[coverage]'
pip install 'testudos[coverage]'
```

Collecting coverage during `testudos run` does **not** need this — each
isolated environment gets its own copy of coverage.

### Requirements

- Python 3.10 or newer
- [uv](https://docs.astral.sh/uv/)

## How it works

For each Python version, testudos runs your tests in an ephemeral environment
created by `uv run --isolated`:

```bash
uv run --isolated --python=3.12 --with pytest --directory /path/to/project pytest
```

- `--isolated` builds a throwaway environment, so nothing leaks between versions
- `--python=X.Y` selects the interpreter, downloading it if needed
- `--with pytest` puts the test runner *inside* that environment, where it can
  see your project's dependencies

`--dry-run` prints exactly these commands, built by the same code that runs
them, so what you see is what would execute.

### Test dependencies

`uv run --isolated` installs your project and its **runtime** dependencies. If
your suite needs anything more — pytest plugins, fixture packages — name it,
either per-invocation or in config:

```bash
testudos run --with pytest-asyncio --with pytest-mock
```

```toml
[tool.testudos]
test-dependencies = ["pytest-asyncio", "pytest-cov>=7"]
```

## Usage

### Choosing versions

```bash
testudos run                                # auto-detect
testudos run --python 3.11 --python 3.12    # explicit (-p)
testudos run --python 3.14.0rc1             # pre-releases are allowed explicitly
testudos run --include-prerelease           # include unreleased cycles when auto-detecting
testudos run --offline                      # skip the API, use cached or bundled data
testudos run ./path/to/project              # a project other than the cwd
```

`testudos versions` shows the same resolution as a table, marking each cycle
stable or pre-release and naming any pre-release it left out.

Offline mode is also enabled by setting `TESTUDOS_OFFLINE`.

### Execution

```bash
testudos run --parallel                     # concurrent across versions (-P)
testudos run --parallel --jobs 4            # cap concurrency (-j)
testudos run --no-fail-fast                 # keep going after a failure (-F)
testudos run --timeout 300                  # seconds per version (-t)
testudos run --no-timeout                   # override a configured timeout
testudos run --verbose                      # more detail (-v)
testudos run --quiet                        # suppress failed-test output (-q)
testudos run --dry-run                      # print commands, run nothing (-n)
```

Fail-fast is on by default in sequential mode. Versions it skips are reported
as `SKIPPED` rather than dropped, so the summary always accounts for every
version you asked for. It does not apply in parallel mode, where every version
is already running by the time the first failure is known.

### Multiple packages

```bash
testudos run --package ./pkg1 --package ./pkg2   # explicit packages (-k)
testudos run ./monorepo --discover               # every dir with a pyproject.toml (-d)
testudos run ./pkg1 --package ./pkg2 --package-jobs 2   # cap package concurrency (-J)
testudos run --package ./pkg2 --no-parallel-packages    # one package at a time
```

Package-level and version-level concurrency are independent: `--parallel` /
`--jobs` control versions within a package, `--parallel-packages` /
`--package-jobs` control packages. Each package keeps its own configuration,
results and coverage data.

### Coverage

```bash
testudos run --coverage                                  # collect (-c)
testudos run --coverage --coverage-report html
testudos run --coverage --coverage-report html --coverage-report xml
testudos run --coverage --coverage-fail-under 80
testudos run --coverage --coverage-source mypackage      # default: src
```

Formats: `term`, `term-missing`, `html`, `xml`, `json`, `lcov`. Data lands in
`.testudos/coverage/`. The `--coverage-fail-under` threshold is checked
against exact totals, so it gives the same verdict whichever report formats
you asked for.

Coverage data can also be managed directly:

```bash
testudos coverage combine     # merge per-version data
testudos coverage report      # generate a report (--format, --fail-under)
testudos coverage clean       # remove .testudos/coverage
```

### Machine-readable output

```bash
testudos run --format json
testudos run --format json --output results.json    # (-o)
testudos run --format json --dry-run
```

JSON output covers single-package runs, and reports per-version results plus a
summary of passed, failed and skipped counts. It is not available with
`--coverage` or in multi-package mode.

### Private indexes

```bash
testudos run --default-index https://pypi.internal.example.com/simple
testudos run --index https://extra.example.com/simple
testudos run --find-links /path/to/wheels
testudos run --no-index --find-links /path/to/wheels
testudos run --index-strategy first-index
```

### Colour

```bash
testudos --color run      # force colour
testudos --no-color run   # disable it
```

These are options on `testudos` itself, so they go **before** the subcommand.
`NO_COLOR` and `FORCE_COLOR` are respected too.

## Configuration

Everything above can be set in `[tool.testudos]`:

```toml
[project]
requires-python = ">=3.11"

[tool.testudos]
# Version selection
python-versions = ["3.11", "3.12", "3.13"]   # override auto-detection
include-prerelease = false                    # include unreleased cycles
offline = false                               # never call endoflife.date

# What to run
test-command = "pytest"
test-args = ["-v", "--tb=short"]
test-dependencies = ["pytest-asyncio"]        # extra packages per isolated env
timeout = 300                                 # seconds per version

# How to run it
parallel = false
max-jobs = 4

# Coverage
coverage = false
coverage-combine = true
coverage-report = ["term"]
coverage-fail-under = 80
coverage-source = ["src"]

# Package indexes
default-index = "https://pypi.internal.example.com/simple"
index = ["https://extra.example.com/simple"]
find-links = ["/path/to/wheels"]
no-index = false
index-strategy = "first-index"
```

CLI options win over configuration. Values are validated on load: a wrong
type or an out-of-range value is an error, and an unrecognised key is a
warning.

`test-command` is a single executable name — `pytest`, `nose2`, `./run-tests`.
To run a module, use `test-command = "python"` with
`test-args = ["-m", "unittest", "discover"]`.

## Documentation

- [Architecture](docs/ARCHITECTURE.md) — how the pieces fit together, and the decisions behind them

## Development

```bash
git clone https://github.com/martinristovski/testudos.git
cd testudos
uv sync --extra dev

just ci            # lint, type-check, test, dogfood, version check
just test          # pytest with coverage
just fix           # auto-fix lint and formatting
just dogfood       # run testudos on itself
```

The test suite is hermetic: the endoflife.date API is stubbed and the version
cache is redirected per test, so no test touches the network or your real
`~/.cache/testudos`.

## License

MIT
