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
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ jobs:
- "3.14"
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v1
uses: astral-sh/setup-uv@v8.1.0

- name: Install dependencies
run: uv sync --dev
run: uv sync --dev --group=docs

- name: Ruff format check
run: uv run --dev ruff format --check --diff .
Expand All @@ -56,7 +56,10 @@ jobs:
# Unset it so pytest can discover pytest-cov and pytest-benchmark via entry points,
# which are required by the addopts configured in pyproject.toml.
unset PYTEST_DISABLE_PLUGIN_AUTOLOAD
uv run --dev pytest -vv --cov=alternative --cov-report=xml --cov-fail-under=100 --junitxml=test-results.xml
uv run --dev pytest --verbosity=2 --cov=alternative --cov-report=xml --cov-fail-under=100 --junit-xml=test-results.xml

- name: Documentation warnings treated as errors
run: uv run --group=docs sphinx-build --fail-on-warning --keep-going --builder=html docs /tmp/alternative-docs-html

- name: Upload coverage to Codecov
if: (success() || hashFiles('coverage.xml') != '') && env.CODECOV_TOKEN != ''
Expand Down
17 changes: 17 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: 2

build:
os: ubuntu-24.04
tools:
python: "3.12"

python:
install:
- method: uv
command: sync
groups:
- docs

sphinx:
configuration: docs/conf.py
fail_on_warning: true
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
The repository defines testing via GitHub actions. When contributing:

* You must check the changes are correct using the same commands as the workflow:
* `uv sync --dev`
* `uv sync --dev --group=docs`
* `uv run --dev ruff format --check --diff .`
* `uv run --dev ruff check .`
* `uv run --dev pyrefly check .`
* `uv run --dev mypy .`
* `uv run --dev pytest -vv --cov=alternative --cov-report=xml --cov-fail-under=100 --junitxml=test-results.xml`
* `uv run --dev pytest --verbosity=2 --cov=alternative --cov-report=xml --cov-fail-under=100 --junit-xml=test-results.xml`
* `uv run --group=docs sphinx-build --fail-on-warning --keep-going --builder=html docs /tmp/alternative-docs-html`
* Format code with `uv run --dev ruff format .` before committing.
* Keep the documentation in `docs/` up to date with user-facing behavior, API, and workflow changes. Documentation must compile without warnings.
* Any change to branching paths in `alternative.py` must be followed by a branch coverage run and review for material missing runtime coverage using `uv run --dev pytest --cov=alternative --cov-branch --cov-report=term-missing:skip-covered`.
* Name tests and functions in `snake_case` and give them triple-quoted docstrings similar to the current codebase.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
[![PyPi Package](https://badge.fury.io/py/alternative.svg)](https://pypi.org/project/alternative/) [![Build Status](https://github.com/Code0x58/alternative/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Code0x58/alternative/actions/workflows/ci.yml) [![Coverage Report](https://codecov.io/gh/Code0x58/alternative/branch/master/graph/badge.svg)](https://codecov.io/gh/Code0x58/alternative)
[![PyPi Package](https://badge.fury.io/py/alternative.svg)](https://pypi.org/project/alternative/) [![Build Status](https://github.com/Code0x58/alternative/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Code0x58/alternative/actions/workflows/ci.yml) [![Coverage Report](https://codecov.io/gh/Code0x58/alternative/branch/master/graph/badge.svg)](https://codecov.io/gh/Code0x58/alternative) [![Documentation Status](https://readthedocs.org/projects/alternative/badge/?version=latest)](https://alternative.readthedocs.io/en/latest/?badge=latest)

# alternative

A tiny, dependency-free library for managing multiple implementations of the same function — especially when you're iterating toward faster or cleaner versions and want those choices to stay explicit, testable, and safe.

Full documentation is available on [Read the Docs](https://alternative.readthedocs.io/en/latest/). The documentation source lives in [`docs/`](docs/).

## Why use this?

When optimizing a hot path, it’s common to accumulate:
Expand Down
34 changes: 34 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
API Reference
=============

This page documents the public API exported by ``alternative``.

Decorator
---------

.. autofunction:: alternative.reference

Alternatives
------------

.. autoclass:: alternative.Alternatives
:members:
:special-members: __call__

Implementation
--------------

.. autoclass:: alternative.Implementation
:members:
:special-members: __call__

Exceptions
----------

.. autoexception:: alternative.AlternativeError

.. autoexception:: alternative.AddTooLateError

.. autoexception:: alternative.MultipleDefaultsError

.. autoexception:: alternative.CrossAlternativesImplementationError
41 changes: 41 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

import os
import sys
from importlib import metadata
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

project = "alternative"
author = "Oliver Bristow"
copyright = "2025, Oliver Bristow"
release = metadata.version("alternative")
version = release

extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.intersphinx",
"sphinx.ext.napoleon",
]

templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]

html_theme = "sphinx_rtd_theme"
html_title = "alternative"
html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "")
html_theme_options = {
"collapse_navigation": False,
"navigation_depth": 3,
}

autodoc_typehints = "description"
autodoc_member_order = "bysource"
autoclass_content = "both"

intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"pytest": ("https://docs.pytest.org/en/stable", None),
}
77 changes: 77 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
alternative
===========

``alternative`` is a tiny, dependency-free Python library for managing multiple
implementations of the same function.

It is designed for a common optimisation workflow:

* keep a trusted reference implementation;
* register faster, clearer, or more specialised candidate implementations;
* run equivalence tests across every implementation;
* select the implementation that normal callers should use.

The library keeps those choices explicit. The selected implementation cannot be
changed after it has been used, and the implementation list cannot be extended
after it has been inspected by test helpers.

Install
-------

.. code-block:: console

$ pip install alternative

``alternative`` supports Python 3.10 and newer.

A First Example
---------------

.. code-block:: python

import alternative


@alternative.reference
def total(values: list[int]) -> int:
result = 0
for value in values:
result += value
return result


@total.add(default=True)
def total_builtin(values: list[int]) -> int:
return sum(values)


assert total([1, 2, 3]) == 6
assert total_builtin([1, 2, 3]) == 6

Calling ``total`` uses the selected default implementation. Calling
``total_builtin`` directly still calls that implementation by itself, which is
useful in tests and benchmarks.

Contents
--------

.. toctree::
:maxdepth: 2

quickstart
workflow
pytest
api

Project Links
-------------

* `PyPI package <https://pypi.org/project/alternative/>`__
* `Source repository <https://github.com/Code0x58/alternative>`__

Indices
-------

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
96 changes: 96 additions & 0 deletions docs/pytest.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
Pytest Integration
==================

The pytest helpers are the main reason to register implementations explicitly.
They let one test body cover every implementation without hand-maintained
parametrize lists.

Equivalence Tests
-----------------

Use :meth:`alternative.Alternatives.pytest_parametrize_pairs` when each
candidate implementation should match the reference:

.. code-block:: python

import alternative


@alternative.reference
def total_loop(values: list[int]) -> int:
result = 0
for value in values:
result += value
return result


@total_loop.add(default=True)
def total_sum(values: list[int]) -> int:
return sum(values)


@total_loop.pytest_parametrize_pairs()
def test_totals_are_equivalent(reference, implementation):
"""Every implementation returns the same total as the reference."""
values = [1, 2, 3]
assert implementation(values) == reference(values)

The helper parametrizes ``reference`` with a cached wrapper around the reference
implementation and parametrizes ``implementation`` with each candidate.

Reference Caching
-----------------

The ``n_cache`` argument is passed to :func:`functools.lru_cache` for the
reference callable used in pairwise tests:

.. code-block:: python

@total_loop.pytest_parametrize_pairs(n_cache=None)
def test_with_unbounded_reference_cache(reference, implementation):
"""The reference result may be reused across implementations."""
assert implementation((1, 2, 3)) == reference((1, 2, 3))

Use ``n_cache=0`` when you do not want effective caching. Use ``None`` for an
unbounded cache when the reference is expensive and arguments are hashable.

Only the Default Implementation
-------------------------------

Set ``only_default=True`` to limit parametrization to the reference and selected
default implementation:

.. code-block:: python

@total_loop.pytest_parametrize(only_default=True)
def test_selected_implementation_accepts_input(implementation):
"""The reference and default implementation both accept list input."""
assert implementation([1, 2, 3]) == 6

This is useful for expensive test suites where exhaustive coverage happens in a
smaller equivalence test.

Benchmark All Implementations
-----------------------------

Use :meth:`alternative.Alternatives.pytest_parametrize` with
``pytest-benchmark`` to benchmark every implementation:

.. code-block:: python

@total_loop.pytest_parametrize()
def test_total_benchmark(benchmark, implementation):
"""Benchmark each registered implementation."""
assert benchmark(implementation, [1, 2, 3]) == 6

Pytest generates readable parameter names from the underlying function names.

Collection Order
----------------

Register implementations before tests inspect the alternatives set. Once
``.implementations`` is read, further additions raise
:class:`alternative.AddTooLateError`.

That rule is deliberate. It prevents a test module from collecting a partial
implementation list and then silently missing an implementation imported later.
Loading
Loading