diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index f9a9fa8..c23597a 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -31,6 +31,7 @@ jobs: - styxdocker - styxpodman - styxsingularity + - styxkit steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index a89da2d..5feb11b 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -21,6 +21,7 @@ on: - 'styxdocker-v*' - 'styxpodman-v*' - 'styxsingularity-v*' + - 'styxkit-v*' jobs: publish: diff --git a/packages/styxkit/LICENSE b/packages/styxkit/LICENSE new file mode 100644 index 0000000..55fdf53 --- /dev/null +++ b/packages/styxkit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Child Mind Institute + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/styxkit/README.md b/packages/styxkit/README.md new file mode 100644 index 0000000..c3643bd --- /dev/null +++ b/packages/styxkit/README.md @@ -0,0 +1,64 @@ +# Styxkit - convenience helpers for the Styx runtime + +`styxkit` is a small convenience layer on top of the Styx runtime. It provides +one-call runner selection across whichever backend packages you have installed, +so you do not have to import each runner class and wire up the global runner by +hand. + +It sits at the top of the runtime stack: + +- `styxdefs` is the base contract (the `Runner` protocol, `LocalRunner`, + `DryRunner`, and `set_global_runner` / `get_global_runner`). +- Each backend (`styxdocker`, `styxpodman`, `styxsingularity`, `styxgraph`) is an + independent package depending only on `styxdefs`. +- `styxkit` depends only on `styxdefs` and imports the backends **lazily**, so + installing it does not pull in any container backend you do not want. + +## Installation + +```bash +pip install styxkit # base, plus styxdefs +pip install "styxkit[docker]" # + the Docker backend +pip install "styxkit[all]" # + every backend (docker, podman, singularity, graph) +``` + +Calling a `use_*()` for a backend you have not installed raises a friendly error +telling you exactly what to install. + +## Usage + +```python +import styxkit + +# Pick a specific runner: +styxkit.use_docker() +styxkit.use_singularity(singularity_executable="apptainer") +styxkit.use_local() + +# ...or let styxkit detect the best available container runtime, +# falling back to local when none is found: +runner = styxkit.use_auto() + +# Each use_*() registers the runner as the global runner and returns it, +# so you can configure it without a get_global_runner() round-trip: +runner = styxkit.use_docker() +runner.data_dir = "/tmp/styx" +``` + +`use_auto()` prefers, in order, the first container backend that is both +installed and has its executable on `PATH`: Docker, then Podman, then +Singularity/Apptainer, otherwise the local runner. + +## Available helpers + +- `use_local`, `use_dry` - always available (from `styxdefs`). +- `use_docker`, `use_podman`, `use_singularity` - lazy, require the matching backend. +- `use_graph(base=None)` - wraps a runner in a graph recorder; defaults to the + current global runner. +- `use_auto()` / `resolve_runner()` - detect and select a runner. +- The core `styxdefs` symbols (`Runner`, `Execution`, `Metadata`, + `set_global_runner`, ...) are re-exported for convenience. + +## License + +`styxkit` is released under the MIT License. See the LICENSE file for details. diff --git a/packages/styxkit/docs/pdoc-theme/frame.html.jinja2 b/packages/styxkit/docs/pdoc-theme/frame.html.jinja2 new file mode 100644 index 0000000..a83fc43 --- /dev/null +++ b/packages/styxkit/docs/pdoc-theme/frame.html.jinja2 @@ -0,0 +1,7 @@ +{% extends "default/frame.html.jinja2" %} + +{% block head %} + + + +{% endblock %} diff --git a/packages/styxkit/docs/pdoc-theme/syntax-highlighting.css b/packages/styxkit/docs/pdoc-theme/syntax-highlighting.css new file mode 100644 index 0000000..84e5ca5 --- /dev/null +++ b/packages/styxkit/docs/pdoc-theme/syntax-highlighting.css @@ -0,0 +1,156 @@ + +/* auto-generated, see templates/README.md */ +pre { line-height: 125%; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 20px; } +.pdoc-code .hll { background-color: #ffffcc } +.pdoc-code { background: #f8f8f8; } +.pdoc-code .c { color: #3D7B7B; font-style: italic } /* Comment */ +.pdoc-code .err { border: 1px solid #FF0000 } /* Error */ +.pdoc-code .k { color: #008000; font-weight: bold } /* Keyword */ +.pdoc-code .o { color: #666666 } /* Operator */ +.pdoc-code .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.pdoc-code .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.pdoc-code .cp { color: #9C6500 } /* Comment.Preproc */ +.pdoc-code .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.pdoc-code .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.pdoc-code .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.pdoc-code .gd { color: #A00000 } /* Generic.Deleted */ +.pdoc-code .ge { font-style: italic } /* Generic.Emph */ +.pdoc-code .gr { color: #E40000 } /* Generic.Error */ +.pdoc-code .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.pdoc-code .gi { color: #008400 } /* Generic.Inserted */ +.pdoc-code .go { color: #717171 } /* Generic.Output */ +.pdoc-code .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.pdoc-code .gs { font-weight: bold } /* Generic.Strong */ +.pdoc-code .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.pdoc-code .gt { color: #0044DD } /* Generic.Traceback */ +.pdoc-code .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.pdoc-code .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.pdoc-code .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.pdoc-code .kp { color: #008000 } /* Keyword.Pseudo */ +.pdoc-code .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.pdoc-code .kt { color: #B00040 } /* Keyword.Type */ +.pdoc-code .m { color: #666666 } /* Literal.Number */ +.pdoc-code .s { color: #BA2121 } /* Literal.String */ +.pdoc-code .na { color: #687822 } /* Name.Attribute */ +.pdoc-code .nb { color: #008000 } /* Name.Builtin */ +.pdoc-code .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.pdoc-code .no { color: #880000 } /* Name.Constant */ +.pdoc-code .nd { color: #AA22FF } /* Name.Decorator */ +.pdoc-code .ni { color: #717171; font-weight: bold } /* Name.Entity */ +.pdoc-code .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.pdoc-code .nf { color: #0000FF } /* Name.Function */ +.pdoc-code .nl { color: #767600 } /* Name.Label */ +.pdoc-code .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.pdoc-code .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.pdoc-code .nv { color: #19177C } /* Name.Variable */ +.pdoc-code .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.pdoc-code .w { color: #bbbbbb } /* Text.Whitespace */ +.pdoc-code .mb { color: #666666 } /* Literal.Number.Bin */ +.pdoc-code .mf { color: #666666 } /* Literal.Number.Float */ +.pdoc-code .mh { color: #666666 } /* Literal.Number.Hex */ +.pdoc-code .mi { color: #666666 } /* Literal.Number.Integer */ +.pdoc-code .mo { color: #666666 } /* Literal.Number.Oct */ +.pdoc-code .sa { color: #BA2121 } /* Literal.String.Affix */ +.pdoc-code .sb { color: #BA2121 } /* Literal.String.Backtick */ +.pdoc-code .sc { color: #BA2121 } /* Literal.String.Char */ +.pdoc-code .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.pdoc-code .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.pdoc-code .s2 { color: #BA2121 } /* Literal.String.Double */ +.pdoc-code .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.pdoc-code .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.pdoc-code .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.pdoc-code .sx { color: #008000 } /* Literal.String.Other */ +.pdoc-code .sr { color: #A45A77 } /* Literal.String.Regex */ +.pdoc-code .s1 { color: #BA2121 } /* Literal.String.Single */ +.pdoc-code .ss { color: #19177C } /* Literal.String.Symbol */ +.pdoc-code .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.pdoc-code .fm { color: #0000FF } /* Name.Function.Magic */ +.pdoc-code .vc { color: #19177C } /* Name.Variable.Class */ +.pdoc-code .vg { color: #19177C } /* Name.Variable.Global */ +.pdoc-code .vi { color: #19177C } /* Name.Variable.Instance */ +.pdoc-code .vm { color: #19177C } /* Name.Variable.Magic */ +.pdoc-code .il { color: #666666 } /* Literal.Number.Integer.Long */ + +@media (prefers-color-scheme: dark) { + /* monokai color scheme, see pdoc/template/README.md */ + pre { line-height: 125%; } + span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 20px; } + .pdoc-code .hll { background-color: #49483e } + .pdoc-code { background: #272822; color: #f8f8f2 } + .pdoc-code .c { color: #75715e } /* Comment */ + .pdoc-code .err { color: #960050; background-color: #1e0010 } /* Error */ + .pdoc-code .esc { color: #f8f8f2 } /* Escape */ + .pdoc-code .g { color: #f8f8f2 } /* Generic */ + .pdoc-code .k { color: #66d9ef } /* Keyword */ + .pdoc-code .l { color: #ae81ff } /* Literal */ + .pdoc-code .n { color: #f8f8f2 } /* Name */ + .pdoc-code .o { color: #f92672 } /* Operator */ + .pdoc-code .x { color: #f8f8f2 } /* Other */ + .pdoc-code .p { color: #f8f8f2 } /* Punctuation */ + .pdoc-code .ch { color: #75715e } /* Comment.Hashbang */ + .pdoc-code .cm { color: #75715e } /* Comment.Multiline */ + .pdoc-code .cp { color: #75715e } /* Comment.Preproc */ + .pdoc-code .cpf { color: #75715e } /* Comment.PreprocFile */ + .pdoc-code .c1 { color: #75715e } /* Comment.Single */ + .pdoc-code .cs { color: #75715e } /* Comment.Special */ + .pdoc-code .gd { color: #f92672 } /* Generic.Deleted */ + .pdoc-code .ge { color: #f8f8f2; font-style: italic } /* Generic.Emph */ + .pdoc-code .gr { color: #f8f8f2 } /* Generic.Error */ + .pdoc-code .gh { color: #f8f8f2 } /* Generic.Heading */ + .pdoc-code .gi { color: #a6e22e } /* Generic.Inserted */ + .pdoc-code .go { color: #66d9ef } /* Generic.Output */ + .pdoc-code .gp { color: #f92672; font-weight: bold } /* Generic.Prompt */ + .pdoc-code .gs { color: #f8f8f2; font-weight: bold } /* Generic.Strong */ + .pdoc-code .gu { color: #75715e } /* Generic.Subheading */ + .pdoc-code .gt { color: #f8f8f2 } /* Generic.Traceback */ + .pdoc-code .kc { color: #66d9ef } /* Keyword.Constant */ + .pdoc-code .kd { color: #66d9ef } /* Keyword.Declaration */ + .pdoc-code .kn { color: #f92672 } /* Keyword.Namespace */ + .pdoc-code .kp { color: #66d9ef } /* Keyword.Pseudo */ + .pdoc-code .kr { color: #66d9ef } /* Keyword.Reserved */ + .pdoc-code .kt { color: #66d9ef } /* Keyword.Type */ + .pdoc-code .ld { color: #e6db74 } /* Literal.Date */ + .pdoc-code .m { color: #ae81ff } /* Literal.Number */ + .pdoc-code .s { color: #e6db74 } /* Literal.String */ + .pdoc-code .na { color: #a6e22e } /* Name.Attribute */ + .pdoc-code .nb { color: #f8f8f2 } /* Name.Builtin */ + .pdoc-code .nc { color: #a6e22e } /* Name.Class */ + .pdoc-code .no { color: #66d9ef } /* Name.Constant */ + .pdoc-code .nd { color: #a6e22e } /* Name.Decorator */ + .pdoc-code .ni { color: #f8f8f2 } /* Name.Entity */ + .pdoc-code .ne { color: #a6e22e } /* Name.Exception */ + .pdoc-code .nf { color: #a6e22e } /* Name.Function */ + .pdoc-code .nl { color: #f8f8f2 } /* Name.Label */ + .pdoc-code .nn { color: #f8f8f2 } /* Name.Namespace */ + .pdoc-code .nx { color: #a6e22e } /* Name.Other */ + .pdoc-code .py { color: #f8f8f2 } /* Name.Property */ + .pdoc-code .nt { color: #f92672 } /* Name.Tag */ + .pdoc-code .nv { color: #f8f8f2 } /* Name.Variable */ + .pdoc-code .ow { color: #f92672 } /* Operator.Word */ + .pdoc-code .w { color: #f8f8f2 } /* Text.Whitespace */ + .pdoc-code .mb { color: #ae81ff } /* Literal.Number.Bin */ + .pdoc-code .mf { color: #ae81ff } /* Literal.Number.Float */ + .pdoc-code .mh { color: #ae81ff } /* Literal.Number.Hex */ + .pdoc-code .mi { color: #ae81ff } /* Literal.Number.Integer */ + .pdoc-code .mo { color: #ae81ff } /* Literal.Number.Oct */ + .pdoc-code .sa { color: #e6db74 } /* Literal.String.Affix */ + .pdoc-code .sb { color: #e6db74 } /* Literal.String.Backtick */ + .pdoc-code .sc { color: #e6db74 } /* Literal.String.Char */ + .pdoc-code .dl { color: #e6db74 } /* Literal.String.Delimiter */ + .pdoc-code .sd { color: #e6db74 } /* Literal.String.Doc */ + .pdoc-code .s2 { color: #e6db74 } /* Literal.String.Double */ + .pdoc-code .se { color: #ae81ff } /* Literal.String.Escape */ + .pdoc-code .sh { color: #e6db74 } /* Literal.String.Heredoc */ + .pdoc-code .si { color: #e6db74 } /* Literal.String.Interpol */ + .pdoc-code .sx { color: #e6db74 } /* Literal.String.Other */ + .pdoc-code .sr { color: #e6db74 } /* Literal.String.Regex */ + .pdoc-code .s1 { color: #e6db74 } /* Literal.String.Single */ + .pdoc-code .ss { color: #e6db74 } /* Literal.String.Symbol */ + .pdoc-code .bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */ + .pdoc-code .fm { color: #a6e22e } /* Name.Function.Magic */ + .pdoc-code .vc { color: #f8f8f2 } /* Name.Variable.Class */ + .pdoc-code .vg { color: #f8f8f2 } /* Name.Variable.Global */ + .pdoc-code .vi { color: #f8f8f2 } /* Name.Variable.Instance */ + .pdoc-code .vm { color: #f8f8f2 } /* Name.Variable.Magic */ +} diff --git a/packages/styxkit/docs/pdoc-theme/theme.css b/packages/styxkit/docs/pdoc-theme/theme.css new file mode 100644 index 0000000..b86b093 --- /dev/null +++ b/packages/styxkit/docs/pdoc-theme/theme.css @@ -0,0 +1,44 @@ +/* pdoc color scheme (see also: examples/dark-mode) */ +:root { + --pdoc-background: #fff; +} + +.pdoc { + --text: #212529; + --muted: #6c757d; + --link: #3660a5; + --link-hover: #1659c5; + --code: #f8f8f8; + --active: #fff598; + + --accent: #eee; + --accent2: #c1c1c1; + + --nav-hover: rgba(255, 255, 255, 0.5); + --name: #0066BB; + --def: #008800; + --annotation: #007020; +} + +@media (prefers-color-scheme: dark) { + :root { + --pdoc-background: #212529; + } + + .pdoc { + --text: #f7f7f7; + --muted: #9d9d9d; + --link: #58a6ff; + --link-hover: #3989ff; + --code: #333; + --active: #555; + + --accent: #343434; + --accent2: #555; + + --nav-hover: rgba(0, 0, 0, 0.1); + --name: #77C1FF; + --def: #0cdd0c; + --annotation: #00c037; + } +} diff --git a/packages/styxkit/pyproject.toml b/packages/styxkit/pyproject.toml new file mode 100644 index 0000000..e4e7af7 --- /dev/null +++ b/packages/styxkit/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "styxkit" +version = "0.1.0" +description = "Convenience helpers spanning the Styx runtime backends." +authors = [ + {name = "Florian Rupprecht", email = "33600480+nx10@users.noreply.github.com"} +] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +dependencies = [ + "styxdefs>=0.7.0,<0.8", +] + +# Backends are imported lazily; install the ones you need (or `styxkit[all]`). +[project.optional-dependencies] +docker = ["styxdocker"] +podman = ["styxpodman"] +singularity = ["styxsingularity"] +graph = ["styxgraph"] +all = ["styxdocker", "styxpodman", "styxsingularity", "styxgraph"] + +[build-system] +requires = ["uv_build>=0.8.13,<0.10.0"] +build-backend = "uv_build" diff --git a/packages/styxkit/src/styxkit/__init__.py b/packages/styxkit/src/styxkit/__init__.py new file mode 100644 index 0000000..27a05b2 --- /dev/null +++ b/packages/styxkit/src/styxkit/__init__.py @@ -0,0 +1,217 @@ +"""Convenience helpers spanning the Styx runtime backends. + +``styxdefs`` is the base contract; each backend (``styxdocker``, ``styxpodman``, +``styxsingularity``, ``styxgraph``) is an independent piece. ``styxkit`` sits on +top and offers one-call runner selection across whichever pieces are installed. + +Backends are imported lazily, so ``styxkit`` itself only requires ``styxdefs``. +Calling a ``use_*()`` for a backend that is not installed raises a friendly +:class:`ModuleNotFoundError` naming the package (and extra) to install. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import shutil +import typing + +# Re-exported for convenience; the public surface is pinned by ``__all__`` below. +from styxdefs import ( + DryRunner, + Execution, + InputPathType, + LocalRunner, + Metadata, + OutputPathType, + Runner, + StyxRuntimeError, + StyxValidationError, + get_global_runner, + set_global_runner, +) + +RunnerType = typing.Literal["local", "docker", "podman", "singularity"] +"""A container-runner kind selectable via :func:`resolve_runner` / :func:`use_auto`.""" + + +class _Backend(typing.NamedTuple): + """A lazily-loaded runner backend: its module, class, exe kwarg, and probes.""" + + module: str + cls: str + exe_kwarg: str | None + executables: tuple[str, ...] + + +# Constructor kwarg names mirror the real backend classes (field-tested in rbc). +_BACKENDS: dict[str, _Backend] = { + "docker": _Backend("styxdocker", "DockerRunner", "docker_executable", ("docker",)), + "podman": _Backend("styxpodman", "PodmanRunner", "podman_executable", ("podman",)), + "singularity": _Backend( + "styxsingularity", + "SingularityRunner", + "singularity_executable", + ("apptainer", "singularity"), + ), + "graph": _Backend("styxgraph", "GraphRunner", None, ()), +} + + +def _runner_factory(kind: str) -> typing.Callable[..., Runner]: + """Import a backend's runner class (a Runner factory), or raise a friendly error. + + Typed as ``Callable[..., Runner]`` rather than ``type[Runner]``: each backend + constructor has its own keyword signature, none of which the base ``Runner`` + protocol declares, so the constructor kwargs are forwarded opaquely. + """ + backend = _BACKENDS[kind] + try: + module = importlib.import_module(backend.module) + except ModuleNotFoundError as exc: + # Only translate the backend package being absent. A ModuleNotFoundError + # naming something else means an *installed* backend failed to import a + # transitive dependency - re-raise that as-is rather than mislabeling it + # as "install styxkit[...]". + if exc.name != backend.module: + raise + raise ModuleNotFoundError( + f"The {kind!r} runner needs the {backend.module!r} package. " + f'Install it with `pip install "styxkit[{kind}]"` ' + f"(or `pip install {backend.module}`)." + ) from exc + return typing.cast("typing.Callable[..., Runner]", getattr(module, backend.cls)) + + +def _use(runner: Runner) -> Runner: + """Register ``runner`` as the global runner and return it.""" + set_global_runner(runner) + return runner + + +def use_local(**kwargs: typing.Any) -> Runner: + """Register a ``LocalRunner`` as the global runner and return it.""" + return _use(LocalRunner(**kwargs)) + + +def use_dry() -> Runner: + """Register a ``DryRunner`` as the global runner and return it. + + ``DryRunner`` takes no configuration, so this helper accepts no arguments. + """ + return _use(DryRunner()) + + +def use_docker(**kwargs: typing.Any) -> Runner: + """Register a ``DockerRunner`` as the global runner and return it. + + Requires the ``styxdocker`` package (``pip install "styxkit[docker]"``). + """ + return _use(_runner_factory("docker")(**kwargs)) + + +def use_podman(**kwargs: typing.Any) -> Runner: + """Register a ``PodmanRunner`` as the global runner and return it. + + Requires the ``styxpodman`` package (``pip install "styxkit[podman]"``). + """ + return _use(_runner_factory("podman")(**kwargs)) + + +def use_singularity(**kwargs: typing.Any) -> Runner: + """Register a ``SingularityRunner`` as the global runner and return it. + + Requires the ``styxsingularity`` package + (``pip install "styxkit[singularity]"``). + """ + return _use(_runner_factory("singularity")(**kwargs)) + + +def use_graph(base: Runner | None = None, **kwargs: typing.Any) -> Runner: + """Wrap a runner in a ``GraphRunner`` and register it as the global runner. + + Unlike the leaf runners, ``GraphRunner`` decorates a base runner. When + ``base`` is omitted it wraps the current global runner, so ``use_graph()`` + starts recording a graph over whatever runner is already active. + + Requires the ``styxgraph`` package (``pip install "styxkit[graph]"``). + """ + resolved_base = base if base is not None else get_global_runner() + return _use(_runner_factory("graph")(resolved_base, **kwargs)) + + +def _available(kind: str) -> bool: + """True iff a backend is importable and one of its executables is on PATH.""" + backend = _BACKENDS[kind] + if importlib.util.find_spec(backend.module) is None: + return False + return any(shutil.which(exe) for exe in backend.executables) + + +def resolve_runner(runner: RunnerType | typing.Literal["auto"] = "auto") -> RunnerType: + """Resolve a runner selection, auto-detecting when ``runner == "auto"``. + + Auto prefers the first container backend that is both installed and has its + executable on PATH (docker > podman > singularity), falling back to + ``"local"``. + + Args: + runner: An explicit runner kind, or ``"auto"`` to detect one. + + Returns: + The resolved runner kind. + """ + if runner != "auto": + return runner + for kind in ("docker", "podman", "singularity"): + if _available(kind): + return typing.cast(RunnerType, kind) + return "local" + + +def use_auto(**kwargs: typing.Any) -> Runner: + """Detect the best available runner, register it as global, and return it. + + Detection order is described in :func:`resolve_runner`. For a container + runner, the detected executable (e.g. ``apptainer`` vs ``singularity``) is + passed through unless the caller already supplied it. Extra keyword + arguments are forwarded to the selected runner's constructor. + """ + kind = resolve_runner("auto") + if kind == "local": + return use_local(**kwargs) + backend = _BACKENDS[kind] + if backend.exe_kwarg and backend.exe_kwarg not in kwargs: + exe = next((e for e in backend.executables if shutil.which(e)), None) + if exe is not None: + kwargs[backend.exe_kwarg] = exe + dispatch: dict[str, typing.Callable[..., Runner]] = { + "docker": use_docker, + "podman": use_podman, + "singularity": use_singularity, + } + return dispatch[kind](**kwargs) + + +__all__ = [ + "DryRunner", + "Execution", + "InputPathType", + "LocalRunner", + "Metadata", + "OutputPathType", + "Runner", + "RunnerType", + "StyxRuntimeError", + "StyxValidationError", + "get_global_runner", + "resolve_runner", + "set_global_runner", + "use_auto", + "use_docker", + "use_dry", + "use_graph", + "use_local", + "use_podman", + "use_singularity", +] diff --git a/packages/styxkit/src/styxkit/py.typed b/packages/styxkit/src/styxkit/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/styxkit/tests/test_styxkit.py b/packages/styxkit/tests/test_styxkit.py new file mode 100644 index 0000000..5d58e31 --- /dev/null +++ b/packages/styxkit/tests/test_styxkit.py @@ -0,0 +1,110 @@ +"""Smoke tests for styxkit.""" + +import importlib +import pathlib as pl + +import pytest +import styxkit +from styxdefs import DryRunner, LocalRunner, get_global_runner + + +def test_use_local_registers_and_returns(tmp_path: pl.Path) -> None: + runner = styxkit.use_local(data_dir=tmp_path) + assert isinstance(runner, LocalRunner) + assert get_global_runner() is runner + + +def test_use_dry_registers_and_returns() -> None: + runner = styxkit.use_dry() + assert isinstance(runner, DryRunner) + assert get_global_runner() is runner + + +def test_use_docker_constructs_real_runner(tmp_path: pl.Path) -> None: + styxdocker = pytest.importorskip("styxdocker") + runner = styxkit.use_docker(data_dir=tmp_path) + assert isinstance(runner, styxdocker.DockerRunner) + assert get_global_runner() is runner + + +def test_use_graph_wraps_current_global_runner(tmp_path: pl.Path) -> None: + styxgraph = pytest.importorskip("styxgraph") + base = styxkit.use_local(data_dir=tmp_path) + runner = styxkit.use_graph() + assert isinstance(runner, styxgraph.GraphRunner) + assert runner.base is base + assert get_global_runner() is runner + + +def test_missing_backend_raises_friendly(monkeypatch: pytest.MonkeyPatch) -> None: + real_import = importlib.import_module + + def fake_import(name: str, package: str | None = None) -> object: + if name == "styxdocker": + # A real missing-package import sets exc.name to the package. + raise ModuleNotFoundError("No module named 'styxdocker'", name="styxdocker") + return real_import(name, package) + + monkeypatch.setattr(importlib, "import_module", fake_import) + with pytest.raises(ModuleNotFoundError, match=r"styxkit\[docker\]"): + styxkit.use_docker() + + +def test_transitive_import_error_is_not_masked(monkeypatch: pytest.MonkeyPatch) -> None: + # An installed backend whose own (transitive) dependency is missing must + # surface as-is, not be relabeled "install styxkit[docker]". + def fake_import(name: str, package: str | None = None) -> object: + raise ModuleNotFoundError( + "No module named 'styxcontainer_common'", name="styxcontainer_common" + ) + + monkeypatch.setattr(importlib, "import_module", fake_import) + with pytest.raises(ModuleNotFoundError) as excinfo: + styxkit.use_docker() + assert excinfo.value.name == "styxcontainer_common" + assert "styxkit[docker]" not in str(excinfo.value) + + +def test_resolve_runner_passthrough() -> None: + assert styxkit.resolve_runner("podman") == "podman" + + +def test_resolve_runner_auto_prefers_first_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(styxkit, "_available", lambda kind: kind == "podman") + assert styxkit.resolve_runner("auto") == "podman" + + +def test_resolve_runner_auto_falls_back_to_local( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(styxkit, "_available", lambda kind: False) + assert styxkit.resolve_runner("auto") == "local" + + +def test_use_auto_local_when_nothing_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(styxkit, "_available", lambda kind: False) + runner = styxkit.use_auto() + assert isinstance(runner, LocalRunner) + assert get_global_runner() is runner + + +def test_use_auto_passes_detected_executable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(styxkit, "_available", lambda kind: kind == "singularity") + + def fake_which(exe: str) -> str | None: + return "/usr/bin/apptainer" if exe == "apptainer" else None + + monkeypatch.setattr(styxkit.shutil, "which", fake_which) + captured: dict[str, object] = {} + + def fake_use_singularity(**kwargs: object) -> object: + captured.update(kwargs) + return object() + + monkeypatch.setattr(styxkit, "use_singularity", fake_use_singularity) + styxkit.use_auto() + assert captured == {"singularity_executable": "apptainer"} diff --git a/pyproject.toml b/pyproject.toml index 160ae18..45e59f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ styxcontainer-common = { workspace = true } styxdocker = { workspace = true } styxpodman = { workspace = true } styxsingularity = { workspace = true } +styxkit = { workspace = true } [tool.pytest.ini_options] testpaths = ["packages"] @@ -55,3 +56,6 @@ convention = "google" [tool.ruff.lint.per-file-ignores] "**/tests/**/*.py" = ["ANN", "D100", "D101", "D102", "D103", "D104"] +# styxkit's use_*() helpers forward arbitrary kwargs to runner constructors, +# so typing.Any in their signatures is intentional. +"packages/styxkit/src/styxkit/__init__.py" = ["ANN401"] diff --git a/uv.lock b/uv.lock index 85aeef5..8888318 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ members = [ "styxdefs", "styxdocker", "styxgraph", + "styxkit", "styxpodman", "styxsingularity", ] @@ -912,6 +913,48 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "styxdefs", editable = "packages/styxdefs" }] +[[package]] +name = "styxkit" +version = "0.1.0" +source = { editable = "packages/styxkit" } +dependencies = [ + { name = "styxdefs" }, +] + +[package.optional-dependencies] +all = [ + { name = "styxdocker" }, + { name = "styxgraph" }, + { name = "styxpodman" }, + { name = "styxsingularity" }, +] +docker = [ + { name = "styxdocker" }, +] +graph = [ + { name = "styxgraph" }, +] +podman = [ + { name = "styxpodman" }, +] +singularity = [ + { name = "styxsingularity" }, +] + +[package.metadata] +requires-dist = [ + { name = "styxdefs", editable = "packages/styxdefs" }, + { name = "styxdocker", marker = "extra == 'all'", editable = "packages/styxdocker" }, + { name = "styxdocker", marker = "extra == 'docker'", editable = "packages/styxdocker" }, + { name = "styxgraph", marker = "extra == 'all'", editable = "packages/styxgraph" }, + { name = "styxgraph", marker = "extra == 'graph'", editable = "packages/styxgraph" }, + { name = "styxpodman", marker = "extra == 'all'", editable = "packages/styxpodman" }, + { name = "styxpodman", marker = "extra == 'podman'", editable = "packages/styxpodman" }, + { name = "styxsingularity", marker = "extra == 'all'", editable = "packages/styxsingularity" }, + { name = "styxsingularity", marker = "extra == 'singularity'", editable = "packages/styxsingularity" }, +] +provides-extras = ["docker", "podman", "singularity", "graph", "all"] + [[package]] name = "styxpodman" version = "0.3.0"