Skip to content

cli

CLI application definition for junix.

Build

Bases: Application

Build flake attributes and produce JUnit XML.

Pass -- followed by extra flags to forward them to nix build::

junix build nixpkgs#hello -- --no-link --rebuild
Source code in src/junix/cli.py
@Junix.subcommand("build")
class Build(cli.Application):  # noqa: D415
    """Build flake attributes and produce JUnit XML.

    Pass ``--`` followed by extra flags to forward them to ``nix build``::

        junix build nixpkgs#hello -- --no-link --rebuild
    """

    PROGNAME = "junix build"

    output = cli.SwitchAttr(
        ["-o", "--output"],
        help="Path to write the JUnit XML report (default: stdout).",
    )
    store = cli.SwitchAttr(
        ["-s", "--store"],
        help="Remote store URL (e.g. ssh-ng://eu.nixbuild.net).",
    )

    @_plumbum_async_main
    async def main(self, *args: str) -> int:  # noqa: D102
        parent = cast(Junix, self.parent)
        attrs, extra_flags = _split_extra_flags(args)
        parent.log(f"building: {' '.join(attrs)}", level=2)

        color_mode = _resolve_color_mode(parent._color)
        store = self.store or _env("JUNIX_BUILD_STORE")

        # Each testcase is named after the exact reproducer command so CI
        # failures are copy-pasteable: `nix build nixpkgs#hello`.  The
        # rename happens inside `run_nix_build` paired with the attrs
        # list, since Nix doesn't expose which drv came from which attr
        # through the @nix protocol alone.
        expected_names = [f"nix build {a}" for a in attrs]
        # Resolve the drv path for each requested attr in one bulk
        # `nix eval --json` call.  This lets `run_nix_build` mark
        # dep-failed targets as failures (instead of cached/skipped)
        # when a transitive dep fails.  Resolving per-attr with
        # `--raw` would also work but adds N calls.  A single
        # `--json` call with a Nix expression that maps each attr to
        # its drv path keeps it to one round trip.
        expected_drv_paths = await _resolve_drv_paths(attrs)
        builds, errors, exit_code, downloaded = await run_nix_build(
            attrs,
            store=store,
            log_level=parent._nix_log_level,
            expected_count=len(attrs),
            expected_names=expected_names,
            expected_drv_paths=expected_drv_paths,
            extra_flags=extra_flags,
        )
        # Per-build wall-clock is in BuildEvent.duration, recorded by
        # NixEventHandler between each actBuild start/stop event in
        # the stream. Cached / synthetic entries have no duration
        # (time="0"). Sum-of-testcase-times equals the suite total
        # that junit.py computes from this map.
        testcase_times = {b.name: (b.duration or 0.0) for b in builds}
        xml = report_to_xml_multi(
            [
                TestSuite(
                    name="nix-build",
                    builds=builds,
                    error_messages=errors or None,
                    testcase_times=testcase_times,
                ),
            ]
        )
        _write_report(xml, self.output or _env("JUNIX_BUILD_OUTPUT"))
        # Same summary as `check`, with no evals to report.  `build` has
        # no per-arch sections, so we list the builds here and then
        # print the final line via the shared helper.  The header
        # matches the style of `check`'s per-arch sections so the
        # visual rhythm of the output is consistent.
        _print_build_list(
            builds,
            downloaded=downloaded,
            header=f"build . ... {len(builds)} build(s)",
        )
        _print_check_summary(
            evals=[],
            builds=builds,
            overall_success=exit_code == 0,
            color_mode=color_mode,
        )
        return exit_code

Check

Bases: Application

Evaluate and build all checks of a flake.

Pass -- followed by extra flags to forward them to nix build::

junix check -- --no-link --rebuild
Source code in src/junix/cli.py
@Junix.subcommand("check")
class Check(cli.Application):  # noqa: D415
    """Evaluate and build all checks of a flake.

    Pass ``--`` followed by extra flags to forward them to ``nix build``::

        junix check -- --no-link --rebuild
    """

    PROGNAME = "junix check"

    output = cli.SwitchAttr(
        ["-o", "--output"],
        help="Path to write the JUnit XML report (default: stdout).",
    )
    eval_arch = cli.SwitchAttr(
        ["-e", "--eval-arch"],
        list=True,
        help="Architecture to evaluate (default: all; repeatable).",
    )
    build_arch = cli.SwitchAttr(
        ["-b", "--build-arch"],
        list=True,
        help="Architecture to build (default: local; repeatable).",
    )
    eval_individually = cli.Flag(
        ["-1", "--eval-individually"],
        help=(
            "Evaluate each check in its own `nix eval` process "
            "(slower, but better for low RAM scenarios)."
        ),
    )

    @staticmethod
    def _build_eval_suite(ac: ArchChecks, path: str) -> tuple[list[BuildEvent], bool]:
        """Build the eval suite BuildEvents for one architecture.

        Args:
            ac: The ArchChecks for this architecture.
            path: The flake path as the user wrote it, used in reproducer
                commands (``nix eval <path>#checks.<arch>.<name>``).

        Returns:
            Tuple of (eval_builds, has_errors).
        """
        eval_builds: list[BuildEvent] = []
        has_errors = False
        if ac.error is not None:
            cmd = f"nix eval {path}#checks.{ac.arch}"
            eval_builds.append(
                BuildEvent(
                    name=cmd,
                    drv_path=cmd,
                    started=True,
                    stopped=True,
                    success=False,
                    cached=False,
                    log_lines=[ac.error],
                )
            )
            has_errors = True
        else:
            for cr in ac.checks:
                cmd = f"nix eval {path}#checks.{cr.arch}.{cr.name}"
                if cr.error is not None:
                    eval_builds.append(
                        BuildEvent(
                            name=cmd,
                            drv_path=cmd,
                            started=True,
                            stopped=True,
                            success=False,
                            cached=False,
                            log_lines=[cr.error],
                        )
                    )
                    has_errors = True
                else:
                    eval_builds.append(
                        BuildEvent(
                            name=cmd,
                            drv_path=cmd,
                            started=True,
                            stopped=True,
                            success=True,
                            cached=False,
                            log_lines=[cr.drv_path] if cr.drv_path else [],
                        )
                    )
        return eval_builds, has_errors

    @staticmethod
    def _build_build_suite_events(
        ac: ArchChecks,
        eval_result_path: str,
    ) -> tuple[list[BuildEvent], list[str], list[str], list[str | None]]:
        """Build the build suite BuildEvents and attrs for one architecture.

        Args:
            ac: The ArchChecks for this architecture.
            eval_result_path: The flake path as the user wrote it, used to
                construct the build URIs and the reproducer command names.

        Returns:
            Tuple of (build_builds, build_attrs, expected_names,
            expected_drv_paths).  ``expected_drv_paths`` is parallel to
            ``expected_names``: it carries the real ``/nix/store/...
            .drv`` path for each buildable check, as reported by
            ``discover_checks``.  ``run_nix_build`` uses it to pair
            ``actBuild`` events by drv path (more reliable than
            positional pairing) and to mark dep-failed targets as
            failures instead of cached/skipped.
        """
        build_builds: list[BuildEvent] = []
        build_attrs: list[str] = []
        expected_names: list[str] = []
        expected_drv_paths: list[str | None] = []
        for cr in ac.checks:
            cmd = f"nix build {eval_result_path}#checks.{cr.arch}.{cr.name}"
            if cr.error is not None:
                build_builds.append(
                    BuildEvent(
                        name=cmd,
                        drv_path=cmd,
                        started=True,
                        stopped=True,
                        success=False,
                        cached=False,
                        log_lines=[cr.error],
                    )
                )
            elif cr.drv_path is not None:
                build_attrs.append(f"{eval_result_path}#checks.{cr.arch}.{cr.name}")
                expected_names.append(cmd)
                expected_drv_paths.append(cr.drv_path)
        return build_builds, build_attrs, expected_names, expected_drv_paths

    @_plumbum_async_main
    async def main(self, *args: str) -> int:  # noqa: D102
        parent = cast(Junix, self.parent)
        path, extra_flags = _split_extra_flags(args)
        flake_path: str = path[0] if path else "."
        parent.log("discovering checks ...", level=1)

        color_mode = _resolve_color_mode(parent._color)

        # Resolve env-var defaults (CLI > env > default).  The CLI value
        # wins because plumbum only calls the setter when the flag is on
        # argv; when it's not, the attribute is None/False/[] and we
        # fall back to the env var.
        eval_arch = cast(list[str] | None, self.eval_arch) or _env_list(
            "JUNIX_CHECK_EVAL_ARCH"
        )
        build_arch = cast(list[str] | None, self.build_arch) or _env_list(
            "JUNIX_CHECK_BUILD_ARCH"
        )
        eval_individually = self.eval_individually or _env_bool(
            "JUNIX_CHECK_EVAL_INDIVIDUALLY"
        )

        # --eval-individually switches the eval phase to per-check isolation.
        # The default (bulk) is faster but evaluates every check's
        # closure in a single Nix process — that blows the RAM budget
        # of CI runners on large flakes (e.g. anything pulling in
        # Odoo's full closure across multiple arches). The per-check
        # path bounds the RAM peak by the largest single check.
        if eval_individually:
            parent.log("evaluating checks individually (per-check isolation)", level=1)
            eval_result, eval_elapsed_per_arch = await discover_checks_individually(
                flake_path,
                eval_arch=eval_arch,
                on_progress=lambda key: parent.log(f"evaluating {key}", level=2),
            )
        else:
            eval_result, eval_elapsed_per_arch = await discover_checks(
                flake_path,
                eval_arch=eval_arch,
                on_trace_recovery=lambda key: parent.log(
                    f"recovering eval trace for {key}", level=1
                ),
            )

        local_system = _current_nix_system()
        build_systems = build_arch if build_arch else [local_system]

        _print_eval_results(eval_result, color_mode=color_mode)

        # Per-testcase and per-suite ``time`` values come from
        # wall-clock measurements taken as the protocol stream is
        # consumed. For the **eval phase** the only honest
        # measurement is the bulk-eval wall-clock around the
        # ``nix eval`` process (the eval stream doesn't emit
        # per-check activity events); we attribute the per-arch
        # time across its testcases so the suite total equals the
        # sum of testcase times. For the **build phase** the
        # actBuild start/stop events DO fire per build, so
        # ``NixEventHandler`` records ``time.monotonic()`` at each
        # and ``BuildEvent.duration`` carries the real per-build
        # wall-clock. In both cases the per-suite ``time`` becomes
        # the sum of the per-testcase times — the standard JUnit
        # convention so consumers (GitLab, Jenkins) that compute
        # totals from testcase times agree with the suite value.
        suites: list[TestSuite] = []
        all_eval_builds: list[BuildEvent] = []
        all_build_builds: list[BuildEvent] = []
        build_results: list[tuple[str, list[BuildEvent]]] = []
        overall_success = not eval_result.has_errors
        total_downloaded = 0

        for ac in eval_result.arches:
            # --- Eval suite for this arch ---
            eval_builds, has_errors = self._build_eval_suite(ac, eval_result.path)
            if has_errors:
                overall_success = False
            all_eval_builds.extend(eval_builds)
            # Split the per-arch eval time across its testcases so
            # ``<testcase time>`` entries sum to the suite total.
            arch_eval_seconds = eval_elapsed_per_arch.get(ac.arch, 0.0)
            n_eval = len(eval_builds) or 1
            per_eval = arch_eval_seconds / n_eval
            eval_testcase_times = {b.name: per_eval for b in eval_builds}
            suites.append(
                TestSuite(
                    name=f"nix-eval.{ac.arch}",
                    builds=eval_builds,
                    testcase_times=eval_testcase_times,
                )
            )

            # --- Build suite for this arch (if in build_systems) ---
            if ac.arch not in build_systems:
                continue

            build_builds, build_attrs, expected_names, expected_drv_paths = (
                self._build_build_suite_events(ac, eval_result.path)
            )

            errors: list[str] = []
            if build_attrs:
                # Drv_path-based rename in run_nix_build: nix emits
                # ``actBuild`` events keyed by their drv path, and the
                # ``--keep-going`` flag ensures every attr is attempted.
                # We pair each event with the matching
                # ``expected_names`` entry by drv path, which is more
                # reliable than positional pairing when a transitive
                # dep fails first (positional would misalign the rest
                # of the list).  When a dep fails, Nix never emits an
                # ``actBuild`` start for the dependent targets, but it
                # does emit ``error: Cannot build '...drv'.`` msg
                # events for each — ``run_nix_build`` uses those to
                # mark the missing targets as failures instead of
                # cached/skipped.
                build_extra = (extra_flags or []) + ["--no-link"]
                (
                    builds,
                    errors,
                    build_exit,
                    downloaded,
                ) = await run_nix_build(
                    build_attrs,
                    default_name="nix check",
                    log_level=parent._nix_log_level,
                    expected_count=len(build_attrs),
                    expected_names=expected_names,
                    expected_drv_paths=expected_drv_paths,
                    extra_flags=build_extra,
                )
                total_downloaded += downloaded
                for b in builds:
                    build_builds.append(b)
                if build_exit != 0:
                    overall_success = False

            _print_build_results(ac.arch, build_builds, color_mode=color_mode)
            build_results.append((ac.arch, build_builds))
            all_build_builds.extend(build_builds)

            # Per-build wall-clock comes from BuildEvent.duration,
            # measured by NixEventHandler between each actBuild
            # start/stop event in the stream. Cached / synthetic
            # entries have no duration and get 0.
            build_testcase_times = {b.name: (b.duration or 0.0) for b in build_builds}
            suites.append(
                TestSuite(
                    name=f"nix-build.{ac.arch}",
                    builds=build_builds,
                    error_messages=errors or None,
                    testcase_times=build_testcase_times,
                )
            )
        if not suites:
            parent.log("no checks found", level=1)
            _write_report(
                report_to_xml_multi([]), self.output or _env("JUNIX_CHECK_OUTPUT")
            )
            _print_check_summary(
                evals=[],
                builds=[],
                overall_success=True,
                color_mode=color_mode,
            )
            return 0

        xml = report_to_xml_multi(suites)
        _write_report(xml, self.output or _env("JUNIX_CHECK_OUTPUT"))
        # `check` prints per-arch sections via `_print_eval_results` and
        # `_print_build_results`, so the final line is all we need here.
        _print_check_summary(
            evals=all_eval_builds,
            builds=all_build_builds,
            overall_success=overall_success,
            color_mode=color_mode,
        )
        return 0 if overall_success else 1

_build_build_suite_events(ac, eval_result_path) staticmethod

Build the build suite BuildEvents and attrs for one architecture.

Parameters:

Name Type Description Default
ac ArchChecks

The ArchChecks for this architecture.

required
eval_result_path str

The flake path as the user wrote it, used to construct the build URIs and the reproducer command names.

required

Returns:

Type Description
list[BuildEvent]

Tuple of (build_builds, build_attrs, expected_names,

list[str]

expected_drv_paths). expected_drv_paths is parallel to

list[str]

expected_names: it carries the real ``/nix/store/...

list[str | None]

.drv`` path for each buildable check, as reported by

tuple[list[BuildEvent], list[str], list[str], list[str | None]]

discover_checks. run_nix_build uses it to pair

tuple[list[BuildEvent], list[str], list[str], list[str | None]]

actBuild events by drv path (more reliable than

tuple[list[BuildEvent], list[str], list[str], list[str | None]]

positional pairing) and to mark dep-failed targets as

tuple[list[BuildEvent], list[str], list[str], list[str | None]]

failures instead of cached/skipped.

Source code in src/junix/cli.py
@staticmethod
def _build_build_suite_events(
    ac: ArchChecks,
    eval_result_path: str,
) -> tuple[list[BuildEvent], list[str], list[str], list[str | None]]:
    """Build the build suite BuildEvents and attrs for one architecture.

    Args:
        ac: The ArchChecks for this architecture.
        eval_result_path: The flake path as the user wrote it, used to
            construct the build URIs and the reproducer command names.

    Returns:
        Tuple of (build_builds, build_attrs, expected_names,
        expected_drv_paths).  ``expected_drv_paths`` is parallel to
        ``expected_names``: it carries the real ``/nix/store/...
        .drv`` path for each buildable check, as reported by
        ``discover_checks``.  ``run_nix_build`` uses it to pair
        ``actBuild`` events by drv path (more reliable than
        positional pairing) and to mark dep-failed targets as
        failures instead of cached/skipped.
    """
    build_builds: list[BuildEvent] = []
    build_attrs: list[str] = []
    expected_names: list[str] = []
    expected_drv_paths: list[str | None] = []
    for cr in ac.checks:
        cmd = f"nix build {eval_result_path}#checks.{cr.arch}.{cr.name}"
        if cr.error is not None:
            build_builds.append(
                BuildEvent(
                    name=cmd,
                    drv_path=cmd,
                    started=True,
                    stopped=True,
                    success=False,
                    cached=False,
                    log_lines=[cr.error],
                )
            )
        elif cr.drv_path is not None:
            build_attrs.append(f"{eval_result_path}#checks.{cr.arch}.{cr.name}")
            expected_names.append(cmd)
            expected_drv_paths.append(cr.drv_path)
    return build_builds, build_attrs, expected_names, expected_drv_paths

_build_eval_suite(ac, path) staticmethod

Build the eval suite BuildEvents for one architecture.

Parameters:

Name Type Description Default
ac ArchChecks

The ArchChecks for this architecture.

required
path str

The flake path as the user wrote it, used in reproducer commands (nix eval <path>#checks.<arch>.<name>).

required

Returns:

Type Description
tuple[list[BuildEvent], bool]

Tuple of (eval_builds, has_errors).

Source code in src/junix/cli.py
@staticmethod
def _build_eval_suite(ac: ArchChecks, path: str) -> tuple[list[BuildEvent], bool]:
    """Build the eval suite BuildEvents for one architecture.

    Args:
        ac: The ArchChecks for this architecture.
        path: The flake path as the user wrote it, used in reproducer
            commands (``nix eval <path>#checks.<arch>.<name>``).

    Returns:
        Tuple of (eval_builds, has_errors).
    """
    eval_builds: list[BuildEvent] = []
    has_errors = False
    if ac.error is not None:
        cmd = f"nix eval {path}#checks.{ac.arch}"
        eval_builds.append(
            BuildEvent(
                name=cmd,
                drv_path=cmd,
                started=True,
                stopped=True,
                success=False,
                cached=False,
                log_lines=[ac.error],
            )
        )
        has_errors = True
    else:
        for cr in ac.checks:
            cmd = f"nix eval {path}#checks.{cr.arch}.{cr.name}"
            if cr.error is not None:
                eval_builds.append(
                    BuildEvent(
                        name=cmd,
                        drv_path=cmd,
                        started=True,
                        stopped=True,
                        success=False,
                        cached=False,
                        log_lines=[cr.error],
                    )
                )
                has_errors = True
            else:
                eval_builds.append(
                    BuildEvent(
                        name=cmd,
                        drv_path=cmd,
                        started=True,
                        stopped=True,
                        success=True,
                        cached=False,
                        log_lines=[cr.drv_path] if cr.drv_path else [],
                    )
                )
    return eval_builds, has_errors

Junix

Bases: Application

Turn Nix build logs into JUnit XML reports for your CI.

Source code in src/junix/cli.py
class Junix(cli.Application):
    """Turn Nix build logs into JUnit XML reports for your CI."""

    PROGNAME = "junix"
    VERSION = __version__

    _verbose: int = 0
    _verbose_passed: bool = False
    _color: str | None = None

    @cli.switch(
        ["-v", "--verbose"],
        list=True,
        help="Increase log verbosity (repeat for more detail).",
    )
    def verbose(self, times: list[str]) -> None:  # type: ignore[misc]  # noqa: D102
        self._verbose = len(times)
        self._verbose_passed = True

    @cli.switch(
        ["--color"],
        cli.Set("auto", "yes", "no"),
        help="When to emit ANSI colors: auto, yes, or no (default: auto).",
    )
    def color(self, value: str) -> None:  # type: ignore[misc]  # noqa: D102
        self._color = value

    def main(self, *_args: str) -> int:  # noqa: D102
        """Resolve env-var defaults for global flags before subcommands run.

        Plumbum calls this before the subcommand's ``main()``, so by the
        time a subcommand reads ``self.parent._verbose`` / ``self.parent
        ._color``, the env vars have been applied (CLI > env > default).
        """
        if not self._verbose_passed:
            env_v = _env("JUNIX_VERBOSE")
            if env_v is not None and env_v.isdigit() and 0 <= int(env_v) <= 7:
                self._verbose = int(env_v)
        if self._color is None:
            self._color = _env("JUNIX_COLOR") or "auto"
        if not self.nested_command:
            return cast(int, super().main(*_args))
        return 0

    def _validate_args(self, swfuncs, tailargs):  # type: ignore[no-untyped-def]
        """Resolve verbose switch before plumbum short-circuits version."""
        verbose_swinfo = self._switches_by_name.get("v")
        if verbose_swinfo and verbose_swinfo.func in swfuncs:
            self.verbose(*swfuncs[verbose_swinfo.func].val)
        return super()._validate_args(swfuncs, tailargs)

    @cli.switch(
        ["--version"],
        overridable=True,
        group="Meta-switches",
        help="Prints the program's version and quits",
    )
    def version(self) -> None:  # type: ignore[misc]  # noqa: D102
        """Prints the program's version and quits."""
        ver = self._get_prog_version()
        ver_name = ver if ver is not None else "(version not set)"
        print(f"{self.PROGNAME} {ver_name}")
        if self._verbose >= 1:
            _print_version_diagnostics()

    @property
    def _nix_log_level(self) -> int:
        """Map CLI verbosity to Nix log level threshold.

        Nix log levels:

        ```
        0 = lvlError
        1 = lvlWarn
        2 = lvlNotice
        3 = lvlInfo
        4 = lvlTalkative
        5 = lvlChatty
        6 = lvlDebug
        7 = lvlVomit
        ```

        Returns:
            Nix log level threshold (default 0 = errors only).
        """
        return {0: 0, 1: 3, 2: 5, 3: 7}.get(self._verbose, 7)

    def log(self, message: str, level: int = 1) -> None:
        """Print a log message to stderr if verbosity is high enough.

        Args:
            message: The message to print.
            level: Minimum verbosity level required to print (default 1).
        """
        if self._verbose >= level:
            print(f"junix: {message}", file=sys.stderr)

_nix_log_level property

Map CLI verbosity to Nix log level threshold.

Nix log levels:

0 = lvlError
1 = lvlWarn
2 = lvlNotice
3 = lvlInfo
4 = lvlTalkative
5 = lvlChatty
6 = lvlDebug
7 = lvlVomit

Returns:

Type Description
int

Nix log level threshold (default 0 = errors only).

_validate_args(swfuncs, tailargs)

Resolve verbose switch before plumbum short-circuits version.

Source code in src/junix/cli.py
def _validate_args(self, swfuncs, tailargs):  # type: ignore[no-untyped-def]
    """Resolve verbose switch before plumbum short-circuits version."""
    verbose_swinfo = self._switches_by_name.get("v")
    if verbose_swinfo and verbose_swinfo.func in swfuncs:
        self.verbose(*swfuncs[verbose_swinfo.func].val)
    return super()._validate_args(swfuncs, tailargs)

log(message, level=1)

Print a log message to stderr if verbosity is high enough.

Parameters:

Name Type Description Default
message str

The message to print.

required
level int

Minimum verbosity level required to print (default 1).

1
Source code in src/junix/cli.py
def log(self, message: str, level: int = 1) -> None:
    """Print a log message to stderr if verbosity is high enough.

    Args:
        message: The message to print.
        level: Minimum verbosity level required to print (default 1).
    """
    if self._verbose >= level:
        print(f"junix: {message}", file=sys.stderr)

main(*_args)

Resolve env-var defaults for global flags before subcommands run.

Plumbum calls this before the subcommand's main(), so by the time a subcommand reads self.parent._verbose / self.parent ._color, the env vars have been applied (CLI > env > default).

Source code in src/junix/cli.py
def main(self, *_args: str) -> int:  # noqa: D102
    """Resolve env-var defaults for global flags before subcommands run.

    Plumbum calls this before the subcommand's ``main()``, so by the
    time a subcommand reads ``self.parent._verbose`` / ``self.parent
    ._color``, the env vars have been applied (CLI > env > default).
    """
    if not self._verbose_passed:
        env_v = _env("JUNIX_VERBOSE")
        if env_v is not None and env_v.isdigit() and 0 <= int(env_v) <= 7:
            self._verbose = int(env_v)
    if self._color is None:
        self._color = _env("JUNIX_COLOR") or "auto"
    if not self.nested_command:
        return cast(int, super().main(*_args))
    return 0

version()

Prints the program's version and quits.

Source code in src/junix/cli.py
@cli.switch(
    ["--version"],
    overridable=True,
    group="Meta-switches",
    help="Prints the program's version and quits",
)
def version(self) -> None:  # type: ignore[misc]  # noqa: D102
    """Prints the program's version and quits."""
    ver = self._get_prog_version()
    ver_name = ver if ver is not None else "(version not set)"
    print(f"{self.PROGNAME} {ver_name}")
    if self._verbose >= 1:
        _print_version_diagnostics()

Translate

Bases: Application

Read @nix JSON lines from stdin and produce JUnit XML.

Source code in src/junix/cli.py
@Junix.subcommand("translate")
class Translate(cli.Application):  # noqa: D415
    """Read `@nix` JSON lines from stdin and produce JUnit XML."""

    PROGNAME = "junix translate"

    output = cli.SwitchAttr(
        ["-o", "--output"],
        help="Path to write the JUnit XML report (default: stdout).",
    )

    def main(self) -> int:  # noqa: D102
        parent = cast(Junix, self.parent)
        color_mode = _resolve_color_mode(parent._color)
        xml, exit_code = translate_stream(
            sys.stdin,
            suite_name="nix-translate",
            log_level=parent._nix_log_level,
            color_mode=color_mode,
        )
        _write_report(xml, self.output or _env("JUNIX_TRANSLATE_OUTPUT"))
        return exit_code

_env(name)

Read a JUNIX_* env var, returning None when unset or empty.

Parameters:

Name Type Description Default
name str

The env var name (e.g. "JUNIX_COLOR").

required

Returns:

Type Description
str | None

The value, or None if unset or empty.

Source code in src/junix/cli.py
def _env(name: str) -> str | None:
    """Read a ``JUNIX_*`` env var, returning ``None`` when unset or empty.

    Args:
        name: The env var name (e.g. ``"JUNIX_COLOR"``).

    Returns:
        The value, or ``None`` if unset or empty.
    """
    value = os.environ.get(name)
    return value if value else None

_env_bool(name)

Read a JUNIX_* env var as a bool.

Truthy: 1, true, yes, on (case-insensitive). Falsy: 0, false, no, off, empty, or unset.

Parameters:

Name Type Description Default
name str

The env var name (e.g. "JUNIX_CHECK_EVAL_INDIVIDUALLY").

required

Returns:

Type Description
bool

The boolean value.

Source code in src/junix/cli.py
def _env_bool(name: str) -> bool:
    """Read a ``JUNIX_*`` env var as a bool.

    Truthy: ``1``, ``true``, ``yes``, ``on`` (case-insensitive).
    Falsy: ``0``, ``false``, ``no``, ``off``, empty, or unset.

    Args:
        name: The env var name (e.g. ``"JUNIX_CHECK_EVAL_INDIVIDUALLY"``).

    Returns:
        The boolean value.
    """
    val = _env(name)
    if val is None:
        return False
    return val.lower() not in ("0", "false", "no", "off", "")

_env_list(name)

Read a JUNIX_* env var as a list, splitting on commas and whitespace.

Parameters:

Name Type Description Default
name str

The env var name (e.g. "JUNIX_CHECK_BUILD_ARCH").

required

Returns:

Type Description
list[str] | None

The split list of tokens, or None if the var is unset/empty.

Source code in src/junix/cli.py
def _env_list(name: str) -> list[str] | None:
    """Read a ``JUNIX_*`` env var as a list, splitting on commas and whitespace.

    Args:
        name: The env var name (e.g. ``"JUNIX_CHECK_BUILD_ARCH"``).

    Returns:
        The split list of tokens, or ``None`` if the var is unset/empty.
    """
    raw = _env(name)
    if raw is None:
        return None
    return [tok for tok in raw.replace(",", " ").split() if tok]

_plumbum_async_main(main_method)

Wrap an async main() so plumbum can call it synchronously.

Plumbum 2's Application.run calls inst.main() synchronously. This decorator bridges that gap by running the coroutine inside asyncio.run().

Important: does not use functools.wraps because plumbum 2 introspects self.main with inspect.getfullargspec() and get_type_hints(). wraps would copy the original's annotations but the wrapper has args, *kwargs, causing a mismatch.

Parameters:

Name Type Description Default
main_method Callable[..., Awaitable[int]]

The async main() coroutine function to wrap.

required

Returns:

Type Description
Callable[..., int]

A sync wrapper function that runs the coroutine via asyncio.run().

Source code in src/junix/cli.py
def _plumbum_async_main(
    main_method: Callable[..., Awaitable[int]],
) -> Callable[..., int]:
    """Wrap an async main() so plumbum can call it synchronously.

    Plumbum 2's Application.run calls inst.main() synchronously. This
    decorator bridges that gap by running the coroutine inside
    asyncio.run().

    Important: does not use functools.wraps because plumbum 2
    introspects self.main with inspect.getfullargspec() and
    get_type_hints(). wraps would copy the original's annotations but
    the wrapper has *args, **kwargs, causing a mismatch.

    Args:
        main_method: The async main() coroutine function to wrap.

    Returns:
        A sync wrapper function that runs the coroutine via asyncio.run().
    """

    def wrapper(*args):  # type: ignore[no-untyped-def]
        return asyncio.run(cast(Coroutine[Any, Any, int], main_method(*args)))

    return wrapper

_print_version_diagnostics()

Print extra diagnostic information alongside verbose --version.

Emits the full path to the junix binary, the output of nix --version, and the full path to the nix binary, all to stderr. Paths fall back to the bare command name when the executable cannot be located.

Source code in src/junix/cli.py
def _print_version_diagnostics() -> None:
    """Print extra diagnostic information alongside verbose ``--version``.

    Emits the full path to the ``junix`` binary, the output of ``nix --version``,
    and the full path to the ``nix`` binary, all to stderr.  Paths fall back to
    the bare command name when the executable cannot be located.
    """
    print(f"junix path: {_resolve_junix_path()}", file=sys.stderr)

    nix_path = shutil.which("nix") or "nix"
    nix_real_path = os.path.realpath(nix_path)
    try:
        nix_version = subprocess.run(
            [nix_path, "--version"],
            check=False,
            capture_output=True,
            text=True,
        ).stdout.strip()
    except OSError:
        nix_version = ""
    if nix_version:
        print(f"nix version: {nix_version}", file=sys.stderr)
    else:
        print("nix version: (unavailable)", file=sys.stderr)

    print(f"nix path: {nix_real_path}", file=sys.stderr)

_resolve_junix_path()

Return the best-effort absolute path to the running junix binary.

When invoked as python -m junix, sys.argv[0] points at the module file (.../__main__.py). We prefer sys.executable in that case, but fall back to the bare argv[0] / which('junix') when the interpreter itself was used to launch the module.

Returns:

Type Description
str

Absolute path, or the original command name if it cannot be resolved.

Source code in src/junix/cli.py
def _resolve_junix_path() -> str:
    """Return the best-effort absolute path to the running ``junix`` binary.

    When invoked as ``python -m junix``, ``sys.argv[0]`` points at the
    module file (``.../__main__.py``).  We prefer ``sys.executable`` in
    that case, but fall back to the bare ``argv[0]`` / ``which('junix')``
    when the interpreter itself was used to launch the module.

    Returns:
        Absolute path, or the original command name if it cannot be resolved.
    """
    junix_path = sys.argv[0]
    if junix_path in ("-c", "-m") or junix_path.endswith("/__main__.py"):
        # Running via `python -m junix` or similar; report the interpreter
        # path that the user actually invoked.
        junix_path = sys.executable
    if not os.path.isabs(junix_path):
        resolved = shutil.which("junix")
        if resolved:
            junix_path = resolved
    return os.path.realpath(junix_path)

_split_extra_flags(args)

Split args into attrs and extra flags for nix.

Plumbum 2 consumes the -- end-of-options marker before our main() ever sees it (it just sticks everything from that point on into the positional *args). So we can't rely on finding a literal -- in args — we have to detect the boundary ourselves. Heuristic: every arg that doesn't start with - is a flake attribute, every arg that does is a nix flag. Splitting at the first flag-shaped arg works because flake attrs cannot legitimately start with - (Nix itself rejects them, so users don't pass them).

Parameters:

Name Type Description Default
args tuple[str, ...]

Positional arguments from the CLI, in order.

required

Returns:

Type Description
list[str]

Tuple of (attrs, extra_flags). extra_flags is None

list[str] | None

when no extra flags were passed.

Source code in src/junix/cli.py
def _split_extra_flags(args: tuple[str, ...]) -> tuple[list[str], list[str] | None]:
    """Split args into attrs and extra flags for nix.

    Plumbum 2 consumes the ``--`` end-of-options marker before our
    ``main()`` ever sees it (it just sticks everything from that
    point on into the positional ``*args``).  So we can't rely on
    finding a literal ``--`` in ``args`` — we have to detect the
    boundary ourselves.  Heuristic: every arg that doesn't start
    with ``-`` is a flake attribute, every arg that does is a
    nix flag.  Splitting at the first flag-shaped arg works
    because flake attrs cannot legitimately start with ``-``
    (Nix itself rejects them, so users don't pass them).

    Args:
        args: Positional arguments from the CLI, in order.

    Returns:
        Tuple of (attrs, extra_flags).  ``extra_flags`` is ``None``
        when no extra flags were passed.
    """
    for i, arg in enumerate(args):
        if arg.startswith("-"):
            return list(args[:i]), list(args[i:])
    return list(args), None

main()

Run the CLI and return the exit code.

Source code in src/junix/cli.py
def main() -> int:
    """Run the CLI and return the exit code."""
    return Junix.run()[1]