diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 079395b3..941f0818 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -37,6 +37,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + # Test the oldest supported Python version to make sure compatibility is maintained. + python-version: ["3.11"] runs-on: ${{ matrix.os }} defaults: run: @@ -46,7 +48,7 @@ jobs: - uses: actions/checkout@v6.0.2 - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - uses: actions/setup-node@v6 with: node-version: "22" diff --git a/python/README.md b/python/README.md index aa82e0c3..3a1c4c73 100644 --- a/python/README.md +++ b/python/README.md @@ -401,11 +401,11 @@ async def handle_user_input(request, invocation): # request["question"] - The question to ask # request.get("choices") - Optional list of choices for multiple choice # request.get("allowFreeform", True) - Whether freeform input is allowed - + print(f"Agent asks: {request['question']}") if request.get("choices"): print(f"Choices: {', '.join(request['choices'])}") - + # Return the user's response return { "answer": "User's answer here", @@ -483,5 +483,5 @@ session = await client.create_session({ ## Requirements -- Python 3.9+ +- Python 3.11+ - GitHub Copilot CLI installed and accessible diff --git a/python/copilot/client.py b/python/copilot/client.py index 88b3d97a..bc231e52 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -19,9 +19,10 @@ import subprocess import sys import threading +from collections.abc import Callable from dataclasses import asdict, is_dataclass from pathlib import Path -from typing import Any, Callable, Optional, cast +from typing import Any, cast from .generated.rpc import ServerRpc from .generated.session_events import session_event_from_dict @@ -51,7 +52,7 @@ ) -def _get_bundled_cli_path() -> Optional[str]: +def _get_bundled_cli_path() -> str | None: """Get the path to the bundled CLI binary, if available.""" # The binary is bundled in copilot/bin/ within the package bin_dir = Path(__file__).parent / "bin" @@ -106,7 +107,7 @@ class CopilotClient: >>> client = CopilotClient({"cli_url": "localhost:3000"}) """ - def __init__(self, options: Optional[CopilotClientOptions] = None): + def __init__(self, options: CopilotClientOptions | None = None): """ Initialize a new CopilotClient. @@ -151,7 +152,7 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): self._is_external_server: bool = False if opts.get("cli_url"): self._actual_host, actual_port = self._parse_cli_url(opts["cli_url"]) - self._actual_port: Optional[int] = actual_port + self._actual_port: int | None = actual_port self._is_external_server = True else: self._actual_port = None @@ -197,19 +198,19 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): if github_token: self.options["github_token"] = github_token - self._process: Optional[subprocess.Popen] = None - self._client: Optional[JsonRpcClient] = None + self._process: subprocess.Popen | None = None + self._client: JsonRpcClient | None = None self._state: ConnectionState = "disconnected" self._sessions: dict[str, CopilotSession] = {} self._sessions_lock = threading.Lock() - self._models_cache: Optional[list[ModelInfo]] = None + self._models_cache: list[ModelInfo] | None = None self._models_cache_lock = asyncio.Lock() self._lifecycle_handlers: list[SessionLifecycleHandler] = [] self._typed_lifecycle_handlers: dict[ SessionLifecycleEventType, list[SessionLifecycleHandler] ] = {} self._lifecycle_handlers_lock = threading.Lock() - self._rpc: Optional[ServerRpc] = None + self._rpc: ServerRpc | None = None @property def rpc(self) -> ServerRpc: @@ -786,7 +787,7 @@ def get_state(self) -> ConnectionState: """ return self._state - async def ping(self, message: Optional[str] = None) -> "PingResponse": + async def ping(self, message: str | None = None) -> "PingResponse": """ Send a ping request to the server to verify connectivity. @@ -956,7 +957,7 @@ async def delete_session(self, session_id: str) -> None: if session_id in self._sessions: del self._sessions[session_id] - async def get_foreground_session_id(self) -> Optional[str]: + async def get_foreground_session_id(self) -> str | None: """ Get the ID of the session currently displayed in the TUI. @@ -1009,7 +1010,7 @@ async def set_foreground_session_id(self, session_id: str) -> None: def on( self, event_type_or_handler: SessionLifecycleEventType | SessionLifecycleHandler, - handler: Optional[SessionLifecycleHandler] = None, + handler: SessionLifecycleHandler | None = None, ) -> Callable[[], None]: """ Subscribe to session lifecycle events. @@ -1267,7 +1268,7 @@ async def read_port(): try: await asyncio.wait_for(read_port(), timeout=10.0) - except asyncio.TimeoutError: + except TimeoutError: raise RuntimeError("Timeout waiting for CLI server to start") async def _connect_to_server(self) -> None: diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 27a2bca2..a557987e 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -10,7 +10,8 @@ from dataclasses import dataclass -from typing import Any, Optional, List, Dict, TypeVar, Type, cast, Callable +from typing import Any, TypeVar, cast +from collections.abc import Callable from enum import Enum @@ -52,22 +53,22 @@ def from_bool(x: Any) -> bool: return x -def to_class(c: Type[T], x: Any) -> dict: +def to_class(c: type[T], x: Any) -> dict: assert isinstance(x, c) return cast(Any, x).to_dict() -def from_list(f: Callable[[Any], T], x: Any) -> List[T]: +def from_list(f: Callable[[Any], T], x: Any) -> list[T]: assert isinstance(x, list) return [f(y) for y in x] -def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: +def from_dict(f: Callable[[Any], T], x: Any) -> dict[str, T]: assert isinstance(x, dict) return { k: f(v) for (k, v) in x.items() } -def to_enum(c: Type[EnumT], x: Any) -> EnumT: +def to_enum(c: type[EnumT], x: Any) -> EnumT: assert isinstance(x, c) return x.value @@ -101,7 +102,7 @@ def to_dict(self) -> dict: @dataclass class PingParams: - message: Optional[str] = None + message: str | None = None """Optional message to echo back""" @staticmethod @@ -138,8 +139,8 @@ def to_dict(self) -> dict: @dataclass class Limits: max_context_window_tokens: float - max_output_tokens: Optional[float] = None - max_prompt_tokens: Optional[float] = None + max_output_tokens: float | None = None + max_prompt_tokens: float | None = None @staticmethod def from_dict(obj: Any) -> 'Limits': @@ -233,16 +234,16 @@ class Model: name: str """Display name""" - billing: Optional[Billing] = None + billing: Billing | None = None """Billing information""" - default_reasoning_effort: Optional[str] = None + default_reasoning_effort: str | None = None """Default reasoning effort level (only present if model supports reasoning effort)""" - policy: Optional[Policy] = None + policy: Policy | None = None """Policy state (if applicable)""" - supported_reasoning_efforts: Optional[List[str]] = None + supported_reasoning_efforts: list[str] | None = None """Supported reasoning effort levels (only present if model supports reasoning effort)""" @staticmethod @@ -275,7 +276,7 @@ def to_dict(self) -> dict: @dataclass class ModelsListResult: - models: List[Model] + models: list[Model] """List of available models with full metadata""" @staticmethod @@ -298,14 +299,14 @@ class Tool: name: str """Tool identifier (e.g., "bash", "grep", "str_replace_editor")""" - instructions: Optional[str] = None + instructions: str | None = None """Optional instructions for how to use this tool effectively""" - namespaced_name: Optional[str] = None + namespaced_name: str | None = None """Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) """ - parameters: Optional[Dict[str, Any]] = None + parameters: dict[str, Any] | None = None """JSON Schema for the tool's input parameters""" @staticmethod @@ -333,7 +334,7 @@ def to_dict(self) -> dict: @dataclass class ToolsListResult: - tools: List[Tool] + tools: list[Tool] """List of available built-in tools with metadata""" @staticmethod @@ -350,7 +351,7 @@ def to_dict(self) -> dict: @dataclass class ToolsListParams: - model: Optional[str] = None + model: str | None = None """Optional model ID — when provided, the returned tool list reflects model-specific overrides """ @@ -385,7 +386,7 @@ class QuotaSnapshot: used_requests: float """Number of requests used so far this period""" - reset_date: Optional[str] = None + reset_date: str | None = None """Date when the quota resets (ISO 8601)""" @staticmethod @@ -413,7 +414,7 @@ def to_dict(self) -> dict: @dataclass class AccountGetQuotaResult: - quota_snapshots: Dict[str, QuotaSnapshot] + quota_snapshots: dict[str, QuotaSnapshot] """Quota snapshots keyed by type (e.g., chat, completions, premium_interactions)""" @staticmethod @@ -430,7 +431,7 @@ def to_dict(self) -> dict: @dataclass class SessionModelGetCurrentResult: - model_id: Optional[str] = None + model_id: str | None = None @staticmethod def from_dict(obj: Any) -> 'SessionModelGetCurrentResult': @@ -447,7 +448,7 @@ def to_dict(self) -> dict: @dataclass class SessionModelSwitchToResult: - model_id: Optional[str] = None + model_id: str | None = None @staticmethod def from_dict(obj: Any) -> 'SessionModelSwitchToResult': @@ -546,7 +547,7 @@ class SessionPlanReadResult: exists: bool """Whether plan.md exists in the workspace""" - content: Optional[str] = None + content: str | None = None """The content of plan.md, or null if it does not exist""" @staticmethod @@ -606,7 +607,7 @@ def to_dict(self) -> dict: @dataclass class SessionWorkspaceListFilesResult: - files: List[str] + files: list[str] """Relative file paths in the workspace files directory""" @staticmethod @@ -708,7 +709,7 @@ def to_dict(self) -> dict: @dataclass class SessionFleetStartParams: - prompt: Optional[str] = None + prompt: str | None = None """Optional user prompt to combine with fleet instructions""" @staticmethod @@ -753,7 +754,7 @@ def to_dict(self) -> dict: @dataclass class SessionAgentListResult: - agents: List[AgentElement] + agents: list[AgentElement] """Available custom agents""" @staticmethod @@ -797,7 +798,7 @@ def to_dict(self) -> dict: @dataclass class SessionAgentGetCurrentResult: - agent: Optional[SessionAgentGetCurrentResultAgent] = None + agent: SessionAgentGetCurrentResultAgent | None = None """Currently selected custom agent, or null if using the default agent""" @staticmethod diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 3a201ba0..0166fc63 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -5,7 +5,8 @@ from enum import Enum from dataclasses import dataclass -from typing import Any, Optional, List, Dict, Union, TypeVar, Type, cast, Callable +from typing import Any, TypeVar, cast +from collections.abc import Callable from datetime import datetime from uuid import UUID import dateutil.parser @@ -25,7 +26,7 @@ def to_float(x: Any) -> float: return x -def to_class(c: Type[T], x: Any) -> dict: +def to_class(c: type[T], x: Any) -> dict: assert isinstance(x, c) return cast(Any, x).to_dict() @@ -49,17 +50,17 @@ def from_union(fs, x): assert False -def to_enum(c: Type[EnumT], x: Any) -> EnumT: +def to_enum(c: type[EnumT], x: Any) -> EnumT: assert isinstance(x, c) return x.value -def from_list(f: Callable[[Any], T], x: Any) -> List[T]: +def from_list(f: Callable[[Any], T], x: Any) -> list[T]: assert isinstance(x, list) return [f(y) for y in x] -def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: +def from_dict(f: Callable[[Any], T], x: Any) -> dict[str, T]: assert isinstance(x, dict) return { k: f(v) for (k, v) in x.items() } @@ -171,11 +172,11 @@ class AttachmentType(Enum): class Attachment: display_name: str type: AttachmentType - line_range: Optional[LineRange] = None - path: Optional[str] = None - file_path: Optional[str] = None - selection: Optional[Selection] = None - text: Optional[str] = None + line_range: LineRange | None = None + path: str | None = None + file_path: str | None = None + selection: Selection | None = None + text: str | None = None @staticmethod def from_dict(obj: Any) -> 'Attachment': @@ -208,7 +209,7 @@ def to_dict(self) -> dict: @dataclass class CodeChanges: - files_modified: List[str] + files_modified: list[str] lines_added: float lines_removed: float @@ -253,9 +254,9 @@ def to_dict(self) -> dict: @dataclass class ContextClass: cwd: str - branch: Optional[str] = None - git_root: Optional[str] = None - repository: Optional[str] = None + branch: str | None = None + git_root: str | None = None + repository: str | None = None @staticmethod def from_dict(obj: Any) -> 'ContextClass': @@ -281,8 +282,8 @@ def to_dict(self) -> dict: @dataclass class ErrorClass: message: str - code: Optional[str] = None - stack: Optional[str] = None + code: str | None = None + stack: str | None = None @staticmethod def from_dict(obj: Any) -> 'ErrorClass': @@ -304,8 +305,8 @@ def to_dict(self) -> dict: @dataclass class Metadata: - prompt_version: Optional[str] = None - variables: Optional[Dict[str, Any]] = None + prompt_version: str | None = None + variables: dict[str, Any] | None = None @staticmethod def from_dict(obj: Any) -> 'Metadata': @@ -401,7 +402,7 @@ class QuotaSnapshot: remaining_percentage: float usage_allowed_with_exhausted_quota: bool used_requests: float - reset_date: Optional[datetime] = None + reset_date: datetime | None = None @staticmethod def from_dict(obj: Any) -> 'QuotaSnapshot': @@ -434,7 +435,7 @@ def to_dict(self) -> dict: class RepositoryClass: name: str owner: str - branch: Optional[str] = None + branch: str | None = None @staticmethod def from_dict(obj: Any) -> 'RepositoryClass': @@ -461,9 +462,9 @@ class Theme(Enum): @dataclass class Icon: src: str - mime_type: Optional[str] = None - sizes: Optional[List[str]] = None - theme: Optional[Theme] = None + mime_type: str | None = None + sizes: list[str] | None = None + theme: Theme | None = None @staticmethod def from_dict(obj: Any) -> 'Icon': @@ -489,9 +490,9 @@ def to_dict(self) -> dict: @dataclass class Resource: uri: str - mime_type: Optional[str] = None - text: Optional[str] = None - blob: Optional[str] = None + mime_type: str | None = None + text: str | None = None + blob: str | None = None @staticmethod def from_dict(obj: Any) -> 'Resource': @@ -526,18 +527,18 @@ class ContentType(Enum): @dataclass class Content: type: ContentType - text: Optional[str] = None - cwd: Optional[str] = None - exit_code: Optional[float] = None - data: Optional[str] = None - mime_type: Optional[str] = None - description: Optional[str] = None - icons: Optional[List[Icon]] = None - name: Optional[str] = None - size: Optional[float] = None - title: Optional[str] = None - uri: Optional[str] = None - resource: Optional[Resource] = None + text: str | None = None + cwd: str | None = None + exit_code: float | None = None + data: str | None = None + mime_type: str | None = None + description: str | None = None + icons: list[Icon] | None = None + name: str | None = None + size: float | None = None + title: str | None = None + uri: str | None = None + resource: Resource | None = None @staticmethod def from_dict(obj: Any) -> 'Content': @@ -590,8 +591,8 @@ def to_dict(self) -> dict: @dataclass class Result: content: str - contents: Optional[List[Content]] = None - detailed_content: Optional[str] = None + contents: list[Content] | None = None + detailed_content: str | None = None @staticmethod def from_dict(obj: Any) -> 'Result': @@ -636,7 +637,7 @@ class ToolRequest: name: str tool_call_id: str arguments: Any = None - type: Optional[ToolRequestType] = None + type: ToolRequestType | None = None @staticmethod def from_dict(obj: Any) -> 'ToolRequest': @@ -660,121 +661,121 @@ def to_dict(self) -> dict: @dataclass class Data: - context: Optional[Union[ContextClass, str]] = None - copilot_version: Optional[str] = None - producer: Optional[str] = None - selected_model: Optional[str] = None - session_id: Optional[str] = None - start_time: Optional[datetime] = None - version: Optional[float] = None - event_count: Optional[float] = None - resume_time: Optional[datetime] = None - error_type: Optional[str] = None - message: Optional[str] = None - provider_call_id: Optional[str] = None - stack: Optional[str] = None - status_code: Optional[int] = None - title: Optional[str] = None - info_type: Optional[str] = None - warning_type: Optional[str] = None - new_model: Optional[str] = None - previous_model: Optional[str] = None - new_mode: Optional[str] = None - previous_mode: Optional[str] = None - operation: Optional[Operation] = None - path: Optional[str] = None + context: ContextClass | str | None = None + copilot_version: str | None = None + producer: str | None = None + selected_model: str | None = None + session_id: str | None = None + start_time: datetime | None = None + version: float | None = None + event_count: float | None = None + resume_time: datetime | None = None + error_type: str | None = None + message: str | None = None + provider_call_id: str | None = None + stack: str | None = None + status_code: int | None = None + title: str | None = None + info_type: str | None = None + warning_type: str | None = None + new_model: str | None = None + previous_model: str | None = None + new_mode: str | None = None + previous_mode: str | None = None + operation: Operation | None = None + path: str | None = None """Relative path within the workspace files directory""" - handoff_time: Optional[datetime] = None - remote_session_id: Optional[str] = None - repository: Optional[Union[RepositoryClass, str]] = None - source_type: Optional[SourceType] = None - summary: Optional[str] = None - messages_removed_during_truncation: Optional[float] = None - performed_by: Optional[str] = None - post_truncation_messages_length: Optional[float] = None - post_truncation_tokens_in_messages: Optional[float] = None - pre_truncation_messages_length: Optional[float] = None - pre_truncation_tokens_in_messages: Optional[float] = None - token_limit: Optional[float] = None - tokens_removed_during_truncation: Optional[float] = None - events_removed: Optional[float] = None - up_to_event_id: Optional[str] = None - code_changes: Optional[CodeChanges] = None - current_model: Optional[str] = None - error_reason: Optional[str] = None - model_metrics: Optional[Dict[str, ModelMetric]] = None - session_start_time: Optional[float] = None - shutdown_type: Optional[ShutdownType] = None - total_api_duration_ms: Optional[float] = None - total_premium_requests: Optional[float] = None - branch: Optional[str] = None - cwd: Optional[str] = None - git_root: Optional[str] = None - current_tokens: Optional[float] = None - messages_length: Optional[float] = None - checkpoint_number: Optional[float] = None - checkpoint_path: Optional[str] = None - compaction_tokens_used: Optional[CompactionTokensUsed] = None - error: Optional[Union[ErrorClass, str]] = None - messages_removed: Optional[float] = None - post_compaction_tokens: Optional[float] = None - pre_compaction_messages_length: Optional[float] = None - pre_compaction_tokens: Optional[float] = None - request_id: Optional[str] = None - success: Optional[bool] = None - summary_content: Optional[str] = None - tokens_removed: Optional[float] = None - agent_mode: Optional[AgentMode] = None - attachments: Optional[List[Attachment]] = None - content: Optional[str] = None - source: Optional[str] = None - transformed_content: Optional[str] = None - turn_id: Optional[str] = None - intent: Optional[str] = None - reasoning_id: Optional[str] = None - delta_content: Optional[str] = None - total_response_size_bytes: Optional[float] = None - encrypted_content: Optional[str] = None - message_id: Optional[str] = None - parent_tool_call_id: Optional[str] = None - phase: Optional[str] = None - reasoning_opaque: Optional[str] = None - reasoning_text: Optional[str] = None - tool_requests: Optional[List[ToolRequest]] = None - api_call_id: Optional[str] = None - cache_read_tokens: Optional[float] = None - cache_write_tokens: Optional[float] = None - cost: Optional[float] = None - duration: Optional[float] = None - initiator: Optional[str] = None - input_tokens: Optional[float] = None - model: Optional[str] = None - output_tokens: Optional[float] = None - quota_snapshots: Optional[Dict[str, QuotaSnapshot]] = None - reason: Optional[str] = None + handoff_time: datetime | None = None + remote_session_id: str | None = None + repository: RepositoryClass | str | None = None + source_type: SourceType | None = None + summary: str | None = None + messages_removed_during_truncation: float | None = None + performed_by: str | None = None + post_truncation_messages_length: float | None = None + post_truncation_tokens_in_messages: float | None = None + pre_truncation_messages_length: float | None = None + pre_truncation_tokens_in_messages: float | None = None + token_limit: float | None = None + tokens_removed_during_truncation: float | None = None + events_removed: float | None = None + up_to_event_id: str | None = None + code_changes: CodeChanges | None = None + current_model: str | None = None + error_reason: str | None = None + model_metrics: dict[str, ModelMetric] | None = None + session_start_time: float | None = None + shutdown_type: ShutdownType | None = None + total_api_duration_ms: float | None = None + total_premium_requests: float | None = None + branch: str | None = None + cwd: str | None = None + git_root: str | None = None + current_tokens: float | None = None + messages_length: float | None = None + checkpoint_number: float | None = None + checkpoint_path: str | None = None + compaction_tokens_used: CompactionTokensUsed | None = None + error: ErrorClass | str | None = None + messages_removed: float | None = None + post_compaction_tokens: float | None = None + pre_compaction_messages_length: float | None = None + pre_compaction_tokens: float | None = None + request_id: str | None = None + success: bool | None = None + summary_content: str | None = None + tokens_removed: float | None = None + agent_mode: AgentMode | None = None + attachments: list[Attachment] | None = None + content: str | None = None + source: str | None = None + transformed_content: str | None = None + turn_id: str | None = None + intent: str | None = None + reasoning_id: str | None = None + delta_content: str | None = None + total_response_size_bytes: float | None = None + encrypted_content: str | None = None + message_id: str | None = None + parent_tool_call_id: str | None = None + phase: str | None = None + reasoning_opaque: str | None = None + reasoning_text: str | None = None + tool_requests: list[ToolRequest] | None = None + api_call_id: str | None = None + cache_read_tokens: float | None = None + cache_write_tokens: float | None = None + cost: float | None = None + duration: float | None = None + initiator: str | None = None + input_tokens: float | None = None + model: str | None = None + output_tokens: float | None = None + quota_snapshots: dict[str, QuotaSnapshot] | None = None + reason: str | None = None arguments: Any = None - tool_call_id: Optional[str] = None - tool_name: Optional[str] = None - mcp_server_name: Optional[str] = None - mcp_tool_name: Optional[str] = None - partial_output: Optional[str] = None - progress_message: Optional[str] = None - is_user_requested: Optional[bool] = None - result: Optional[Result] = None - tool_telemetry: Optional[Dict[str, Any]] = None - allowed_tools: Optional[List[str]] = None - name: Optional[str] = None - agent_description: Optional[str] = None - agent_display_name: Optional[str] = None - agent_name: Optional[str] = None - tools: Optional[List[str]] = None - hook_invocation_id: Optional[str] = None - hook_type: Optional[str] = None + tool_call_id: str | None = None + tool_name: str | None = None + mcp_server_name: str | None = None + mcp_tool_name: str | None = None + partial_output: str | None = None + progress_message: str | None = None + is_user_requested: bool | None = None + result: Result | None = None + tool_telemetry: dict[str, Any] | None = None + allowed_tools: list[str] | None = None + name: str | None = None + agent_description: str | None = None + agent_display_name: str | None = None + agent_name: str | None = None + tools: list[str] | None = None + hook_invocation_id: str | None = None + hook_type: str | None = None input: Any = None output: Any = None - metadata: Optional[Metadata] = None - role: Optional[Role] = None + metadata: Metadata | None = None + role: Role | None = None @staticmethod def from_dict(obj: Any) -> 'Data': @@ -1187,8 +1188,8 @@ class SessionEvent: id: UUID timestamp: datetime type: SessionEventType - ephemeral: Optional[bool] = None - parent_id: Optional[UUID] = None + ephemeral: bool | None = None + parent_id: UUID | None = None @staticmethod def from_dict(obj: Any) -> 'SessionEvent': diff --git a/python/copilot/jsonrpc.py b/python/copilot/jsonrpc.py index 8b736968..fc825527 100644 --- a/python/copilot/jsonrpc.py +++ b/python/copilot/jsonrpc.py @@ -10,8 +10,8 @@ import json import threading import uuid -from collections.abc import Awaitable -from typing import Any, Callable, Optional, Union +from collections.abc import Awaitable, Callable +from typing import Any class JsonRpcError(Exception): @@ -30,7 +30,7 @@ class ProcessExitedError(Exception): pass -RequestHandler = Callable[[dict], Union[dict, Awaitable[dict]]] +RequestHandler = Callable[[dict], dict | Awaitable[dict]] class JsonRpcClient: @@ -49,19 +49,19 @@ def __init__(self, process): """ self.process = process self.pending_requests: dict[str, asyncio.Future] = {} - self.notification_handler: Optional[Callable[[str, dict], None]] = None + self.notification_handler: Callable[[str, dict], None] | None = None self.request_handlers: dict[str, RequestHandler] = {} self._running = False - self._read_thread: Optional[threading.Thread] = None - self._stderr_thread: Optional[threading.Thread] = None - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._read_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None self._write_lock = threading.Lock() self._pending_lock = threading.Lock() - self._process_exit_error: Optional[str] = None + self._process_exit_error: str | None = None self._stderr_output: list[str] = [] self._stderr_lock = threading.Lock() - def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): + def start(self, loop: asyncio.AbstractEventLoop | None = None): """Start listening for messages in background thread""" if not self._running: self._running = True @@ -104,7 +104,7 @@ async def stop(self): self._stderr_thread.join(timeout=1.0) async def request( - self, method: str, params: Optional[dict] = None, timeout: Optional[float] = None + self, method: str, params: dict | None = None, timeout: float | None = None ) -> Any: """ Send a JSON-RPC request and wait for response @@ -149,7 +149,7 @@ async def request( with self._pending_lock: self.pending_requests.pop(request_id, None) - async def notify(self, method: str, params: Optional[dict] = None): + async def notify(self, method: str, params: dict | None = None): """ Send a JSON-RPC notification (no response expected) @@ -258,7 +258,7 @@ def _read_exact(self, num_bytes: int) -> bytes: remaining -= len(chunk) return b"".join(chunks) - def _read_message(self) -> Optional[dict]: + def _read_message(self) -> dict | None: """ Read a single JSON-RPC message with Content-Length header (blocking) @@ -367,7 +367,7 @@ async def _send_response(self, request_id: str, result: dict): await self._send_message(response) async def _send_error_response( - self, request_id: str, code: int, message: str, data: Optional[dict] + self, request_id: str, code: int, message: str, data: dict | None ): response = { "jsonrpc": "2.0", diff --git a/python/copilot/session.py b/python/copilot/session.py index 658d2902..a02dcf1e 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -8,7 +8,8 @@ import asyncio import inspect import threading -from typing import Any, Callable, Optional, cast +from collections.abc import Callable +from typing import Any, cast from .generated.rpc import SessionRpc from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict @@ -55,7 +56,7 @@ class CopilotSession: ... unsubscribe() """ - def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = None): + def __init__(self, session_id: str, client: Any, workspace_path: str | None = None): """ Initialize a new CopilotSession. @@ -76,13 +77,13 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = self._event_handlers_lock = threading.Lock() self._tool_handlers: dict[str, ToolHandler] = {} self._tool_handlers_lock = threading.Lock() - self._permission_handler: Optional[_PermissionHandlerFn] = None + self._permission_handler: _PermissionHandlerFn | None = None self._permission_handler_lock = threading.Lock() - self._user_input_handler: Optional[UserInputHandler] = None + self._user_input_handler: UserInputHandler | None = None self._user_input_handler_lock = threading.Lock() - self._hooks: Optional[SessionHooks] = None + self._hooks: SessionHooks | None = None self._hooks_lock = threading.Lock() - self._rpc: Optional[SessionRpc] = None + self._rpc: SessionRpc | None = None @property def rpc(self) -> SessionRpc: @@ -92,7 +93,7 @@ def rpc(self) -> SessionRpc: return self._rpc @property - def workspace_path(self) -> Optional[str]: + def workspace_path(self) -> str | None: """ Path to the session workspace directory when infinite sessions are enabled. @@ -137,8 +138,8 @@ async def send(self, options: MessageOptions) -> str: return response["messageId"] async def send_and_wait( - self, options: MessageOptions, timeout: Optional[float] = None - ) -> Optional[SessionEvent]: + self, options: MessageOptions, timeout: float | None = None + ) -> SessionEvent | None: """ Send a message to this session and wait until the session becomes idle. @@ -157,7 +158,7 @@ async def send_and_wait( The final assistant message event, or None if none was received. Raises: - asyncio.TimeoutError: If the timeout is reached before session becomes idle. + TimeoutError: If the timeout is reached before session becomes idle. Exception: If the session has been destroyed or the connection fails. Example: @@ -168,8 +169,8 @@ async def send_and_wait( effective_timeout = timeout if timeout is not None else 60.0 idle_event = asyncio.Event() - error_event: Optional[Exception] = None - last_assistant_message: Optional[SessionEvent] = None + error_event: Exception | None = None + last_assistant_message: SessionEvent | None = None def handler(event: SessionEventTypeAlias) -> None: nonlocal last_assistant_message, error_event @@ -190,10 +191,8 @@ def handler(event: SessionEventTypeAlias) -> None: if error_event: raise error_event return last_assistant_message - except asyncio.TimeoutError: - raise asyncio.TimeoutError( - f"Timeout after {effective_timeout}s waiting for session.idle" - ) + except TimeoutError: + raise TimeoutError(f"Timeout after {effective_timeout}s waiting for session.idle") finally: unsubscribe() @@ -252,7 +251,7 @@ def _dispatch_event(self, event: SessionEvent) -> None: except Exception as e: print(f"Error in session event handler: {e}") - def _register_tools(self, tools: Optional[list[Tool]]) -> None: + def _register_tools(self, tools: list[Tool] | None) -> None: """ Register custom tool handlers for this session. @@ -276,7 +275,7 @@ def _register_tools(self, tools: Optional[list[Tool]]) -> None: continue self._tool_handlers[tool.name] = tool.handler - def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: + def _get_tool_handler(self, name: str) -> ToolHandler | None: """ Retrieve a registered tool handler by name. @@ -293,7 +292,7 @@ def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: with self._tool_handlers_lock: return self._tool_handlers.get(name) - def _register_permission_handler(self, handler: Optional[_PermissionHandlerFn]) -> None: + def _register_permission_handler(self, handler: _PermissionHandlerFn | None) -> None: """ Register a handler for permission requests. @@ -341,7 +340,7 @@ async def _handle_permission_request( # Handler failed, deny permission return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} - def _register_user_input_handler(self, handler: Optional[UserInputHandler]) -> None: + def _register_user_input_handler(self, handler: UserInputHandler | None) -> None: """ Register a handler for user input requests. @@ -392,7 +391,7 @@ async def _handle_user_input_request(self, request: dict) -> UserInputResponse: except Exception: raise - def _register_hooks(self, hooks: Optional[SessionHooks]) -> None: + def _register_hooks(self, hooks: SessionHooks | None) -> None: """ Register hook handlers for session lifecycle events. diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 43c1ed99..e3e60099 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -9,7 +9,8 @@ import inspect import json -from typing import Any, Callable, TypeVar, get_type_hints, overload +from collections.abc import Callable +from typing import Any, TypeVar, get_type_hints, overload from pydantic import BaseModel diff --git a/python/copilot/types.py b/python/copilot/types.py index 142aee47..ef3e27aa 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -4,11 +4,9 @@ from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Callable, Literal, TypedDict, Union - -from typing_extensions import NotRequired +from typing import Any, Literal, NotRequired, TypedDict # Import generated SessionEvent types from .generated.session_events import SessionEvent @@ -65,7 +63,7 @@ class SelectionAttachment(TypedDict): # Attachment type - union of all attachment types -Attachment = Union[FileAttachment, DirectoryAttachment, SelectionAttachment] +Attachment = FileAttachment | DirectoryAttachment | SelectionAttachment # Options for creating a CopilotClient @@ -127,7 +125,7 @@ class ToolInvocation(TypedDict): arguments: Any -ToolHandler = Callable[[ToolInvocation], Union[ToolResult, Awaitable[ToolResult]]] +ToolHandler = Callable[[ToolInvocation], ToolResult | Awaitable[ToolResult]] @dataclass @@ -162,7 +160,7 @@ class SystemMessageReplaceConfig(TypedDict): # Union type - use one or the other -SystemMessageConfig = Union[SystemMessageAppendConfig, SystemMessageReplaceConfig] +SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig # Permission request types @@ -188,7 +186,7 @@ class PermissionRequestResult(TypedDict, total=False): _PermissionHandlerFn = Callable[ [PermissionRequest, dict[str, str]], - Union[PermissionRequestResult, Awaitable[PermissionRequestResult]], + PermissionRequestResult | Awaitable[PermissionRequestResult], ] @@ -220,7 +218,7 @@ class UserInputResponse(TypedDict): UserInputHandler = Callable[ [UserInputRequest, dict[str, str]], - Union[UserInputResponse, Awaitable[UserInputResponse]], + UserInputResponse | Awaitable[UserInputResponse], ] @@ -257,7 +255,7 @@ class PreToolUseHookOutput(TypedDict, total=False): PreToolUseHandler = Callable[ [PreToolUseHookInput, dict[str, str]], - Union[PreToolUseHookOutput, None, Awaitable[Union[PreToolUseHookOutput, None]]], + PreToolUseHookOutput | None | Awaitable[PreToolUseHookOutput | None], ] @@ -281,7 +279,7 @@ class PostToolUseHookOutput(TypedDict, total=False): PostToolUseHandler = Callable[ [PostToolUseHookInput, dict[str, str]], - Union[PostToolUseHookOutput, None, Awaitable[Union[PostToolUseHookOutput, None]]], + PostToolUseHookOutput | None | Awaitable[PostToolUseHookOutput | None], ] @@ -303,11 +301,7 @@ class UserPromptSubmittedHookOutput(TypedDict, total=False): UserPromptSubmittedHandler = Callable[ [UserPromptSubmittedHookInput, dict[str, str]], - Union[ - UserPromptSubmittedHookOutput, - None, - Awaitable[Union[UserPromptSubmittedHookOutput, None]], - ], + UserPromptSubmittedHookOutput | None | Awaitable[UserPromptSubmittedHookOutput | None], ] @@ -329,7 +323,7 @@ class SessionStartHookOutput(TypedDict, total=False): SessionStartHandler = Callable[ [SessionStartHookInput, dict[str, str]], - Union[SessionStartHookOutput, None, Awaitable[Union[SessionStartHookOutput, None]]], + SessionStartHookOutput | None | Awaitable[SessionStartHookOutput | None], ] @@ -353,7 +347,7 @@ class SessionEndHookOutput(TypedDict, total=False): SessionEndHandler = Callable[ [SessionEndHookInput, dict[str, str]], - Union[SessionEndHookOutput, None, Awaitable[Union[SessionEndHookOutput, None]]], + SessionEndHookOutput | None | Awaitable[SessionEndHookOutput | None], ] @@ -378,7 +372,7 @@ class ErrorOccurredHookOutput(TypedDict, total=False): ErrorOccurredHandler = Callable[ [ErrorOccurredHookInput, dict[str, str]], - Union[ErrorOccurredHookOutput, None, Awaitable[Union[ErrorOccurredHookOutput, None]]], + ErrorOccurredHookOutput | None | Awaitable[ErrorOccurredHookOutput | None], ] @@ -420,7 +414,7 @@ class MCPRemoteServerConfig(TypedDict, total=False): headers: NotRequired[dict[str, str]] # HTTP headers -MCPServerConfig = Union[MCPLocalServerConfig, MCPRemoteServerConfig] +MCPServerConfig = MCPLocalServerConfig | MCPRemoteServerConfig # ============================================================================ diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 4842d782..47cb1b5a 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -444,7 +444,7 @@ def on_event(event): # Wait for completion try: await asyncio.wait_for(done_event.wait(), timeout=60) - except asyncio.TimeoutError: + except TimeoutError: pytest.fail("Timed out waiting for session.idle") # Should have received delta events @@ -498,7 +498,7 @@ def on_event(event): # Wait for session to become idle try: await asyncio.wait_for(idle_event.wait(), timeout=60) - except asyncio.TimeoutError: + except TimeoutError: pytest.fail("Timed out waiting for session.idle") # Should have received multiple events diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 4417f567..9eb4c6a6 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -9,7 +9,6 @@ import shutil import tempfile from pathlib import Path -from typing import Optional from copilot import CopilotClient @@ -46,8 +45,8 @@ def __init__(self): self.home_dir: str = "" self.work_dir: str = "" self.proxy_url: str = "" - self._proxy: Optional[CapiProxy] = None - self._client: Optional[CopilotClient] = None + self._proxy: CapiProxy | None = None + self._client: CopilotClient | None = None async def setup(self): """Set up the test context with a shared client.""" diff --git a/python/e2e/testharness/proxy.py b/python/e2e/testharness/proxy.py index e26ec65c..65dd8bda 100644 --- a/python/e2e/testharness/proxy.py +++ b/python/e2e/testharness/proxy.py @@ -9,7 +9,7 @@ import platform import re import subprocess -from typing import Any, Optional +from typing import Any import httpx @@ -18,8 +18,8 @@ class CapiProxy: """Manages a replaying proxy server for E2E tests.""" def __init__(self): - self._process: Optional[subprocess.Popen] = None - self._proxy_url: Optional[str] = None + self._process: subprocess.Popen | None = None + self._proxy_url: str | None = None async def start(self) -> str: """Launch the proxy server and return its URL.""" @@ -107,6 +107,6 @@ async def get_exchanges(self) -> list[dict[str, Any]]: return resp.json() @property - def url(self) -> Optional[str]: + def url(self) -> str | None: """Return the proxy URL, or None if not started.""" return self._proxy_url diff --git a/python/pyproject.toml b/python/pyproject.toml index 6c4d3e72..170534ba 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -7,7 +7,7 @@ name = "github-copilot-sdk" version = "0.1.0" description = "Python SDK for GitHub Copilot CLI" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" license = "MIT" # license-files is set by scripts/build-wheels.mjs for bundled CLI wheels authors = [ @@ -17,15 +17,14 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "python-dateutil>=2.9.0.post0", "pydantic>=2.0", - "typing-extensions>=4.0.0", ] [project.urls] @@ -47,7 +46,7 @@ packages = ["copilot"] [tool.ruff] line-length = 100 -target-version = "py39" +target-version = "py311" exclude = [ "generated", "copilot/generated", @@ -61,9 +60,6 @@ select = [ "I", # isort "UP", # pyupgrade ] -ignore = [ - "UP006", -] [tool.ruff.format] quote-style = "double" diff --git a/python/test_client.py b/python/test_client.py index c6ad027f..f31c3e9e 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -213,6 +213,9 @@ async def test_resume_session_forwards_client_name(self): async def mock_request(method, params): captured[method] = params + if method == "session.resume": + # Return a fake response to avoid needing real auth + return {"sessionId": session.session_id} return await original_request(method, params) client._client.request = mock_request diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index aa688782..944ac269 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -21,6 +21,54 @@ import { // ── Utilities ─────────────────────────────────────────────────────────────── +/** + * Modernize quicktype's Python 3.7 output to Python 3.11+ syntax: + * - Optional[T] → T | None + * - List[T] → list[T] + * - Dict[K, V] → dict[K, V] + * - Type[T] → type[T] + * - Callable from collections.abc instead of typing + * - Clean up unused typing imports + */ +function modernizePython(code: string): string { + // Replace Optional[X] with X | None (handles nested brackets) + code = code.replace(/Optional\[([^\[\]]*(?:\[[^\[\]]*\])*[^\[\]]*)\]/g, "$1 | None"); + + // Replace Union[X, Y] with X | Y + code = code.replace(/Union\[([^\[\]]*(?:\[[^\[\]]*\])*[^\[\]]*)\]/g, (_match, inner: string) => { + return inner.split(",").map((s: string) => s.trim()).join(" | "); + }); + + // Replace List[X] with list[X] + code = code.replace(/\bList\[/g, "list["); + + // Replace Dict[K, V] with dict[K, V] + code = code.replace(/\bDict\[/g, "dict["); + + // Replace Type[T] with type[T] + code = code.replace(/\bType\[/g, "type["); + + // Move Callable from typing to collections.abc + code = code.replace( + /from typing import (.*), Callable$/m, + "from typing import $1\nfrom collections.abc import Callable" + ); + code = code.replace( + /from typing import Callable, (.*)$/m, + "from typing import $1\nfrom collections.abc import Callable" + ); + + // Remove now-unused imports from typing (Optional, List, Dict, Type) + code = code.replace(/from typing import (.+)$/m, (_match, imports: string) => { + const items = imports.split(",").map((s: string) => s.trim()); + const remove = new Set(["Optional", "List", "Dict", "Type", "Union"]); + const kept = items.filter((i: string) => !remove.has(i)); + return `from typing import ${kept.join(", ")}`; + }); + + return code; +} + function toSnakeCase(s: string): string { return s .replace(/([a-z])([A-Z])/g, "$1_$2") @@ -75,6 +123,8 @@ async function generateSessionEvents(schemaPath?: string): Promise { code = code.replace(/: Any$/gm, ": Any = None"); // Fix bare except: to use Exception (required by ruff/pylint) code = code.replace(/except:/g, "except Exception:"); + // Modernize to Python 3.11+ syntax + code = modernizePython(code); // Add UNKNOWN enum value for forward compatibility code = code.replace( @@ -162,6 +212,8 @@ async function generateRpc(schemaPath?: string): Promise { typesCode = typesCode.replace(/except:/g, "except Exception:"); // Remove unnecessary pass when class has methods (quicktype generates pass for empty schemas) typesCode = typesCode.replace(/^(\s*)pass\n\n(\s*@staticmethod)/gm, "$2"); + // Modernize to Python 3.11+ syntax + typesCode = modernizePython(typesCode); const lines: string[] = []; lines.push(`"""