def test_case(self, name: str) -> Iterator[None]: """Execute a test case. This context manager provides a very lightweight testing framework. If the body of the context manager raises an exception, the test case is considered to have failed; otherwise it is considered to have succeeded. In either case the execution time and status of the test are recorded in `test_results`. Example: A simple workflow that executes a table-driven test: ``` @dataclass class TestCase: name: str files: list[str] test_cases = [ TestCase(name="short", files=["quicktests.td"]), TestCase(name="long", files=["longtest1.td", "longtest2.td"]), ] def workflow_default(c: Composition): for tc in test_cases: with c.test_case(tc.name): c.run("testdrive", *tc.files) ``` Args: name: The name of the test case. Must be unique across the lifetime of a composition. """ if name in self.test_results: raise UIError(f"test case {name} executed twice") ui.header(f"Running test case {name}") error = None start_time = time.time() try: yield ui.header(f"mzcompose: test case {name} succeeded") except Exception as e: error = str(e) if isinstance(e, UIError): print(f"mzcompose: test case {name} failed: {e}", file=sys.stderr) else: print(f"mzcompose: test case {name} failed:", file=sys.stderr) traceback.print_exc() elapsed = time.time() - start_time self.test_results[name] = Composition.TestResult(elapsed, error)
def acquire(self) -> None: """Download or build the image if it does not exist locally.""" if self.acquired: return ui.header(f"Acquiring {self.spec()}") try: spawn.runv( ["docker", "pull", self.spec()], stdout=sys.stderr.buffer, ) except subprocess.CalledProcessError: self.build() self.acquired = True
def run(self, args: argparse.Namespace) -> None: if args.help: output = self.capture( ["docker-compose", self.name, "--help"], stderr=subprocess.STDOUT ) output = output.replace("docker-compose", "./mzcompose") output += "\nThis command is a wrapper around Docker Compose." if self.help_epilog: output += "\n" output += self.help_epilog print(output, file=sys.stderr) return # Make sure Docker Compose is new enough. output = ( self.capture( ["docker-compose", "version", "--short"], stderr=subprocess.STDOUT ) .strip() .strip("v") ) version = tuple(int(i) for i in output.split(".")) if version < MIN_COMPOSE_VERSION: raise UIError( f"unsupported docker-compose version v{output}", hint=f"minimum version allowed: v{'.'.join(str(p) for p in MIN_COMPOSE_VERSION)}", ) composition = load_composition(args) ui.header("Collecting mzbuild images") for d in composition.dependencies: ui.say(d.spec()) if self.runs_containers: if args.coverage: # If the user has requested coverage information, create the # coverage directory as the current user, so Docker doesn't create # it as root. (composition.path / "coverage").mkdir(exist_ok=True) self.check_docker_resource_limits() composition.dependencies.acquire() if "services" in composition.compose: composition.pull_if_variable(composition.compose["services"].keys()) self.handle_composition(args, composition)
def workflow(self, name: str, *args: str) -> None: """Run a workflow in the composition. Raises a `KeyError` if the workflow does not exist. Args: name: The name of the workflow to run. args: The arguments to pass to the workflow function. """ ui.header(f"Running workflow {name}") func = self.workflows[name] parser = WorkflowArgumentParser(name, inspect.getdoc(func), list(args)) if len(inspect.signature(func).parameters) > 1: func(self, parser) else: # If the workflow doesn't have an `args` parameter, parse them here # with an empty parser to reject bogus arguments and to handle the # trivial help message. parser.parse_args() func(self)
def upload_junit_report(suite: str, junit_report: Path) -> None: """Upload a JUnit report to Buildkite Test Analytics. Outside of CI, this function does nothing. Inside of CI, the API key for Buildkite Test Analytics is expected to be in the environment variable `BUILDKITE_TEST_ANALYTICS_API_KEY_{SUITE}`, where `{SUITE}` is the upper-snake-cased rendition of the `suite` parameter. Args: suite: The identifier for the test suite in Buildkite Test Analytics. junit_report: The path to the JUnit XML-formatted report file. """ if "CI" not in os.environ: return ui.header( f"Uploading report for suite {suite!r} to Buildkite Test Analytics") suite = suite.upper().replace("-", "_") token = os.environ[f"BUILDKITE_TEST_ANALYTICS_API_KEY_{suite}"] res = requests.post( "https://analytics-api.buildkite.com/v1/uploads", headers={"Authorization": f"Token {token}"}, json={ "format": "junit", "run_env": { "key": os.environ["BUILDKITE_BUILD_ID"], "CI": "buildkite", "number": os.environ["BUILDKITE_BUILD_NUMBER"], "job_id": os.environ["BUILDKITE_JOB_ID"], "branch": os.environ["BUILDKITE_BRANCH"], "commit_sha": os.environ["BUILDKITE_COMMIT"], "message": os.environ["BUILDKITE_MESSAGE"], "url": os.environ["BUILDKITE_BUILD_URL"], }, "data": junit_report.read_text(), }, ) print(res.status_code, res.json()) res.raise_for_status()
def acquire(self, force_build: bool = False) -> None: """Download or build all of the images in the dependency set. Args: force_build: Whether to force all images that are not already available locally to be built from source, regardless of whether the image is available for download. Note that this argument has no effect if the image is already available locally. push: Whether to push any images that will built locally to Docker Hub. """ known_images = docker_images() for d in self: spec = d.spec() if spec not in known_images: if force_build: ui.header(f"Force-building {spec}") d.build() else: ui.header(f"Acquiring {spec}") d.acquire() else: ui.header(f"Already built {spec}")
def handle_composition(self, args: argparse.Namespace, composition: mzcompose.Composition) -> None: ui.header("Delegating to Docker Compose") composition.invoke(*args.unknown_args, self.name, *args.unknown_subargs)