Metadata-Version: 2.4
Name: literegistry-podman-client
Version: 0.1.4
Summary: A standalone asynchronous client for Podman sessions through a LiteRegistry gateway.
Author: Goncalo Faria
License-Expression: MIT
Keywords: podman,containers,literegistry,asyncio,docker-mirror
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aiohttp<4,>=3.9
Requires-Dist: fire<1,>=0.7
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Provides-Extra: publish
Requires-Dist: build>=1.2; extra == "publish"
Requires-Dist: twine>=5; extra == "publish"
Dynamic: license-file

# literegistry-podman-client

`literegistry-podman-client` is a small, standalone async Python client for
running commands in rootless Podman containers through a LiteRegistry gateway.
The person using it needs only a gateway URL; they do not need Redis, Podman,
Docker, or the full `literegistry` package.

The same gateway may also expose a Docker Hub pull-through mirror. Mirror use
is configured on the Podman servers by the operator, so client code still only
passes a normal image such as `docker.io/library/ubuntu:24.04`.

## Install

Install through the LiteRegistry extra:

```bash
pip install "literegistry[podman_client]"
```

The standalone distribution is also available directly:

```bash
pip install literegistry-podman-client
```

From this repository:

```bash
pip install ./literegistry_podman_client
```

The distribution name uses hyphens. Python imports use underscores:

```python
from literegistry_podman_client import PodmanClient
```

Its runtime dependencies are `aiohttp` and Python Fire.

## Give a deployment to someone

The operator gives the user one value:

```bash
export PODMAN_GATEWAY_URL=http://gateway.example:8080
```

The user can verify both gateway features:

```bash
curl -fsS "$PODMAN_GATEWAY_URL/health"
curl -fsS "$PODMAN_GATEWAY_URL/v2/"
```

`/health` checks the LiteRegistry gateway. `/v2/` checks its Docker Registry
V2 mirror route. No Redis URL or Podman replica address is exposed to users.

## Small async example

This creates one container, writes a file, reads it in a separate command, and
always deletes the container at the end:

### Without `async with`

```python
import asyncio

from literegistry_podman_client import PodmanClient


async def main() -> None:
    gateway_url = "http://gateway.example:8080"
    client = PodmanClient(gateway_url, workdir="/tmp")
    podman = None
    await client.open()
    try:
        podman = await client.handshake(
            image="docker.io/library/ubuntu:24.04",
            client_id="rollout-17",
        )
        print(podman.container_id)

        await podman.execute("echo ai2 hello > hello.txt", check=True)
        result = await podman.execute("cat hello.txt", check=True)
        print(result.stdout, end="")
    finally:
        try:
            if podman is not None:
                await podman.close()
        finally:
            await client.aclose()


asyncio.run(main())
```

### With `async with`

```python
import asyncio

from literegistry_podman_client import PodmanClient


async def main() -> None:
    async with PodmanClient(
        "http://gateway.example:8080",
        workdir="/tmp",
    ) as client:
        async with client.session(
            image="docker.io/library/ubuntu:24.04",
            client_id="rollout-17",
        ) as podman:
            print(podman.container_id)
            await podman.execute("echo ai2 hello > hello.txt", check=True)
            result = await podman.execute("cat hello.txt", check=True)
            print(result.stdout, end="")


asyncio.run(main())
```

The image pull happens on the selected Podman replica. If the operator wired
those replicas to the gateway's mirror, the pull is transparently cached; the
user does not change the image reference or client configuration.

The installed Fire CLI exposes the same smoke test:

```bash
literegistry-podman-client ai2-hello --gateway="$PODMAN_GATEWAY_URL"
```

The runnable version is [`examples/ai2_hello.py`](examples/ai2_hello.py):

```bash
python literegistry_podman_client/examples/ai2_hello.py \
  --gateway "$PODMAN_GATEWAY_URL"
```

## Terminal sandbox loss

With client 0.1.4, `PodmanContainerLostError` signals that the current sandbox
cannot be used again. Catch it at the episode boundary; do not silently create a
replacement container and continue the same episode. The client does not
re-handshake automatically.

```python
from literegistry_podman_client import PodmanContainerLostError

try:
    result = await session.execute(command)
except PodmanContainerLostError as exc:
    # End or discard this episode according to the training policy.
    # A fresh session belongs to a new episode.
    print(exc.reason, exc.limit, exc.observed, exc.enforcement)
    raise
```

A confirmed loss marks `PodmanSession.closed` true. Later `execute()` calls raise
the same typed error locally, and `close()` is a no-op. An already-lost response
during close is also treated as successful cleanup. Low-level
`client.execute(affinity_id, ...)` raises the typed error but retains no per-session
state; callers using that API must stop submitting commands for the lost ID.

LiteRegistry 1.0.52 returns HTTP 410 with a `detail` object containing
`code="sandbox_lost"`, `recoverable=false`, the container ID, and the watchdog
reason (`memory_limit` or `pids_limit`). `limit`, `observed`, and `unit` carry the
confirmed measurement; `enforcement="userspace_watchdog"` distinguishes this from
a kernel OOM. Memory observations are approximate aggregate RSS, not native
cgroup accounting.

Reasons are retained in memory on the owning replica for one hour, capped at
10,000 records. They are lost on replica restart or eviction. The client also
recognizes gateway `410 affinity_owner_lost` and legacy structured
`404 container_not_found` responses, including FastAPI's `detail` envelope.
Legacy missing-container responses have reason `unknown`, not `memory_limit`.
Generic 404/503 responses and ordinary command failures remain gateway/command
errors; they do not prove permanent sandbox loss. In-flight requests already
sent before a loss is observed cannot be recalled.

## Explicit lifecycle

Handshake, execute, and close are all async. The affinity ID returned by the
handshake keeps every command on the replica that owns its container.

```python
client = PodmanClient(gateway_url, workdir="/home/user")
await client.open()
session = None
try:
    session = await client.handshake(image=container_image)
    first = await client.execute(
        session.affinity_id,
        "python -c 'print(6 * 7)'",
        timeout=60,
    )
    first.check_returncode()
finally:
    try:
        if session is not None:
            await session.close()
    finally:
        await client.aclose()
```

- `client.close(affinity_id)` or `session.close()` deletes the container and
  its gateway affinity binding.
- `client.aclose()` only closes the local HTTP connection pool. It cannot
  guess which concurrent sessions should be deleted.
- `check=True` or `result.check_returncode()` raises `PodmanCommandError` for a
  non-zero command exit. Without it, stdout, stderr, and the exit code remain
  available on `CommandResult`.
- `result.stdout_truncated` and `result.stderr_truncated` report whether the
  server discarded output beyond its configured capture limits. Always check
  these flags before treating captured output as complete.

## Concurrent trajectories

One `PodmanClient` is intentionally shareable. It does not store a
private "current container". Each handshake returns a separate session:

```python
async def rollout(client: PodmanClient, number: int) -> str:
    podman = await client.handshake(client_id=f"rollout-{number}")
    try:
        result = await podman.execute("echo $((20 + 22))", check=True)
        return result.stdout.strip()
    finally:
        await podman.close()


client = PodmanClient(gateway_url)
await client.open()
try:
    outputs = await asyncio.gather(
        *(rollout(client, i) for i in range(128))
    )
finally:
    await client.aclose()
```

The HTTP pool is shared, while every request carries its explicit affinity ID.
As soon as one trajectory completes, application code may start another.

## Mirror behavior

There are two distinct flows behind the gateway URL:

```text
Python client -> /affinity/* -> Podman replica -> rootless container
Podman pull   -> /v2/*       -> Docker mirror -> Docker Hub
```

The client exposes `await client.mirror_health()` as a convenience probe, but
it does not configure the mirror. The deployment operator configures each
Podman replica's `containers-registries.conf` to use the gateway URL. This is
what ensures all users of the gateway benefit from the cache.

## Build and publish

```bash
cd literegistry_podman_client
python -m build
python -m twine check dist/*
python -m twine upload dist/*
```

Publishing requires a PyPI account and token. Verify that the distribution
name `literegistry-podman-client` is available before the first upload.
