container_images/pytest/main.py (132 lines of code) (raw):

#!/usr/bin/env python3 # Copyright 2020 Google Inc. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Execute pytest within a virtual environment.""" import configparser import os from pathlib import Path import re import shutil import subprocess import sys import tempfile import typing import toml import tox # Dependencies to install in addition to user's requested dependencies. _common_test_deps = ["pytest==6.0.1"] # Command that's passed to tox. # # This is passed through 'format', so use double braces to bypass # the formatter's replacement (they'll be converted to a single brace). _per_interpreter_command = [ # Don't invoke the `pytest` binary directly, as it circumvents # the virtual environment. "python", "-m", "pytest", # Write test result to stdout out, except if test passes. "-ra", # Ensure junit report uses xunit1, which is the syntax used # by spyglass. # https://github.com/GoogleCloudPlatform/testgrid/blob/master/metadata/junit/junit.go "--override-ini=junit_family=xunit1", # Within the junit report, add a namespace for the environment, # to ensure that tests from different interpreters aren't # considered duplicates. "--junit-prefix={{envname}}", # Write a new junit xml file for each interpreter. The filename pattern # is defined in our Prow instance's configuration: # https://github.com/GoogleCloudPlatform/oss-test-infra/blob/cc1f0cf1ffecc0e3b75664b0d264abc6165276d1/prow/oss/config.yaml#L98 "--junit-xml={artifact_dir}/junit_{{envname}}.xml", ] class TestConfig: def __init__(self, repo_root: Path, package_root: Path, envlist: typing.Iterable[str], pip_deps: typing.Iterable[str], local_deps: typing.Iterable[str]): """ The user's test specifications. Args: repo_root: Filesystem path to the repo's root directory. package_root: Filesystem path to the Python package's root directory. envlist: Interpreters to run tests with. pip_deps: List of dependencies from Pip local_deps: List of dependencies installable from this repo. """ self.repo_root = repo_root self.package_root = package_root self.envlist = envlist self.pip_deps = pip_deps self.local_deps = local_deps def setup_execution_root(cfg: TestConfig) -> Path: """Create execution directory and copy project's code to it. Tox creates in-directory files, and its execution fails when the source tree is mounted read-only. Args: package_root: Absolute path to the Python package's root directory. Returns: Absolute path to execution root. """ assert cfg.package_root.exists(), \ f"Expected {cfg.package_root} to exist and to be an absolute path." exec_root = Path(tempfile.mkdtemp()) shutil.copytree(cfg.package_root, exec_root, dirs_exist_ok=True) if cfg.local_deps: dep_dir = exec_root / "deps" dep_dir.mkdir() for dep in cfg.local_deps: shutil.copytree(cfg.repo_root.absolute() / dep, dep_dir / dep) return Path(exec_root) def to_tox_version(py_version: str): """Convert `{major}.{minor}` to `py{major}{minor}`""" if re.fullmatch(r"\d.\d{1,2}", py_version): major, minor = py_version.split(".") return "py{major}{minor}".format(major=major, minor=minor) else: raise ValueError("Invalid version number: " + py_version) def read_tox_ini(repo_root: Path, package_root: Path) -> TestConfig: """Read pyproject.toml and write a new tox.ini file. The tox.ini file is generated to ensure that tests are run consistently, and that test reports are written to the correct location. """ cfg = package_root / "pyproject.toml" assert cfg.exists(), "Expected pyproject.toml to exist." with cfg.open() as f: project_cfg = toml.load(f) # Read the [tool.gcp-guest-pytest] section of pyproject.toml. test_cfg = project_cfg.get("tool", {}).get("gcp-guest-pytest", {}) # Which interpreters to enable. envlist = [to_tox_version(env) for env in test_cfg.get("envlist", [])] if not envlist: raise ValueError( "pyproject.toml must contain a section [tool.gcp-guest-pytest] " "with a key `envlist` and at least one interpreter.") pip_deps, local_deps = [], [] for dep in test_cfg.get("test-deps", []): if dep.startswith("//"): local = dep[2:] if not (repo_root / local).exists(): raise ValueError("Dependency {} not found".format(dep)) local_deps.append(local) else: pip_deps.append(dep) return TestConfig( repo_root=repo_root, package_root=package_root, envlist=envlist, pip_deps=pip_deps, local_deps=local_deps ) def write_tox_ini(cfg: TestConfig, artifact_dir: Path, execution_root: Path): """Write the test config to a new tox.ini file. The tox.ini file is generated to ensure that tests are run consistently, and that test reports are written to the correct location. """ config = configparser.ConfigParser() config["tox"] = { "envlist": ", ".join(cfg.envlist), } local_deps = [] dep_dir = execution_root.absolute() / "deps" for d in cfg.local_deps: local_deps.append((dep_dir / d).as_posix()) config["testenv"] = { "envlogdir": "{artifact_dir}/tox/{{envname}}".format(artifact_dir=artifact_dir), "deps": "\n\t".join(_common_test_deps + cfg.pip_deps + local_deps), "commands": " ".join(_per_interpreter_command).format(artifact_dir=artifact_dir), } if os.path.exists("tox.ini"): print("Removing existing tox.ini file.") os.remove("tox.ini") with open("tox.ini", "w") as f: config.write(f) def archive_configs(dst: Path): if dst.exists(): assert dst.is_dir(), f"Expected {dst} to be a directory." else: dst.mkdir() print("Saving config files to ", dst) for fname in ["pyproject.toml", "tox.ini"]: shutil.copy2(fname, dst) def validate_args(args: typing.List[str]) -> typing.Tuple[Path, Path]: if (len(args) > 1 and os.path.isdir(args[1]) and os.path.isfile(os.path.join(args[1], "pyproject.toml"))): package_root = os.path.abspath(args[1]) else: raise ValueError("First argument must be path to python package") if "ARTIFACTS" in os.environ and os.path.exists(os.environ["ARTIFACTS"]): artifact_dir = os.path.abspath(os.environ["ARTIFACTS"]) else: raise ValueError("$ARTIFACTS must point to a directory that exists") return Path(artifact_dir), Path(package_root) def main(): print("args:", sys.argv) print("$ARTIFACTS:", os.environ.get("ARTIFACTS")) artifact_dir, package_root = validate_args(sys.argv) cfg = read_tox_ini(Path.cwd(), package_root) # Create a new execution area, since we'll be writing files. execution_root = setup_execution_root(cfg) os.chdir(execution_root) subprocess.run(["ls", "-lah", "deps"]) print("Executing tests in", execution_root) write_tox_ini(cfg, artifact_dir, Path()) archive_configs(artifact_dir / "tox") result_code = tox.cmdline(["-v"]) sys.exit(result_code) if __name__ == "__main__": main()