ipinb 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.
- ipi/__init__.py +5 -0
- ipi/_ansi.py +274 -0
- ipi/_dependency_metadata.py +149 -0
- ipi/_exception_groups.py +12 -0
- ipi/_git_provenance.py +122 -0
- ipi/_kernel_argv.py +25 -0
- ipi/_kernel_launcher.py +56 -0
- ipi/_launch_gate.py +29 -0
- ipi/_markdown.py +14 -0
- ipi/_model_output.py +287 -0
- ipi/_names.py +16 -0
- ipi/_notebook_mirror.py +284 -0
- ipi/_owner_watchdog.py +44 -0
- ipi/_parent_process.py +39 -0
- ipi/_platform_support.py +30 -0
- ipi/_self_wheel.py +139 -0
- ipi/_timing.py +34 -0
- ipi/_toml.py +67 -0
- ipi/_transport/__init__.py +41 -0
- ipi/_transport/address.py +190 -0
- ipi/_transport/artifacts.py +23 -0
- ipi/_transport/contract.py +125 -0
- ipi/_transport/local.py +268 -0
- ipi/_transport/master_owner.py +98 -0
- ipi/_transport/owned_directory.py +163 -0
- ipi/_transport/portable_uv.py +158 -0
- ipi/_transport/ssh.py +1816 -0
- ipi/_transport/staging.py +135 -0
- ipi/_transport/supervisor.py +817 -0
- ipi/_transport/supervisor_protocol.py +8 -0
- ipi/_ynotebook_ops.py +235 -0
- ipi/cell_export.py +478 -0
- ipi/config.py +376 -0
- ipi/execution_backend.py +305 -0
- ipi/execution_models.py +193 -0
- ipi/formatting.py +699 -0
- ipi/jupyter_server.py +207 -0
- ipi/kernel.py +7252 -0
- ipi/labextensions/@ipi/notebook-extension/package.json +52 -0
- ipi/labextensions/@ipi/notebook-extension/static/585.5ce41466a93651634fda.js +1 -0
- ipi/labextensions/@ipi/notebook-extension/static/665.bfdd2e9a96724c3cb559.js +1 -0
- ipi/labextensions/@ipi/notebook-extension/static/remoteEntry.e0b57e0b0817ceee899e.js +1 -0
- ipi/labextensions/@ipi/notebook-extension/static/style.js +4 -0
- ipi/labextensions/@ipi/notebook-extension/static/third-party-licenses.json +16 -0
- ipi/notebook_publisher.py +646 -0
- ipi/notebook_trace.py +47 -0
- ipi/plugins/__init__.py +47 -0
- ipi/plugins/_model.py +1119 -0
- ipi/plugins/api.py +568 -0
- ipi/plugins/builtins/__init__.py +47 -0
- ipi/plugins/builtins/_agents_md_impl.py +100 -0
- ipi/plugins/builtins/_image_outputs_impl.py +176 -0
- ipi/plugins/builtins/agents_md.py +25 -0
- ipi/plugins/builtins/autoreload.py +26 -0
- ipi/plugins/builtins/dataframe_display.py +21 -0
- ipi/plugins/builtins/http_user_agent.py +65 -0
- ipi/plugins/builtins/image_outputs.py +41 -0
- ipi/plugins/builtins/import_guidance.py +55 -0
- ipi/plugins/builtins/pdb.py +1029 -0
- ipi/plugins/builtins/pip_guidance.py +193 -0
- ipi/plugins/builtins/project_import.py +28 -0
- ipi/plugins/builtins/shell_history.py +152 -0
- ipi/plugins/catalog.py +387 -0
- ipi/plugins/discovery.py +1058 -0
- ipi/plugins/kernel.py +661 -0
- ipi/plugins/py.typed +0 -0
- ipi/plugins/server_deps.py +372 -0
- ipi/py.typed +1 -0
- ipi/records/__init__.py +433 -0
- ipi/records/py.typed +1 -0
- ipi/redaction.py +136 -0
- ipi/references.py +84 -0
- ipi/resource_registry.py +1386 -0
- ipi/resources/notebook_http_skill.md +8 -0
- ipi/resources/notebook_skill.md +24 -0
- ipi/resources/references/debugging.md +36 -0
- ipi/resources/references/execution-and-results.md +34 -0
- ipi/resources/references/kernel-environments.md +46 -0
- ipi/resources/references/notebook-publishing.md +55 -0
- ipi/resources/references/parallel-execution.md +40 -0
- ipi/resources/references/rich-output.md +40 -0
- ipi/runtime/__init__.py +94 -0
- ipi/runtime/_subprocess.py +233 -0
- ipi/runtime/audit.py +81 -0
- ipi/runtime/bootstrap.py +65 -0
- ipi/runtime/cell_types.py +228 -0
- ipi/runtime/concurrent.py +312 -0
- ipi/runtime/context.py +29 -0
- ipi/runtime/debugger.py +587 -0
- ipi/runtime/environment.py +72 -0
- ipi/runtime/formatters.py +78 -0
- ipi/runtime/magics.py +412 -0
- ipi/runtime/path_context.py +268 -0
- ipi/runtime/project.py +74 -0
- ipi/runtime/py.typed +0 -0
- ipi/runtime/replay.py +80 -0
- ipi/runtime/shell_history.py +458 -0
- ipi/runtime/state_delta.py +55 -0
- ipi/server/__init__.py +1 -0
- ipi/server/__main__.py +747 -0
- ipi/server/builtins/__init__.py +46 -0
- ipi/server/builtins/harness_context.py +246 -0
- ipi/server/builtins/inline_python_guidance.py +191 -0
- ipi/server/builtins/session_trace.py +436 -0
- ipi/server/builtins/shell_forwarding.py +194 -0
- ipi/server/builtins/shell_history.py +236 -0
- ipi/server/builtins/view_image.py +174 -0
- ipi/server/execution_content.py +75 -0
- ipi/server/execution_server.py +663 -0
- ipi/server/harness.py +839 -0
- ipi/server/harness_environment.py +172 -0
- ipi/server/harness_transport.py +105 -0
- ipi/server/integration.py +176 -0
- ipi/server/narration_doctor.py +340 -0
- ipi/server/notebook_channel.py +278 -0
- ipi/server/plugin_resolver.py +296 -0
- ipi/server/plugin_tools.py +208 -0
- ipi/server/py.typed +1 -0
- ipi/server/rollout_narration.py +190 -0
- ipi/server/server.py +204 -0
- ipi/session.py +373 -0
- ipi/tunnel.py +352 -0
- ipinb-0.1.0.dist-info/METADATA +438 -0
- ipinb-0.1.0.dist-info/RECORD +128 -0
- ipinb-0.1.0.dist-info/WHEEL +5 -0
- ipinb-0.1.0.dist-info/entry_points.txt +2 -0
- ipinb-0.1.0.dist-info/licenses/LICENSE +21 -0
- ipinb-0.1.0.dist-info/top_level.txt +1 -0
ipi/__init__.py
ADDED
ipi/_ansi.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""ANSI/control-sequence removal for model-facing text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
_AnsiState = Literal[
|
|
9
|
+
"text",
|
|
10
|
+
"escape",
|
|
11
|
+
"escape_intermediate",
|
|
12
|
+
"csi",
|
|
13
|
+
"osc",
|
|
14
|
+
"control_string",
|
|
15
|
+
"string_escape",
|
|
16
|
+
]
|
|
17
|
+
_ControlStringState = Literal["osc", "control_string"]
|
|
18
|
+
|
|
19
|
+
_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
|
20
|
+
_OCTAL_DIGITS = frozenset("01234567")
|
|
21
|
+
_CONTROL_STRING_ESC_STARTS = frozenset({"P", "X", "^", "_"})
|
|
22
|
+
_CONTROL_STRING_C1_STARTS = frozenset({"\x90", "\x98", "\x9e", "\x9f"})
|
|
23
|
+
_C1_RE = re.compile("[\x80-\x9f]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _SerializedControlDecoder:
|
|
27
|
+
"""Decode printable Python/JSON spellings of ESC and C1 controls."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._pending = ""
|
|
31
|
+
self._serialized_escape_width: int | None = None
|
|
32
|
+
|
|
33
|
+
def feed(self, text: str) -> str:
|
|
34
|
+
if not self._pending and self._serialized_escape_width is None and "\\" not in text:
|
|
35
|
+
return text
|
|
36
|
+
source = self._pending + text
|
|
37
|
+
self._pending = ""
|
|
38
|
+
output: list[str] = []
|
|
39
|
+
index = 0
|
|
40
|
+
|
|
41
|
+
while index < len(source):
|
|
42
|
+
slash = source.find("\\", index)
|
|
43
|
+
if slash < 0:
|
|
44
|
+
output.append(source[index:])
|
|
45
|
+
if source[index:]:
|
|
46
|
+
self._serialized_escape_width = None
|
|
47
|
+
break
|
|
48
|
+
if prefix := source[index:slash]:
|
|
49
|
+
output.append(prefix)
|
|
50
|
+
self._serialized_escape_width = None
|
|
51
|
+
|
|
52
|
+
after_slashes = slash
|
|
53
|
+
while after_slashes < len(source) and source[after_slashes] == "\\":
|
|
54
|
+
after_slashes += 1
|
|
55
|
+
if after_slashes == len(source):
|
|
56
|
+
self._pending = source[slash:]
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
consumed, control = _decode_serialized_control(source, after_slashes)
|
|
60
|
+
if consumed < 0:
|
|
61
|
+
self._pending = source[slash:]
|
|
62
|
+
break
|
|
63
|
+
slash_count = after_slashes - slash
|
|
64
|
+
if self._serialized_escape_width is not None and control is None:
|
|
65
|
+
# repr()/JSON doubles both the leading slash in a serialized ESC
|
|
66
|
+
# and the slash in its ESC + "\\" string terminator at each
|
|
67
|
+
# nesting layer. Decode only the terminator's source characters;
|
|
68
|
+
# any remaining slashes are visible text and must stay serialized.
|
|
69
|
+
terminator_width = min(slash_count, 2 * self._serialized_escape_width)
|
|
70
|
+
output.append("\\")
|
|
71
|
+
output.append("\\" * (slash_count - terminator_width))
|
|
72
|
+
self._serialized_escape_width = None
|
|
73
|
+
index = after_slashes
|
|
74
|
+
continue
|
|
75
|
+
if consumed == 0:
|
|
76
|
+
output.append(source[slash:after_slashes])
|
|
77
|
+
index = after_slashes
|
|
78
|
+
continue
|
|
79
|
+
if control is None:
|
|
80
|
+
output.append(source[slash : after_slashes + consumed])
|
|
81
|
+
else:
|
|
82
|
+
# Consume the complete slash run so nested repr()/JSON layers
|
|
83
|
+
# collapse to the one control character they represent.
|
|
84
|
+
output.append(control)
|
|
85
|
+
self._serialized_escape_width = slash_count if control == "\x1b" else None
|
|
86
|
+
index = after_slashes + consumed
|
|
87
|
+
|
|
88
|
+
return "".join(output)
|
|
89
|
+
|
|
90
|
+
def finish(self) -> str:
|
|
91
|
+
pending, self._pending = self._pending, ""
|
|
92
|
+
if self._serialized_escape_width is not None and pending and set(pending) == {"\\"}:
|
|
93
|
+
terminator_width = min(len(pending), 2 * self._serialized_escape_width)
|
|
94
|
+
pending = "\\" + "\\" * (len(pending) - terminator_width)
|
|
95
|
+
self._serialized_escape_width = None
|
|
96
|
+
return pending
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _decode_serialized_control(source: str, index: int) -> tuple[int, str | None]:
|
|
100
|
+
"""Return ``(consumed, control)``; ``consumed == -1`` means incomplete."""
|
|
101
|
+
|
|
102
|
+
lead = source[index]
|
|
103
|
+
widths = {"x": 2, "u": 4, "U": 8}
|
|
104
|
+
if (width := widths.get(lead)) is not None:
|
|
105
|
+
end = index + 1 + width
|
|
106
|
+
digits = source[index + 1 : end]
|
|
107
|
+
if end > len(source):
|
|
108
|
+
return (
|
|
109
|
+
(-1, None) if all(character in _HEX_DIGITS for character in digits) else (1, None)
|
|
110
|
+
)
|
|
111
|
+
if any(character not in _HEX_DIGITS for character in digits):
|
|
112
|
+
return 1, None
|
|
113
|
+
return 1 + width, _control_character(int(digits, 16))
|
|
114
|
+
|
|
115
|
+
if lead in "0123":
|
|
116
|
+
end = index + 3
|
|
117
|
+
digits = source[index:end]
|
|
118
|
+
if end > len(source):
|
|
119
|
+
return (
|
|
120
|
+
(-1, None) if all(character in _OCTAL_DIGITS for character in digits) else (1, None)
|
|
121
|
+
)
|
|
122
|
+
if any(character not in _OCTAL_DIGITS for character in digits):
|
|
123
|
+
return 1, None
|
|
124
|
+
return 3, _control_character(int(digits, 8))
|
|
125
|
+
|
|
126
|
+
return 0, None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _control_character(codepoint: int) -> str | None:
|
|
130
|
+
if codepoint == 0x1B or 0x80 <= codepoint <= 0x9F:
|
|
131
|
+
return chr(codepoint)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _contains_c1(text: str) -> bool:
|
|
136
|
+
return _C1_RE.search(text) is not None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _AnsiControlStripper:
|
|
140
|
+
"""Incrementally remove ECMA-48 controls while retaining visible text."""
|
|
141
|
+
|
|
142
|
+
def __init__(self) -> None:
|
|
143
|
+
self._state: _AnsiState = "text"
|
|
144
|
+
self._control_string_state: _ControlStringState | None = None
|
|
145
|
+
|
|
146
|
+
def feed(self, text: str) -> str:
|
|
147
|
+
if self._state == "text" and "\x1b" not in text and not _contains_c1(text):
|
|
148
|
+
return text
|
|
149
|
+
output: list[str] = []
|
|
150
|
+
for character in text:
|
|
151
|
+
self._consume(character, output)
|
|
152
|
+
return "".join(output)
|
|
153
|
+
|
|
154
|
+
def finish(self) -> str:
|
|
155
|
+
# An unfinished control sequence has no visible terminal representation.
|
|
156
|
+
self._state = "text"
|
|
157
|
+
self._control_string_state = None
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
def _consume(self, character: str, output: list[str]) -> None:
|
|
161
|
+
state = self._state
|
|
162
|
+
codepoint = ord(character)
|
|
163
|
+
|
|
164
|
+
if state == "text":
|
|
165
|
+
if character == "\x1b":
|
|
166
|
+
self._state = "escape"
|
|
167
|
+
elif character == "\x9b":
|
|
168
|
+
self._state = "csi"
|
|
169
|
+
elif character == "\x9d":
|
|
170
|
+
self._state = "osc"
|
|
171
|
+
elif character in _CONTROL_STRING_C1_STARTS:
|
|
172
|
+
self._state = "control_string"
|
|
173
|
+
elif not 0x80 <= codepoint <= 0x9F:
|
|
174
|
+
output.append(character)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if state == "escape":
|
|
178
|
+
if character == "[":
|
|
179
|
+
self._state = "csi"
|
|
180
|
+
elif character == "]":
|
|
181
|
+
self._state = "osc"
|
|
182
|
+
elif character in _CONTROL_STRING_ESC_STARTS:
|
|
183
|
+
self._state = "control_string"
|
|
184
|
+
elif "\x20" <= character <= "\x2f":
|
|
185
|
+
self._state = "escape_intermediate"
|
|
186
|
+
elif "\x30" <= character <= "\x7e":
|
|
187
|
+
self._state = "text"
|
|
188
|
+
elif character != "\x1b":
|
|
189
|
+
self._state = "text"
|
|
190
|
+
self._consume(character, output)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
if state == "escape_intermediate":
|
|
194
|
+
if "\x20" <= character <= "\x2f":
|
|
195
|
+
return
|
|
196
|
+
self._state = "text"
|
|
197
|
+
if not "\x30" <= character <= "\x7e":
|
|
198
|
+
self._consume(character, output)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if state == "csi":
|
|
202
|
+
if "\x30" <= character <= "\x3f" or "\x20" <= character <= "\x2f":
|
|
203
|
+
return
|
|
204
|
+
if "\x40" <= character <= "\x7e":
|
|
205
|
+
self._state = "text"
|
|
206
|
+
elif character == "\x1b":
|
|
207
|
+
self._state = "escape"
|
|
208
|
+
else:
|
|
209
|
+
self._state = "text"
|
|
210
|
+
self._consume(character, output)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
if state in {"osc", "control_string"}:
|
|
214
|
+
if character == "\x9c" or (state == "osc" and character == "\x07"):
|
|
215
|
+
self._state = "text"
|
|
216
|
+
elif character == "\x1b":
|
|
217
|
+
self._control_string_state = cast(_ControlStringState, state)
|
|
218
|
+
self._state = "string_escape"
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if state == "string_escape":
|
|
222
|
+
if character in {"\\", "\x9c"}:
|
|
223
|
+
self._state = "text"
|
|
224
|
+
self._control_string_state = None
|
|
225
|
+
elif character != "\x1b":
|
|
226
|
+
self._state = self._control_string_state or "control_string"
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
raise AssertionError(f"unknown ANSI parser state: {state}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class AnsiSanitizer:
|
|
233
|
+
"""Incrementally strip actual and serialized ANSI control sequences."""
|
|
234
|
+
|
|
235
|
+
def __init__(self) -> None:
|
|
236
|
+
self._decoder = _SerializedControlDecoder()
|
|
237
|
+
self._stripper = _AnsiControlStripper()
|
|
238
|
+
|
|
239
|
+
def feed(self, text: str) -> str:
|
|
240
|
+
"""Consume one text chunk and return currently complete visible text."""
|
|
241
|
+
|
|
242
|
+
return self._stripper.feed(self._decoder.feed(text))
|
|
243
|
+
|
|
244
|
+
def finish(self) -> str:
|
|
245
|
+
"""Finish the stream, preserving incomplete plain text and dropping controls."""
|
|
246
|
+
|
|
247
|
+
return self._stripper.feed(self._decoder.finish()) + self._stripper.finish()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def strip_ansi(text: str) -> str:
|
|
251
|
+
"""Remove actual or serialized ANSI controls from complete text."""
|
|
252
|
+
|
|
253
|
+
sanitizer = AnsiSanitizer()
|
|
254
|
+
return sanitizer.feed(text) + sanitizer.finish()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def strip_ansi_deep(value: Any) -> Any:
|
|
258
|
+
"""Recursively strip ANSI controls from string keys and values in JSON-like data."""
|
|
259
|
+
|
|
260
|
+
if isinstance(value, str):
|
|
261
|
+
return strip_ansi(value)
|
|
262
|
+
if isinstance(value, list):
|
|
263
|
+
return [strip_ansi_deep(item) for item in cast(list[Any], value)]
|
|
264
|
+
if isinstance(value, tuple):
|
|
265
|
+
return tuple(strip_ansi_deep(item) for item in cast(tuple[Any, ...], value))
|
|
266
|
+
if isinstance(value, dict):
|
|
267
|
+
return {
|
|
268
|
+
strip_ansi(key) if isinstance(key, str) else key: strip_ansi_deep(item)
|
|
269
|
+
for key, item in cast(dict[Any, Any], value).items()
|
|
270
|
+
}
|
|
271
|
+
return value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
__all__ = ["AnsiSanitizer", "strip_ansi", "strip_ansi_deep"]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Dependency metadata for local plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
13
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
14
|
+
from packaging.version import Version
|
|
15
|
+
|
|
16
|
+
from ipi._toml import TomlParseError, read_toml, read_toml_text
|
|
17
|
+
|
|
18
|
+
# The canonical PEP 723 block shape, restricted to standardized ``script``
|
|
19
|
+
# blocks when extracting dependency metadata.
|
|
20
|
+
_PEP723_BLOCK = re.compile(
|
|
21
|
+
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)[ \t]*$[\r\n]"
|
|
22
|
+
r"(?P<content>(?:^#(?:| .*)$[\r\n]?)+)^# ///[ \t]*$"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DependencyMetadataError(ValueError):
|
|
27
|
+
"""A Python source's dependency declaration is invalid."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class DependencyDeclaration:
|
|
32
|
+
"""Validated dependencies declared by one Python source."""
|
|
33
|
+
|
|
34
|
+
requirements: tuple[str, ...]
|
|
35
|
+
requires_python: str | None
|
|
36
|
+
source: str
|
|
37
|
+
|
|
38
|
+
def __bool__(self) -> bool:
|
|
39
|
+
return bool(self.requirements)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def script_dependencies(module_path: Path) -> DependencyDeclaration:
|
|
43
|
+
"""Read PEP 723 dependency metadata from one Python file."""
|
|
44
|
+
|
|
45
|
+
source = str(module_path)
|
|
46
|
+
try:
|
|
47
|
+
text = module_path.read_text(encoding="utf-8")
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
raise DependencyMetadataError(f"could not read Python source {source}: {exc}") from exc
|
|
50
|
+
|
|
51
|
+
blocks = [match for match in _PEP723_BLOCK.finditer(text) if match.group("type") == "script"]
|
|
52
|
+
if not blocks:
|
|
53
|
+
return DependencyDeclaration((), None, source)
|
|
54
|
+
if len(blocks) > 1:
|
|
55
|
+
raise DependencyMetadataError(
|
|
56
|
+
f"Python source {source} contains more than one PEP 723 'script' block"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
content = "\n".join(
|
|
60
|
+
line[2:] if line.startswith("# ") else line[1:]
|
|
61
|
+
for line in blocks[0].group("content").splitlines()
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
data = read_toml_text(content)
|
|
65
|
+
except TomlParseError as exc:
|
|
66
|
+
raise DependencyMetadataError(
|
|
67
|
+
f"Python source {source} has an invalid PEP 723 'script' block: {exc}"
|
|
68
|
+
) from exc
|
|
69
|
+
if data is None:
|
|
70
|
+
raise DependencyMetadataError(
|
|
71
|
+
f"Python source {source} has a PEP 723 'script' block that is not a TOML table"
|
|
72
|
+
)
|
|
73
|
+
return _declaration_from_table(data, source=f"{source} (PEP 723 script block)")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def project_dependencies(pyproject_path: Path) -> DependencyDeclaration:
|
|
77
|
+
"""Read ``[project]`` dependencies from one adjacent ``pyproject.toml``."""
|
|
78
|
+
|
|
79
|
+
source = str(pyproject_path)
|
|
80
|
+
try:
|
|
81
|
+
data = read_toml(pyproject_path)
|
|
82
|
+
except TomlParseError as exc:
|
|
83
|
+
raise DependencyMetadataError(f"invalid pyproject at {source}: {exc}") from exc
|
|
84
|
+
if data is None:
|
|
85
|
+
return DependencyDeclaration((), None, source)
|
|
86
|
+
raw_project = data.get("project")
|
|
87
|
+
if raw_project is None:
|
|
88
|
+
return DependencyDeclaration((), None, source)
|
|
89
|
+
if not isinstance(raw_project, Mapping):
|
|
90
|
+
raise DependencyMetadataError(f"{source}: [project] must be a table")
|
|
91
|
+
return _declaration_from_table(cast(Mapping[str, object], raw_project), source=source)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _declaration_from_table(
|
|
95
|
+
table: Mapping[str, object],
|
|
96
|
+
*,
|
|
97
|
+
source: str,
|
|
98
|
+
) -> DependencyDeclaration:
|
|
99
|
+
raw_dependencies = table.get("dependencies", [])
|
|
100
|
+
if not isinstance(raw_dependencies, list):
|
|
101
|
+
raise DependencyMetadataError(f"{source}: 'dependencies' must be a list of strings")
|
|
102
|
+
|
|
103
|
+
requirements: list[str] = []
|
|
104
|
+
for raw in cast(list[object], raw_dependencies):
|
|
105
|
+
if not isinstance(raw, str):
|
|
106
|
+
raise DependencyMetadataError(f"{source}: 'dependencies' must be a list of strings")
|
|
107
|
+
requirement_text = raw.strip()
|
|
108
|
+
if not requirement_text:
|
|
109
|
+
raise DependencyMetadataError(f"{source}: dependency entries must be nonempty")
|
|
110
|
+
try:
|
|
111
|
+
Requirement(requirement_text)
|
|
112
|
+
except InvalidRequirement as exc:
|
|
113
|
+
raise DependencyMetadataError(
|
|
114
|
+
f"{source}: invalid dependency requirement {requirement_text!r}: {exc}"
|
|
115
|
+
) from exc
|
|
116
|
+
requirements.append(requirement_text)
|
|
117
|
+
|
|
118
|
+
requires_python: str | None = None
|
|
119
|
+
raw_requires_python = table.get("requires-python")
|
|
120
|
+
if raw_requires_python is not None:
|
|
121
|
+
if not isinstance(raw_requires_python, str):
|
|
122
|
+
raise DependencyMetadataError(f"{source}: 'requires-python' must be a string")
|
|
123
|
+
requires_python = raw_requires_python.strip()
|
|
124
|
+
_require_compatible_python(requires_python, source=source)
|
|
125
|
+
|
|
126
|
+
return DependencyDeclaration(tuple(requirements), requires_python, source)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _require_compatible_python(specifier_text: str, *, source: str) -> None:
|
|
130
|
+
try:
|
|
131
|
+
specifier = SpecifierSet(specifier_text)
|
|
132
|
+
except InvalidSpecifier as exc:
|
|
133
|
+
raise DependencyMetadataError(
|
|
134
|
+
f"{source}: invalid 'requires-python' specifier {specifier_text!r}"
|
|
135
|
+
) from exc
|
|
136
|
+
interpreter = Version(".".join(str(part) for part in sys.version_info[:3]))
|
|
137
|
+
if not specifier.contains(interpreter, prereleases=True):
|
|
138
|
+
raise DependencyMetadataError(
|
|
139
|
+
f"{source}: 'requires-python' {specifier_text!r} does not admit the running "
|
|
140
|
+
f"interpreter {interpreter}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = [
|
|
145
|
+
"DependencyDeclaration",
|
|
146
|
+
"DependencyMetadataError",
|
|
147
|
+
"project_dependencies",
|
|
148
|
+
"script_dependencies",
|
|
149
|
+
]
|
ipi/_exception_groups.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Python 3.10/3.11-compatible exception-group types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if sys.version_info < (3, 11):
|
|
8
|
+
from exceptiongroup import BaseExceptionGroup, ExceptionGroup
|
|
9
|
+
else:
|
|
10
|
+
from builtins import BaseExceptionGroup, ExceptionGroup
|
|
11
|
+
|
|
12
|
+
__all__ = ["BaseExceptionGroup", "ExceptionGroup"]
|
ipi/_git_provenance.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Credential-aware, immutable Git source reconstruction from PEP 610 metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import ParseResult, urldefrag, urlparse
|
|
6
|
+
|
|
7
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def authenticated_git_source(
|
|
11
|
+
metadata_url: str,
|
|
12
|
+
*,
|
|
13
|
+
configured_requirement: str | None,
|
|
14
|
+
configured_name: str,
|
|
15
|
+
missing_requirement_name: str,
|
|
16
|
+
purpose: str,
|
|
17
|
+
repository_label: str = "installed IPi Git repository",
|
|
18
|
+
install_subject: str = "IPi",
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Recover credentials only from a configured requirement for the same repository."""
|
|
21
|
+
|
|
22
|
+
if configured_requirement is None:
|
|
23
|
+
if has_redacted_credentials(metadata_url):
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f"{purpose} cannot use Git provenance with redacted credentials. Set "
|
|
26
|
+
f"{missing_requirement_name} to the authenticated Git requirement used "
|
|
27
|
+
f"to install {install_subject}."
|
|
28
|
+
)
|
|
29
|
+
return metadata_url
|
|
30
|
+
|
|
31
|
+
candidate = requirement_url(configured_requirement)
|
|
32
|
+
if candidate is None or not same_git_repository(candidate, metadata_url):
|
|
33
|
+
raise RuntimeError(f"{configured_name} does not select the {repository_label}")
|
|
34
|
+
return candidate
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def requirement_url(value: str) -> str | None:
|
|
38
|
+
"""Extract a PEP 508 URL, accepting a bare VCS URL for launcher compatibility."""
|
|
39
|
+
|
|
40
|
+
text = value.strip()
|
|
41
|
+
try:
|
|
42
|
+
requirement = Requirement(text)
|
|
43
|
+
except InvalidRequirement:
|
|
44
|
+
requirement = None
|
|
45
|
+
if requirement is not None:
|
|
46
|
+
return requirement.url
|
|
47
|
+
return text if urlparse(text).scheme.casefold().startswith("git+") else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def has_redacted_credentials(url: str) -> bool:
|
|
51
|
+
"""Return whether URL user information contains uv's credential redaction marker."""
|
|
52
|
+
|
|
53
|
+
parsed = urlparse(url)
|
|
54
|
+
userinfo = parsed.netloc.rpartition("@")[0] if "@" in parsed.netloc else ""
|
|
55
|
+
return "****" in userinfo
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def strip_redacted_git_credentials(url: str) -> str:
|
|
59
|
+
"""Remove invalid redacted user information while preserving the repository URL."""
|
|
60
|
+
|
|
61
|
+
if not has_redacted_credentials(url):
|
|
62
|
+
return url
|
|
63
|
+
parsed = urlparse(url)
|
|
64
|
+
return parsed._replace(netloc=credential_free_netloc(parsed)).geturl()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pin_git_source(url: str, commit: str) -> str:
|
|
68
|
+
"""Return a Git requirement URL pinned to an immutable commit."""
|
|
69
|
+
|
|
70
|
+
source, _fragment = urldefrag(url)
|
|
71
|
+
if not source.startswith("git+"):
|
|
72
|
+
source = f"git+{source}"
|
|
73
|
+
parsed = urlparse(source)
|
|
74
|
+
path, separator, revision = parsed.path.rpartition("@")
|
|
75
|
+
if separator and revision:
|
|
76
|
+
parsed = parsed._replace(path=path)
|
|
77
|
+
return f"{parsed.geturl()}@{commit}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def same_git_repository(left: str, right: str) -> bool:
|
|
81
|
+
"""Compare Git URLs without credentials, revisions, or fragments."""
|
|
82
|
+
|
|
83
|
+
return git_repository_key(left) == git_repository_key(right)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def git_repository_key(url: str) -> tuple[str, str, str, str]:
|
|
87
|
+
"""Return a credential-free repository identity suitable for equality checks."""
|
|
88
|
+
|
|
89
|
+
source, _fragment = urldefrag(url)
|
|
90
|
+
if not source.startswith("git+"):
|
|
91
|
+
source = f"git+{source}"
|
|
92
|
+
parsed = urlparse(source)
|
|
93
|
+
vcs, separator, transport = parsed.scheme.partition("+")
|
|
94
|
+
if vcs.casefold() != "git" or not separator or not transport:
|
|
95
|
+
raise RuntimeError("IPi installation requires a Git source URL")
|
|
96
|
+
path, revision_separator, revision = parsed.path.rpartition("@")
|
|
97
|
+
if revision_separator and revision:
|
|
98
|
+
parsed = parsed._replace(path=path)
|
|
99
|
+
return (
|
|
100
|
+
transport.casefold(),
|
|
101
|
+
credential_free_netloc(parsed).casefold(),
|
|
102
|
+
parsed.path.rstrip("/"),
|
|
103
|
+
parsed.query,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def credential_free_netloc(parsed: ParseResult) -> str:
|
|
108
|
+
"""Remove URL user information without changing the host or port."""
|
|
109
|
+
|
|
110
|
+
return parsed.netloc.rpartition("@")[2] if "@" in parsed.netloc else parsed.netloc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = [
|
|
114
|
+
"authenticated_git_source",
|
|
115
|
+
"credential_free_netloc",
|
|
116
|
+
"git_repository_key",
|
|
117
|
+
"has_redacted_credentials",
|
|
118
|
+
"pin_git_source",
|
|
119
|
+
"requirement_url",
|
|
120
|
+
"same_git_repository",
|
|
121
|
+
"strip_redacted_git_credentials",
|
|
122
|
+
]
|
ipi/_kernel_argv.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Kernel command-line helpers shared by direct and Jupyter launches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_CONNECTION_FILE_PLACEHOLDER = "{connection_file}"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_ipykernel_argv(
|
|
9
|
+
*,
|
|
10
|
+
python_argv: list[str],
|
|
11
|
+
disable_standard_streams: bool,
|
|
12
|
+
connection_file: str = _CONNECTION_FILE_PLACEHOLDER,
|
|
13
|
+
) -> list[str]:
|
|
14
|
+
argv = [
|
|
15
|
+
*python_argv,
|
|
16
|
+
"-m",
|
|
17
|
+
"ipykernel_launcher",
|
|
18
|
+
"-f",
|
|
19
|
+
connection_file,
|
|
20
|
+
"--HistoryManager.hist_file=:memory:",
|
|
21
|
+
]
|
|
22
|
+
if disable_standard_streams:
|
|
23
|
+
argv.extend(["--no-stdout", "--no-stderr"])
|
|
24
|
+
argv.append("--colors=NoColor")
|
|
25
|
+
return argv
|
ipi/_kernel_launcher.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Kernel launcher wrapper with parent-death cleanup and optional log capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
from ipi._parent_process import set_parent_death_signal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_args(
|
|
14
|
+
argv: list[str] | None = None,
|
|
15
|
+
) -> tuple[int | None, bool, Path | None, Path | None, list[str]]:
|
|
16
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
17
|
+
parser.add_argument("--parent-pid", type=int)
|
|
18
|
+
parser.add_argument("--parent-death-signal", action="store_true")
|
|
19
|
+
parser.add_argument("--stdout-path", type=Path)
|
|
20
|
+
parser.add_argument("--stderr-path", type=Path)
|
|
21
|
+
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
22
|
+
ns = parser.parse_args(argv)
|
|
23
|
+
|
|
24
|
+
command = list(ns.command)
|
|
25
|
+
if command[:1] == ["--"]:
|
|
26
|
+
command = command[1:]
|
|
27
|
+
if not command:
|
|
28
|
+
raise SystemExit("kernel launcher requires a command after '--'")
|
|
29
|
+
return ns.parent_pid, ns.parent_death_signal, ns.stdout_path, ns.stderr_path, command
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _redirect_fd(fd: int, path: Path) -> None:
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
handle_fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
35
|
+
os.dup2(handle_fd, fd)
|
|
36
|
+
os.close(handle_fd)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _exec(command: list[str]) -> NoReturn:
|
|
40
|
+
os.execvpe(command[0], command, os.environ.copy())
|
|
41
|
+
raise AssertionError("os.execvpe should never return")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
parent_pid, parent_death_signal, stdout_path, stderr_path, command = _parse_args(argv)
|
|
46
|
+
if stdout_path is not None:
|
|
47
|
+
_redirect_fd(1, stdout_path)
|
|
48
|
+
if stderr_path is not None:
|
|
49
|
+
_redirect_fd(2, stderr_path)
|
|
50
|
+
if parent_pid is not None or parent_death_signal:
|
|
51
|
+
set_parent_death_signal(parent_pid)
|
|
52
|
+
_exec(command)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
raise SystemExit(main())
|
ipi/_launch_gate.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Per-owner abort signal for in-flight kernel launches.
|
|
2
|
+
|
|
3
|
+
Kernel launches run inside executor threads that the interpreter joins at
|
|
4
|
+
exit. A cold launch (fresh uv environment) can legitimately spend up to the
|
|
5
|
+
kernel ready timeout waiting, so a closing runtime owner triggers its signal
|
|
6
|
+
to make those waits abort promptly; launch abandonment then closes the
|
|
7
|
+
half-started kernel processes before the owner finishes exiting. The signal
|
|
8
|
+
is owner-scoped (not process-global) so one server's shutdown cannot poison
|
|
9
|
+
unrelated kernel launches in the same process, and it lives in its own module
|
|
10
|
+
so the (deliberately lazy-imported) kernel machinery and the MCP server can
|
|
11
|
+
share it without the server paying the kernel import chain.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LaunchAbort:
|
|
20
|
+
"""Latched abort signal owned by one runtime manager."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._event = threading.Event()
|
|
24
|
+
|
|
25
|
+
def trigger(self) -> None:
|
|
26
|
+
self._event.set()
|
|
27
|
+
|
|
28
|
+
def triggered(self) -> bool:
|
|
29
|
+
return self._event.is_set()
|
ipi/_markdown.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Small Markdown rendering helpers shared by notebook artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from html import escape
|
|
7
|
+
|
|
8
|
+
_INLINE_ESCAPE_RE = re.compile(r"([\\`*_{}\[\]()#+.!|>$~-])")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def escape_markdown_inline(value: str) -> str:
|
|
12
|
+
"""Render arbitrary text literally in an inline Markdown context."""
|
|
13
|
+
|
|
14
|
+
return _INLINE_ESCAPE_RE.sub(r"\\\1", escape(value, quote=False))
|