From 543d2405351ef7296304c559a78ad9ce64c8adcb Mon Sep 17 00:00:00 2001 From: actae0n <19864268+xpcmdshell@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:37:17 -0800 Subject: [PATCH 1/4] feat: improve Session API ergonomics with convenience constructors - Make storage parameter required (fix type hint lie) - Add Session.from_base() for simple local dev setup - Add Session.subprocess(), in_process(), container() preset constructors - Re-export executor types at py_code_mode top level - from_base() auto-discovers tools/, skills/, artifacts/, requirements.txt Breaking change: storage parameter is now required (was already required at runtime) --- src/py_code_mode/__init__.py | 35 +++++ src/py_code_mode/session.py | 295 +++++++++++++++++++++++++++++++++-- 2 files changed, 321 insertions(+), 9 deletions(-) diff --git a/src/py_code_mode/__init__.py b/src/py_code_mode/__init__.py index 638b024..4db067f 100644 --- a/src/py_code_mode/__init__.py +++ b/src/py_code_mode/__init__.py @@ -21,6 +21,30 @@ ToolNotFoundError, ToolTimeoutError, ) + +# Execution (commonly needed at top level) +from py_code_mode.execution import ( + CONTAINER_AVAILABLE, + SUBPROCESS_AVAILABLE, + Capability, + Executor, + InProcessConfig, + InProcessExecutor, +) + +# Conditionally import optional executors +if SUBPROCESS_AVAILABLE: + from py_code_mode.execution import SubprocessConfig, SubprocessExecutor +else: + SubprocessConfig = None # type: ignore[assignment, misc] + SubprocessExecutor = None # type: ignore[assignment, misc] + +if CONTAINER_AVAILABLE: + from py_code_mode.execution import ContainerConfig, ContainerExecutor +else: + ContainerConfig = None # type: ignore[assignment, misc] + ContainerExecutor = None # type: ignore[assignment, misc] + from py_code_mode.session import Session # Storage backends (commonly needed at top level) @@ -45,6 +69,17 @@ "StorageBackend", "FileStorage", "RedisStorage", + # Execution + "Executor", + "Capability", + "InProcessExecutor", + "InProcessConfig", + "SubprocessExecutor", + "SubprocessConfig", + "ContainerExecutor", + "ContainerConfig", + "SUBPROCESS_AVAILABLE", + "CONTAINER_AVAILABLE", # Errors "CodeModeError", "ToolNotFoundError", diff --git a/src/py_code_mode/session.py b/src/py_code_mode/session.py index 016ab45..d8a8d8a 100644 --- a/src/py_code_mode/session.py +++ b/src/py_code_mode/session.py @@ -7,6 +7,7 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING, Any from py_code_mode.execution import Executor @@ -33,27 +34,30 @@ class Session: def __init__( self, - storage: StorageBackend | None = None, + storage: StorageBackend, executor: Executor | None = None, sync_deps_on_start: bool = False, ) -> None: """Initialize session. Args: - storage: Storage backend (FileStorage or RedisStorage). - Required (cannot be None). - executor: Executor instance (InProcessExecutor, ContainerExecutor). - Default: InProcessExecutor() + storage: Storage backend (FileStorage or RedisStorage). Required. + executor: Executor instance (InProcessExecutor, SubprocessExecutor, + ContainerExecutor). Default: InProcessExecutor() sync_deps_on_start: If True, install all configured dependencies when session starts. Default: False. Raises: - TypeError: If executor is a string (unsupported). - ValueError: If storage is None. + TypeError: If executor is a string (unsupported) or wrong type. + + For convenience, use class methods instead of __init__ directly: + - Session.from_base(path) - auto-discover tools/skills/artifacts + - Session.subprocess(...) - subprocess isolation (recommended) + - Session.in_process(...) - same process (fastest, no isolation) + - Session.container(...) - Docker isolation (most secure) """ - # Validate storage if storage is None: - raise ValueError("storage parameter is required and cannot be None") + raise TypeError("storage is required (use FileStorage or RedisStorage)") # Reject string-based executor selection if isinstance(executor, str): @@ -61,6 +65,7 @@ def __init__( f"String-based executor selection is no longer supported. " f"Use typed executor instances instead:\n" f" Session(storage=storage, executor=InProcessExecutor())\n" + f" Session(storage=storage, executor=SubprocessExecutor(config))\n" f" Session(storage=storage, executor=ContainerExecutor(config))\n" f"Got: executor={executor!r}" ) @@ -78,6 +83,278 @@ def __init__( self._closed = False self._sync_deps_on_start = sync_deps_on_start + @classmethod + def from_base( + cls, + base: str | Path, + *, + timeout: float | None = 30.0, + extra_deps: tuple[str, ...] | None = None, + allow_runtime_deps: bool = True, + sync_deps_on_start: bool = False, + ) -> Session: + """Convenience constructor for local development. + + Auto-discovers from workspace directory: + - tools/ for tool definitions + - skills/ for skill files + - artifacts/ for persistent data + - requirements.txt for pre-configured dependencies + + Uses InProcessExecutor for simplicity. For process isolation use + Session.subprocess(), for Docker isolation use Session.container(). + + Args: + base: Workspace directory (e.g., "./.code-mode"). + timeout: Execution timeout in seconds (None = unlimited). + extra_deps: Additional packages to install beyond requirements.txt. + allow_runtime_deps: Allow deps.add()/remove() at runtime. + sync_deps_on_start: Install configured deps on start. + + Example: + async with Session.from_base("./.code-mode") as session: + await session.run("tools.list()") + """ + from py_code_mode.storage import FileStorage + + base_path = Path(base).expanduser().resolve() + storage = FileStorage(base_path=base_path) + + tools_path = base_path / "tools" + tools_dir = tools_path if tools_path.is_dir() else None + + deps_file = base_path / "requirements.txt" + deps_file_resolved = deps_file if deps_file.is_file() else None + + executor = cls._create_in_process_executor( + tools_path=tools_dir, + timeout=timeout, + deps=extra_deps, + deps_file=deps_file_resolved, + allow_runtime_deps=allow_runtime_deps, + ) + + return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) + + @classmethod + def subprocess( + cls, + storage: StorageBackend | None = None, + storage_path: str | Path | None = None, + tools_path: str | Path | None = None, + sync_deps_on_start: bool = False, + python_version: str | None = None, + default_timeout: float | None = 60.0, + startup_timeout: float = 30.0, + allow_runtime_deps: bool = True, + deps: tuple[str, ...] | None = None, + deps_file: str | Path | None = None, + cache_venv: bool = True, + ) -> Session: + """Create session with SubprocessExecutor (recommended for most use cases). + + Runs code in an isolated subprocess with its own virtualenv. + Provides process isolation without Docker overhead. + + Args: + storage: Storage backend. If None, creates FileStorage from storage_path. + storage_path: Path to storage directory (required if storage is None). + tools_path: Path to tools directory. + sync_deps_on_start: Install configured deps on start. + python_version: Python version for venv (e.g., "3.11"). Defaults to current. + default_timeout: Execution timeout in seconds (None = unlimited). + startup_timeout: Kernel startup timeout in seconds. + allow_runtime_deps: Allow deps.add()/deps.remove() at runtime. + deps: Tuple of packages to pre-install. + deps_file: Path to requirements.txt-style file. + cache_venv: Reuse cached venv across runs (recommended). + + Returns: + Configured Session with SubprocessExecutor. + + Raises: + ImportError: If subprocess executor dependencies not installed. + ValueError: If neither storage nor storage_path provided. + """ + from py_code_mode.execution import SUBPROCESS_AVAILABLE + + if not SUBPROCESS_AVAILABLE: + raise ImportError( + "SubprocessExecutor requires jupyter_client and ipykernel. " + "Install with: pip install jupyter_client ipykernel" + ) + + from py_code_mode.execution import SubprocessConfig, SubprocessExecutor + + if storage is None: + if storage_path is None: + raise ValueError("Either storage or storage_path must be provided") + from py_code_mode.storage import FileStorage + + storage = FileStorage(base_path=Path(storage_path).expanduser().resolve()) + + config = SubprocessConfig( + tools_path=Path(tools_path).expanduser().resolve() if tools_path else None, + python_version=python_version, + default_timeout=default_timeout, + startup_timeout=startup_timeout, + allow_runtime_deps=allow_runtime_deps, + deps=deps, + deps_file=Path(deps_file).expanduser().resolve() if deps_file else None, + cache_venv=cache_venv, + ) + executor = SubprocessExecutor(config=config) + + return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) + + @classmethod + def in_process( + cls, + storage: StorageBackend | None = None, + storage_path: str | Path | None = None, + tools_path: str | Path | None = None, + sync_deps_on_start: bool = False, + default_timeout: float | None = 30.0, + allow_runtime_deps: bool = True, + deps: tuple[str, ...] | None = None, + deps_file: str | Path | None = None, + ) -> Session: + """Create session with InProcessExecutor (fastest, no isolation). + + Runs code directly in the same process. Fast but provides no isolation. + Use when you trust the code completely and need maximum performance. + + Args: + storage: Storage backend. If None, creates FileStorage from storage_path. + storage_path: Path to storage directory (required if storage is None). + tools_path: Path to tools directory. + sync_deps_on_start: Install configured deps on start. + default_timeout: Execution timeout in seconds (None = unlimited). + allow_runtime_deps: Allow deps.add()/deps.remove() at runtime. + deps: Tuple of packages to pre-install. + deps_file: Path to requirements.txt-style file. + + Returns: + Configured Session with InProcessExecutor. + + Raises: + ValueError: If neither storage nor storage_path provided. + """ + from py_code_mode.execution import InProcessConfig, InProcessExecutor + + if storage is None: + if storage_path is None: + raise ValueError("Either storage or storage_path must be provided") + from py_code_mode.storage import FileStorage + + storage = FileStorage(base_path=Path(storage_path).expanduser().resolve()) + + config = InProcessConfig( + tools_path=Path(tools_path).expanduser().resolve() if tools_path else None, + default_timeout=default_timeout, + allow_runtime_deps=allow_runtime_deps, + deps=deps, + deps_file=Path(deps_file).expanduser().resolve() if deps_file else None, + ) + executor = InProcessExecutor(config=config) + + return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) + + @classmethod + def container( + cls, + storage: StorageBackend | None = None, + storage_path: str | Path | None = None, + tools_path: str | Path | None = None, + sync_deps_on_start: bool = False, + image: str = "py-code-mode-tools:latest", + timeout: float = 30.0, + startup_timeout: float = 60.0, + allow_runtime_deps: bool = True, + deps: tuple[str, ...] | None = None, + deps_file: str | Path | None = None, + remote_url: str | None = None, + auth_token: str | None = None, + auth_disabled: bool = False, + ) -> Session: + """Create session with ContainerExecutor (Docker isolation). + + Runs code in an isolated Docker container. Most secure option. + Use for untrusted code or production deployments. + + Args: + storage: Storage backend. If None, creates FileStorage from storage_path. + storage_path: Path to storage directory (required if storage is None). + tools_path: Path to tools directory. + sync_deps_on_start: Install configured deps on start. + image: Docker image to use. + timeout: Execution timeout in seconds. + startup_timeout: Container startup timeout in seconds. + allow_runtime_deps: Allow deps.add()/deps.remove() at runtime. + deps: Tuple of packages to pre-install. + deps_file: Path to requirements.txt-style file. + remote_url: URL of remote session server (skips Docker). + auth_token: Authentication token for container API. + auth_disabled: Disable authentication (local dev only). + + Returns: + Configured Session with ContainerExecutor. + + Raises: + ImportError: If Docker SDK not installed. + ValueError: If neither storage nor storage_path provided. + """ + from py_code_mode.execution import CONTAINER_AVAILABLE + + if not CONTAINER_AVAILABLE: + raise ImportError( + "ContainerExecutor requires Docker SDK. Install with: pip install docker" + ) + + from py_code_mode.execution import ContainerConfig, ContainerExecutor + + if storage is None: + if storage_path is None: + raise ValueError("Either storage or storage_path must be provided") + from py_code_mode.storage import FileStorage + + storage = FileStorage(base_path=Path(storage_path).expanduser().resolve()) + + config = ContainerConfig( + image=image, + timeout=timeout, + startup_timeout=startup_timeout, + allow_runtime_deps=allow_runtime_deps, + tools_path=Path(tools_path).expanduser().resolve() if tools_path else None, + deps=deps, + deps_file=Path(deps_file).expanduser().resolve() if deps_file else None, + remote_url=remote_url, + auth_token=auth_token, + auth_disabled=auth_disabled, + ) + executor = ContainerExecutor(config=config) + + return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) + + @staticmethod + def _create_in_process_executor( + tools_path: Path | None = None, + timeout: float | None = 30.0, + deps: tuple[str, ...] | None = None, + deps_file: Path | None = None, + allow_runtime_deps: bool = True, + ) -> Executor: + from py_code_mode.execution import InProcessConfig, InProcessExecutor + + config = InProcessConfig( + tools_path=tools_path, + default_timeout=timeout, + deps=deps, + deps_file=deps_file, + allow_runtime_deps=allow_runtime_deps, + ) + return InProcessExecutor(config=config) + @property def storage(self) -> StorageBackend: """Access the storage backend.""" From e96e5be96d0991ae5d0e6688b5febf6142462bea Mon Sep 17 00:00:00 2001 From: actae0n <19864268+xpcmdshell@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:40:00 -0800 Subject: [PATCH 2/4] docs: update docs for new Session convenience constructors - Update Quick Start to use Session.from_base() - Add class method documentation to session-api.md - Update getting-started.md with new patterns --- README.md | 26 ++++--- docs/getting-started.md | 28 ++++---- docs/session-api.md | 150 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 170 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index d6f968e..71b0748 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,10 @@ Over time, agents build a library of reliable capabilities. Simple skills become ## Quick Start ```python -from pathlib import Path -from py_code_mode import Session, FileStorage -from py_code_mode.execution import SubprocessExecutor, SubprocessConfig +from py_code_mode import Session -storage = FileStorage(base_path=Path("./data")) - -# SubprocessExecutor provides process isolation (recommended) -config = SubprocessConfig(tools_path=Path("./tools")) -executor = SubprocessExecutor(config=config) - -async with Session(storage=storage, executor=executor) as session: - # Agent writes code with tools, skills, and artifacts available +# One line setup - auto-discovers tools/, skills/, artifacts/, requirements.txt +async with Session.from_base("./.code-mode") as session: result = await session.run(''' # Search for existing skills results = skills.search("github analysis") @@ -60,6 +52,18 @@ skills.create( ''') ``` +**Need more control?** Use explicit constructors: + +```python +# Process isolation (recommended for untrusted code) +async with Session.subprocess(storage_path="./data", tools_path="./tools") as session: + ... + +# Docker isolation (most secure) +async with Session.container(storage_path="./data", image="my-image") as session: + ... +``` + **Also ships as an MCP server for Claude Code:** ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index c7c93b1..0ebd1e1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -43,20 +43,10 @@ The storage directory will contain `skills/` and `artifacts/` subdirectories. ### As a Python Library ```python -from pathlib import Path -from py_code_mode import Session, FileStorage -from py_code_mode.execution import SubprocessConfig, SubprocessExecutor +from py_code_mode import Session -# Create storage backend for skills and artifacts -storage = FileStorage(base_path=Path("./data")) - -# Configure executor with tools path (SubprocessExecutor recommended for most use cases) -config = SubprocessConfig(tools_path=Path("./tools")) -executor = SubprocessExecutor(config=config) - -# Create a session -async with Session(storage=storage, executor=executor) as session: - # Run agent code +# One line setup - auto-discovers tools/, skills/, artifacts/, requirements.txt +async with Session.from_base("./data") as session: result = await session.run(''' # Search for existing skills results = skills.search("data processing") @@ -81,6 +71,18 @@ print(greeting) print(f"Result: {result.value}") ``` +**Need more control?** Use explicit constructors: + +```python +# Process isolation (recommended for untrusted code) +async with Session.subprocess(storage_path="./data", tools_path="./tools") as session: + ... + +# Docker isolation (most secure) +async with Session.container(storage_path="./data", image="my-image") as session: + ... +``` + ### With Claude Code (MCP) Once installed, the MCP server provides these tools to Claude: diff --git a/docs/session-api.md b/docs/session-api.md index a5b7a06..4c15423 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -7,21 +7,138 @@ Complete reference for the Session class - the primary interface for py-code-mod Session wraps a storage backend and executor, providing a unified API for code execution with tools, skills, and artifacts. ```python -from pathlib import Path -from py_code_mode import Session, FileStorage -from py_code_mode.execution import SubprocessExecutor, SubprocessConfig +from py_code_mode import Session -storage = FileStorage(base_path=Path("./data")) -config = SubprocessConfig(tools_path=Path("./tools")) -executor = SubprocessExecutor(config=config) - -async with Session(storage=storage, executor=executor) as session: +# Simplest: auto-discovers tools/, skills/, artifacts/, requirements.txt +async with Session.from_base("./.code-mode") as session: result = await session.run("tools.curl.get(url='https://api.github.com')") ``` --- -## Constructor +## Convenience Constructors + +### from_base() + +One-liner for local development. Auto-discovers resources from a workspace directory. + +```python +Session.from_base( + base: str | Path, + *, + timeout: float | None = 30.0, + extra_deps: tuple[str, ...] | None = None, + allow_runtime_deps: bool = True, + sync_deps_on_start: bool = False, +) -> Session +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `base` | `str \| Path` | Workspace directory path. | +| `timeout` | `float \| None` | Execution timeout in seconds. None = unlimited. | +| `extra_deps` | `tuple[str, ...]` | Additional packages beyond requirements.txt. | +| `allow_runtime_deps` | `bool` | Allow deps.add()/remove() at runtime. | +| `sync_deps_on_start` | `bool` | Install configured deps when session starts. | + +**Auto-discovers:** +- `{base}/tools/` - Tool definitions (YAML files) +- `{base}/skills/` - Skill files (Python) +- `{base}/artifacts/` - Persistent data storage +- `{base}/requirements.txt` - Pre-configured dependencies + +**Example:** + +```python +async with Session.from_base("./.code-mode") as session: + await session.run("tools.list()") +``` + +### subprocess() + +Process isolation via subprocess with dedicated virtualenv. + +```python +Session.subprocess( + storage: StorageBackend | None = None, + storage_path: str | Path | None = None, + tools_path: str | Path | None = None, + sync_deps_on_start: bool = False, + python_version: str | None = None, + default_timeout: float | None = 60.0, + startup_timeout: float = 30.0, + allow_runtime_deps: bool = True, + deps: tuple[str, ...] | None = None, + deps_file: str | Path | None = None, + cache_venv: bool = True, +) -> Session +``` + +**Example:** + +```python +async with Session.subprocess( + storage_path="./data", + tools_path="./tools", + deps=("pandas", "numpy"), +) as session: + await session.run("import pandas; pandas.__version__") +``` + +### in_process() + +Fastest execution, no isolation. Use when you trust the code completely. + +```python +Session.in_process( + storage: StorageBackend | None = None, + storage_path: str | Path | None = None, + tools_path: str | Path | None = None, + sync_deps_on_start: bool = False, + default_timeout: float | None = 30.0, + allow_runtime_deps: bool = True, + deps: tuple[str, ...] | None = None, + deps_file: str | Path | None = None, +) -> Session +``` + +### container() + +Docker isolation. Most secure, recommended for untrusted code. + +```python +Session.container( + storage: StorageBackend | None = None, + storage_path: str | Path | None = None, + tools_path: str | Path | None = None, + sync_deps_on_start: bool = False, + image: str = "py-code-mode-tools:latest", + timeout: float = 30.0, + startup_timeout: float = 60.0, + allow_runtime_deps: bool = True, + deps: tuple[str, ...] | None = None, + deps_file: str | Path | None = None, + remote_url: str | None = None, + auth_token: str | None = None, + auth_disabled: bool = False, +) -> Session +``` + +**Example:** + +```python +async with Session.container( + storage_path="./data", + image="my-secure-image:latest", +) as session: + await session.run("import os; os.getcwd()") +``` + +--- + +## Direct Constructor + +For full control, use the constructor directly with explicit storage and executor. ```python Session( @@ -34,9 +151,22 @@ Session( | Parameter | Type | Description | |-----------|------|-------------| | `storage` | `StorageBackend` | Required. FileStorage or RedisStorage instance. | -| `executor` | `Executor` | Optional. Defaults to SubprocessExecutor if not provided. | +| `executor` | `Executor` | Optional. Defaults to InProcessExecutor. | | `sync_deps_on_start` | `bool` | If True, install pre-configured deps when session starts. | +**Example:** + +```python +from py_code_mode import Session, FileStorage, SubprocessExecutor, SubprocessConfig + +storage = FileStorage(base_path=Path("./data")) +config = SubprocessConfig(tools_path=Path("./tools")) +executor = SubprocessExecutor(config=config) + +async with Session(storage=storage, executor=executor) as session: + ... +``` + --- ## Lifecycle Methods From 617711972ea1341b73ca1e9bbc1e031b6f3dec9c Mon Sep 17 00:00:00 2001 From: actae0n <19864268+xpcmdshell@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:51:43 -0800 Subject: [PATCH 3/4] refactor: simplify preset constructors, remove container convenience method - subprocess() and inprocess() now take base_path with auto-discovery - Removed container() - too complex for convenience method, use direct constructor - Consistent parameter naming (base_path everywhere) - Updated docs to match --- README.md | 11 +- docs/getting-started.md | 9 +- docs/session-api.md | 66 +++-------- src/py_code_mode/session.py | 215 ++++++++++-------------------------- 4 files changed, 83 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index 71b0748..06211c1 100644 --- a/README.md +++ b/README.md @@ -52,18 +52,15 @@ skills.create( ''') ``` -**Need more control?** Use explicit constructors: +**Need process isolation?** Use subprocess: ```python -# Process isolation (recommended for untrusted code) -async with Session.subprocess(storage_path="./data", tools_path="./tools") as session: - ... - -# Docker isolation (most secure) -async with Session.container(storage_path="./data", image="my-image") as session: +async with Session.subprocess("~/.code-mode") as session: ... ``` +**Need Docker isolation?** Use the explicit constructor with ContainerExecutor (see [Executors docs](./docs/executors.md)). + **Also ships as an MCP server for Claude Code:** ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 0ebd1e1..cd075a6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -71,15 +71,10 @@ print(greeting) print(f"Result: {result.value}") ``` -**Need more control?** Use explicit constructors: +**Need process isolation?** ```python -# Process isolation (recommended for untrusted code) -async with Session.subprocess(storage_path="./data", tools_path="./tools") as session: - ... - -# Docker isolation (most secure) -async with Session.container(storage_path="./data", image="my-image") as session: +async with Session.subprocess("~/.code-mode") as session: ... ``` diff --git a/docs/session-api.md b/docs/session-api.md index 4c15423..0cc7e97 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -56,20 +56,17 @@ async with Session.from_base("./.code-mode") as session: ### subprocess() -Process isolation via subprocess with dedicated virtualenv. +Process isolation via subprocess with dedicated virtualenv. Same auto-discovery as `from_base()`. ```python Session.subprocess( - storage: StorageBackend | None = None, - storage_path: str | Path | None = None, - tools_path: str | Path | None = None, + base_path: str | Path, + *, + timeout: float | None = 60.0, + extra_deps: tuple[str, ...] | None = None, + allow_runtime_deps: bool = True, sync_deps_on_start: bool = False, python_version: str | None = None, - default_timeout: float | None = 60.0, - startup_timeout: float = 30.0, - allow_runtime_deps: bool = True, - deps: tuple[str, ...] | None = None, - deps_file: str | Path | None = None, cache_venv: bool = True, ) -> Session ``` @@ -77,61 +74,30 @@ Session.subprocess( **Example:** ```python -async with Session.subprocess( - storage_path="./data", - tools_path="./tools", - deps=("pandas", "numpy"), -) as session: +async with Session.subprocess("~/.code-mode") as session: await session.run("import pandas; pandas.__version__") ``` -### in_process() +### inprocess() -Fastest execution, no isolation. Use when you trust the code completely. +Fastest execution, no isolation. Same auto-discovery as `from_base()`. ```python -Session.in_process( - storage: StorageBackend | None = None, - storage_path: str | Path | None = None, - tools_path: str | Path | None = None, - sync_deps_on_start: bool = False, - default_timeout: float | None = 30.0, +Session.inprocess( + base_path: str | Path, + *, + timeout: float | None = 30.0, + extra_deps: tuple[str, ...] | None = None, allow_runtime_deps: bool = True, - deps: tuple[str, ...] | None = None, - deps_file: str | Path | None = None, -) -> Session -``` - -### container() - -Docker isolation. Most secure, recommended for untrusted code. - -```python -Session.container( - storage: StorageBackend | None = None, - storage_path: str | Path | None = None, - tools_path: str | Path | None = None, sync_deps_on_start: bool = False, - image: str = "py-code-mode-tools:latest", - timeout: float = 30.0, - startup_timeout: float = 60.0, - allow_runtime_deps: bool = True, - deps: tuple[str, ...] | None = None, - deps_file: str | Path | None = None, - remote_url: str | None = None, - auth_token: str | None = None, - auth_disabled: bool = False, ) -> Session ``` **Example:** ```python -async with Session.container( - storage_path="./data", - image="my-secure-image:latest", -) as session: - await session.run("import os; os.getcwd()") +async with Session.inprocess("~/.code-mode") as session: + await session.run("1 + 1") ``` --- diff --git a/src/py_code_mode/session.py b/src/py_code_mode/session.py index d8a8d8a..d04b71e 100644 --- a/src/py_code_mode/session.py +++ b/src/py_code_mode/session.py @@ -86,7 +86,7 @@ def __init__( @classmethod def from_base( cls, - base: str | Path, + base_path: str | Path, *, timeout: float | None = 30.0, extra_deps: tuple[str, ...] | None = None, @@ -102,28 +102,28 @@ def from_base( - requirements.txt for pre-configured dependencies Uses InProcessExecutor for simplicity. For process isolation use - Session.subprocess(), for Docker isolation use Session.container(). + Session.subprocess(). Args: - base: Workspace directory (e.g., "./.code-mode"). + base_path: Workspace directory (e.g., "~/.code-mode"). timeout: Execution timeout in seconds (None = unlimited). - extra_deps: Additional packages to install beyond requirements.txt. + extra_deps: Additional packages beyond requirements.txt. allow_runtime_deps: Allow deps.add()/remove() at runtime. sync_deps_on_start: Install configured deps on start. Example: - async with Session.from_base("./.code-mode") as session: + async with Session.from_base("~/.code-mode") as session: await session.run("tools.list()") """ from py_code_mode.storage import FileStorage - base_path = Path(base).expanduser().resolve() - storage = FileStorage(base_path=base_path) + base = Path(base_path).expanduser().resolve() + storage = FileStorage(base_path=base) - tools_path = base_path / "tools" + tools_path = base / "tools" tools_dir = tools_path if tools_path.is_dir() else None - deps_file = base_path / "requirements.txt" + deps_file = base / "requirements.txt" deps_file_resolved = deps_file if deps_file.is_file() else None executor = cls._create_in_process_executor( @@ -139,42 +139,31 @@ def from_base( @classmethod def subprocess( cls, - storage: StorageBackend | None = None, - storage_path: str | Path | None = None, - tools_path: str | Path | None = None, + base_path: str | Path, + *, + timeout: float | None = 60.0, + extra_deps: tuple[str, ...] | None = None, + allow_runtime_deps: bool = True, sync_deps_on_start: bool = False, python_version: str | None = None, - default_timeout: float | None = 60.0, - startup_timeout: float = 30.0, - allow_runtime_deps: bool = True, - deps: tuple[str, ...] | None = None, - deps_file: str | Path | None = None, cache_venv: bool = True, ) -> Session: - """Create session with SubprocessExecutor (recommended for most use cases). + """Create session with SubprocessExecutor (process isolation). - Runs code in an isolated subprocess with its own virtualenv. - Provides process isolation without Docker overhead. + Auto-discovers from base_path like from_base(), but uses subprocess + for process isolation via a dedicated virtualenv. Args: - storage: Storage backend. If None, creates FileStorage from storage_path. - storage_path: Path to storage directory (required if storage is None). - tools_path: Path to tools directory. + base_path: Workspace directory (e.g., "~/.code-mode"). + timeout: Execution timeout in seconds (None = unlimited). + extra_deps: Additional packages beyond requirements.txt. + allow_runtime_deps: Allow deps.add()/remove() at runtime. sync_deps_on_start: Install configured deps on start. - python_version: Python version for venv (e.g., "3.11"). Defaults to current. - default_timeout: Execution timeout in seconds (None = unlimited). - startup_timeout: Kernel startup timeout in seconds. - allow_runtime_deps: Allow deps.add()/deps.remove() at runtime. - deps: Tuple of packages to pre-install. - deps_file: Path to requirements.txt-style file. - cache_venv: Reuse cached venv across runs (recommended). - - Returns: - Configured Session with SubprocessExecutor. + python_version: Python version for venv (e.g., "3.11"). + cache_venv: Reuse cached venv across runs. Raises: - ImportError: If subprocess executor dependencies not installed. - ValueError: If neither storage nor storage_path provided. + ImportError: If jupyter_client/ipykernel not installed. """ from py_code_mode.execution import SUBPROCESS_AVAILABLE @@ -185,22 +174,24 @@ def subprocess( ) from py_code_mode.execution import SubprocessConfig, SubprocessExecutor + from py_code_mode.storage import FileStorage - if storage is None: - if storage_path is None: - raise ValueError("Either storage or storage_path must be provided") - from py_code_mode.storage import FileStorage + base = Path(base_path).expanduser().resolve() + storage = FileStorage(base_path=base) - storage = FileStorage(base_path=Path(storage_path).expanduser().resolve()) + tools_path = base / "tools" + tools_dir = tools_path if tools_path.is_dir() else None + + deps_file = base / "requirements.txt" + deps_file_resolved = deps_file if deps_file.is_file() else None config = SubprocessConfig( - tools_path=Path(tools_path).expanduser().resolve() if tools_path else None, - python_version=python_version, - default_timeout=default_timeout, - startup_timeout=startup_timeout, + tools_path=tools_dir, + default_timeout=timeout, + deps=extra_deps, + deps_file=deps_file_resolved, allow_runtime_deps=allow_runtime_deps, - deps=deps, - deps_file=Path(deps_file).expanduser().resolve() if deps_file else None, + python_version=python_version, cache_venv=cache_venv, ) executor = SubprocessExecutor(config=config) @@ -208,134 +199,50 @@ def subprocess( return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) @classmethod - def in_process( + def inprocess( cls, - storage: StorageBackend | None = None, - storage_path: str | Path | None = None, - tools_path: str | Path | None = None, - sync_deps_on_start: bool = False, - default_timeout: float | None = 30.0, + base_path: str | Path, + *, + timeout: float | None = 30.0, + extra_deps: tuple[str, ...] | None = None, allow_runtime_deps: bool = True, - deps: tuple[str, ...] | None = None, - deps_file: str | Path | None = None, + sync_deps_on_start: bool = False, ) -> Session: """Create session with InProcessExecutor (fastest, no isolation). - Runs code directly in the same process. Fast but provides no isolation. - Use when you trust the code completely and need maximum performance. + Auto-discovers from base_path like from_base(). Runs code directly + in the same process - fast but no isolation. Args: - storage: Storage backend. If None, creates FileStorage from storage_path. - storage_path: Path to storage directory (required if storage is None). - tools_path: Path to tools directory. + base_path: Workspace directory (e.g., "~/.code-mode"). + timeout: Execution timeout in seconds (None = unlimited). + extra_deps: Additional packages beyond requirements.txt. + allow_runtime_deps: Allow deps.add()/remove() at runtime. sync_deps_on_start: Install configured deps on start. - default_timeout: Execution timeout in seconds (None = unlimited). - allow_runtime_deps: Allow deps.add()/deps.remove() at runtime. - deps: Tuple of packages to pre-install. - deps_file: Path to requirements.txt-style file. - - Returns: - Configured Session with InProcessExecutor. - - Raises: - ValueError: If neither storage nor storage_path provided. """ from py_code_mode.execution import InProcessConfig, InProcessExecutor + from py_code_mode.storage import FileStorage - if storage is None: - if storage_path is None: - raise ValueError("Either storage or storage_path must be provided") - from py_code_mode.storage import FileStorage + base = Path(base_path).expanduser().resolve() + storage = FileStorage(base_path=base) - storage = FileStorage(base_path=Path(storage_path).expanduser().resolve()) + tools_path = base / "tools" + tools_dir = tools_path if tools_path.is_dir() else None + + deps_file = base / "requirements.txt" + deps_file_resolved = deps_file if deps_file.is_file() else None config = InProcessConfig( - tools_path=Path(tools_path).expanduser().resolve() if tools_path else None, - default_timeout=default_timeout, + tools_path=tools_dir, + default_timeout=timeout, + deps=extra_deps, + deps_file=deps_file_resolved, allow_runtime_deps=allow_runtime_deps, - deps=deps, - deps_file=Path(deps_file).expanduser().resolve() if deps_file else None, ) executor = InProcessExecutor(config=config) return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) - @classmethod - def container( - cls, - storage: StorageBackend | None = None, - storage_path: str | Path | None = None, - tools_path: str | Path | None = None, - sync_deps_on_start: bool = False, - image: str = "py-code-mode-tools:latest", - timeout: float = 30.0, - startup_timeout: float = 60.0, - allow_runtime_deps: bool = True, - deps: tuple[str, ...] | None = None, - deps_file: str | Path | None = None, - remote_url: str | None = None, - auth_token: str | None = None, - auth_disabled: bool = False, - ) -> Session: - """Create session with ContainerExecutor (Docker isolation). - - Runs code in an isolated Docker container. Most secure option. - Use for untrusted code or production deployments. - - Args: - storage: Storage backend. If None, creates FileStorage from storage_path. - storage_path: Path to storage directory (required if storage is None). - tools_path: Path to tools directory. - sync_deps_on_start: Install configured deps on start. - image: Docker image to use. - timeout: Execution timeout in seconds. - startup_timeout: Container startup timeout in seconds. - allow_runtime_deps: Allow deps.add()/deps.remove() at runtime. - deps: Tuple of packages to pre-install. - deps_file: Path to requirements.txt-style file. - remote_url: URL of remote session server (skips Docker). - auth_token: Authentication token for container API. - auth_disabled: Disable authentication (local dev only). - - Returns: - Configured Session with ContainerExecutor. - - Raises: - ImportError: If Docker SDK not installed. - ValueError: If neither storage nor storage_path provided. - """ - from py_code_mode.execution import CONTAINER_AVAILABLE - - if not CONTAINER_AVAILABLE: - raise ImportError( - "ContainerExecutor requires Docker SDK. Install with: pip install docker" - ) - - from py_code_mode.execution import ContainerConfig, ContainerExecutor - - if storage is None: - if storage_path is None: - raise ValueError("Either storage or storage_path must be provided") - from py_code_mode.storage import FileStorage - - storage = FileStorage(base_path=Path(storage_path).expanduser().resolve()) - - config = ContainerConfig( - image=image, - timeout=timeout, - startup_timeout=startup_timeout, - allow_runtime_deps=allow_runtime_deps, - tools_path=Path(tools_path).expanduser().resolve() if tools_path else None, - deps=deps, - deps_file=Path(deps_file).expanduser().resolve() if deps_file else None, - remote_url=remote_url, - auth_token=auth_token, - auth_disabled=auth_disabled, - ) - executor = ContainerExecutor(config=config) - - return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) - @staticmethod def _create_in_process_executor( tools_path: Path | None = None, From 4547242dd63fe83cc529f9ec2340199ba5c1361e Mon Sep 17 00:00:00 2001 From: actae0n <19864268+xpcmdshell@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:57:40 -0800 Subject: [PATCH 4/4] feat: auto-create tools/ directory, clarify full control in README - Convenience methods now create tools/ dir alongside storage dirs - README now shows explicit constructor usage for full control - Makes clear convenience methods are shortcuts, not the only API --- README.md | 20 +++++++++++++++++++- src/py_code_mode/session.py | 24 +++++++++++++++--------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 06211c1..b68fabd 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,25 @@ async with Session.subprocess("~/.code-mode") as session: ... ``` -**Need Docker isolation?** Use the explicit constructor with ContainerExecutor (see [Executors docs](./docs/executors.md)). +**Full control?** The convenience methods above are shortcuts. For production deployments, custom storage backends, or container isolation, use the component APIs directly: + +```python +from py_code_mode import Session, FileStorage, RedisStorage +from py_code_mode import SubprocessExecutor, SubprocessConfig, ContainerExecutor, ContainerConfig + +# Custom storage +storage = RedisStorage(url="redis://localhost:6379", prefix="myapp") + +# Custom executor +config = SubprocessConfig(tools_path="./tools", python_version="3.11", cache_venv=True) +executor = SubprocessExecutor(config=config) + +# Full control +async with Session(storage=storage, executor=executor) as session: + ... +``` + +See [Session API](./docs/session-api.md) and [Executors](./docs/executors.md) for complete documentation. **Also ships as an MCP server for Claude Code:** diff --git a/src/py_code_mode/session.py b/src/py_code_mode/session.py index d04b71e..8c45c51 100644 --- a/src/py_code_mode/session.py +++ b/src/py_code_mode/session.py @@ -118,10 +118,12 @@ def from_base( from py_code_mode.storage import FileStorage base = Path(base_path).expanduser().resolve() - storage = FileStorage(base_path=base) + base.mkdir(parents=True, exist_ok=True) + + tools_dir = base / "tools" + tools_dir.mkdir(exist_ok=True) - tools_path = base / "tools" - tools_dir = tools_path if tools_path.is_dir() else None + storage = FileStorage(base_path=base) deps_file = base / "requirements.txt" deps_file_resolved = deps_file if deps_file.is_file() else None @@ -177,10 +179,12 @@ def subprocess( from py_code_mode.storage import FileStorage base = Path(base_path).expanduser().resolve() - storage = FileStorage(base_path=base) + base.mkdir(parents=True, exist_ok=True) - tools_path = base / "tools" - tools_dir = tools_path if tools_path.is_dir() else None + tools_dir = base / "tools" + tools_dir.mkdir(exist_ok=True) + + storage = FileStorage(base_path=base) deps_file = base / "requirements.txt" deps_file_resolved = deps_file if deps_file.is_file() else None @@ -224,10 +228,12 @@ def inprocess( from py_code_mode.storage import FileStorage base = Path(base_path).expanduser().resolve() - storage = FileStorage(base_path=base) + base.mkdir(parents=True, exist_ok=True) - tools_path = base / "tools" - tools_dir = tools_path if tools_path.is_dir() else None + tools_dir = base / "tools" + tools_dir.mkdir(exist_ok=True) + + storage = FileStorage(base_path=base) deps_file = base / "requirements.txt" deps_file_resolved = deps_file if deps_file.is_file() else None