Metadata-Version: 2.4
Name: pydantic-monty-client
Version: 1.5.0
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: Unix
Classifier: Operating System :: POSIX :: Linux
Classifier: Environment :: MacOS X
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Internet
Classifier: Programming Language :: Python :: Implementation
Requires-Dist: typing-extensions>=4.5
Requires-Dist: opentelemetry-api>=1.39.0 ; extra == 'opentelemetry'
Provides-Extra: opentelemetry
Summary: Python client for the Monty sandbox
Home-Page: https://github.com/pydantic/monty/
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://github.com/pydantic/monty
Project-URL: Source, https://github.com/pydantic/monty

# pydantic-monty-client

Python client for the Monty sandbox.

Most users want [`pydantic-monty`](https://pypi.org/project/pydantic-monty/)
instead, which pulls in this package plus
[`pydantic-monty-runtime`](https://pypi.org/project/pydantic-monty-runtime/) and
is documented in full on its PyPI page.

Install this package directly to use the remote client alone,
or if you're installing the `monty` binary another way.

```bash
uv add pydantic-monty-client
# or
pip install pydantic-monty-client
```

## Usage with a remote monty server

You can use this library alone to connect to a remote monty server over gRPC: one HTTP/2 connection per pool, one
stream per session. `AsyncMontyWebsocket` is the same class under its former name, deprecated.

```python test="skip"
from pydantic_monty import AsyncMontyRemote


async def main() -> None:
    url = 'http://...'
    async with AsyncMontyRemote(url) as pool:
        async with pool.checkout() as session:
            output = await session.feed_run('1 + 1')
            print('output ->', output)


if __name__ == '__main__':
    import asyncio

    asyncio.run(main())
```

`MontyDisconnectError` exposes `status_code`, `status_message`, `cause` and `transport_error` when available.
These fields preserve the status the server ended the stream with, or the transport or protocol failure, not proof that
the request did or did not run.
The client does not automatically replay a disconnected request.
`MontyRefusedError`, with `status_code`, `status_message` and `cause`, is a server refusing to open the session at all;
nothing ran, so retrying is safe.
It subclasses `MontyError`, not `RuntimeError`, so a retry loop around `checkout()` must catch it by name.

## Host decode budget

`AsyncMontyRemote(max_decode_bytes=...)` can lower the default 1 GiB allocation budget for each decoded protocol frame.
Values above 1 GiB are rejected when the client is constructed.
The budget excludes wire buffers and Python conversion, and does not change
sandbox memory limits. Exhaustion ends the session with a protocol error;
the client does not replay the request.
Separately, the transport refuses any message larger than the budget, clamped between 64 KiB and the 256 MiB frame limit, before buffering it.

## Usage with a local monty worker

`AsyncFutureSnapshot.resume_auto()` raises without consuming the snapshot when no host coroutines remain to await,
including after `load_snapshot()`.
Resolve the pending calls manually with `await snapshot.resume({call_id: {'return_value': value}})` instead.

Host objects and classes cross the boundary through the `ClassInstance` / `ClassType` wrappers; see the
`pydantic-monty` README.
`external_modules` on `checkout()` maps import names to entries for every feed of the session, each either an
`ExternalModule` or a zero-argument factory returning one; the mapping is read live and each entry validated at its
first import, and a factory runs once, when a feed first needs it, and may be async under `AsyncMonty`.
An `ExternalModule` copies its `attrs` dict at construction and imports as a `module` object: callables become host
functions, nested `ExternalModule` values become importable submodules, and other values are converted normally.
Its `id` identifies the module when it crosses back out of the sandbox.
The optional `.pyi` source in `stubs` is requested at the first type-checked import and retained for the session.
An import of a non-bundled name the mapping does not answer reads `<cwd>/name/__init__.py` (or `name.py`) through the
`os` handler, then the feed's mounts, and runs it as the module, once per session.

This requires the `pydantic-monty-runtime` package, which is generally
installed as part of the `pydantic-monty` meta-package.

```python
from pydantic_monty import Monty

with Monty() as pool:
    with pool.checkout(limits={'max_suspensions': 100}) as session:
        print(session.feed_run('1 + 2'))
        #> 3
```

`max_suspensions` limits host-serviced suspensions per checkout (default
1000; it cannot be disabled). Exceeding it
aborts the feed with an uncatchable `RuntimeError`; the session remains usable,
but its suspension count remains spent.

or in async code:

```python
from pydantic_monty import AsyncMonty


async def main() -> None:
    async with AsyncMonty() as pool:
        async with pool.checkout() as session:
            output = await session.feed_run('1 + 1')
            print('output from local worker ->', output)
            #> output from local worker -> 2


if __name__ == '__main__':
    import asyncio

    asyncio.run(main())
```

## Where a snapshot stopped

Every snapshot exposes `position`, a `SourceRange` with `filename`, `start` and `end` locating the suspending
expression: the call of a `FunctionSnapshot`, the name of a `NameLookupSnapshot`, and the `await` the main task is
blocked on for a `FutureSnapshot`.
`start` and `end` are UTF-8 byte offsets into the source, `end` exclusive, so slice the encoded source rather than the
string; `filename` is the traceback filename of the source (`<python-input-N>` for the session's N-th feed, `<string>`
inside `eval()` / `exec()`).

```python
from pydantic_monty import FunctionSnapshot, Monty

with Monty() as pool:
    with pool.checkout() as session:
        code = 'x = 1\ny = greet(x)'
        snapshot = session.feed_start(code)
        assert isinstance(snapshot, FunctionSnapshot)
        position = snapshot.position
        print(position.start, position.end)
        #> 10 18
        print(code.encode()[position.start : position.end].decode())
        #> greet(x)
```

## Tracing snapshot handlers

All sync and async snapshot types provide `snapshot.trace_context()` for manual handlers.
It returns a standard OpenTelemetry `Context`, not a context manager, and requires `opentelemetry-api` to be installed.
Use OTel's `attach` / `detach` to activate it, including across `await` in the same task:

```python
from opentelemetry import context

from pydantic_monty import FunctionSnapshot, Monty, MontyComplete

with Monty() as pool:
    with pool.checkout() as session:
        snapshot = session.feed_start('greet(name)', inputs={'name': 'Ada'})
        assert isinstance(snapshot, FunctionSnapshot)
        token = context.attach(snapshot.trace_context())
        try:
            greeting = f'hello {snapshot.args[0]}'
        finally:
            context.detach(token)
        result = snapshot.resume({'return_value': greeting})
        assert isinstance(result, MontyComplete)
        print(result.output)
        #> hello Ada
```

The returned context preserves baggage and other entries captured at `feed_start` / `load_snapshot`, with the
suspension's span when Monty tracing is enabled.
Without Monty tracing it returns the captured context unchanged.
Context is not serialized: restoring captures the restoring caller's context instead.
The method does not activate the context or resume execution.
It raises `ImportError` without `opentelemetry-api`, or `RuntimeError` after resume.
Previously returned contexts remain usable but do not keep the suspension span open.
`resume_auto()` already activates the suspension span around callbacks.
See the [snapshot documentation](https://pydantic.dev/docs/monty/concepts/snapshots/).

## Restoring snapshots

`session.load_session()` and `session.load_snapshot()` require unmodified snapshots from a trusted, compatible Monty producer.
The caller must establish provenance and integrity before loading; Monty does not authenticate snapshots.
Invalid snapshots have no correctness or availability guarantees.
Successful loading does not establish validity.
See the [snapshot security documentation](https://pydantic.dev/docs/monty/concepts/security/#deserializing-snapshots).

## Working directory

Pass `cwd='/data'` to `session.feed_run()` or `session.feed_start()` to set the sandbox's virtual working directory.
The async session methods accept the same option.
The path must be absolute and uses POSIX `/` separators on every host.
On the first feed, omitting `cwd` selects the first mount's virtual path, or `/` if no mount is supplied.
The directory then persists across feeds, including successful `os.chdir(path=...)` calls, until another feed sets `cwd`.
`os.getcwd()` and `Path.cwd()` report it, and relative `open()`, `os`, and `pathlib` requests resolve against it.
Setting `cwd` does not grant filesystem access; provide `mount=` or `os=` to handle filesystem operations.

`OSAccess(max_urandom_bytes=...)` sets the largest `os.urandom()` request the default handler serves, 1 MiB by default.
Larger requests raise `MemoryError` before allocating.
`max_total_file_bytes` (50 MB) caps the `MemoryFile` content all files may grow to; a larger write raises `OSError`.
Unseeded `random` generators request host entropy only under `os_policy={'random_start': 'call_host'}`.
Otherwise they use worker OS entropy or the configured seed.

By default, `date.today()`, `datetime.now()` and the `time` module's clocks read the worker's clock.
`time.process_time()` reports `0.0` unless `os_policy={'process_time': 'elapsed'}` opts into the session's
execution time.
The pool handles `time.sleep()` and `asyncio.sleep()`, capped per call by `sleep_system_max`.
Setting `datetime` or `sleep` to `'call_host'` in `checkout(os_policy=...)` routes those calls to `os=`.
Every `time` module clock then reaches `AbstractOS.time(caller)` as the one OS function `time.time`, with `caller`
naming the function that asked (`'time.monotonic'`, `'time.localtime'`, ...).
A subclass that overrides `def time(self)` without the `caller` parameter raises `TypeError` on any clock read.
`OSAccess` answers from the host process and caps each wait at `max_sleep`.

A `random.Random` instance or the `random.Random` class returned from the sandbox converts to its repr string.
Return the generated values or `rng.getstate()` instead.

See the [`pydantic-monty`](https://pypi.org/project/pydantic-monty/) README for
more details.

## Execution time

Execution duration limits use wall time by default.
Both clocks charge bytecode and native builtin execution, excluding host suspension.
Enable CPU mode with `Monty(execution_clock='cpu', request_timeout=2)` or the same options on `AsyncMonty`.
CPU mode needs Linux workers: on macOS or Windows, entering a CPU-mode `Monty` (`with`) or `AsyncMonty`
(`async with`) raises `RuntimeError`.
Remote clients on any platform adopt the server's clock.
The clock applies to the whole pool; checkouts cannot override it.
CPU mode uses the executing thread's CPU time, sampled with a cache of up to 1 ms.
Smaller remaining budgets shorten that interval.
Execution boundaries take fresh samples. WebAssembly workers support wall time only.
Workers reject CPU configuration if the initial thread CPU counter read fails.
If the counter later becomes unusable during execution, the VM uses elapsed time for that window.
The OS controls CPU counter accuracy and resolution.
Native pool backstops use the selected clock and configured grace.
CPU mode uses an independent OS process counter and requires a finite wall-clock `request_timeout`.
Snapshots retain their clock and VM consumption.
CPU restore approximates prior supervisor usage from saved VM consumption.
The snapshot does not record protocol CPU or supervisor grace separately.
Load them through a pool with the same execution clock.
Snapshots without a clock use wall time.

