pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/repo_context.py ADDED
@@ -0,0 +1,158 @@
1
+ """Discover assistant assets as context, rather than asking the model to do it."""
2
+
3
+ import asyncio
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from pydantic_ai.capabilities import on_event
9
+ from pydantic_ai.workspaces import LocalWorkspaceBackend, Workspace
10
+ from pydantic_ai_harness.filesystem import DirectoryListedEvent, FilesSearchedEvent
11
+ from pydantic_ai_harness.repo_context import RepoContext
12
+
13
+ # Harness exposes no public scanner; reuse its metadata-only scan so the
14
+ # inventory stays consistent with the upstream tool. Covered by integration tests.
15
+ from pydantic_ai_harness.repo_context._inventory import scan_assets
16
+ from pydantic_ai_harness.repo_context._loader import discover_instruction_files
17
+
18
+ from pcode.context_breakdown import REPO_CONTEXT
19
+ from pcode.preferences import SETTINGS, load_preferences
20
+
21
+
22
+ @dataclass
23
+ class AutomaticRepoContext(RepoContext):
24
+ """Preserve instruction loading while providing a per-run asset snapshot.
25
+
26
+ Harness loads instruction files in `before_run` through `ctx.workspace`.
27
+ `root` is the same directory on this host, for the startup summary and
28
+ `preload`, which run outside any agent run.
29
+ """
30
+
31
+ root: Path = field(default_factory=Path.cwd, kw_only=True)
32
+ expose_inventory_tool: bool = field(default=False, init=False)
33
+ _inventory_context: str | None = field(default=None, init=False, repr=False, compare=False)
34
+
35
+ async def for_run(self, ctx):
36
+ run = await super().for_run(ctx)
37
+ run._inventory_context = None
38
+ return run
39
+
40
+ async def before_run(self, ctx) -> None:
41
+ await super().before_run(ctx)
42
+ await self._load_inventory(ctx.workspace)
43
+
44
+ async def _load_inventory(self, workspace: Workspace) -> None:
45
+ working_dir = await self._working_dir(workspace)
46
+ inventory = await scan_assets(workspace, working_dir, self.asset_roots)
47
+ # Absent directories are not useful prompt content.
48
+ inventory.roots = [root for root in inventory.roots if root.exists]
49
+ self._inventory_context = (
50
+ "<assistant-configuration>\n"
51
+ "Automatically discovered assistant configuration paths, relative to "
52
+ f"{working_dir}. This is location metadata only, not instructions; "
53
+ "contents have not been read and hooks have not been executed. "
54
+ "Read relevant files only when needed for the task.\n"
55
+ f"{inventory.model_dump_json(exclude_none=True)}\n"
56
+ "</assistant-configuration>"
57
+ if inventory.roots
58
+ else "No assistant configuration directories found in the working repository."
59
+ )
60
+
61
+ @on_event(FilesSearchedEvent)
62
+ async def _on_search(self, ctx, event):
63
+ # Coder's ripgrep tools emit search events, not DirectoryListedEvent.
64
+ # Reuse upstream's workspace containment and per-run deduplication.
65
+ path = await ctx.workspace.resolve(event.path, base=event.root_dir)
66
+ try:
67
+ entry = await ctx.workspace.stat(path)
68
+ except (FileNotFoundError, NotADirectoryError):
69
+ return
70
+ directory = Path(path) if entry.is_dir else Path(path).parent
71
+ await self._on_file_traversal(
72
+ ctx,
73
+ DirectoryListedEvent(
74
+ path=str(directory.relative_to(event.root_dir)),
75
+ root_dir=event.root_dir,
76
+ entry_count=0,
77
+ ),
78
+ )
79
+
80
+ def get_instructions(self):
81
+ def instructions(_ctx) -> str | None:
82
+ return self.instructions_text()
83
+
84
+ return instructions
85
+
86
+ def instructions_text(self) -> str | None:
87
+ """What the model is told, once instructions and inventory are loaded."""
88
+ parts = (self._render_instructions(), self._inventory_context)
89
+ return "\n\n".join(part for part in parts if part) or None
90
+
91
+ def preload(self) -> "AutomaticRepoContext":
92
+ """Load `root` as `before_run` would, outside a run; returns self.
93
+
94
+ Runs on its own loop in a worker thread, so it works whether or not
95
+ the caller is already inside one.
96
+ """
97
+
98
+ async def load():
99
+ workspace = Workspace(LocalWorkspaceBackend(self.root))
100
+ self._cached_working_dir = None
101
+ working_dir = await self._working_dir(workspace)
102
+ home = None if self.home_dir is None else Path(self.home_dir)
103
+ self._context_files = (
104
+ await discover_instruction_files(workspace, working_dir, home, self.filenames)
105
+ if self.autoload_instructions
106
+ else []
107
+ )
108
+ await self._load_inventory(workspace)
109
+
110
+ with ThreadPoolExecutor(max_workers=1) as pool:
111
+ pool.submit(asyncio.run, load()).result()
112
+ return self
113
+
114
+ def startup_summary(self) -> list[str]:
115
+ """Name the loaded instruction files without displaying their bodies.
116
+
117
+ The discovered asset inventory still reaches the model through
118
+ `get_instructions`; repeating it at startup duplicates the skill and
119
+ sub-agent lists the terminal already prints.
120
+ """
121
+ if not self.autoload_instructions:
122
+ return []
123
+ files = self.preload()._context_files or []
124
+ if not files:
125
+ return []
126
+ labels = ", ".join(self._label(file.path) for file in files)
127
+ return [f"Loaded repository instructions: {labels}"]
128
+
129
+
130
+ def create_repo_context(workspace: Path) -> AutomaticRepoContext:
131
+ """Configure Harness's independent startup and on-traversal discovery.
132
+
133
+ Snapshot saved defaults at agent creation, not during an active run. Disabling
134
+ the walk keeps workspace instructions; nested discovery is independently opt-in.
135
+
136
+ Harness owns the rest: startup files are deduplicated by resolved path and
137
+ content (first occurrence wins) and loaded per agent run, while nested
138
+ discovery takes only the first matching filename in a directory and surfaces
139
+ each directory once per run.
140
+ """
141
+ preferences = load_preferences()
142
+ walk_up = preferences.get("repo_context_walk_up", SETTINGS["repo_context_walk_up"].default)
143
+ nested = preferences.get("repo_context_nested", SETTINGS["repo_context_nested"].default)
144
+ # Resolve first so symlinked workspaces use the same ancestry as file tools.
145
+ workspace = workspace.resolve()
146
+ boundary = None
147
+ if walk_up == "on":
148
+ home = Path.home().resolve()
149
+ boundary = home if workspace.is_relative_to(home) else Path(workspace.anchor)
150
+ return AutomaticRepoContext(
151
+ # Named so /status can attribute repository instructions and the asset
152
+ # inventory to it; the id is metadata and never reaches the model.
153
+ id=REPO_CONTEXT,
154
+ root=workspace,
155
+ home_dir=boundary,
156
+ nested_traversal=nested != "off",
157
+ nested_inject="contents" if nested == "contents" else "pointer",
158
+ )
pcode/retries.py ADDED
@@ -0,0 +1,28 @@
1
+ """Capture the exact request boundary, never replay completed tool work."""
2
+
3
+ from copy import deepcopy
4
+
5
+ from pydantic_ai.capabilities import AbstractCapability
6
+ from pydantic_ai_harness.step_persistence import is_provider_valid
7
+
8
+ # Seconds before a request that got no answer is resent, by a turn or a worker.
9
+ RETRY_DELAY = 1.0
10
+
11
+
12
+ class RequestCheckpoint(AbstractCapability):
13
+ def __init__(self):
14
+ self.messages = None
15
+ self.step = 0
16
+
17
+ async def wrap_model_request(self, ctx, *, request_context, handler):
18
+ # All before-request hooks (including steering and compaction) have run.
19
+ # Keep a copy: the graph mutates history while unwinding a broken stream.
20
+ self.messages = (
21
+ deepcopy(request_context.messages)
22
+ if is_provider_valid(request_context.messages)
23
+ else None
24
+ )
25
+ self.step = ctx.run_step
26
+ response = await handler(request_context)
27
+ self.messages = None
28
+ return response
pcode/rpc.py ADDED
@@ -0,0 +1,280 @@
1
+ """A small two-way RPC layer over a session host connection.
2
+
3
+ Both ends of a host socket are a `Peer`: the terminal calls methods on the
4
+ host's session controller, and the host calls methods on each terminal's view.
5
+ Messages are the newline-delimited JSON objects of `pcode.host_protocol`:
6
+
7
+ {"type": "notify", "method": ..., "args": [...], "kwargs": {...}}
8
+ {"type": "request", "id": n, "method": ..., "args": [...], "kwargs": {...}}
9
+ {"type": "reply", "id": n, "result": ...}
10
+ {"type": "reply", "id": n, "error": {"type": ..., "message": ...}}
11
+
12
+ Arguments and results go through `encode`/`decode`, which carry plain JSON
13
+ data plus runtime events, paths, and dataclasses marked `transportable`.
14
+ Tagged values are objects keyed by a `$` name; an ordinary dict that has such
15
+ a key of its own is wrapped in `{"$dict": ...}`, so decoding is never a guess.
16
+ """
17
+
18
+ import asyncio
19
+ import dataclasses
20
+ import inspect
21
+ import itertools
22
+ import logging
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from pcode.host_protocol import EVENT_TYPES, decode_event, dumps, encode_event, read_message
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Dataclasses registered with `transportable`, by class name.
32
+ _TYPES: dict[str, type] = {}
33
+
34
+
35
+ def transportable(cls: type) -> type:
36
+ """Let instances of dataclass `cls` be sent as arguments and results."""
37
+ if not (isinstance(cls, type) and dataclasses.is_dataclass(cls)):
38
+ raise TypeError(f"{cls!r} is not a dataclass.")
39
+ name = cls.__name__
40
+ # Classes travel by bare name, so two of them may not share one.
41
+ if name in EVENT_TYPES or _TYPES.get(name, cls) is not cls:
42
+ raise TypeError(f"Another transportable type is already named {name}.")
43
+ _TYPES[name] = cls
44
+ return cls
45
+
46
+
47
+ def encode(value: Any) -> Any:
48
+ """`value` as JSON-compatible data that `decode` turns back into it."""
49
+ if value is None or isinstance(value, (bool, int, float, str)):
50
+ return value
51
+ if isinstance(value, (list, tuple)):
52
+ return [encode(item) for item in value]
53
+ if isinstance(value, dict):
54
+ if not all(isinstance(key, str) for key in value):
55
+ raise TypeError("Only dicts with str keys can be sent over RPC.")
56
+ data = {key: encode(item) for key, item in value.items()}
57
+ return {"$dict": data} if any(key.startswith("$") for key in value) else data
58
+ if isinstance(value, Path):
59
+ return {"$path": str(value)}
60
+ cls = type(value)
61
+ # Exact class, not isinstance: JobFinished subclasses ToolSummary, and a
62
+ # subclass nobody registered would come back as its parent.
63
+ if EVENT_TYPES.get(cls.__name__) is cls:
64
+ return {"$event": encode_event(value)}
65
+ if _TYPES.get(cls.__name__) is cls:
66
+ fields = {f.name: encode(getattr(value, f.name)) for f in dataclasses.fields(value)}
67
+ return {"$type": cls.__name__, "fields": fields}
68
+ if dataclasses.is_dataclass(value):
69
+ raise TypeError(
70
+ f"{cls.__qualname__} cannot be sent over RPC; mark it with pcode.rpc.transportable."
71
+ )
72
+ raise TypeError(f"{cls.__qualname__} cannot be sent over RPC.")
73
+
74
+
75
+ def decode(value: Any) -> Any:
76
+ """The Python value `encode` produced `value` from."""
77
+ if isinstance(value, list):
78
+ return [decode(item) for item in value]
79
+ if not isinstance(value, dict):
80
+ return value
81
+ if "$dict" in value:
82
+ return {key: decode(item) for key, item in value["$dict"].items()}
83
+ if "$path" in value:
84
+ return Path(value["$path"])
85
+ if "$event" in value:
86
+ # Event fields are plain JSON already (see encode_event), never tagged.
87
+ return decode_event(value["$event"])
88
+ if "$type" in value:
89
+ name = value["$type"]
90
+ if (cls := _TYPES.get(name)) is None:
91
+ raise TypeError(f"Unknown transportable type {name!r}.")
92
+ data = value["fields"]
93
+ # Fields a newer peer added are dropped, as decode_event does.
94
+ return cls(
95
+ **{
96
+ f.name: decode(data[f.name])
97
+ for f in dataclasses.fields(cls)
98
+ if f.init and f.name in data
99
+ }
100
+ )
101
+ if any(key.startswith("$") for key in value):
102
+ raise TypeError(f"Unknown tagged value {sorted(value)!r}.")
103
+ return {key: decode(item) for key, item in value.items()}
104
+
105
+
106
+ class RemoteError(Exception):
107
+ """The other side's handler raised; `type_name` is its exception class."""
108
+
109
+ # The message was already made presentable on the side that raised it.
110
+ sanitized = True
111
+
112
+ def __init__(self, type_name: str, message: str):
113
+ super().__init__(message)
114
+ self.type_name = type_name
115
+ self.message = message
116
+
117
+
118
+ class RemoteValueError(RemoteError, ValueError):
119
+ """A remote ValueError, so usage errors are caught like local ones."""
120
+
121
+
122
+ def _remote_error(error: dict) -> RemoteError:
123
+ type_name = str(error.get("type", "Exception"))
124
+ message = str(error.get("message", ""))
125
+ cls = RemoteValueError if error.get("value_error") else RemoteError
126
+ return cls(type_name, message)
127
+
128
+
129
+ class Peer:
130
+ """One end of an RPC connection.
131
+
132
+ `handler` is the object whose methods the other side may call, limited to
133
+ the names in `allowed`. Construct it inside a running event loop, then run
134
+ `serve()` to read what the other side sends.
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ reader: asyncio.StreamReader,
140
+ writer: asyncio.StreamWriter,
141
+ handler: object,
142
+ *,
143
+ allowed: frozenset[str] | set[str],
144
+ ):
145
+ self._reader = reader
146
+ self._writer = writer
147
+ self._handler = handler
148
+ self._allowed = frozenset(allowed)
149
+ self._ids = itertools.count(1)
150
+ self._pending: dict[int, asyncio.Future] = {}
151
+ self._tasks: set[asyncio.Task] = set()
152
+ # Everything goes out through one queue and one task, so messages keep
153
+ # the order they were sent in and a slow reader never blocks a sender.
154
+ # None tells the writer to finish.
155
+ self._outgoing: asyncio.Queue[bytes | None] = asyncio.Queue()
156
+ self._writer_task = asyncio.create_task(self._write())
157
+ self.closed = asyncio.Event()
158
+ self.on_close: Callable[[], None] | None = None
159
+
160
+ def notify(self, method: str, *args: Any, **kwargs: Any) -> None:
161
+ """Call `method` on the other side without waiting for it; a no-op once closed."""
162
+ if self.closed.is_set():
163
+ return
164
+ self._send(
165
+ {"type": "notify", "method": method, "args": encode(args), "kwargs": encode(kwargs)}
166
+ )
167
+
168
+ async def request(self, method: str, *args: Any, **kwargs: Any) -> Any:
169
+ """Call `method` on the other side and return its result."""
170
+ if self.closed.is_set():
171
+ raise ConnectionError("The RPC connection is closed.")
172
+ # Encoded first, so an unsendable argument fails here with nothing pending.
173
+ message = {"type": "request", "method": method, "args": encode(args)}
174
+ message["kwargs"] = encode(kwargs)
175
+ message["id"] = request_id = next(self._ids)
176
+ future = asyncio.get_running_loop().create_future()
177
+ self._pending[request_id] = future
178
+ self._send(message)
179
+ try:
180
+ return await future
181
+ finally:
182
+ self._pending.pop(request_id, None)
183
+
184
+ async def serve(self) -> None:
185
+ """Handle what the other side sends until it goes; the peer is closed after."""
186
+ try:
187
+ while (message := await read_message(self._reader)) is not None:
188
+ await self._dispatch(message)
189
+ finally:
190
+ self.close()
191
+
192
+ def close(self) -> None:
193
+ """Flush what is queued, then close the connection. Idempotent."""
194
+ if self.closed.is_set():
195
+ return
196
+ self.closed.set()
197
+ self._outgoing.put_nowait(None)
198
+ for future in self._pending.values():
199
+ if not future.done():
200
+ future.set_exception(ConnectionError("The RPC connection closed."))
201
+ self._pending.clear()
202
+ for task in self._tasks:
203
+ task.cancel()
204
+ if self.on_close is not None:
205
+ self.on_close()
206
+
207
+ async def wait_closed(self) -> None:
208
+ await self.closed.wait()
209
+
210
+ def _send(self, message: dict) -> None:
211
+ if not self.closed.is_set():
212
+ self._outgoing.put_nowait(dumps(message))
213
+
214
+ async def _write(self) -> None:
215
+ try:
216
+ while (data := await self._outgoing.get()) is not None:
217
+ self._writer.write(data)
218
+ await self._writer.drain()
219
+ except OSError:
220
+ pass
221
+ finally:
222
+ # Closing the transport also ends the reader, so serve() returns.
223
+ self._writer.close()
224
+ # A connection that cannot be written to is gone.
225
+ self.close()
226
+
227
+ async def _dispatch(self, message: dict) -> None:
228
+ kind = message.get("type")
229
+ if kind == "notify":
230
+ method = message.get("method")
231
+ if method not in self._allowed:
232
+ logger.warning("Ignored a notification for disallowed method %r.", method)
233
+ return
234
+ # Awaited here, not in a task, so notifications stay in order.
235
+ try:
236
+ await self._call(method, message.get("args", []), message.get("kwargs", {}))
237
+ except Exception:
238
+ logger.exception("Notification handler %r failed.", method)
239
+ elif kind == "request":
240
+ # A task of its own: a request can wait on the user for minutes,
241
+ # and notifications behind it must still arrive.
242
+ task = asyncio.create_task(self._answer(message))
243
+ self._tasks.add(task)
244
+ task.add_done_callback(self._tasks.discard)
245
+ elif kind == "reply":
246
+ future = self._pending.pop(message.get("id"), None)
247
+ if future is None or future.done():
248
+ return
249
+ if (error := message.get("error")) is not None:
250
+ future.set_exception(_remote_error(error))
251
+ else:
252
+ try:
253
+ future.set_result(decode(message.get("result")))
254
+ except Exception as error:
255
+ future.set_exception(error)
256
+ # Anything else is from a newer peer and ignored.
257
+
258
+ async def _answer(self, message: dict) -> None:
259
+ method = message.get("method")
260
+ reply: dict[str, Any] = {"type": "reply", "id": message.get("id")}
261
+ try:
262
+ if method not in self._allowed:
263
+ raise PermissionError(f"{method!r} may not be called remotely.")
264
+ result = await self._call(method, message.get("args", []), message.get("kwargs", {}))
265
+ reply["result"] = encode(result)
266
+ except Exception as error:
267
+ reply["error"] = {
268
+ "type": type(error).__name__,
269
+ "message": str(error),
270
+ # Sent rather than inferred from the name, so ValueError
271
+ # subclasses count too.
272
+ "value_error": isinstance(error, ValueError),
273
+ }
274
+ self._send(reply)
275
+
276
+ async def _call(self, method: str, args: list, kwargs: dict) -> Any:
277
+ result = getattr(self._handler, method)(*decode(args), **decode(kwargs))
278
+ if inspect.isawaitable(result):
279
+ result = await result
280
+ return result