|
| 1 | +"""Run performance comparisons for versions of coverage""" |
| 2 | + |
| 3 | +import contextlib |
| 4 | +import dataclasses |
| 5 | +import os |
| 6 | +import shutil |
| 7 | +import statistics |
| 8 | +import subprocess |
| 9 | +import time |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +from typing import Iterator, List, Optional, Tuple, Union |
| 13 | + |
| 14 | + |
| 15 | +class ShellSession: |
| 16 | + """A logged shell session. |
| 17 | +
|
| 18 | + The duration of the last command is available as .last_duration. |
| 19 | + """ |
| 20 | + |
| 21 | + def __init__(self, output_filename: str): |
| 22 | + self.output_filename = output_filename |
| 23 | + self.last_duration: float = 0 |
| 24 | + |
| 25 | + def __enter__(self): |
| 26 | + self.foutput = open(self.output_filename, "a", encoding="utf-8") |
| 27 | + print(f"Logging output to {os.path.abspath(self.output_filename)}") |
| 28 | + return self |
| 29 | + |
| 30 | + def __exit__(self, exc_type, exc_value, traceback): |
| 31 | + self.foutput.close() |
| 32 | + |
| 33 | + def print(self, *args, **kwargs): |
| 34 | + print(*args, **kwargs, file=self.foutput) |
| 35 | + |
| 36 | + def run_command(self, cmd: str) -> str: |
| 37 | + """ |
| 38 | + Run a command line (with a shell). |
| 39 | +
|
| 40 | + Returns: |
| 41 | + str: the output of the command. |
| 42 | +
|
| 43 | + """ |
| 44 | + self.print(f"\n========================\n$ {cmd}") |
| 45 | + start = time.perf_counter() |
| 46 | + proc = subprocess.run( |
| 47 | + cmd, |
| 48 | + shell=True, |
| 49 | + check=False, |
| 50 | + stdout=subprocess.PIPE, |
| 51 | + stderr=subprocess.STDOUT, |
| 52 | + ) |
| 53 | + output = proc.stdout.decode("utf-8") |
| 54 | + self.last_duration = time.perf_counter() - start |
| 55 | + self.print(output, end="") |
| 56 | + self.print(f"(was: {cmd})") |
| 57 | + self.print(f"(in {os.getcwd()}, duration: {self.last_duration:.3f}s)") |
| 58 | + |
| 59 | + if proc.returncode != 0: |
| 60 | + self.print(f"ERROR: command returned {proc.returncode}") |
| 61 | + raise Exception( |
| 62 | + f"Command failed ({proc.returncode}): {cmd!r}, output was:\n{output}" |
| 63 | + ) |
| 64 | + |
| 65 | + return output.strip() |
| 66 | + |
| 67 | + |
| 68 | +def rmrf(path: Path) -> None: |
| 69 | + """ |
| 70 | + Remove a directory tree. It's OK if it doesn't exist. |
| 71 | + """ |
| 72 | + if path.exists(): |
| 73 | + shutil.rmtree(path) |
| 74 | + |
| 75 | + |
| 76 | +@contextlib.contextmanager |
| 77 | +def change_dir(newdir: Path) -> Iterator[Path]: |
| 78 | + """ |
| 79 | + Change to a new directory, and then change back. |
| 80 | +
|
| 81 | + Will make the directory if needed. |
| 82 | + """ |
| 83 | + old_dir = os.getcwd() |
| 84 | + newdir.mkdir(parents=True, exist_ok=True) |
| 85 | + os.chdir(newdir) |
| 86 | + try: |
| 87 | + yield newdir |
| 88 | + finally: |
| 89 | + os.chdir(old_dir) |
| 90 | + |
| 91 | + |
| 92 | +@contextlib.contextmanager |
| 93 | +def file_replace(file_name: Path, old_text: str, new_text: str) -> Iterator[None]: |
| 94 | + """ |
| 95 | + Replace some text in `file_name`, and change it back. |
| 96 | + """ |
| 97 | + if old_text: |
| 98 | + file_text = file_name.read_text() |
| 99 | + if old_text not in file_text: |
| 100 | + raise Exception("Old text {old_text!r} not found in {file_name}") |
| 101 | + updated_text = file_text.replace(old_text, new_text) |
| 102 | + file_name.write_text(updated_text) |
| 103 | + try: |
| 104 | + yield |
| 105 | + finally: |
| 106 | + if old_text: |
| 107 | + file_name.write_text(file_text) |
| 108 | + |
| 109 | + |
| 110 | +class ProjectToTest: |
| 111 | + """Information about a project to use as a test case.""" |
| 112 | + |
| 113 | + # Where can we clone the project from? |
| 114 | + git_url: Optional[str] = None |
| 115 | + |
| 116 | + def __init__(self): |
| 117 | + self.slug = self.git_url.split("/")[-1] |
| 118 | + self.dir = Path(self.slug) |
| 119 | + |
| 120 | + def get_source(self, shell): |
| 121 | + """Get the source of the project.""" |
| 122 | + if self.dir.exists(): |
| 123 | + rmrf(self.dir) |
| 124 | + shell.run_command(f"git clone {self.git_url}") |
| 125 | + |
| 126 | + def prep_environment(self, env): |
| 127 | + """Prepare the environment to run the test suite. |
| 128 | +
|
| 129 | + This is not timed. |
| 130 | + """ |
| 131 | + pass |
| 132 | + |
| 133 | + def run_no_coverage(self, env): |
| 134 | + """Run the test suite with no coverage measurement.""" |
| 135 | + pass |
| 136 | + |
| 137 | + def run_with_coverage(self, env, pip_args, cov_options): |
| 138 | + """Run the test suite with coverage measurement.""" |
| 139 | + pass |
| 140 | + |
| 141 | + |
| 142 | +class ToxProject(ProjectToTest): |
| 143 | + """A project using tox to run the test suite.""" |
| 144 | + |
| 145 | + def prep_environment(self, env): |
| 146 | + env.shell.run_command(f"{env.python} -m pip install tox") |
| 147 | + self.run_tox(env, env.pyver.toxenv, "--notest") |
| 148 | + |
| 149 | + def run_tox(self, env, toxenv, toxargs=""): |
| 150 | + env.shell.run_command(f"{env.python} -m tox -e {toxenv} {toxargs}") |
| 151 | + return env.shell.last_duration |
| 152 | + |
| 153 | + def run_no_coverage(self, env): |
| 154 | + return self.run_tox(env, env.pyver.toxenv, "--skip-pkg-install") |
| 155 | + |
| 156 | + |
| 157 | +class PytestHtml(ToxProject): |
| 158 | + """pytest-dev/pytest-html""" |
| 159 | + |
| 160 | + git_url = "https://github.com/pytest-dev/pytest-html" |
| 161 | + |
| 162 | + def run_with_coverage(self, env, pip_args, cov_options): |
| 163 | + covenv = env.pyver.toxenv + "-cov" |
| 164 | + self.run_tox(env, covenv, "--notest") |
| 165 | + env.shell.run_command(f".tox/{covenv}/bin/python -m pip install {pip_args}") |
| 166 | + if cov_options: |
| 167 | + replace = ("# reference: https", f"[run]\n{cov_options}\n#") |
| 168 | + else: |
| 169 | + replace = ("", "") |
| 170 | + with file_replace(Path(".coveragerc"), *replace): |
| 171 | + env.shell.run_command("cat .coveragerc") |
| 172 | + env.shell.run_command(f".tox/{covenv}/bin/python -m coverage debug sys") |
| 173 | + return self.run_tox(env, covenv, "--skip-pkg-install") |
| 174 | + |
| 175 | + |
| 176 | +class PyVersion: |
| 177 | + # The command to run this Python |
| 178 | + command: str |
| 179 | + # The tox environment to run this Python |
| 180 | + toxenv: str |
| 181 | + |
| 182 | + |
| 183 | +class Python(PyVersion): |
| 184 | + """A version of CPython to use.""" |
| 185 | + |
| 186 | + def __init__(self, major, minor): |
| 187 | + self.command = f"python{major}.{minor}" |
| 188 | + self.toxenv = f"py{major}{minor}" |
| 189 | + |
| 190 | + |
| 191 | +class PyPy(PyVersion): |
| 192 | + """A version of PyPy to use.""" |
| 193 | + |
| 194 | + def __init__(self, major, minor): |
| 195 | + self.command = f"pypy{major}.{minor}" |
| 196 | + self.toxenv = f"pypy{major}{minor}" |
| 197 | + |
| 198 | + |
| 199 | +@dataclasses.dataclass |
| 200 | +class Env: |
| 201 | + """An environment to run a test suite in.""" |
| 202 | + |
| 203 | + pyver: PyVersion |
| 204 | + python: Path |
| 205 | + shell: ShellSession |
| 206 | + |
| 207 | + |
| 208 | +def run_experiments( |
| 209 | + py_versions: List[PyVersion], |
| 210 | + cov_versions: List[Tuple[str, Optional[str], Optional[str]]], |
| 211 | + projects: List[ProjectToTest], |
| 212 | + num_runs=3, |
| 213 | +): |
| 214 | + """Run test suites under different conditions.""" |
| 215 | + |
| 216 | + for proj in projects: |
| 217 | + print(f"Testing with {proj.git_url}") |
| 218 | + with ShellSession(f"output_{proj.slug}.log") as shell: |
| 219 | + proj.get_source(shell) |
| 220 | + |
| 221 | + for pyver in py_versions: |
| 222 | + print(f"Making venv for {proj.slug} {pyver.command}") |
| 223 | + venv_dir = f"venv_{proj.slug}_{pyver.command}" |
| 224 | + shell.run_command(f"{pyver.command} -m venv {venv_dir}") |
| 225 | + python = Path.cwd() / f"{venv_dir}/bin/python" |
| 226 | + shell.run_command(f"{python} -V") |
| 227 | + env = Env(pyver, python, shell) |
| 228 | + |
| 229 | + with change_dir(Path(proj.slug)): |
| 230 | + print(f"Prepping for {proj.slug} {pyver.command}") |
| 231 | + proj.prep_environment(env) |
| 232 | + for cov_slug, cov_pip, cov_options in cov_versions: |
| 233 | + durations = [] |
| 234 | + for run_num in range(num_runs): |
| 235 | + print( |
| 236 | + f"Running tests, cov={cov_slug}, {run_num+1} of {num_runs}" |
| 237 | + ) |
| 238 | + if cov_pip is None: |
| 239 | + dur = proj.run_no_coverage(env) |
| 240 | + else: |
| 241 | + dur = proj.run_with_coverage(env, cov_pip, cov_options) |
| 242 | + print(f"Tests took {dur:.3f}s") |
| 243 | + durations.append(dur) |
| 244 | + med = statistics.median(durations) |
| 245 | + print( |
| 246 | + f"## Median for {pyver.command}, cov={cov_slug}: {med:.3f}s" |
| 247 | + ) |
| 248 | + |
| 249 | + |
| 250 | +PERF_DIR = Path("/tmp/covperf") |
| 251 | + |
| 252 | + |
| 253 | +print(f"Removing and re-making {PERF_DIR}") |
| 254 | +rmrf(PERF_DIR) |
| 255 | + |
| 256 | +with change_dir(PERF_DIR): |
| 257 | + |
| 258 | + run_experiments( |
| 259 | + py_versions=[ |
| 260 | + Python(3, 10), |
| 261 | + ], |
| 262 | + cov_versions=[ |
| 263 | + ("none", None, None), |
| 264 | + ("6.4", "coverage==6.4", ""), |
| 265 | + ("6.4 timid", "coverage==6.4", "timid=True"), |
| 266 | + ( |
| 267 | + "PR 1381", |
| 268 | + "git+https://github.com/cfbolz/coveragepy.git@f_trace_lines", |
| 269 | + "", |
| 270 | + ), |
| 271 | + ( |
| 272 | + "PR 1381 timid", |
| 273 | + "git+https://github.com/cfbolz/coveragepy.git@f_trace_lines", |
| 274 | + "timid=True", |
| 275 | + ), |
| 276 | + ], |
| 277 | + projects=[ |
| 278 | + PytestHtml(), |
| 279 | + ], |
| 280 | + num_runs=3, |
| 281 | + ) |
| 282 | + |
| 283 | + run_experiments( |
| 284 | + py_versions=[ |
| 285 | + PyPy(3, 9), |
| 286 | + ], |
| 287 | + cov_versions=[ |
| 288 | + ("none", None, None), |
| 289 | + ("6.4", "coverage==6.4", ""), |
| 290 | + ( |
| 291 | + "PR 1381", |
| 292 | + "git+https://github.com/cfbolz/coveragepy.git@f_trace_lines", |
| 293 | + "", |
| 294 | + ), |
| 295 | + ], |
| 296 | + projects=[ |
| 297 | + PytestHtml(), |
| 298 | + ], |
| 299 | + num_runs=3, |
| 300 | + ) |
0 commit comments