Ejemplo n.º 1
0
    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)
Ejemplo n.º 2
0
    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
Ejemplo n.º 3
0
    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)
Ejemplo n.º 4
0
    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)
Ejemplo n.º 5
0
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()
Ejemplo n.º 6
0
    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}")
Ejemplo n.º 7
0
 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)