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()