Skip to content

Commit 98da99c

Browse files
Merge pull request #25 from hsaunders1904/add_benchmarks
Add benchmarks
2 parents 1c17632 + eebc238 commit 98da99c

File tree

13 files changed

+530
-148
lines changed

13 files changed

+530
-148
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
uv run pre-commit run --all-files
2424
- name: Test
2525
run: |
26-
uv run pytest --cov --cov-report=term-missing --cov-report=xml
26+
uv run pytest --cov --cov-report=term-missing --cov-report=xml --benchmark-disable
2727
- name: Upload coverage to Codecov
2828
uses: codecov/codecov-action@v3
2929
with:
@@ -44,4 +44,4 @@ jobs:
4444
run: uv sync --all-groups
4545
- name: Test
4646
run: |
47-
uv run pytest --cov --cov-report=term-missing
47+
uv run pytest --cov --cov-report=term-missing --benchmark-disable

CONTRIBUTING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ To run the test suite:
5151
uv run pytest tests/
5252
```
5353

54+
### Benchmarks
55+
56+
To monitor performance, a set of benchmarks can be run:
57+
58+
```console
59+
uv run pytest benches/
60+
```
61+
5462
### Code Quality
5563

5664
Python linting and code formatting is provided by `ruff`.

benches/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Benchmarks test module."""

benches/conftest.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Pytest configuration for benchmarks."""
2+
3+
import base64
4+
import os
5+
import sys
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
from typing import Generator
9+
from unittest import mock
10+
11+
import pytest
12+
from typing_extensions import Buffer
13+
14+
import pyautoenv
15+
from benches.tools import environment_variable, make_venv
16+
from tests.tools import clear_lru_caches
17+
18+
POETRY_PYPROJECT = """[project]
19+
name = "{project_name}"
20+
version = "0.1.0"
21+
description = ""
22+
authors = [
23+
{{name = "A Name",email = "someemail@abc.com"}}
24+
]
25+
readme = "README.md"
26+
requires-python = ">=3.12"
27+
dependencies = [
28+
]
29+
30+
[tool.poetry]
31+
packages = [{{include = "{project_name}", from = "src"}}]
32+
33+
[build-system]
34+
requires = ["poetry-core>=2.0.0,<3.0.0"]
35+
build-backend = "poetry.core.masonry.api"
36+
"""
37+
38+
39+
@pytest.fixture(autouse=True)
40+
def reset_caches() -> None:
41+
"""Reset the LRU caches in pyautoenv."""
42+
clear_lru_caches(pyautoenv)
43+
44+
45+
@pytest.fixture(autouse=True, scope="module")
46+
def capture_logging() -> Generator[None, None, None]:
47+
"""Capture all logging as benchmarks are extremely noisy."""
48+
if __debug__:
49+
logging_disable = pyautoenv.logger.disabled
50+
try:
51+
pyautoenv.logger.disabled = True
52+
yield
53+
finally:
54+
pyautoenv.logger.disabled = logging_disable
55+
else:
56+
yield None
57+
58+
59+
@pytest.fixture(autouse=True, scope="module")
60+
def deactivate_venvs() -> Generator[None, None, None]:
61+
"""Fixture to 'deactivate' any currently active virtualenvs."""
62+
original_venv = os.environ.get("VIRTUAL_ENV")
63+
try:
64+
os.environ.pop("VIRTUAL_ENV", None)
65+
yield
66+
finally:
67+
if original_venv is not None:
68+
os.environ["VIRTUAL_ENV"] = original_venv
69+
70+
71+
@pytest.fixture
72+
def venv(tmp_path: Path) -> Path:
73+
"""Fixture returning a venv in a temporary directory."""
74+
return make_venv(tmp_path / "venv_fixture")
75+
76+
77+
@dataclass
78+
class PoetryVenvFixture:
79+
"""Poetry virtual environment fixture data."""
80+
81+
project_dir: Path
82+
venv_dir: Path
83+
84+
85+
@pytest.fixture
86+
def poetry_venv(tmp_path: Path) -> Generator[PoetryVenvFixture, None, None]:
87+
"""Create a poetry virtual environment and associated project."""
88+
# Make poetry's cache directory.
89+
cache_dir = tmp_path / "pypoetry"
90+
cache_dir.mkdir()
91+
virtualenvs_dir = cache_dir / "virtualenvs"
92+
virtualenvs_dir.mkdir()
93+
94+
# Create a virtual environment within the cache directory.
95+
project_name = "benchmark"
96+
py_version = ".".join(
97+
str(v) for v in [sys.version_info.major, sys.version_info.minor]
98+
)
99+
fake_hash = "SOMEHASH" + "A" * (32 - 8)
100+
venv_name = f"{project_name}-{fake_hash[:8]}-py{py_version}"
101+
venv_dir = make_venv(virtualenvs_dir, venv_name)
102+
103+
# Create a poetry project directory with a lockfile and pyproject.
104+
project_dir = tmp_path / project_name
105+
project_dir.mkdir()
106+
pyproject = project_dir / "pyproject.toml"
107+
with pyproject.open("w") as f:
108+
f.write(POETRY_PYPROJECT.format(project_name=project_name))
109+
(project_dir / "poetry.lock").touch()
110+
111+
# Mock base64 encode to return a fixed hash so the poetry env is
112+
# discoverable. Actually run the encoder so the benchmark is more
113+
# representative, but return a fixed value.
114+
real_b64_encode = base64.urlsafe_b64encode
115+
116+
def b64encode(s: Buffer) -> bytes:
117+
real_b64_encode(s)
118+
return fake_hash.encode()
119+
120+
with (
121+
mock.patch("base64.urlsafe_b64encode", new=b64encode),
122+
environment_variable("POETRY_CACHE_DIR", str(cache_dir)),
123+
):
124+
yield PoetryVenvFixture(project_dir, venv_dir)

benches/test_benchmarks.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Benchmarks for pyautoenv's main function."""
2+
3+
from io import StringIO
4+
from pathlib import Path
5+
from typing import Union
6+
7+
import pytest
8+
9+
import pyautoenv
10+
from benches.conftest import PoetryVenvFixture
11+
from benches.tools import make_venv, venv_active, working_directory
12+
from tests.tools import clear_lru_caches
13+
14+
15+
class ResettingStream(StringIO):
16+
"""
17+
A writable stream that resets its position to 0 after each write.
18+
19+
We can use this in benchmarks to check what's written to the stream
20+
in the final iteration.
21+
"""
22+
23+
def write(self, s):
24+
r = super().write(s)
25+
self.seek(0)
26+
return r
27+
28+
29+
def run_main_benchmark(benchmark, *, shell: Union[str, None] = None):
30+
stream = ResettingStream()
31+
argv = []
32+
if shell:
33+
argv.append(f"--{shell}")
34+
benchmark(pyautoenv.main, argv, stdout=stream)
35+
clear_lru_caches(pyautoenv)
36+
return stream.getvalue()
37+
38+
39+
def test_no_activation(benchmark, tmp_path: Path):
40+
with working_directory(tmp_path):
41+
assert not run_main_benchmark(benchmark)
42+
43+
44+
def test_deactivate(benchmark, venv: Path, tmp_path: Path):
45+
with venv_active(venv), working_directory(tmp_path):
46+
assert run_main_benchmark(benchmark) == "deactivate"
47+
48+
49+
@pytest.mark.parametrize("shell", [None, "fish", "pwsh"])
50+
def test_venv_activate(shell, benchmark, venv: Path):
51+
with working_directory(venv):
52+
output = run_main_benchmark(benchmark, shell=shell)
53+
54+
assert all(s in output.lower() for s in ["activate", str(venv).lower()]), (
55+
output
56+
)
57+
58+
59+
def test_venv_already_active(benchmark, venv: Path):
60+
with venv_active(venv), working_directory(venv):
61+
assert not run_main_benchmark(benchmark)
62+
63+
64+
@pytest.mark.parametrize("shell", [None, "fish", "pwsh"])
65+
def test_venv_switch_venv(shell, benchmark, venv: Path, tmp_path: Path):
66+
make_venv(tmp_path)
67+
68+
with venv_active(venv), working_directory(tmp_path):
69+
output = run_main_benchmark(benchmark, shell=shell)
70+
71+
assert all(
72+
s in output for s in ["deactivate", "&&", str(tmp_path), "activate"]
73+
), output
74+
75+
76+
@pytest.mark.parametrize("shell", [None, "fish", "pwsh"])
77+
def test_poetry_activate(shell, benchmark, poetry_venv: PoetryVenvFixture):
78+
with working_directory(poetry_venv.project_dir):
79+
output = run_main_benchmark(benchmark, shell=shell)
80+
81+
assert "activate" in output.lower()
82+
assert str(poetry_venv.venv_dir).lower() in output.lower()
83+
84+
85+
def test_poetry_already_active(benchmark, poetry_venv: PoetryVenvFixture):
86+
with (
87+
venv_active(poetry_venv.venv_dir),
88+
working_directory(poetry_venv.project_dir),
89+
):
90+
assert not run_main_benchmark(benchmark)
91+
92+
93+
@pytest.mark.parametrize("shell", [None, "fish", "pwsh"])
94+
def test_venv_switch_to_poetry(
95+
shell, benchmark, poetry_venv: PoetryVenvFixture, venv: Path
96+
):
97+
with venv_active(venv), working_directory(poetry_venv.project_dir):
98+
output = run_main_benchmark(benchmark, shell=shell)
99+
100+
assert all(
101+
s in output
102+
for s in ["deactivate", "&&", str(poetry_venv.venv_dir), "activate"]
103+
), output
104+
105+
106+
@pytest.mark.parametrize("shell", [None, "fish", "pwsh"])
107+
def test_poetry_switch_to_venv(
108+
shell, benchmark, poetry_venv: PoetryVenvFixture, venv: Path
109+
):
110+
with venv_active(poetry_venv.venv_dir), working_directory(venv):
111+
output = run_main_benchmark(benchmark, shell=shell)
112+
113+
assert all(
114+
s in output for s in ["deactivate", "&&", str(venv), "activate"]
115+
), output

benches/tools.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Utilities for benchmarks."""
2+
3+
import os
4+
from contextlib import contextmanager
5+
from pathlib import Path
6+
from typing import Generator
7+
8+
import virtualenv
9+
10+
11+
def make_venv(path: Path, venv_name: str = ".venv") -> Path:
12+
"""Make a virtual environment in the given directory."""
13+
venv_dir = path / venv_name
14+
virtualenv.cli_run([str(venv_dir)])
15+
return venv_dir
16+
17+
18+
@contextmanager
19+
def environment_variable(
20+
variable: str, value: str
21+
) -> Generator[None, None, None]:
22+
"""Set an environment variable within a context."""
23+
original_value = os.environ.get(variable)
24+
try:
25+
os.environ[variable] = value
26+
yield
27+
finally:
28+
if original_value:
29+
os.environ[variable] = original_value
30+
else:
31+
os.environ.pop(variable)
32+
33+
34+
@contextmanager
35+
def working_directory(path: Path) -> Generator[None, None, None]:
36+
"""Set the current working directory within a context."""
37+
original_path = Path.cwd()
38+
try:
39+
os.chdir(path)
40+
yield
41+
finally:
42+
os.chdir(original_path)
43+
44+
45+
@contextmanager
46+
def venv_active(venv_dir: Path) -> Generator[None, None, None]:
47+
"""Activate a virtual environment within a context."""
48+
if not venv_dir.is_dir():
49+
raise ValueError(f"Directory '{venv_dir}' does not exist.")
50+
with environment_variable("VIRTUAL_ENV", str(venv_dir)):
51+
yield

0 commit comments

Comments
 (0)