Skip to content

Commit ae50507

Browse files
committed
feat(workspaces): mount volumes and name the container of a DockerWorkspace
A tool run from a project directory - pydantic-deep's CLI in Docker mode - wants its project mounted in the container and the same container every time it starts, and has no message history to carry a ref in. Both were reachable only by building DockerWorkspaceBackend with a sandbox factory. volumes mounts host directories. container_name names one container for every run: created on first use, attached after. A ref naming it must still find it there, and no ref reaches another container through it. Also ships py.typed, without which type checkers in projects installing this package read every name as Any. Verified: 1320 tests at 100% coverage; ruff, pyright, mypy. On a real daemon a mounted host file reads inside, a file written inside appears on the host, and a second backend reaches the same container by name.
1 parent e9b0caa commit ae50507

5 files changed

Lines changed: 133 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`DockerWorkspace(volumes=..., container_name=...)`.** `volumes` mounts host
13+
directories into the container, so an agent can work on a project in place.
14+
`container_name` gives every run the same container, named by whoever
15+
configures the workspace: created on first use, attached after, by this
16+
process or the next. A ref naming it must still find it there, and no ref
17+
reaches another container through it. Both were reachable only by building
18+
`DockerWorkspaceBackend` with a sandbox factory of your own.
19+
20+
### Fixed
21+
22+
- **The package ships `py.typed`.** Its annotations were invisible to type
23+
checkers in projects that install it, which read every name as `Any`.
24+
1025
## [0.2.30] - 2026-10-05
1126

1227
**⚠️ Breaking: the library is now built on Pydantic AI workspaces.** Pydantic AI

‎docs/concepts/docker.md‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,33 @@ with the conversation, and every turn reaches the same environment; a different
118118
gets a container of its own. Call `destroy(ref)` when a conversation is deleted — nothing
119119
removes a container for you.
120120

121+
### One container, named by you
122+
123+
A tool that runs from a project directory - a CLI, an editor plugin - wants the same
124+
container every time it starts, with the project mounted inside, and has no history to
125+
carry a ref in. Name the container and mount the project:
126+
127+
```python
128+
workspace = DockerWorkspace(
129+
container_name="my-project-sandbox",
130+
volumes={"/home/me/my-project": "/workspace"},
131+
)
132+
```
133+
134+
The first run creates `my-project-sandbox`; every later one, in this process or the next,
135+
attaches to it, installed packages included. A ref naming it still has to find it there -
136+
one that outlived the container is `WorkspaceUnavailableError` - and a ref naming any other
137+
container is left to another capability. `destroy(ref)` removes it like any other.
138+
121139
What `DockerWorkspace` does not do is manage a fleet: idle reaping, a ceiling on running
122140
containers, per-tenant capacity, hibernation. That is what [`sandboxd`](remote.md) is for,
123141
built on the same `DockerSandbox` and the same command path.
124142

125143
## Options
126144

127-
`image`, `runtime`, `work_dir`, `network_mode`, `mem_limit`, `cpus` and `oci_runtime` are
128-
fields of `DockerWorkspace`. For a container option it does not expose — volumes, a tmpfs,
129-
a pids limit — build the backend yourself with a factory that returns the `DockerSandbox`
145+
`image`, `runtime`, `work_dir`, `network_mode`, `mem_limit`, `cpus`, `oci_runtime`, `env`,
146+
`volumes` and `container_name` are fields of `DockerWorkspace`. For a container option it
147+
does not expose — a tmpfs, a pids limit — build the backend yourself with a factory that returns the `DockerSandbox`
130148
you want:
131149

132150
```python

‎src/pydantic_ai_backends/py.typed‎

Whitespace-only changes.

‎src/pydantic_ai_backends/workspaces/_docker.py‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ class DockerWorkspaceBackend(ContainerWorkspaceBackend):
7979
the image, runtime and limits, and must not start the container.
8080
ref: The workspace to attach to; `None` creates one on first use.
8181
env: Variables every command gets, under any a call passes.
82+
container_name: A container chosen by whoever configures the workspace
83+
rather than by this class: created under that name on first use
84+
when it does not exist, attached when it does. A ref naming it must
85+
still find it there, and no ref reaches any other container.
8286
"""
8387

8488
def __init__(
@@ -87,11 +91,16 @@ def __init__(
8791
sandbox_factory: SandboxFactory,
8892
ref: WorkspaceRef | None = None,
8993
env: Mapping[str, str] | None = None,
94+
container_name: str | None = None,
9095
) -> None:
9196
async def open_container(name: str | None) -> tuple[str, RunnerSandbox]:
92-
if name is None:
97+
if container_name is not None and name is None:
98+
# First use of a configured container: whatever state it is in,
99+
# it is the one asked for, and starting the sandbox creates it.
100+
name = container_name
101+
elif name is None:
93102
name = f"{CONTAINER_PREFIX}{uuid.uuid4().hex[:16]}"
94-
elif not _created_here(name):
103+
elif name != container_name and not _created_here(name):
95104
raise SandboxUnavailableError(
96105
f"container {name!r} was not created by a DockerWorkspace"
97106
)
@@ -161,6 +170,22 @@ class DockerWorkspace(AbstractCapability[object]):
161170
env: Mapping[str, str] | None = field(default=None, repr=False)
162171
"""Variables every command gets. Nothing is read from the host's environment."""
163172

173+
volumes: Mapping[str, str] | None = None
174+
"""Host directories mounted into the container, as `{"/host/path": "/container/path"}`.
175+
176+
Mounting a project at `work_dir` lets the agent work on its files in place;
177+
the container then reaches exactly those host files.
178+
"""
179+
180+
container_name: str | None = None
181+
"""One container for every run, named by you: created on first use, attached after.
182+
183+
Without it each run without a ref gets a new container and only the ref
184+
leads back to it. With it a later process finds the same container -
185+
installed packages included - by name. A ref naming another container is
186+
left to another capability.
187+
"""
188+
164189
def __post_init__(self) -> None:
165190
if self.defer_loading:
166191
raise UserError(
@@ -178,11 +203,17 @@ def _sandbox(self, name: str) -> DockerSandbox:
178203
mem_limit=self.mem_limit,
179204
cpus=self.cpus,
180205
oci_runtime=self.oci_runtime,
206+
volumes=dict(self.volumes) if self.volumes else None,
181207
)
182208

183209
def backend(self, ref: WorkspaceRef | None = None) -> DockerWorkspaceBackend:
184210
"""A backend for `ref`, or for a new container; no I/O until its first operation."""
185-
return DockerWorkspaceBackend(sandbox_factory=self._sandbox, ref=ref, env=self.env)
211+
return DockerWorkspaceBackend(
212+
sandbox_factory=self._sandbox,
213+
ref=ref,
214+
env=self.env,
215+
container_name=self.container_name,
216+
)
186217

187218
def get_workspace(
188219
self, ctx: RunContext[object], *, ref: WorkspaceRef | None
@@ -191,6 +222,8 @@ def get_workspace(
191222
del ctx
192223
if ref is not None and ref.provider != DOCKER_PROVIDER:
193224
return None
225+
if ref is not None and self.container_name is not None and ref.id != self.container_name:
226+
return None
194227
return self.backend(ref)
195228

196229
async def destroy(self, ref: WorkspaceRef) -> None:
@@ -202,6 +235,6 @@ async def destroy(self, ref: WorkspaceRef) -> None:
202235
"""
203236
if ref.provider != DOCKER_PROVIDER:
204237
raise ValueError(f"expected a {DOCKER_PROVIDER!r} workspace ref, got {ref.provider!r}")
205-
if not _created_here(ref.id):
238+
if ref.id != self.container_name and not _created_here(ref.id):
206239
raise ValueError(f"container {ref.id!r} was not created by a DockerWorkspace")
207240
await anyio.to_thread.run_sync(_remove_container, ref.id)

‎tests/test_workspace_docker.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,63 @@ def test_a_valid_timeout_passes(self) -> None:
491491
def test_a_finished_outcome_needs_an_exit_code(self) -> None:
492492
with pytest.raises(ValueError, match="exit code"):
493493
command_result(CommandOutcome(stdout="", stderr=""), timeout=None, limit=1)
494+
495+
496+
class TestAConfiguredContainer:
497+
"""A container named by whoever configures the workspace, for every run."""
498+
499+
async def test_is_created_under_its_name_on_first_use(self, client: _Client) -> None:
500+
factory = _Factory()
501+
backend = DockerWorkspaceBackend(sandbox_factory=factory, container_name="project-box")
502+
await backend.run(["true"])
503+
assert factory.built[0].name == "project-box"
504+
assert backend.ref == WorkspaceRef(provider="docker", id="project-box")
505+
506+
async def test_a_ref_to_it_attaches_while_it_is_there(self, client: _Client) -> None:
507+
client.containers.known["project-box"] = _Container(client.api, status="exited")
508+
factory = _Factory()
509+
backend = DockerWorkspaceBackend(
510+
sandbox_factory=factory,
511+
ref=WorkspaceRef(provider="docker", id="project-box"),
512+
container_name="project-box",
513+
)
514+
await backend.run(["true"])
515+
assert factory.built[0].name == "project-box"
516+
517+
async def test_a_ref_to_it_once_it_is_gone_is_unavailable(self, client: _Client) -> None:
518+
backend = DockerWorkspaceBackend(
519+
sandbox_factory=_Factory(),
520+
ref=WorkspaceRef(provider="docker", id="project-box"),
521+
container_name="project-box",
522+
)
523+
with pytest.raises(WorkspaceUnavailableError, match="no longer exists"):
524+
await backend.working_dir()
525+
526+
async def test_no_ref_reaches_another_container(self, client: _Client) -> None:
527+
client.containers.known["postgres"] = _Container(client.api)
528+
backend = DockerWorkspaceBackend(
529+
sandbox_factory=_Factory(),
530+
ref=WorkspaceRef(provider="docker", id="postgres"),
531+
container_name="project-box",
532+
)
533+
with pytest.raises(WorkspaceUnavailableError, match="not created by a DockerWorkspace"):
534+
await backend.working_dir()
535+
536+
def test_the_capability_leaves_other_refs_alone(self) -> None:
537+
capability = DockerWorkspace(container_name="project-box")
538+
ctx: Any = None
539+
assert capability.get_workspace(ctx, ref=WorkspaceRef(provider="docker", id="x")) is None
540+
own = capability.get_workspace(ctx, ref=WorkspaceRef(provider="docker", id="project-box"))
541+
assert isinstance(own, DockerWorkspaceBackend)
542+
543+
async def test_destroy_removes_it(self, client: _Client) -> None:
544+
box = _Container(client.api)
545+
client.containers.known["project-box"] = box
546+
await DockerWorkspace(container_name="project-box").destroy(
547+
WorkspaceRef(provider="docker", id="project-box")
548+
)
549+
assert box.removed
550+
551+
def test_volumes_reach_the_sandbox(self) -> None:
552+
sandbox = DockerWorkspace(volumes={"/host/project": "/workspace"})._sandbox("name")
553+
assert sandbox._volumes == {"/host/project": "/workspace"}

0 commit comments

Comments
 (0)