Metadata-Version: 2.1
Name: wexample-runner
Version: 9.3.0
Summary: Executes commands across local, SSH, and Docker environments through a unified runner interface with lifecycle management (build, start, stop, destroy)
Author-Email: weeger <contact@wexample.com>
License: MIT
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Project-URL: homepage, https://github.com/wexample/python-runner
Requires-Python: >=3.10
Requires-Dist: paramiko>=3.0.0
Requires-Dist: wexample-helpers>=19.1.0
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Description-Content-Type: text/markdown

# runner

Version: 9.3.0

`wexample-runner` gives Python ≥ 3.10 code a single `execute(cmd)` call that runs shell commands on the local machine via subprocess, on a remote server over SSH (paramiko), or inside a Docker container — without the caller branching on the target environment. Every runner follows the same `build / start / stop / destroy` lifecycle and returns a `RunnerResult` dataclass carrying `stdout`, `stderr`, and `exit_code`. It targets developers who automate operations across mixed execution environments and want one interface that works regardless of where the command lands.

## Table of Contents

- [Installation](#installation)
- [Quickstart](#quickstart)
- [Tests](#tests)
- [Architecture](#architecture)
- [Integration in the Suite](#integration-in-the-suite)
- [Dependencies](#dependencies)
- [Versioning & Compatibility Policy](#versioning--compatibility-policy)
- [License](#license)
- [About us](#about-us)
- [Known Limitations & Roadmap](#known-limitations--roadmap)
- [Status & Compatibility](#status--compatibility)
- [Useful Links](#useful-links)
- [Migration Notes](#migration-notes)

## Installation

```bash
pip install wexample-runner
```

Requires Python >=3.10.

## Quickstart

Install the package:

```bash
pip install wexample-runner
```

`LocalRunner` runs commands on the local machine with no setup:

```python
from wexample_runner.runner.local_runner import LocalRunner

runner = LocalRunner()
result = runner.execute(["echo", "hello"])

print(result.stdout)       # hello\n
print(result.exit_code)    # 0
print(result.is_success()) # True
```

`execute` accepts a list of arguments or a plain shell string and always returns a `RunnerResult` dataclass carrying `stdout`, `stderr`, and `exit_code`.

To raise instead of checking the exit code manually:

```python
result.raise_on_error()
```

## Tests

This project uses `pytest` for testing and `pytest-cov` for code coverage analysis.

### Installation

First, install the required testing dependencies:
```bash
.venv/bin/python -m pip install pytest pytest-cov
```

### Basic Usage

Run all tests with coverage:
```bash
.venv/bin/python -m pytest --cov --cov-report=html
```

### Common Commands
```bash
# Run tests with coverage for a specific module
.venv/bin/python -m pytest --cov=your_module

# Show which lines are not covered
.venv/bin/python -m pytest --cov=your_module --cov-report=term-missing

# Generate an HTML coverage report
.venv/bin/python -m pytest --cov=your_module --cov-report=html

# Combine terminal and HTML reports
.venv/bin/python -m pytest --cov=your_module --cov-report=term-missing --cov-report=html

# Run specific test file with coverage
.venv/bin/python -m pytest tests/test_file.py --cov=your_module --cov-report=term-missing
```

### Viewing HTML Reports

After generating an HTML report, open `htmlcov/index.html` in your browser to view detailed line-by-line coverage information.

### Coverage Threshold

To enforce a minimum coverage percentage:
```bash
.venv/bin/python -m pytest --cov=your_module --cov-fail-under=80
```

This will cause the test suite to fail if coverage drops below 80%.

## Architecture

The package is organised around one abstract base, one result dataclass, one optional config dataclass, one registry, and five concrete runner classes. Every public entry point funnels through `AbstractRunner.execute()` and every response comes back as a `RunnerResult`.

### Abstract base

src/wexample_runner/runner/abstract_runner.py defines the contract every runner implements.

**Lifecycle** — four methods cover the full arc of an environment:

| Method | Responsibility |
|---|---|
| `build()` | Prepare the environment (build image, verify connectivity). No-op by default. |
| `start()` | Make the environment ready to receive commands. No-op by default. |
| `stop()` | Pause without removing anything. |
| `destroy()` | Remove completely. |

`ensure_running()` composes `build()` then `start()`. The class also implements the context manager protocol: `__enter__` calls `ensure_running()`, `__exit__` calls `stop()` and, when `ephemeral=True`, `destroy()`.

**Execution** — the one abstract method every subclass must provide:

```python
def execute(
    self,
    cmd: list[str] | str,
    workdir: str | None = None,
    env: dict[str, str] | None = None,
) -> RunnerResult:
```

### Result

src/wexample_runner/runner_result.py is a plain dataclass. Every `execute()` call returns one:

```python
@dataclass
class RunnerResult:
    exit_code: int
    stderr: str
    stdout: str
```

Two helpers cover the common patterns: `is_success()` returns `exit_code == 0`; `raise_on_error()` raises `RuntimeError` on non-zero exit, embedding `stderr` or `stdout` in the message.

### Configuration

src/wexample_runner/runner_config.py is an optional declarative layer for Docker-based runners. It groups the fields a runner needs at construction time — `dockerfile`, `image_name`, `mount_path`, `container_workdir`, `volumes`, and `ephemeral` — into one object a caller can pass around rather than threading individual keyword arguments.

### Registry

src/wexample_runner/runner_registry.py holds named `AbstractRunner` instances. It extends `SharedRegistry` from `wexample_helpers`, which provides a class-level singleton via `.shared()` alongside ordinary instantiation for isolated registries.

Callers register a pre-configured runner under an explicit string key and retrieve it by the same key:

```python
RunnerRegistry.shared().register(runner, key="prod")
runner = RunnerRegistry.shared().get_or_raise("prod")
```

`status()` introspects every registered runner and returns a list of dicts with `name`, `type`, `is_built`, `is_running`, and `ephemeral`.

### Concrete runners

### LocalRunner

src/wexample_runner/runner/local_runner.py runs commands on the local machine through `wexample_helpers.helper.shell.shell_run`. Both `is_built` and `is_running` always return `True`; the runner has no lifecycle to manage. `execute()` captures stdout and stderr with `capture=True` and returns them in a `RunnerResult`.

### SshRunner

src/wexample_runner/runner/ssh_runner.py wraps a `paramiko.SSHClient`. The connection is opened in `start()` and closed in `stop()`. `execute()` assembles a shell string: if `cmd` is a list it joins it with `shlex.join`, prepends `cd {workdir} &&` when a workdir is given, and prefixes environment variables as `KEY=value` pairs before calling `exec_command`. `is_running` inspects the paramiko transport's active state.

### AbstractDockerRunner

src/wexample_runner/runner/abstract_docker_runner.py provides the shared `docker exec` logic used by both Docker subclasses. `execute()` builds a `docker exec` invocation:

```
docker exec [--user U] [-w workdir] [-e K=V ...] <container_name> <cmd>
```

It also provides `rebase_path(host_path)`, which translates an absolute host path to its equivalent inside the container by matching it against the deepest registered volume mount. The `_find_mount_for` helper picks the longest matching mount so nested mounts take priority.

### DockerRunner

src/wexample_runner/runner/docker_runner.py owns its container end-to-end. `build()` calls `docker_build_image` when the image does not exist yet. `start()` creates the container with `docker_run_container` (pinning it to the current host uid:gid) or restarts an existing stopped one with `docker_start_container`. `destroy()` stops the container, removes it, then removes the image. `is_built` checks whether the image exists via `docker_image_exists`.

### DockerAttachedRunner

src/wexample_runner/runner/docker_attached_runner.py attaches to a container it does not own. `start()` raises `RuntimeError` if the container is not already running; `stop()` is a no-op; `destroy()` always raises to prevent accidental removal. `rebase_path()` extends the base implementation by lazily loading the container's actual mounts from Docker via `docker_container_mounts` when no volumes have been configured.

### Call path

A command reaches the target environment in four steps:

1. **Optionally prepare** — the caller calls `ensure_running()` (or uses the runner as a context manager). This triggers `build()` then `start()`. For `LocalRunner` and `SshRunner.build()` these are no-ops; for `DockerRunner` they build the image and create or restart the container; for `SshRunner.start()` the paramiko connection is opened.

2. **Call `execute(cmd)`** — the caller passes a list of arguments or a raw shell string, with optional `workdir` and `env` overrides.

3. **Backend dispatch** — `LocalRunner` forwards to `shell_run`; `SshRunner` assembles a shell string and calls `exec_command`; the Docker runners assemble a `docker exec` invocation and forward it to `shell_run`.

4. **Result** — the backend's stdout, stderr, and exit code are wrapped in a `RunnerResult` and returned to the caller unchanged.

## Integration in the Suite

This package is part of the Wexample Suite — a collection of high-quality, modular tools designed to work seamlessly together across multiple languages and environments.

### Related Packages

The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.

Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.

## Dependencies

- paramiko: >=3.0.0
- wexample-helpers: >=19.1.0

## Versioning & Compatibility Policy

Wexample packages follow **Semantic Versioning** (SemVer):

- **MAJOR**: Breaking changes
- **MINOR**: New features, backward compatible
- **PATCH**: Bug fixes, backward compatible

We maintain backward compatibility within major versions and provide clear migration guides for breaking changes.

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

Free to use in both personal and commercial projects.

## About us

[Wexample](https://wexample.com) stands as a cornerstone of the digital ecosystem — a collective of seasoned engineers, researchers, and creators driven by a relentless pursuit of technological excellence. More than a media platform, it has grown into a vibrant community where innovation meets craftsmanship, and where every line of code reflects a commitment to clarity, durability, and shared intelligence.

This packages suite embodies this spirit. Trusted by professionals and enthusiasts alike, it delivers a consistent, high-quality foundation for modern development — open, elegant, and battle-tested. Its reputation is built on years of collaboration, refinement, and rigorous attention to detail, making it a natural choice for those who demand both robustness and beauty in their tools.

Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.

## Known Limitations & Roadmap

Current limitations and planned features are tracked in the GitHub issues.

See the [project roadmap](https://github.com/wexample/python-runner/issues) for upcoming features and improvements.

## Status & Compatibility

**Maturity**: Production-ready

**Python Support**: >=3.10

**OS Support**: Linux, macOS, Windows

**Status**: Actively maintained

## Useful Links

- **Homepage**: https://github.com/wexample/python-runner
- **Documentation**: [docs.wexample.com](https://docs.wexample.com)
- **Issue Tracker**: https://github.com/wexample/python-runner/issues
- **Discussions**: https://github.com/wexample/python-runner/discussions
- **PyPI**: [pypi.org/project/wexample-runner](https://pypi.org/project/wexample-runner/)

## Migration Notes

When upgrading between major versions, refer to the migration guides in the documentation.

Breaking changes are clearly documented with upgrade paths and examples.
