diff --git a/README.md b/README.md index d6f968e..b68fabd 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,33 @@ skills.create( ''') ``` +**Need process isolation?** Use subprocess: + +```python +async with Session.subprocess("~/.code-mode") as session: + ... +``` + +**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:** ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index c7c93b1..cd075a6 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,13 @@ print(greeting) print(f"Result: {result.value}") ``` +**Need process isolation?** + +```python +async with Session.subprocess("~/.code-mode") 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..0cc7e97 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -7,21 +7,104 @@ 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. Same auto-discovery as `from_base()`. + +```python +Session.subprocess( + 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, + cache_venv: bool = True, +) -> Session +``` + +**Example:** + +```python +async with Session.subprocess("~/.code-mode") as session: + await session.run("import pandas; pandas.__version__") +``` + +### inprocess() + +Fastest execution, no isolation. Same auto-discovery as `from_base()`. + +```python +Session.inprocess( + base_path: 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 +``` + +**Example:** + +```python +async with Session.inprocess("~/.code-mode") as session: + await session.run("1 + 1") +``` + +--- + +## Direct Constructor + +For full control, use the constructor directly with explicit storage and executor. ```python Session( @@ -34,9 +117,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 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..8c45c51 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,191 @@ def __init__( self._closed = False self._sync_deps_on_start = sync_deps_on_start + @classmethod + def from_base( + cls, + base_path: 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(). + + Args: + 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. + + Example: + async with Session.from_base("~/.code-mode") as session: + await session.run("tools.list()") + """ + from py_code_mode.storage import FileStorage + + base = Path(base_path).expanduser().resolve() + base.mkdir(parents=True, exist_ok=True) + + 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 + + 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, + 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, + cache_venv: bool = True, + ) -> Session: + """Create session with SubprocessExecutor (process isolation). + + Auto-discovers from base_path like from_base(), but uses subprocess + for process isolation via a dedicated virtualenv. + + Args: + 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"). + cache_venv: Reuse cached venv across runs. + + Raises: + ImportError: If jupyter_client/ipykernel not installed. + """ + 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 + from py_code_mode.storage import FileStorage + + base = Path(base_path).expanduser().resolve() + base.mkdir(parents=True, exist_ok=True) + + 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 + + config = SubprocessConfig( + tools_path=tools_dir, + default_timeout=timeout, + deps=extra_deps, + deps_file=deps_file_resolved, + allow_runtime_deps=allow_runtime_deps, + python_version=python_version, + cache_venv=cache_venv, + ) + executor = SubprocessExecutor(config=config) + + return cls(storage=storage, executor=executor, sync_deps_on_start=sync_deps_on_start) + + @classmethod + def inprocess( + cls, + base_path: 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: + """Create session with InProcessExecutor (fastest, no isolation). + + Auto-discovers from base_path like from_base(). Runs code directly + in the same process - fast but no isolation. + + Args: + 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. + """ + from py_code_mode.execution import InProcessConfig, InProcessExecutor + from py_code_mode.storage import FileStorage + + base = Path(base_path).expanduser().resolve() + base.mkdir(parents=True, exist_ok=True) + + 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 + + config = InProcessConfig( + tools_path=tools_dir, + default_timeout=timeout, + deps=extra_deps, + deps_file=deps_file_resolved, + allow_runtime_deps=allow_runtime_deps, + ) + executor = InProcessExecutor(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."""