diff --git a/github-copilot-sdk/README.md b/github-copilot-sdk/README.md new file mode 100644 index 0000000000..c8842ba90a --- /dev/null +++ b/github-copilot-sdk/README.md @@ -0,0 +1,3 @@ +# GitHub Copilot SDK: Put a Coding Agent in Your Python Code + +This folder provides the code examples for the Real Python tutorial [GitHub Copilot SDK: Put a Coding Agent in Your Python Code](https://realpython.com/github-copilot-sdk/) diff --git a/github-copilot-sdk/ci_agent.py b/github-copilot-sdk/ci_agent.py new file mode 100644 index 0000000000..9ea2a1c223 --- /dev/null +++ b/github-copilot-sdk/ci_agent.py @@ -0,0 +1,47 @@ +import asyncio +import os +import sys +from pathlib import Path + +from copilot import CopilotClient +from copilot.rpc import ( + PermissionDecisionApproveOnce, + PermissionDecisionReject, +) + +TARGET = sys.argv[1] if len(sys.argv) > 1 else "project/shopping.py" +ALLOWED = Path(TARGET).resolve() + + +def is_target(path): + return Path(path).resolve() == ALLOWED + + +def policy(request, invocation): + path = getattr(request, "path", "") + if request.kind == "read" and is_target(path): + print(f"[approved] read {path}", file=sys.stderr) + return PermissionDecisionApproveOnce() + print(f"[rejected] {request.kind} {path}", file=sys.stderr) + return PermissionDecisionReject(feedback="CI runs are read-only.") + + +async def main() -> int: + token = os.environ["COPILOT_GITHUB_TOKEN"] + async with CopilotClient(github_token=token) as client: + session = await client.create_session( + on_permission_request=policy, + model="claude-haiku-5.5", + ) + reply = await session.send_and_wait( + f"Review {TARGET} for bugs. Reply with only PASS if it is fine, " + "or FAIL followed by the problems." + ) + await session.disconnect() + verdict = reply.data.content.strip() if reply else "FAIL: no reply" + print(verdict) + return 0 if verdict == "PASS" else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/github-copilot-sdk/hello_agent.py b/github-copilot-sdk/hello_agent.py new file mode 100644 index 0000000000..8c3fb1a28c --- /dev/null +++ b/github-copilot-sdk/hello_agent.py @@ -0,0 +1,22 @@ +import asyncio +import os + +from copilot import CopilotClient, PermissionHandler + + +async def main(): + token = os.environ["COPILOT_GITHUB_TOKEN"] + async with CopilotClient(github_token=token) as client: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + model="claude-haiku-5.5", + ) + reply = await session.send_and_wait( + "What is the walrus operator in Python? One sentence." + ) + print(reply.data.content) + await session.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/github-copilot-sdk/issue_agent.py b/github-copilot-sdk/issue_agent.py new file mode 100644 index 0000000000..ad08d17fa8 --- /dev/null +++ b/github-copilot-sdk/issue_agent.py @@ -0,0 +1,44 @@ +import asyncio +import json +import os +from pathlib import Path +from typing import Literal + +from copilot import CopilotClient, PermissionHandler, define_tool +from pydantic import BaseModel + +ISSUES = json.loads(Path("tracker/issues.json").read_text()) + + +class ListIssuesParams(BaseModel): + status: Literal["open", "closed"] + label: str | None = None + + +@define_tool(description="List issues from the tracker") +async def list_issues(params: ListIssuesParams): + return [ + issue + for issue in ISSUES + if issue["status"] == params.status + and (params.label is None or params.label in issue["labels"]) + ] + + +async def main(): + token = os.environ["COPILOT_GITHUB_TOKEN"] + async with CopilotClient(github_token=token) as client: + session = await client.create_session( + tools=[list_issues], + on_permission_request=PermissionHandler.approve_all, + model="claude-haiku-5.5", + ) + reply = await session.send_and_wait( + "Which open bugs are assigned to Ana? Use the tracker tool." + ) + print(reply.data.content) + await session.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/github-copilot-sdk/project/shopping.py b/github-copilot-sdk/project/shopping.py new file mode 100644 index 0000000000..448200f66e --- /dev/null +++ b/github-copilot-sdk/project/shopping.py @@ -0,0 +1,9 @@ +def compute_total(prices): + total = 0 + for price in prices: + total = total + price + return total + + +def compute_average(prices): + return compute_total(prices) / len(prices) diff --git a/github-copilot-sdk/project/test_shopping.py b/github-copilot-sdk/project/test_shopping.py new file mode 100644 index 0000000000..d8933e3482 --- /dev/null +++ b/github-copilot-sdk/project/test_shopping.py @@ -0,0 +1,9 @@ +from shopping import compute_average, compute_total + + +def test_compute_total(): + assert compute_total([1, 2, 3]) == 6 + + +def test_compute_average(): + assert compute_average([2, 4]) == 3 diff --git a/github-copilot-sdk/refactor_agent.py b/github-copilot-sdk/refactor_agent.py new file mode 100644 index 0000000000..f274d62673 --- /dev/null +++ b/github-copilot-sdk/refactor_agent.py @@ -0,0 +1,48 @@ +import asyncio +import os +import shutil +import tempfile +from pathlib import Path + +from copilot import CopilotClient, PermissionHandler, SessionEventType + +PROJECT = Path(__file__).with_name("project") + + +def on_event(event): + match event.type: + case SessionEventType.ASSISTANT_MESSAGE_DELTA: + print(event.data.delta_content, end="", flush=True) + case SessionEventType.TOOL_EXECUTION_START: + print(f"\n[tool start] {event.data.tool_name}") + case SessionEventType.TOOL_EXECUTION_COMPLETE: + print(f"[tool done] success={event.data.success}") + case SessionEventType.SESSION_IDLE: + print("\n[idle]") + + +async def main(): + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) / "project" + shutil.copytree(PROJECT, workdir) + print(f"Working in {workdir}") + token = os.environ["COPILOT_GITHUB_TOKEN"] + async with CopilotClient(github_token=token) as client: + session = await client.create_session( + streaming=True, + working_directory=str(workdir), + on_permission_request=PermissionHandler.approve_all, + model="claude-haiku-5.5", + ) + session.on(on_event) + await session.send_and_wait( + "In shopping.py, make compute_total() use sum() and make " + "compute_average() return 0 for an empty list. " + "Don't touch the tests." + ) + await session.disconnect() + print((workdir / "shopping.py").read_text()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/github-copilot-sdk/requirements.txt b/github-copilot-sdk/requirements.txt new file mode 100644 index 0000000000..025c457ca8 --- /dev/null +++ b/github-copilot-sdk/requirements.txt @@ -0,0 +1,2 @@ +github-copilot-sdk==1.0.26 +pydantic==2.14.1 diff --git a/github-copilot-sdk/tracker/issues.json b/github-copilot-sdk/tracker/issues.json new file mode 100644 index 0000000000..572e418283 --- /dev/null +++ b/github-copilot-sdk/tracker/issues.json @@ -0,0 +1,37 @@ +[ + { + "id": 1, + "title": "Crash when config file is missing", + "status": "open", + "labels": ["bug"], + "assignee": "ana" + }, + { + "id": 2, + "title": "Add --verbose flag", + "status": "open", + "labels": ["enhancement"], + "assignee": null + }, + { + "id": 3, + "title": "Typo in README", + "status": "closed", + "labels": ["docs"], + "assignee": "ben" + }, + { + "id": 4, + "title": "Slow startup on Windows", + "status": "open", + "labels": ["bug", "performance"], + "assignee": "ana" + }, + { + "id": 5, + "title": "Wrong exit code after a failed sync", + "status": "open", + "labels": ["bug"], + "assignee": "ben" + } +]