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.
Files changed (128) hide show
  1. ipi/__init__.py +5 -0
  2. ipi/_ansi.py +274 -0
  3. ipi/_dependency_metadata.py +149 -0
  4. ipi/_exception_groups.py +12 -0
  5. ipi/_git_provenance.py +122 -0
  6. ipi/_kernel_argv.py +25 -0
  7. ipi/_kernel_launcher.py +56 -0
  8. ipi/_launch_gate.py +29 -0
  9. ipi/_markdown.py +14 -0
  10. ipi/_model_output.py +287 -0
  11. ipi/_names.py +16 -0
  12. ipi/_notebook_mirror.py +284 -0
  13. ipi/_owner_watchdog.py +44 -0
  14. ipi/_parent_process.py +39 -0
  15. ipi/_platform_support.py +30 -0
  16. ipi/_self_wheel.py +139 -0
  17. ipi/_timing.py +34 -0
  18. ipi/_toml.py +67 -0
  19. ipi/_transport/__init__.py +41 -0
  20. ipi/_transport/address.py +190 -0
  21. ipi/_transport/artifacts.py +23 -0
  22. ipi/_transport/contract.py +125 -0
  23. ipi/_transport/local.py +268 -0
  24. ipi/_transport/master_owner.py +98 -0
  25. ipi/_transport/owned_directory.py +163 -0
  26. ipi/_transport/portable_uv.py +158 -0
  27. ipi/_transport/ssh.py +1816 -0
  28. ipi/_transport/staging.py +135 -0
  29. ipi/_transport/supervisor.py +817 -0
  30. ipi/_transport/supervisor_protocol.py +8 -0
  31. ipi/_ynotebook_ops.py +235 -0
  32. ipi/cell_export.py +478 -0
  33. ipi/config.py +376 -0
  34. ipi/execution_backend.py +305 -0
  35. ipi/execution_models.py +193 -0
  36. ipi/formatting.py +699 -0
  37. ipi/jupyter_server.py +207 -0
  38. ipi/kernel.py +7252 -0
  39. ipi/labextensions/@ipi/notebook-extension/package.json +52 -0
  40. ipi/labextensions/@ipi/notebook-extension/static/585.5ce41466a93651634fda.js +1 -0
  41. ipi/labextensions/@ipi/notebook-extension/static/665.bfdd2e9a96724c3cb559.js +1 -0
  42. ipi/labextensions/@ipi/notebook-extension/static/remoteEntry.e0b57e0b0817ceee899e.js +1 -0
  43. ipi/labextensions/@ipi/notebook-extension/static/style.js +4 -0
  44. ipi/labextensions/@ipi/notebook-extension/static/third-party-licenses.json +16 -0
  45. ipi/notebook_publisher.py +646 -0
  46. ipi/notebook_trace.py +47 -0
  47. ipi/plugins/__init__.py +47 -0
  48. ipi/plugins/_model.py +1119 -0
  49. ipi/plugins/api.py +568 -0
  50. ipi/plugins/builtins/__init__.py +47 -0
  51. ipi/plugins/builtins/_agents_md_impl.py +100 -0
  52. ipi/plugins/builtins/_image_outputs_impl.py +176 -0
  53. ipi/plugins/builtins/agents_md.py +25 -0
  54. ipi/plugins/builtins/autoreload.py +26 -0
  55. ipi/plugins/builtins/dataframe_display.py +21 -0
  56. ipi/plugins/builtins/http_user_agent.py +65 -0
  57. ipi/plugins/builtins/image_outputs.py +41 -0
  58. ipi/plugins/builtins/import_guidance.py +55 -0
  59. ipi/plugins/builtins/pdb.py +1029 -0
  60. ipi/plugins/builtins/pip_guidance.py +193 -0
  61. ipi/plugins/builtins/project_import.py +28 -0
  62. ipi/plugins/builtins/shell_history.py +152 -0
  63. ipi/plugins/catalog.py +387 -0
  64. ipi/plugins/discovery.py +1058 -0
  65. ipi/plugins/kernel.py +661 -0
  66. ipi/plugins/py.typed +0 -0
  67. ipi/plugins/server_deps.py +372 -0
  68. ipi/py.typed +1 -0
  69. ipi/records/__init__.py +433 -0
  70. ipi/records/py.typed +1 -0
  71. ipi/redaction.py +136 -0
  72. ipi/references.py +84 -0
  73. ipi/resource_registry.py +1386 -0
  74. ipi/resources/notebook_http_skill.md +8 -0
  75. ipi/resources/notebook_skill.md +24 -0
  76. ipi/resources/references/debugging.md +36 -0
  77. ipi/resources/references/execution-and-results.md +34 -0
  78. ipi/resources/references/kernel-environments.md +46 -0
  79. ipi/resources/references/notebook-publishing.md +55 -0
  80. ipi/resources/references/parallel-execution.md +40 -0
  81. ipi/resources/references/rich-output.md +40 -0
  82. ipi/runtime/__init__.py +94 -0
  83. ipi/runtime/_subprocess.py +233 -0
  84. ipi/runtime/audit.py +81 -0
  85. ipi/runtime/bootstrap.py +65 -0
  86. ipi/runtime/cell_types.py +228 -0
  87. ipi/runtime/concurrent.py +312 -0
  88. ipi/runtime/context.py +29 -0
  89. ipi/runtime/debugger.py +587 -0
  90. ipi/runtime/environment.py +72 -0
  91. ipi/runtime/formatters.py +78 -0
  92. ipi/runtime/magics.py +412 -0
  93. ipi/runtime/path_context.py +268 -0
  94. ipi/runtime/project.py +74 -0
  95. ipi/runtime/py.typed +0 -0
  96. ipi/runtime/replay.py +80 -0
  97. ipi/runtime/shell_history.py +458 -0
  98. ipi/runtime/state_delta.py +55 -0
  99. ipi/server/__init__.py +1 -0
  100. ipi/server/__main__.py +747 -0
  101. ipi/server/builtins/__init__.py +46 -0
  102. ipi/server/builtins/harness_context.py +246 -0
  103. ipi/server/builtins/inline_python_guidance.py +191 -0
  104. ipi/server/builtins/session_trace.py +436 -0
  105. ipi/server/builtins/shell_forwarding.py +194 -0
  106. ipi/server/builtins/shell_history.py +236 -0
  107. ipi/server/builtins/view_image.py +174 -0
  108. ipi/server/execution_content.py +75 -0
  109. ipi/server/execution_server.py +663 -0
  110. ipi/server/harness.py +839 -0
  111. ipi/server/harness_environment.py +172 -0
  112. ipi/server/harness_transport.py +105 -0
  113. ipi/server/integration.py +176 -0
  114. ipi/server/narration_doctor.py +340 -0
  115. ipi/server/notebook_channel.py +278 -0
  116. ipi/server/plugin_resolver.py +296 -0
  117. ipi/server/plugin_tools.py +208 -0
  118. ipi/server/py.typed +1 -0
  119. ipi/server/rollout_narration.py +190 -0
  120. ipi/server/server.py +204 -0
  121. ipi/session.py +373 -0
  122. ipi/tunnel.py +352 -0
  123. ipinb-0.1.0.dist-info/METADATA +438 -0
  124. ipinb-0.1.0.dist-info/RECORD +128 -0
  125. ipinb-0.1.0.dist-info/WHEEL +5 -0
  126. ipinb-0.1.0.dist-info/entry_points.txt +2 -0
  127. ipinb-0.1.0.dist-info/licenses/LICENSE +21 -0
  128. ipinb-0.1.0.dist-info/top_level.txt +1 -0
ipi/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """IPi: persistent IPython kernels for MCP agents."""
2
+
3
+ from toolfuncs import import_path
4
+
5
+ __all__ = ("import_path",)
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
+ ]
@@ -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
@@ -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))