container-pool 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ """
2
+ container-pool: Provider-agnostic async container pool with expiry recovery.
3
+
4
+ Quick start:
5
+ from container_pool import ContainerPool
6
+ from container_pool.backends.openai import OpenAIContainerBackend
7
+
8
+ backend = OpenAIContainerBackend(openai_client)
9
+ pool = ContainerPool(backend, max_pool_size=5, acquire_timeout=30.0, container_name="ci")
10
+
11
+ container = await pool.acquire()
12
+ try:
13
+ uploaded = await container.upload_file("/tmp/data.csv")
14
+ ...
15
+ finally:
16
+ await pool.release(container)
17
+
18
+ await pool.shutdown()
19
+ """
20
+
21
+ from ._backend import BaseContainerBackend
22
+ from ._container import Container
23
+ from ._exceptions import (
24
+ ContainerCreationError,
25
+ ContainerExpiredError,
26
+ ContainerFileError,
27
+ ContainerPoolError,
28
+ ContainerPoolExhaustedError,
29
+ )
30
+ from ._pool import ContainerPool
31
+ from ._tracker import RequestFileTracker
32
+ from ._types import ContainerInfo, ContainerStatus, UploadedFile
33
+
34
+ __all__ = [
35
+ # Core
36
+ "ContainerPool",
37
+ "Container",
38
+ "RequestFileTracker",
39
+ # ABC
40
+ "BaseContainerBackend",
41
+ # Types
42
+ "ContainerInfo",
43
+ "ContainerStatus",
44
+ "UploadedFile",
45
+ # Exceptions
46
+ "ContainerPoolError",
47
+ "ContainerPoolExhaustedError",
48
+ "ContainerExpiredError",
49
+ "ContainerCreationError",
50
+ "ContainerFileError",
51
+ ]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ._types import ContainerInfo, UploadedFile
6
+
7
+
8
+ class BaseContainerBackend(ABC):
9
+ """
10
+ Abstract interface every backend must implement.
11
+
12
+ All methods are async. Implementations are responsible for:
13
+ - Translating vendor-specific errors into ContainerExpiredError /
14
+ ContainerCreationError / ContainerFileError.
15
+ - Never leaking vendor SDK types through return values.
16
+ - Best-effort operations (destroy, delete_file) should swallow 404 silently.
17
+ """
18
+
19
+ # -------------------------------------------------------------------------
20
+ # Lifecycle
21
+ # -------------------------------------------------------------------------
22
+
23
+ @abstractmethod
24
+ async def create_container(self, name: str) -> ContainerInfo:
25
+ """
26
+ Create and activate a new container.
27
+ Raises ContainerCreationError on failure (after any internal retry).
28
+ """
29
+
30
+ @abstractmethod
31
+ async def get_container(self, container_id: str) -> ContainerInfo:
32
+ """
33
+ Fetch the current status of an existing container.
34
+ Raises ContainerExpiredError if the container is gone or expired.
35
+ """
36
+
37
+ @abstractmethod
38
+ async def destroy_container(self, container_id: str) -> None:
39
+ """
40
+ Permanently destroy a container.
41
+ Best-effort: should swallow 404 silently.
42
+ """
43
+
44
+ # -------------------------------------------------------------------------
45
+ # File operations
46
+ # -------------------------------------------------------------------------
47
+
48
+ @abstractmethod
49
+ async def upload_file(self, container_id: str, local_path: str) -> UploadedFile:
50
+ """
51
+ Upload a local file into the container.
52
+ Returns an UploadedFile with container_id, file_id, container_path.
53
+ Raises ContainerFileError on failure.
54
+ """
55
+
56
+ @abstractmethod
57
+ async def download_file_content(self, container_id: str, file_id: str) -> bytes:
58
+ """
59
+ Download a file's raw bytes by file_id.
60
+ Raises ContainerFileError on failure.
61
+ """
62
+
63
+ @abstractmethod
64
+ async def download_file_to_disk(
65
+ self,
66
+ container_id: str,
67
+ file_id: str,
68
+ local_path: str,
69
+ ) -> int:
70
+ """
71
+ Download a file and write it to local_path.
72
+ Returns the number of bytes written.
73
+ Raises ContainerFileError on failure.
74
+ """
75
+
76
+ @abstractmethod
77
+ async def delete_file(self, container_id: str, file_id: str) -> None:
78
+ """
79
+ Delete a single file from the container.
80
+ Best-effort: should swallow 404 silently.
81
+ """
82
+
83
+ @abstractmethod
84
+ async def list_files(
85
+ self,
86
+ container_id: str,
87
+ path_prefix: str = "",
88
+ ) -> dict[str, str]:
89
+ """
90
+ List files inside the container whose path starts with path_prefix.
91
+ Returns {filename: file_id}.
92
+ """
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ from ._backend import BaseContainerBackend
7
+ from ._types import UploadedFile
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ class Container:
13
+ """
14
+ A live handle to a single container slot.
15
+
16
+ Returned by ContainerPool.acquire(). Callers use this object to
17
+ upload/download files. They must call pool.release(container) when done.
18
+
19
+ Container does not manage expiry or recreation — that is the pool's job.
20
+ Container does not hold a reference to the pool.
21
+ """
22
+
23
+ def __init__(self, container_id: str, backend: BaseContainerBackend) -> None:
24
+ self.container_id = container_id
25
+ self._backend = backend
26
+
27
+ # -------------------------------------------------------------------------
28
+ # File operations — thin delegation to backend
29
+ # -------------------------------------------------------------------------
30
+
31
+ async def upload_file(self, local_path: str) -> UploadedFile:
32
+ return await self._backend.upload_file(self.container_id, local_path)
33
+
34
+ async def download_file_content(self, file_id: str) -> bytes:
35
+ return await self._backend.download_file_content(self.container_id, file_id)
36
+
37
+ async def download_file_to_disk(self, file_id: str, local_path: str) -> int:
38
+ return await self._backend.download_file_to_disk(
39
+ self.container_id, file_id, local_path
40
+ )
41
+
42
+ async def delete_file(self, file_id: str) -> None:
43
+ await self._backend.delete_file(self.container_id, file_id)
44
+
45
+ async def delete_files(self, file_ids: list[str]) -> None:
46
+ """Best-effort bulk delete. Logs and continues on individual failures."""
47
+ for fid in file_ids:
48
+ try:
49
+ await self._backend.delete_file(self.container_id, fid)
50
+ except Exception:
51
+ log.warning(
52
+ "Failed to delete file %r from container %r",
53
+ fid,
54
+ self.container_id,
55
+ exc_info=True,
56
+ )
57
+
58
+ async def list_output_files(self, path_prefix: str = "") -> dict[str, str]:
59
+ """List files in the container, optionally filtered by path prefix."""
60
+ return await self._backend.list_files(self.container_id, path_prefix)
61
+
62
+ async def download_files(
63
+ self,
64
+ file_ids: dict[str, str],
65
+ output_dir: str,
66
+ ) -> dict[str, str]:
67
+ """
68
+ Download multiple files to a local directory.
69
+
70
+ Args:
71
+ file_ids: {filename: file_id}
72
+ output_dir: Local directory to save files into
73
+
74
+ Returns:
75
+ {filename: local_path}
76
+ """
77
+ results: dict[str, str] = {}
78
+ for filename, file_id in file_ids.items():
79
+ local_path = os.path.join(output_dir, filename)
80
+ await self._backend.download_file_to_disk(
81
+ self.container_id, file_id, local_path
82
+ )
83
+ results[filename] = local_path
84
+ return results
85
+
86
+ def __repr__(self) -> str:
87
+ return f"Container(id={self.container_id!r})"
@@ -0,0 +1,46 @@
1
+ class ContainerPoolError(Exception):
2
+ """Base class for all container-pool errors."""
3
+
4
+
5
+ class ContainerPoolExhaustedError(ContainerPoolError):
6
+ """
7
+ Raised by ContainerPool.acquire() when no container becomes
8
+ available within acquire_timeout seconds.
9
+ """
10
+
11
+ def __init__(self, timeout: float, pool_size: int) -> None:
12
+ self.timeout = timeout
13
+ self.pool_size = pool_size
14
+ super().__init__(
15
+ f"No container available after {timeout}s (pool_size={pool_size})"
16
+ )
17
+
18
+
19
+ class ContainerExpiredError(ContainerPoolError):
20
+ """
21
+ Raised by a backend when it detects a container has expired
22
+ (404 or status=expired). The pool catches this and recreates.
23
+ Callers should never see this in normal flow.
24
+ """
25
+
26
+ def __init__(self, container_id: str) -> None:
27
+ self.container_id = container_id
28
+ super().__init__(f"Container {container_id!r} has expired or been deleted")
29
+
30
+
31
+ class ContainerCreationError(ContainerPoolError):
32
+ """
33
+ Raised when all retries to create a new container have failed.
34
+ Wraps the last underlying exception.
35
+ """
36
+
37
+ def __init__(self, attempts: int, cause: BaseException) -> None:
38
+ self.attempts = attempts
39
+ self.cause = cause
40
+ super().__init__(
41
+ f"Container creation failed after {attempts} attempt(s): {cause}"
42
+ )
43
+
44
+
45
+ class ContainerFileError(ContainerPoolError):
46
+ """Raised when a file upload, download, or delete operation fails."""
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ from ._backend import BaseContainerBackend
7
+ from ._container import Container
8
+ from ._exceptions import (
9
+ ContainerCreationError,
10
+ ContainerExpiredError,
11
+ ContainerPoolExhaustedError,
12
+ )
13
+ from ._retry import retry_with_backoff
14
+ from ._types import ContainerStatus
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ class ContainerPool:
20
+ """
21
+ Async FIFO pool of Container objects backed by any BaseContainerBackend.
22
+
23
+ Usage:
24
+ pool = ContainerPool(
25
+ backend,
26
+ max_pool_size=5,
27
+ acquire_timeout=30.0,
28
+ container_name="mypool",
29
+ )
30
+ container = await pool.acquire()
31
+ try:
32
+ ...
33
+ finally:
34
+ await pool.release(container)
35
+ await pool.shutdown()
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ backend: BaseContainerBackend,
41
+ *,
42
+ max_pool_size: int,
43
+ acquire_timeout: float,
44
+ container_name: str,
45
+ creation_max_attempts: int = 3,
46
+ creation_base_delay: float = 1.0,
47
+ ) -> None:
48
+ if not (1 <= max_pool_size <= 50):
49
+ raise ValueError(f"max_pool_size must be 1-50, got {max_pool_size}")
50
+ if acquire_timeout <= 0:
51
+ raise ValueError("acquire_timeout must be positive")
52
+
53
+ self._backend = backend
54
+ self.max_pool_size = max_pool_size
55
+ self.acquire_timeout = acquire_timeout
56
+ self.container_name = container_name
57
+ self._creation_max_attempts = creation_max_attempts
58
+ self._creation_base_delay = creation_base_delay
59
+
60
+ self._queue: asyncio.Queue[Container] = asyncio.Queue()
61
+ # Total containers ever created = items in queue + items checked out.
62
+ # Only decremented on failed creation (rollback) — not on expiry/replace.
63
+ self._total: int = 0
64
+ self._lock = asyncio.Lock() # guards the growth decision (_total += 1)
65
+ self._closed: bool = False
66
+
67
+ # -------------------------------------------------------------------------
68
+ # Public API
69
+ # -------------------------------------------------------------------------
70
+
71
+ async def acquire(self) -> Container:
72
+ """
73
+ Return a validated, live Container from the pool.
74
+
75
+ Flow:
76
+ 1. Queue has an item (non-blocking)? → validate_or_recreate, return.
77
+ 2. _total < max_pool_size? → create new container, return.
78
+ 3. Otherwise: wait up to acquire_timeout for a release().
79
+ Raises ContainerPoolExhaustedError on timeout.
80
+ """
81
+ if self._closed:
82
+ raise RuntimeError("ContainerPool is shut down")
83
+
84
+ # Fast path: something already in the queue
85
+ try:
86
+ container = self._queue.get_nowait()
87
+ return await self._validate_or_recreate(container)
88
+ except asyncio.QueueEmpty:
89
+ pass
90
+
91
+ # Growth path: can we create a new one?
92
+ async with self._lock:
93
+ if self._total < self.max_pool_size:
94
+ self._total += 1
95
+ should_create = True
96
+ else:
97
+ should_create = False
98
+
99
+ if should_create:
100
+ try:
101
+ return await self._create_container_with_retry()
102
+ except Exception:
103
+ async with self._lock:
104
+ self._total -= 1 # roll back — creation failed
105
+ raise
106
+
107
+ # Blocking path: wait for someone to release
108
+ try:
109
+ container = await asyncio.wait_for(
110
+ self._queue.get(),
111
+ timeout=self.acquire_timeout,
112
+ )
113
+ except asyncio.TimeoutError:
114
+ raise ContainerPoolExhaustedError(
115
+ timeout=self.acquire_timeout,
116
+ pool_size=self.max_pool_size,
117
+ )
118
+
119
+ return await self._validate_or_recreate(container)
120
+
121
+ async def release(self, container: Container) -> None:
122
+ """
123
+ Return a container to the pool.
124
+
125
+ No re-validation at release time — validation happens at the next
126
+ acquire(). This keeps release() fast (safe to call in finally blocks).
127
+
128
+ If the pool is already shut down, destroys the container instead
129
+ of queuing it.
130
+ """
131
+ if self._closed:
132
+ await self._safe_destroy(container)
133
+ return
134
+ await self._queue.put(container)
135
+
136
+ async def shutdown(self) -> None:
137
+ """
138
+ Mark pool as closed and destroy all containers currently in the queue.
139
+
140
+ Containers that are checked out at shutdown time will be destroyed
141
+ when release() is called on them. This method is idempotent.
142
+ """
143
+ if self._closed:
144
+ return
145
+ self._closed = True
146
+ log.info("ContainerPool shutting down. Draining queue...")
147
+
148
+ destroyed = 0
149
+ while True:
150
+ try:
151
+ container = self._queue.get_nowait()
152
+ await self._safe_destroy(container)
153
+ destroyed += 1
154
+ except asyncio.QueueEmpty:
155
+ break
156
+
157
+ log.info("ContainerPool shutdown complete. Destroyed %d container(s).", destroyed)
158
+
159
+ # -------------------------------------------------------------------------
160
+ # Internal helpers
161
+ # -------------------------------------------------------------------------
162
+
163
+ async def _create_container_with_retry(self) -> Container:
164
+ info = await retry_with_backoff(
165
+ lambda: self._backend.create_container(self.container_name),
166
+ max_attempts=self._creation_max_attempts,
167
+ base_delay=self._creation_base_delay,
168
+ retryable=ContainerCreationError,
169
+ )
170
+ log.debug("Created container %r", info.container_id)
171
+ return Container(container_id=info.container_id, backend=self._backend)
172
+
173
+ async def _validate_or_recreate(self, container: Container) -> Container:
174
+ """
175
+ Confirm the container is alive. If expired or gone, replace it
176
+ with a freshly created one (total pool count stays the same: 1-for-1).
177
+ """
178
+ try:
179
+ info = await self._backend.get_container(container.container_id)
180
+ if info.status == ContainerStatus.ACTIVE:
181
+ return container
182
+ log.info(
183
+ "Container %r has status=%r, recreating.",
184
+ container.container_id,
185
+ info.status,
186
+ )
187
+ except ContainerExpiredError:
188
+ log.info(
189
+ "Container %r returned expired/404, recreating.",
190
+ container.container_id,
191
+ )
192
+ except Exception as exc:
193
+ log.warning(
194
+ "Unexpected error validating container %r (%s), recreating.",
195
+ container.container_id,
196
+ exc,
197
+ )
198
+
199
+ await self._safe_destroy(container)
200
+ info = await retry_with_backoff(
201
+ lambda: self._backend.create_container(self.container_name),
202
+ max_attempts=self._creation_max_attempts,
203
+ base_delay=self._creation_base_delay,
204
+ retryable=ContainerCreationError,
205
+ )
206
+ log.debug(
207
+ "Recreated: %r -> %r", container.container_id, info.container_id
208
+ )
209
+ return Container(container_id=info.container_id, backend=self._backend)
210
+
211
+ async def _safe_destroy(self, container: Container) -> None:
212
+ """Destroy a container, swallowing all errors."""
213
+ try:
214
+ await self._backend.destroy_container(container.container_id)
215
+ except Exception:
216
+ log.warning(
217
+ "Failed to destroy container %r during cleanup",
218
+ container.container_id,
219
+ exc_info=True,
220
+ )
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import random
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import TypeVar
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ async def retry_with_backoff(
15
+ fn: Callable[[], Awaitable[T]],
16
+ *,
17
+ max_attempts: int = 3,
18
+ base_delay: float = 1.0,
19
+ max_delay: float = 30.0,
20
+ jitter: bool = True,
21
+ retryable: type[BaseException] | tuple[type[BaseException], ...] = Exception,
22
+ ) -> T:
23
+ """
24
+ Call fn() up to max_attempts times with exponential backoff.
25
+
26
+ Delay schedule (before jitter): 1s, 2s, 4s, 8s... capped at max_delay.
27
+ Jitter is ±20% of the computed delay to avoid thundering herd.
28
+
29
+ Only retries on exceptions matching `retryable`.
30
+ Raises the last exception after all attempts are exhausted.
31
+ """
32
+ last_exc: BaseException | None = None
33
+
34
+ for attempt in range(1, max_attempts + 1):
35
+ try:
36
+ return await fn()
37
+ except retryable as exc:
38
+ last_exc = exc
39
+ if attempt == max_attempts:
40
+ break
41
+ delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
42
+ if jitter:
43
+ delay *= random.uniform(0.8, 1.2)
44
+ log.warning(
45
+ "Attempt %d/%d failed (%s). Retrying in %.2fs.",
46
+ attempt,
47
+ max_attempts,
48
+ exc,
49
+ delay,
50
+ )
51
+ await asyncio.sleep(delay)
52
+
53
+ raise last_exc # type: ignore[misc]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from ._container import Container
6
+ from ._types import UploadedFile
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ class RequestFileTracker:
12
+ """
13
+ Wraps a Container for the duration of a single request.
14
+
15
+ Tracks every file_id returned by upload_file(). Call cleanup() once the
16
+ request is complete to delete all tracked files. Keeps containers clean
17
+ between users without the caller needing to track individual file ids.
18
+
19
+ Usage:
20
+ container = await pool.acquire()
21
+ tracker = RequestFileTracker(container)
22
+ try:
23
+ uploaded = await tracker.upload_file("/tmp/input.csv")
24
+ # ... use container ...
25
+ finally:
26
+ await tracker.cleanup()
27
+ await pool.release(container)
28
+ """
29
+
30
+ def __init__(self, container: Container) -> None:
31
+ self._container = container
32
+ self._tracked_file_ids: list[str] = []
33
+
34
+ @property
35
+ def container(self) -> Container:
36
+ return self._container
37
+
38
+ async def upload_file(self, local_path: str) -> UploadedFile:
39
+ """Upload a file and automatically track its file_id for cleanup."""
40
+ result = await self._container.upload_file(local_path)
41
+ self._tracked_file_ids.append(result.file_id)
42
+ return result
43
+
44
+ async def cleanup(self) -> None:
45
+ """
46
+ Delete all tracked files. Best-effort: always clears the tracking
47
+ list even if some deletes fail. Errors are logged, not raised.
48
+ """
49
+ if not self._tracked_file_ids:
50
+ return
51
+
52
+ ids_to_delete = list(self._tracked_file_ids)
53
+ self._tracked_file_ids.clear() # clear before deleting — safe on partial failure
54
+
55
+ await self._container.delete_files(ids_to_delete)
56
+ log.debug("RequestFileTracker cleaned up %d file(s).", len(ids_to_delete))
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+
8
+ class ContainerStatus(StrEnum):
9
+ ACTIVE = "active"
10
+ EXPIRED = "expired"
11
+ UNKNOWN = "unknown"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ContainerInfo:
16
+ """
17
+ Vendor-neutral snapshot of a container's identity and status.
18
+ Backends return this; the pool never touches raw vendor objects.
19
+ """
20
+
21
+ container_id: str
22
+ status: ContainerStatus
23
+ metadata: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class UploadedFile:
28
+ """
29
+ Returned by backend.upload_file(). Carries all three identifiers
30
+ the caller needs: the container it belongs to, the remote file id,
31
+ and the path the runtime sees it at.
32
+ """
33
+
34
+ container_id: str
35
+ file_id: str
36
+ container_path: str
@@ -0,0 +1,4 @@
1
+ # Backend implementations.
2
+ # Import explicitly to avoid crashing when optional deps are not installed:
3
+ #
4
+ # from container_pool.backends.openai import OpenAIContainerBackend
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ try:
6
+ import openai as _openai
7
+ from openai import AsyncOpenAI
8
+ except ImportError as exc:
9
+ raise ImportError(
10
+ "The OpenAI backend requires the 'openai' package. "
11
+ "Install it with: pip install 'container-pool[openai]'"
12
+ ) from exc
13
+
14
+ from .._backend import BaseContainerBackend
15
+ from .._exceptions import (
16
+ ContainerCreationError,
17
+ ContainerExpiredError,
18
+ ContainerFileError,
19
+ )
20
+ from .._types import ContainerInfo, ContainerStatus, UploadedFile
21
+
22
+
23
+ class OpenAIContainerBackend(BaseContainerBackend):
24
+ """
25
+ Concrete backend using the OpenAI Code Interpreter Container API.
26
+
27
+ Requires openai>=1.0.0 and an AsyncOpenAI client. Install the extra:
28
+ pip install 'container-pool[openai]'
29
+
30
+ All vendor-specific errors are translated into ContainerPoolError subclasses.
31
+ The pool and Container classes never see openai SDK types.
32
+ """
33
+
34
+ def __init__(self, client: AsyncOpenAI) -> None:
35
+ self._client = client
36
+
37
+ # -------------------------------------------------------------------------
38
+ # Lifecycle
39
+ # -------------------------------------------------------------------------
40
+
41
+ async def create_container(self, name: str) -> ContainerInfo:
42
+ try:
43
+ result = await self._client.containers.create(name=name)
44
+ return ContainerInfo(
45
+ container_id=result.id,
46
+ status=self._parse_status(result.status),
47
+ )
48
+ except _openai.APIError as exc:
49
+ raise ContainerCreationError(attempts=1, cause=exc) from exc
50
+
51
+ async def get_container(self, container_id: str) -> ContainerInfo:
52
+ try:
53
+ result = await self._client.containers.retrieve(container_id)
54
+ status = self._parse_status(result.status)
55
+ if status != ContainerStatus.ACTIVE:
56
+ raise ContainerExpiredError(container_id)
57
+ return ContainerInfo(container_id=result.id, status=status)
58
+ except _openai.NotFoundError as exc:
59
+ raise ContainerExpiredError(container_id) from exc
60
+ except ContainerExpiredError:
61
+ raise
62
+ except _openai.APIConnectionError as exc:
63
+ # Network error — treat as expired so the pool recreates
64
+ raise ContainerExpiredError(container_id) from exc
65
+ except _openai.APIError as exc:
66
+ raise ContainerExpiredError(container_id) from exc
67
+
68
+ async def destroy_container(self, container_id: str) -> None:
69
+ try:
70
+ await self._client.containers.delete(container_id)
71
+ except (_openai.NotFoundError, _openai.APIError):
72
+ pass # best-effort
73
+
74
+ # -------------------------------------------------------------------------
75
+ # File operations
76
+ # -------------------------------------------------------------------------
77
+
78
+ async def upload_file(self, container_id: str, local_path: str) -> UploadedFile:
79
+ try:
80
+ with open(local_path, "rb") as f:
81
+ result = await self._client.containers.files.create(
82
+ container_id=container_id,
83
+ file=f,
84
+ )
85
+ return UploadedFile(
86
+ container_id=container_id,
87
+ file_id=result.id,
88
+ container_path=result.path,
89
+ )
90
+ except FileNotFoundError:
91
+ raise
92
+ except Exception as exc:
93
+ raise ContainerFileError(
94
+ f"Upload failed for {local_path!r}: {exc}"
95
+ ) from exc
96
+
97
+ async def download_file_content(self, container_id: str, file_id: str) -> bytes:
98
+ try:
99
+ response = await self._client.containers.files.content.retrieve(
100
+ file_id=file_id,
101
+ container_id=container_id,
102
+ )
103
+ return response.content
104
+ except Exception as exc:
105
+ raise ContainerFileError(
106
+ f"Download failed for file {file_id!r}: {exc}"
107
+ ) from exc
108
+
109
+ async def download_file_to_disk(
110
+ self,
111
+ container_id: str,
112
+ file_id: str,
113
+ local_path: str,
114
+ ) -> int:
115
+ content = await self.download_file_content(container_id, file_id)
116
+ os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
117
+ with open(local_path, "wb") as f:
118
+ f.write(content)
119
+ return len(content)
120
+
121
+ async def delete_file(self, container_id: str, file_id: str) -> None:
122
+ try:
123
+ await self._client.containers.files.delete(
124
+ file_id=file_id,
125
+ container_id=container_id,
126
+ )
127
+ except (_openai.NotFoundError, _openai.APIError):
128
+ pass # best-effort
129
+
130
+ async def list_files(
131
+ self,
132
+ container_id: str,
133
+ path_prefix: str = "",
134
+ ) -> dict[str, str]:
135
+ try:
136
+ response = await self._client.containers.files.list(
137
+ container_id=container_id
138
+ )
139
+ files: dict[str, str] = {}
140
+ for f in response.data:
141
+ if path_prefix and not f.path.startswith(path_prefix):
142
+ continue
143
+ filename = os.path.basename(f.path)
144
+ files[filename] = f.id
145
+ return files
146
+ except Exception as exc:
147
+ raise ContainerFileError(f"list_files failed: {exc}") from exc
148
+
149
+ # -------------------------------------------------------------------------
150
+ # Helpers
151
+ # -------------------------------------------------------------------------
152
+
153
+ @staticmethod
154
+ def _parse_status(raw: str) -> ContainerStatus:
155
+ try:
156
+ return ContainerStatus(raw)
157
+ except ValueError:
158
+ return ContainerStatus.UNKNOWN
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: container-pool
3
+ Version: 0.1.0
4
+ Summary: Provider-agnostic async container pool with expiry recovery and per-request file tracking
5
+ Project-URL: Repository, https://github.com/aayushgzip/container-pool
6
+ Project-URL: Issues, https://github.com/aayushgzip/container-pool/issues
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 aayushdwids
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: async,asyncio,code-interpreter,container,openai,pool
30
+ Classifier: Development Status :: 4 - Beta
31
+ Classifier: Framework :: AsyncIO
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
39
+ Requires-Python: >=3.11
40
+ Provides-Extra: dev
41
+ Requires-Dist: build; extra == 'dev'
42
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
43
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
44
+ Requires-Dist: pytest>=8.0; extra == 'dev'
45
+ Requires-Dist: twine; extra == 'dev'
46
+ Provides-Extra: openai
47
+ Requires-Dist: openai>=1.0.0; extra == 'openai'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # container-pool
51
+
52
+ Production-grade async container pool for Python. Handles container lifecycle, reuse, concurrency, and automatic recovery from expiry — so you don't have to.
53
+
54
+ Built for OpenAI's Code Interpreter, but designed to work with **any sandboxed container runtime** via a pluggable backend interface.
55
+
56
+ ## The Problem
57
+
58
+ When running sandboxed containers behind a multi-user backend, you hit problems no provider solves for you:
59
+
60
+ - **Containers expire silently** after 20 minutes of inactivity. Your next request fails with a 404.
61
+ - **No built-in pooling.** Every request creates a new container (~2-3s overhead).
62
+ - **No concurrency management.** Two users hitting your API simultaneously? You're on your own.
63
+ - **File cleanup is your problem.** Leaked files accumulate and you eat the storage cost.
64
+
65
+ `container-pool` is the infrastructure layer that handles all of this.
66
+
67
+ ## What This Does
68
+
69
+ ```
70
+ Request A ──→ acquire() ──→ [Container 1] ──→ release() ──→ back to pool
71
+ Request B ──→ acquire() ──→ [Container 2] ──→ release() ──→ back to pool
72
+ Request C ──→ acquire() ──→ (pool full, blocks until release) ──→ ...
73
+ ```
74
+
75
+ - **FIFO pool** with configurable size, blocking acquisition with timeout when exhausted
76
+ - **Automatic expiry recovery** — detects expired containers (404, status=expired) and transparently recreates them
77
+ - **Per-request file tracking** with cleanup, so containers stay clean between users
78
+ - **Retry with exponential backoff** on container creation failures
79
+ - **Graceful shutdown** that destroys all containers on exit
80
+ - **Provider-agnostic** — implement `BaseContainerBackend` to support any runtime
81
+
82
+ ## Installation
83
+
84
+ ```bash
85
+ pip install container-pool # core only
86
+ pip install "container-pool[openai]" # with OpenAI backend
87
+ ```
88
+
89
+ ## Usage
90
+
91
+ ```python
92
+ from openai import AsyncOpenAI
93
+ from container_pool import ContainerPool, RequestFileTracker
94
+ from container_pool.backends.openai import OpenAIContainerBackend
95
+
96
+ client = AsyncOpenAI()
97
+ backend = OpenAIContainerBackend(client)
98
+
99
+ pool = ContainerPool(
100
+ backend,
101
+ max_pool_size=5,
102
+ acquire_timeout=30.0,
103
+ container_name="my-pool",
104
+ )
105
+
106
+ # Acquire, use, release
107
+ container = await pool.acquire()
108
+ try:
109
+ tracker = RequestFileTracker(container)
110
+ uploaded = await tracker.upload_file("/tmp/data.csv")
111
+ # ... run code interpreter with container.container_id ...
112
+ files = await container.list_output_files("/mnt/data/")
113
+ results = await container.download_files(files, "/tmp/output")
114
+ finally:
115
+ await tracker.cleanup() # delete uploaded files
116
+ await pool.release(container) # return to pool
117
+
118
+ # On app shutdown
119
+ await pool.shutdown()
120
+ ```
121
+
122
+ ## Custom Backends
123
+
124
+ Implement `BaseContainerBackend` to plug in any container runtime:
125
+
126
+ ```python
127
+ from container_pool import BaseContainerBackend, ContainerInfo, UploadedFile
128
+
129
+ class MyBackend(BaseContainerBackend):
130
+ async def create_container(self, name: str) -> ContainerInfo: ...
131
+ async def get_container(self, container_id: str) -> ContainerInfo: ...
132
+ async def destroy_container(self, container_id: str) -> None: ...
133
+ async def upload_file(self, container_id: str, local_path: str) -> UploadedFile: ...
134
+ async def download_file_content(self, container_id: str, file_id: str) -> bytes: ...
135
+ async def download_file_to_disk(self, container_id: str, file_id: str, local_path: str) -> int: ...
136
+ async def delete_file(self, container_id: str, file_id: str) -> None: ...
137
+ async def list_files(self, container_id: str, path_prefix: str = "") -> dict[str, str]: ...
138
+ ```
139
+
140
+ ## How It Works
141
+
142
+ ### Acquire Flow
143
+
144
+ ```
145
+ acquire()
146
+ ├─ Queue has available container? → validate it's alive → return
147
+ ├─ Pool below max size? → create new container → return
148
+ └─ Pool exhausted? → block until someone calls release() (with timeout)
149
+ ```
150
+
151
+ ### Expiry Recovery
152
+
153
+ `container-pool` handles silent expiry transparently — callers always get a live container:
154
+
155
+ ```
156
+ validate_or_recreate(container)
157
+ ├─ active status → use it
158
+ ├─ expired status → recreate
159
+ ├─ 404 → recreate
160
+ └─ connection error → recreate
161
+ ```
162
+
163
+ ### Performance
164
+
165
+ | Operation | Latency |
166
+ |---|---|
167
+ | Warm acquire | <100ms |
168
+ | Cold acquire | ~2-3s (container creation) |
169
+ | Pool exhausted | Blocks up to `acquire_timeout` |
170
+ | Expiry recovery | ~2-3s (transparent recreation) |
171
+
172
+ ## Configuration
173
+
174
+ | Parameter | Description |
175
+ |---|---|
176
+ | `max_pool_size` | Max containers in pool (1–50) |
177
+ | `acquire_timeout` | Seconds to wait when pool is exhausted |
178
+ | `container_name` | Name prefix for created containers |
179
+ | `creation_max_attempts` | Retry attempts on creation failure (default: 3) |
180
+ | `creation_base_delay` | Base delay for exponential backoff in seconds (default: 1.0) |
181
+
182
+ ## Roadmap
183
+
184
+ ### v1 (current)
185
+ - [x] FIFO pool with `asyncio.Queue`
186
+ - [x] Automatic expiry detection and recovery
187
+ - [x] Per-request file tracking and cleanup
188
+ - [x] Retry with exponential backoff
189
+ - [x] Graceful shutdown
190
+ - [x] Pluggable backend interface
191
+ - [x] OpenAI Code Interpreter backend
192
+
193
+ ### v2
194
+ - [ ] **Pool pre-warming** — create containers at startup to eliminate cold-start latency
195
+ - [ ] **Background keep-alive** — periodic pings to prevent idle expiry
196
+ - [ ] **Distributed state** — Redis/PostgreSQL backend for multi-node deployments
197
+ - [ ] **Observability** — metrics for pool utilization, acquire wait times, expiry rate
198
+ - [ ] **Pool strategies** — LRU, priority-based in addition to FIFO
199
+
200
+ ## Contributing
201
+
202
+ Contributions welcome. Please open an issue first to discuss what you'd like to change.
203
+
204
+ ## Why This Exists
205
+
206
+ Built after hitting every one of these problems while running Code Interpreter in a multi-user production backend. OpenAI's docs hand you a container ID and say good luck — this is the "good luck" part.
207
+
208
+ — [@aayushgzip](https://github.com/aayushgzip)
209
+
210
+ ## License
211
+
212
+ [MIT](LICENSE)
@@ -0,0 +1,14 @@
1
+ container_pool/__init__.py,sha256=KaW0rh1XJ9lt1PYvrFtsNUc-LJnTK022ccU3m6J96VA,1311
2
+ container_pool/_backend.py,sha256=PJPKk_tp9HRQ_J7LejH7VxAGk13fiDH5H9SQ6Trn3PI,2946
3
+ container_pool/_container.py,sha256=03y3VC2OOknWmTpkgu-bjp3FpyEBZcuLlY4KfMMsWQg,3069
4
+ container_pool/_exceptions.py,sha256=CMCcPTwC3NMrfmZLFDxvOsz8mBc4tEQkC9t3szlSdwk,1484
5
+ container_pool/_pool.py,sha256=5yTkorgy1UpkFEJwE_mp3zQ6GFPiiHjwyJ9c8e2fmk4,7714
6
+ container_pool/_retry.py,sha256=c6ScGsCP8KPI49NalUDIWbfKsfLjg92d3eW3GkufVuU,1508
7
+ container_pool/_tracker.py,sha256=bzxzSr0Y9izCrbxaf3aRyC-T3LvZ0J3kXSYQs6qPwAk,1856
8
+ container_pool/_types.py,sha256=0h2Czgk5eysLvdFSSGa6I2msejs28-GfC7u7q8MTzec,844
9
+ container_pool/backends/__init__.py,sha256=Pv8ebNKK-lcPwymzyF5JKpvNJRYxgjEDDyfDL76-xaU,175
10
+ container_pool/backends/openai.py,sha256=sZKjtZkFuAttQ9tA464stmfVhgbEi5I4PuHODoYJuj8,5734
11
+ container_pool-0.1.0.dist-info/METADATA,sha256=fq23Vx8rkcerhltizaxMNODzPUdgkLNiKzP5OBO8cHk,8362
12
+ container_pool-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
13
+ container_pool-0.1.0.dist-info/licenses/LICENSE,sha256=5E2CMJpaO4JdczEgKliBHB2aSxCnkvyB5NNEtX0TgXY,1068
14
+ container_pool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aayushdwids
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.