partial-python 0.1.0a1__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.
@@ -0,0 +1,7 @@
1
+ """Safe syntactic boundaries for streaming Python."""
2
+
3
+ from .executor import Execution, Executor, ExecutorState, ParserState, UnsupportedSyntaxError, incremental_exec
4
+ from .parser import CodeUnit, Parser
5
+ from .preview import Allow, Completion, complete
6
+
7
+ __all__ = ["Allow", "CodeUnit", "Completion", "Execution", "Executor", "ExecutorState", "Parser", "ParserState", "UnsupportedSyntaxError", "complete", "incremental_exec"]
@@ -0,0 +1,358 @@
1
+ """Sequential execution of original source under explicit interactive semantics."""
2
+
3
+ import __future__
4
+
5
+ import ast
6
+ import asyncio
7
+ import inspect
8
+ import sys
9
+ from collections import deque
10
+ from collections.abc import AsyncGenerator, AsyncIterable
11
+ from contextlib import aclosing
12
+ from dataclasses import dataclass
13
+ from time import monotonic
14
+ from types import CodeType
15
+ from typing import Any, Literal, cast
16
+
17
+ from .parser import _FUTURES, CodeUnit, Parser, _source_line, _tree
18
+
19
+
20
+ def _character_column(text: str, byte_column: int | None) -> int | None:
21
+ if byte_column is None or byte_column <= 0:
22
+ return byte_column
23
+ return len(text.encode()[: byte_column - 1].decode(errors="replace")) + 1
24
+
25
+
26
+ ExecutorState = Literal["open", "finished", "failed", "aborted"]
27
+
28
+
29
+ class UnsupportedSyntaxError(SyntaxError):
30
+ """Syntax whose cross-unit semantics cannot be preserved by this executor."""
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ParserState:
35
+ """Immutable parser snapshot; read ``executor.parser_state`` again for current values.
36
+
37
+ ``committed`` counts source characters sealed by parsing, not executed code.
38
+ ``closed`` records parser EOF, not the executor's terminal state.
39
+ """
40
+
41
+ pending: str
42
+ committed: int
43
+ closed: bool
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Execution:
48
+ """One successfully completed unit, with zero-based index and monotonic-second timestamps.
49
+
50
+ Timing includes compilation and scheduling; an absent event does not prove
51
+ that a failed or cancelled unit had no effects.
52
+ """
53
+
54
+ index: int
55
+ unit: CodeUnit
56
+ started_at: float
57
+ completed_at: float
58
+
59
+
60
+ class _ModuleScope:
61
+ def __init__(self) -> None:
62
+ self.annotations: list[ast.AnnAssign] = []
63
+ self.globals: list[ast.Global] = []
64
+
65
+ def visit(self, tree: ast.AST) -> None:
66
+ pending = [tree]
67
+ while pending:
68
+ node = pending.pop()
69
+ # Declarations inside another scope cannot affect earlier units.
70
+ # Expressions cannot contain either declaration; skipping them also
71
+ # avoids Python recursion limits on otherwise valid deep expressions.
72
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.expr)):
73
+ continue
74
+ if isinstance(node, ast.AnnAssign):
75
+ # Even non-simple targets can reset deferred module metadata on
76
+ # 3.14+, despite not creating a new __annotate__ function themselves.
77
+ self.annotations.append(node)
78
+ elif isinstance(node, ast.Global):
79
+ self.globals.append(node)
80
+ else:
81
+ pending.extend(reversed(list(ast.iter_child_nodes(node))))
82
+
83
+
84
+ class Executor:
85
+ """One append-only execution stream sharing a caller-owned global namespace.
86
+
87
+ Failure and cancellation are terminal: earlier effects remain, and accepted
88
+ statements are never retried. This is an in-process executor, not a sandbox.
89
+ Concurrent or recursive writes are rejected rather than silently reordered.
90
+ """
91
+
92
+ def __init__(self, namespace: dict[str, Any] | None = None, *, filename: str = "<partial-python>"):
93
+ self._namespace = namespace if namespace is not None else {"__name__": "__main__"}
94
+ self._parser = Parser(filename=filename)
95
+ self._events: list[Execution] = []
96
+ self._flags = ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
97
+ self._prologue = True
98
+ self._first = True
99
+ self._busy = False
100
+ self._state: ExecutorState = "open"
101
+ self._failed_unit: CodeUnit | None = None
102
+ self._error: BaseException | None = None
103
+ self._queued: deque[CodeUnit] = deque()
104
+
105
+ @property
106
+ def namespace(self) -> dict[str, Any]:
107
+ return self._namespace
108
+
109
+ @property
110
+ def error(self) -> BaseException | None:
111
+ return self._error
112
+
113
+ @property
114
+ def failed_unit(self) -> CodeUnit | None:
115
+ return self._failed_unit
116
+
117
+ @property
118
+ def parser_state(self) -> ParserState:
119
+ """Snapshot of parsing progress, separate from completed execution events."""
120
+ return ParserState(self._parser.pending, self._parser.committed, self._parser.closed)
121
+
122
+ @property
123
+ def queued(self) -> tuple[CodeUnit, ...]:
124
+ """Sealed units not yet started; excludes the unsealed ``parser_state.pending`` tail."""
125
+ return tuple(self._queued)
126
+
127
+ @property
128
+ def events(self) -> tuple[Execution, ...]:
129
+ return tuple(self._events)
130
+
131
+ @property
132
+ def state(self) -> ExecutorState:
133
+ return self._state
134
+
135
+ def _enter(self) -> None:
136
+ if self._busy:
137
+ raise RuntimeError("executor already has an active operation")
138
+ if self._state != "open":
139
+ raise RuntimeError(f"executor is {self._state}")
140
+ self._busy = True
141
+
142
+ def _syntax_error(self, error_type: type[SyntaxError], message: str, statement: CodeUnit, node: ast.stmt, *, shifted: bool = False) -> SyntaxError:
143
+ line = node.lineno - (statement.start_line - 1 if shifted else 0)
144
+ text = _source_line(statement.source, line)
145
+ column = _character_column(text, node.col_offset + 1)
146
+ return error_type(message, (self._parser.filename, statement.start_line + line - 1, column, text))
147
+
148
+ def _compile(self, statement: CodeUnit) -> CodeType:
149
+ tree = _tree(statement.source, self._parser.filename, self._flags, statement.start_line)
150
+ scope = _ModuleScope()
151
+ scope.visit(tree)
152
+ if scope.globals:
153
+ raise self._syntax_error(UnsupportedSyntaxError, "module-scope global declarations are unsupported across incremental compilation units", statement, scope.globals[0])
154
+ future = [node for node in tree.body if isinstance(node, ast.ImportFrom) and node.module == "__future__"]
155
+ if future and not self._prologue:
156
+ raise self._syntax_error(SyntaxError, "from __future__ imports must occur at the beginning of the stream", statement, future[0])
157
+ docstring = bool(tree.body and isinstance(tree.body[0], ast.Expr) and isinstance(tree.body[0].value, ast.Constant) and isinstance(tree.body[0].value.value, str))
158
+ if docstring and not self._first:
159
+ # Each code object otherwise treats its first string as a new module docstring.
160
+ tree.body.insert(0, ast.copy_location(ast.Pass(), tree.body[0]))
161
+ ast.increment_lineno(tree, statement.start_line - 1)
162
+ try:
163
+ code = compile(tree, self._parser.filename, "exec", flags=self._flags, dont_inherit=True)
164
+ except SyntaxError as error:
165
+ if error.lineno is not None:
166
+ error.text = _source_line(statement.source, error.lineno - statement.start_line + 1)
167
+ # AST compiler ranges are UTF-8 byte columns. Once source text
168
+ # is attached, convert both endpoints for diagnostic renderers.
169
+ error.offset = _character_column(error.text, error.offset)
170
+ if error.end_lineno is not None:
171
+ end_text = _source_line(statement.source, error.end_lineno - statement.start_line + 1)
172
+ error.end_offset = _character_column(end_text, error.end_offset)
173
+ error.args = (error.msg, (self._parser.filename, error.lineno, error.offset, error.text, error.end_lineno, error.end_offset))
174
+ raise
175
+ if sys.version_info >= (3, 14) and scope.annotations and not code.co_flags & __future__.annotations.compiler_flag:
176
+ raise self._syntax_error(
177
+ UnsupportedSyntaxError, "incremental module annotations require `from __future__ import annotations` on Python 3.14+", statement, scope.annotations[0], shifted=True
178
+ )
179
+ # Read feature requests from this unit, not code-object bits such as
180
+ # CO_NESTED that overlap legacy future flags. Never borrow future flags
181
+ # from later queued units accepted by the parser in the same feed.
182
+ for future_node in future:
183
+ for name in future_node.names:
184
+ self._flags |= _FUTURES.get(name.name, 0)
185
+ for index, node in enumerate(tree.body):
186
+ if isinstance(node, ast.ImportFrom) and node.module == "__future__":
187
+ continue
188
+ if self._first and index == 0 and docstring:
189
+ continue
190
+ self._prologue = False
191
+ break
192
+ if tree.body:
193
+ self._first = False
194
+ return cast(CodeType, code)
195
+
196
+ async def _run(self, statement: CodeUnit) -> Execution:
197
+ started = monotonic()
198
+ try:
199
+ task = asyncio.current_task()
200
+ cancellations = task.cancelling() if task is not None else 0
201
+ # Deliver an already-pending cancellation before running user code.
202
+ # A historical caught cancellation count alone does not poison a new operation.
203
+ await asyncio.sleep(0)
204
+ code = self._compile(statement)
205
+ # eval returns the top-level-await coroutine; exec would discard it.
206
+ result = eval(code, self.namespace, self.namespace)
207
+ if inspect.isawaitable(result):
208
+ await result
209
+ # User code may catch cancellation. Do not start another unit while
210
+ # the owning task still carries an outstanding cancellation request.
211
+ if task is not None and task.cancelling() > cancellations:
212
+ raise asyncio.CancelledError
213
+ except BaseException:
214
+ # The owning operation records terminal state; this layer identifies the attempted unit.
215
+ self._failed_unit = statement
216
+ raise
217
+ event = Execution(len(self._events), statement, started, monotonic())
218
+ self._events.append(event)
219
+ return event
220
+
221
+ async def feed(self, delta: str) -> tuple[Execution, ...]:
222
+ """Append new source and execute its sealed units in order.
223
+
224
+ Return only this call's completed events. On failure, earlier effects
225
+ remain; inspect ``events``, ``failed_unit``, ``queued`` and ``parser_state``.
226
+ """
227
+ self._enter()
228
+ try:
229
+ self._queued.extend(self._parser.feed(delta))
230
+ events = []
231
+ while self._queued:
232
+ events.append(await self._run(self._queued.popleft()))
233
+ return tuple(events)
234
+ except BaseException as error:
235
+ self._error = error
236
+ self._state = "failed"
237
+ raise
238
+ finally:
239
+ self._busy = False
240
+
241
+ async def finish(self) -> tuple[Execution, ...]:
242
+ """Declare successful EOF and execute the remaining source, then close.
243
+
244
+ Never use as unconditional cleanup: this can execute a pending loop.
245
+ Invalid EOF or execution failure is terminal and preserves earlier effects.
246
+ """
247
+ self._enter()
248
+ try:
249
+ self._queued.extend(self._parser.finish())
250
+ events = []
251
+ while self._queued:
252
+ events.append(await self._run(self._queued.popleft()))
253
+ self._state = "finished"
254
+ return tuple(events)
255
+ except BaseException as error:
256
+ self._error = error
257
+ self._state = "failed"
258
+ raise
259
+ finally:
260
+ self._busy = False
261
+
262
+ def abort(self) -> None:
263
+ """Abandon pending source after a transport failure, retaining it for diagnostics."""
264
+ if self._busy:
265
+ raise RuntimeError("cancel the active operation before aborting")
266
+ if self._state == "open":
267
+ self._state = "aborted"
268
+
269
+ async def stream(self, chunks: AsyncIterable[str]) -> AsyncGenerator[Execution, None]:
270
+ """Execute a source iterator, retaining diagnostics on this executor.
271
+
272
+ Normal exhaustion declares EOF. Adapters must raise for truncation or
273
+ failed transport. Every exit attempts to await the acquired iterator’s
274
+ ``aclose()`` when provided.
275
+ This operation owns the executor until exhausted or explicitly closed.
276
+ """
277
+ self._enter()
278
+ iterator = None
279
+ yielding = False
280
+ try:
281
+ iterator = aiter(chunks)
282
+ while True:
283
+ # Each anext may be resumed by a different task (e.g. wait_for).
284
+ task = asyncio.current_task()
285
+ cancellations = task.cancelling() if task is not None else 0
286
+ try:
287
+ chunk = await anext(iterator)
288
+ except StopAsyncIteration:
289
+ if task is not None and task.cancelling() > cancellations:
290
+ raise asyncio.CancelledError
291
+ break
292
+ if task is not None and task.cancelling() > cancellations:
293
+ raise asyncio.CancelledError
294
+ self._queued.extend(self._parser.feed(chunk))
295
+ if not self._queued:
296
+ # Buffered input can return immediately without suspending.
297
+ # Keep empty/unfinished streams cancellable between chunks.
298
+ await asyncio.sleep(0)
299
+ while self._queued:
300
+ event = await self._run(self._queued.popleft())
301
+ yielding = True
302
+ yield event
303
+ yielding = False
304
+ self._queued.extend(self._parser.finish())
305
+ while self._queued:
306
+ event = await self._run(self._queued.popleft())
307
+ yielding = True
308
+ yield event
309
+ yielding = False
310
+ self._state = "finished"
311
+ except BaseException as error:
312
+ # Only GeneratorExit injected at our event yield is consumer closure.
313
+ # The same exception from a source or executed unit is a real failure.
314
+ if not (yielding and isinstance(error, GeneratorExit)):
315
+ self._error = error
316
+ self._state = "failed"
317
+ raise
318
+ finally:
319
+ if self._state == "open":
320
+ self._state = "aborted"
321
+ try:
322
+ cleanup_task = asyncio.current_task()
323
+ cancellations = cleanup_task.cancelling() if cleanup_task is not None else 0
324
+ close = getattr(iterator, "aclose", None)
325
+ if close is not None:
326
+ await close()
327
+ if cleanup_task is not None and cleanup_task.cancelling() > cancellations:
328
+ raise asyncio.CancelledError
329
+ except BaseException as cleanup_error:
330
+ # The entry guard excludes stale errors from earlier terminal operations.
331
+ if self._error is None or not isinstance(cleanup_error, Exception):
332
+ self._error = cleanup_error
333
+ self._state = "failed"
334
+ raise
335
+ try:
336
+ BaseException.add_note(self._error, f"Source iterator cleanup also failed: {cleanup_error!r}")
337
+ except Exception: # noqa: BLE001, S110 - secondary diagnostics must not replace the primary error.
338
+ # A broken exception repr or notes attribute must not replace
339
+ # the execution failure merely while attaching diagnostics.
340
+ pass
341
+ except BaseException as note_error:
342
+ self._error = note_error
343
+ raise
344
+ finally:
345
+ self._busy = False
346
+
347
+
348
+ async def incremental_exec(chunks: AsyncIterable[str], namespace: dict[str, Any] | None = None, *, filename: str = "<partial-python>") -> dict[str, Any]:
349
+ """Execute validated source as it arrives and return the namespace used.
350
+
351
+ Normal iterator exhaustion authorizes EOF. The source adapter must raise on
352
+ truncation or failure. Use ``Executor.stream`` for events and diagnostics.
353
+ """
354
+ executor = Executor(namespace, filename=filename)
355
+ async with aclosing(executor.stream(chunks)) as events:
356
+ async for _ in events:
357
+ pass
358
+ return executor.namespace
@@ -0,0 +1,405 @@
1
+ """Conservative boundaries for append-only Python source."""
2
+
3
+ import __future__
4
+
5
+ import ast
6
+ import io
7
+ import re
8
+ import tokenize
9
+ from collections.abc import Iterator, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Literal, cast
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class CodeUnit:
16
+ """An exact source slice; offsets count Unicode characters, lines are one-based."""
17
+
18
+ source: str
19
+ start: int
20
+ end: int
21
+ start_line: int
22
+ end_line: int
23
+
24
+
25
+ _COMPOUND = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith, ast.Try, ast.TryStar, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Match)
26
+ _CLAUSES = frozenset({"elif", "else", "except", "finally"})
27
+ _OPENERS = {")": "(", "]": "[", "}": "{"}
28
+ _LINE_END = re.compile(r"[\r\n]")
29
+ _FUTURES = {name: getattr(__future__, name).compiler_flag for name in __future__.all_feature_names}
30
+ _TRIVIA = frozenset({tokenize.COMMENT, tokenize.NL, tokenize.ENCODING})
31
+
32
+
33
+ def _tree(source: str, filename: str, flags: int = 0, start_line: int = 1) -> ast.Module:
34
+ try:
35
+ return cast(ast.Module, compile(source, filename, "exec", flags=flags | ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, dont_inherit=True))
36
+ except SyntaxError:
37
+ if start_line == 1:
38
+ raise
39
+ # Only the error path pays for leading lines. Let CPython construct both
40
+ # serialized locations and messages that themselves mention a line number.
41
+ return cast(ast.Module, compile("\n" * (start_line - 1) + source, filename, "exec", flags=flags | ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, dont_inherit=True))
42
+
43
+
44
+ def _source_line(source: str, line: int) -> str:
45
+ # str.splitlines also splits formfeeds and Unicode separators, unlike Python.
46
+ start = 0
47
+ for _ in range(max(0, line - 1)):
48
+ match = _LINE_END.search(source, start)
49
+ if match is None:
50
+ return ""
51
+ start = match.end() + (match[0] == "\r" and source[match.end() : match.end() + 1] == "\n")
52
+ match = _LINE_END.search(source, start)
53
+ if match is None:
54
+ return source[start:]
55
+ end = match.end() + (match[0] == "\r" and source[match.end() : match.end() + 1] == "\n")
56
+ return source[start:end]
57
+
58
+
59
+ def _line_end(source: str, start: int) -> int:
60
+ match = _LINE_END.search(source, start)
61
+ if match is None:
62
+ return len(source)
63
+ # Include one lookahead character even after a bare CR: _normalize holds a
64
+ # trailing CR until it can distinguish CRLF. This is a probe window, not a unit.
65
+ return min(match.end() + (match[0] == "\r"), len(source))
66
+
67
+
68
+ def _normalize(source: str) -> tuple[str, Sequence[int]]:
69
+ if "\r" not in source:
70
+ return source, range(len(source) + 1)
71
+ normalized = []
72
+ offsets = [0]
73
+ index = 0
74
+ while index < len(source):
75
+ character = source[index]
76
+ if character == "\r":
77
+ if index + 1 == len(source):
78
+ break # CR may be half of CRLF; do not commit it before the next chunk.
79
+ index += 2 if source[index + 1] == "\n" else 1
80
+ normalized.append("\n")
81
+ else:
82
+ index += 1
83
+ normalized.append(character)
84
+ offsets.append(index)
85
+ return "".join(normalized), offsets
86
+
87
+
88
+ _STRING_STARTS = frozenset(getattr(tokenize, name) for name in ("FSTRING_START", "TSTRING_START") if hasattr(tokenize, name))
89
+ _STRING_ENDS = frozenset(getattr(tokenize, name) for name in ("FSTRING_END", "TSTRING_END") if hasattr(tokenize, name))
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class _Boundary:
94
+ offset: int
95
+ kind: Literal["successor", "newline", "resume", "continuation", "started", "string"]
96
+ delimiters: tuple[str, ...] = ()
97
+ logical_root: bool = True
98
+ quote: str = ""
99
+
100
+
101
+ def _quote_delimiter(token_text: str) -> str:
102
+ marker = token_text.lstrip("rRuUbBfFtT")
103
+ return marker[:3] if marker[:3] in ("'''", '"""') else marker[:1]
104
+
105
+
106
+ def _escaped_before(text: str, index: int, before_window: bool) -> bool:
107
+ start = index
108
+ while start and text[start - 1] == "\\":
109
+ start -= 1
110
+ return bool((index - start) % 2) ^ (before_window if start == 0 else False)
111
+
112
+
113
+ def _quote_window(text: str, before_window: bool) -> tuple[str, bool]:
114
+ # Two quote characters suffice for a split triple delimiter, but a slash
115
+ # run can span any number of chunks. Carry its parity before this window.
116
+ start = max(len(text) - 2, 0)
117
+ return text[start:], _escaped_before(text, start, before_window)
118
+
119
+
120
+ def _has_unescaped_closer(text: str, quote: str, before_window: bool) -> bool:
121
+ start = 0
122
+ while (candidate := text.find(quote, start)) >= 0:
123
+ if not _escaped_before(text, candidate, before_window):
124
+ return True
125
+ # An escaped quote followed by three more quotes closes at the second
126
+ # quote. Preserve overlapping candidates instead of skipping a delimiter.
127
+ start = candidate + 1
128
+ return False
129
+
130
+
131
+ def _unfinished_quote(source: str, last_end: tuple[int, int]) -> tuple[str, tuple[int, int]] | None:
132
+ eof = (source.count("\n") + 1, len(source.rsplit("\n", 1)[-1]))
133
+ # Ask the host tokenizer to identify an unfinished STRING crossing actual EOF.
134
+ # Four quotes also close a trailing backslash. Never reinterpret an already
135
+ # emitted token (e.g. r'' becoming r''' merely because of the probe suffix).
136
+ for quote in ("'", '"'):
137
+ try:
138
+ for token in tokenize.generate_tokens(io.StringIO(source + quote * 4).readline):
139
+ if token.type == tokenize.STRING and last_end <= token.start < eof < token.end:
140
+ return _quote_delimiter(token.string), token.start
141
+ except (tokenize.TokenError, IndentationError, SyntaxError, UnicodeError):
142
+ pass
143
+ return None
144
+
145
+
146
+ def _candidates(source: str, delimiters: tuple[str, ...], logical_root: bool) -> Iterator[_Boundary]:
147
+ source, original_offsets = _normalize(source)
148
+ starts = [0]
149
+ starts.extend(index + 1 for index, character in enumerate(source) if character == "\n")
150
+ prefix = "".join(delimiters) + "\n" if delimiters else ""
151
+ row_shift = bool(prefix)
152
+
153
+ def offset(position: tuple[int, int]) -> int:
154
+ row, column = position
155
+ row -= row_shift
156
+ return starts[row - 1] + column if row <= len(starts) else len(source)
157
+
158
+ def root_prefix(position: tuple[int, int]) -> bool:
159
+ if position[0] - row_shift == 1:
160
+ return True
161
+ # Python 3.11's pure-Python tokenizer can miss indentation after an
162
+ # explicit join. Ask the host grammar whether this prefix is truly root.
163
+ # The sentinel is only a lexical-context probe, never executable source.
164
+ try:
165
+ tree = _tree(source[: offset(position)] + "pass\n", "<partial-python-boundary>")
166
+ except (SyntaxError, ValueError, UnicodeError):
167
+ return False
168
+ return len(tree.body) == 1 and isinstance(tree.body[0], ast.Pass)
169
+
170
+ # This synthetic prefix restores lexical bracket context only. It never
171
+ # enters a CodeUnit or the compiler. Checkpoints carry exact opener kinds
172
+ # and the ORIGINAL logical line's indentation, not the prefix's column zero.
173
+ stack = list(delimiters)
174
+ string_quotes: list[str] = []
175
+ last_end = (1, 0)
176
+ lexical_error = False
177
+ logical_start = not delimiters
178
+ try:
179
+ for token in tokenize.generate_tokens(io.StringIO(prefix + source).readline):
180
+ if token.start[0] <= row_shift:
181
+ continue
182
+ kind = token.type
183
+ if token.string and kind not in {tokenize.DEDENT, tokenize.ENDMARKER}:
184
+ last_end = token.end
185
+ if kind in _STRING_STARTS:
186
+ string_quotes.append(_quote_delimiter(token.string))
187
+ elif kind in _STRING_ENDS:
188
+ string_quotes.pop()
189
+ elif kind == tokenize.ERRORTOKEN:
190
+ lexical_error = True
191
+ # An incomplete short quote can expose leading whitespace as
192
+ # ERRORTOKEN on 3.11. It cannot prove a successor's indentation.
193
+ if token.string in {" ", "\t", "\f"}:
194
+ continue
195
+ elif kind == tokenize.OP:
196
+ if token.string in "([{":
197
+ stack.append(token.string)
198
+ elif token.string in ")]}" and (not stack or stack.pop() != _OPENERS[token.string]):
199
+ return
200
+ if kind == tokenize.INDENT:
201
+ logical_root = False
202
+ elif kind == tokenize.DEDENT:
203
+ continue
204
+ elif kind == tokenize.NEWLINE:
205
+ # tokenize invents NEWLINE and DEDENT at EOF. Neither closes a live stream.
206
+ if token.string and logical_root:
207
+ yield _Boundary(original_offsets[offset(token.end)], "newline")
208
+ if token.string:
209
+ yield _Boundary(original_offsets[offset(token.end)], "resume")
210
+ return
211
+ logical_start = True
212
+ elif kind == tokenize.NL and token.string:
213
+ if logical_start and not stack:
214
+ yield _Boundary(original_offsets[offset(token.end)], "resume")
215
+ return
216
+ if stack and not string_quotes and not lexical_error:
217
+ # Never checkpoint f/t-string lexical state or a partial
218
+ # token. Persist only this completed NL snapshot, not any
219
+ # transient delimiter changes observed at temporary EOF.
220
+ yield _Boundary(original_offsets[offset(token.end)], "continuation", tuple(stack), logical_root)
221
+ return
222
+ elif kind not in _TRIVIA and kind != tokenize.ENDMARKER:
223
+ if logical_start and logical_root:
224
+ # A literal may grow, but cannot become a continuation clause.
225
+ # An ERRORTOKEN at temporary EOF is not enough: a trailing
226
+ # backslash can disappear into a join before a real else.
227
+ stable = (
228
+ offset(token.end) < len(source)
229
+ or kind in {tokenize.OP, tokenize.NUMBER, tokenize.STRING}
230
+ or kind in _STRING_STARTS
231
+ or (kind == tokenize.NAME and not any(clause.startswith(token.string) for clause in _CLAUSES))
232
+ )
233
+ if stable and token.string not in _CLAUSES and root_prefix(token.start):
234
+ # This window starts at the logical line's beginning.
235
+ # Keep formfeeds and explicit joins with the successor
236
+ # so its source columns and physical lines stay intact.
237
+ yield _Boundary(0, "successor")
238
+ if stable:
239
+ yield _Boundary(0, "started")
240
+ elif logical_start:
241
+ yield _Boundary(0, "started")
242
+ logical_start = False
243
+ except (tokenize.TokenError, IndentationError, SyntaxError, UnicodeError):
244
+ if string_quotes:
245
+ yield _Boundary(0, "string", quote=string_quotes[0])
246
+ elif opening := _unfinished_quote(prefix + source, last_end):
247
+ quote, position = opening
248
+ if logical_start:
249
+ # A host-proven string opener already excludes continuation
250
+ # clauses; its unfinished contents need not delay a predecessor.
251
+ if logical_root and root_prefix(position):
252
+ yield _Boundary(0, "successor")
253
+ yield _Boundary(0, "started")
254
+ yield _Boundary(0, "string", quote=quote)
255
+ # Valid earlier tokens remain useful even when the unfinished suffix is not tokenizable.
256
+ return
257
+
258
+
259
+ class Parser:
260
+ """Emit only syntactically sealed source, never guessed closures.
261
+
262
+ ``finish`` declares actual EOF. A failed finish closes the parser, because a
263
+ transport EOF cannot subsequently be reinterpreted as another text chunk.
264
+ """
265
+
266
+ def __init__(self, *, filename: str = "<partial-python>"):
267
+ self.filename = filename
268
+ self._pending = ""
269
+ self._chunks: list[str] = []
270
+ self._await_quote = ""
271
+ self._quote_tail = ""
272
+ self._quote_tail_escaped = False
273
+ self._offset = 0
274
+ self._line = 1
275
+ self._closed = False
276
+ self._flags = 0
277
+ self._scan = 0
278
+ self._retry_full_window = False
279
+ self._line_started = False
280
+ self._delimiters: tuple[str, ...] = ()
281
+ self._logical_root = True
282
+ self._checked = 0
283
+ self._checked_successor = False
284
+
285
+ @property
286
+ def pending(self) -> str:
287
+ if self._chunks:
288
+ self._pending += "".join(self._chunks)
289
+ self._chunks.clear()
290
+ return self._pending
291
+
292
+ @property
293
+ def closed(self) -> bool:
294
+ return self._closed
295
+
296
+ @property
297
+ def committed(self) -> int:
298
+ return self._offset
299
+
300
+ def _ensure_open(self) -> None:
301
+ if self._closed:
302
+ raise RuntimeError("parser is closed")
303
+
304
+ def _statement(self, source: str) -> CodeUnit:
305
+ statement = CodeUnit(source, self._offset, self._offset + len(source), self._line, self._line + source.count("\n") + source.count("\r") - source.count("\r\n"))
306
+ self._offset = statement.end
307
+ self._line = statement.end_line
308
+ return statement
309
+
310
+ def feed(self, delta: str) -> tuple[CodeUnit, ...]:
311
+ """Append a delta and emit original-source units stable under further appends."""
312
+ self._ensure_open()
313
+ if not isinstance(delta, str):
314
+ raise TypeError("delta must be str")
315
+ if self._await_quote:
316
+ window = self._quote_tail + delta
317
+ # Even f/t strings need an unescaped outer closer. Nested fields
318
+ # can cause extra retries; only the host tokenizer decides closure.
319
+ if not _has_unescaped_closer(window, self._await_quote, self._quote_tail_escaped):
320
+ if delta:
321
+ self._chunks.append(delta)
322
+ self._quote_tail, self._quote_tail_escaped = _quote_window(window, self._quote_tail_escaped)
323
+ return ()
324
+ self._await_quote = ""
325
+ resolves_cr = (self._chunks[-1] if self._chunks else self._pending).endswith("\r")
326
+ # Once the first token is unambiguous, only a physical line ending can
327
+ # produce NEWLINE. Skip growing operands/literals until that can happen.
328
+ if self._line_started and not resolves_cr and "\n" not in delta and "\r" not in delta:
329
+ if delta:
330
+ # Join only when scanning or reading diagnostics; copying the
331
+ # growing operand on every small delta is otherwise quadratic.
332
+ self._chunks.append(delta)
333
+ return ()
334
+ self._pending = self.pending + delta
335
+ result = []
336
+ consumed = 0
337
+ scan = self._scan
338
+ window_end = len(self._pending) if self._retry_full_window else _line_end(self._pending, scan)
339
+ self._retry_full_window = False
340
+ while scan < len(self._pending):
341
+ checkpoint = None
342
+ for boundary in _candidates(self._pending[scan:window_end], self._delimiters, self._logical_root):
343
+ end = scan + boundary.offset
344
+ kind = boundary.kind
345
+ if kind == "string":
346
+ if window_end == len(self._pending):
347
+ self._await_quote = boundary.quote
348
+ self._quote_tail, self._quote_tail_escaped = _quote_window(self._pending, False)
349
+ continue
350
+ if kind == "started":
351
+ self._line_started = True
352
+ continue
353
+ if kind in {"resume", "continuation"}:
354
+ self._line_started = kind == "continuation"
355
+ self._delimiters = boundary.delimiters
356
+ self._logical_root = boundary.logical_root
357
+ checkpoint = end
358
+ break
359
+ successor = kind == "successor"
360
+ if end <= consumed or end < self._checked or (end == self._checked and successor == self._checked_successor):
361
+ continue
362
+ self._checked = end
363
+ self._checked_successor = successor
364
+ source = self._pending[consumed:end]
365
+ try:
366
+ tree = _tree(source, self.filename, self._flags)
367
+ except (SyntaxError, ValueError, UnicodeError):
368
+ continue
369
+ if not tree.body or (not successor and isinstance(tree.body[-1], _COMPOUND)):
370
+ continue
371
+ for node in tree.body:
372
+ if isinstance(node, ast.ImportFrom) and node.module == "__future__":
373
+ for name in node.names:
374
+ self._flags |= _FUTURES.get(name.name, 0)
375
+ result.append(self._statement(source))
376
+ consumed = end
377
+ if checkpoint is None or checkpoint <= scan:
378
+ if window_end == len(self._pending):
379
+ self._retry_full_window = True
380
+ break
381
+ # Exponential windows avoid revisiting every prior physical line
382
+ # of an unfinished list/string in one large transport chunk.
383
+ window_end = min(len(self._pending), scan + max(1, window_end - scan) * 2)
384
+ else:
385
+ scan = checkpoint
386
+ window_end = _line_end(self._pending, scan)
387
+ # Lexer checkpoints retain only reconstructible state. Original-source
388
+ # compilation still validates the complete suite and every source unit.
389
+ self._scan = max(scan - consumed, 0)
390
+ self._checked = max(self._checked - consumed, 0)
391
+ self._pending = self._pending[consumed:]
392
+ return tuple(result)
393
+
394
+ def finish(self) -> tuple[CodeUnit, ...]:
395
+ """Declare successful EOF and emit the final unit; even invalid EOF closes the parser."""
396
+ self._ensure_open()
397
+ self._closed = True
398
+ # Parse even a trivia-only tail: an unterminated comment string is harmless,
399
+ # but a stray backslash or bracket must never disappear silently.
400
+ _tree(self.pending, self.filename, self._flags, self._line)
401
+ if not self._pending:
402
+ return ()
403
+ statement = self._statement(self._pending)
404
+ self._pending = ""
405
+ return (statement,)
@@ -0,0 +1,176 @@
1
+ """Inspectable display completions. No generated text is an execution boundary."""
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from enum import IntFlag, auto
6
+ from typing import cast
7
+
8
+ from .parser import Parser
9
+
10
+
11
+ class Allow(IntFlag):
12
+ """Opt into provisional display; these options never affect Parser or Executor."""
13
+
14
+ NONE = 0
15
+ TAIL = auto()
16
+ STRING = auto()
17
+ BRACKET = auto()
18
+ SUITE = auto()
19
+ ALL = TAIL | STRING | BRACKET | SUITE
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Completion:
24
+ """``prefix + withheld`` is the input; ``prefix + suffix`` is display source.
25
+
26
+ Only ``committed`` characters have passed Parser's streaming boundary rules.
27
+ Even a preview with no synthetic suffix may contain an uncommitted statement.
28
+ ``diagnostic`` describes the original input, and can simply mean incomplete.
29
+ """
30
+
31
+ prefix: str
32
+ suffix: str
33
+ withheld: str
34
+ committed: int
35
+ diagnostic: SyntaxError | UnicodeError | None = None
36
+
37
+ @property
38
+ def source(self) -> str:
39
+ return self.prefix + self.suffix
40
+
41
+ @property
42
+ def provisional(self) -> bool:
43
+ return bool(self.suffix) or len(self.prefix) > self.committed
44
+
45
+
46
+ def _parse(source: str, flags: int, start_line: int = 1) -> ast.Module:
47
+ # Snapshot validation can afford padding: warnings, errors and returned AST
48
+ # nodes then all use the same original line coordinates.
49
+ return cast(ast.Module, compile("\n" * (start_line - 1) + source, "<partial-python-preview>", "exec", flags=flags | ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, dont_inherit=True))
50
+
51
+
52
+ def _closures(source: str, allow: Allow) -> str | None:
53
+ # This scanner only proposes suffixes. CPython validates every proposal below;
54
+ # unsupported syntax stays pending instead of being rewritten into other code.
55
+ source = source.replace("\r\n", "\n").replace("\r", "\n")
56
+ stack: list[str] = []
57
+ index = 0
58
+ last = -1
59
+ suffix = ""
60
+ while index < len(source):
61
+ char = source[index]
62
+ if char == "#":
63
+ end = source.find("\n", index)
64
+ if end < 0:
65
+ if stack:
66
+ suffix = "\n"
67
+ break
68
+ index = end + 1
69
+ continue
70
+ if char in "\"'":
71
+ prefix_start = index
72
+ while prefix_start and (source[prefix_start - 1].isalnum() or source[prefix_start - 1] == "_"):
73
+ prefix_start -= 1
74
+ prefix = source[prefix_start:index].lower()
75
+ if prefix in {"f", "fr", "rf", "t", "tr", "rt"}:
76
+ return None # f/t strings have nested grammar; do not guess their braces or format specs.
77
+ quote = char * (3 if source.startswith(char * 3, index) else 1)
78
+ cursor = index + len(quote)
79
+ closed = False
80
+ partial_closer = 0
81
+ while cursor < len(source):
82
+ if source[cursor] == "\\":
83
+ cursor += 2
84
+ elif source.startswith(quote, cursor):
85
+ cursor += len(quote)
86
+ closed = True
87
+ break
88
+ elif len(quote) == 3 and len(source) - cursor < 3 and source[cursor:] in {char, char * 2}:
89
+ partial_closer = len(source) - cursor
90
+ cursor = len(source)
91
+ elif len(quote) == 1 and source[cursor] in "\r\n":
92
+ return None
93
+ else:
94
+ cursor += 1
95
+ if not closed:
96
+ if not allow & Allow.STRING or cursor > len(source):
97
+ return None
98
+ # One or two quote characters at the end of a triple string are
99
+ # the start of its closer; append only the missing characters.
100
+ suffix = quote[partial_closer:]
101
+ break
102
+ index = cursor
103
+ last = index - 1
104
+ continue
105
+ if char in "([{":
106
+ stack.append(char)
107
+ elif char in ")]}":
108
+ if not stack or stack.pop() != {")": "(", "]": "[", "}": "{"}[char]:
109
+ return None
110
+ elif char == "\\" and index + 1 >= len(source):
111
+ # Explicit line continuations need real source; do not invent an operand.
112
+ return None
113
+ if not char.isspace():
114
+ last = index
115
+ index += 1
116
+ if stack:
117
+ if not allow & Allow.BRACKET:
118
+ return None
119
+ suffix += "".join({"(": ")", "[": "]", "{": "}"}[char] for char in reversed(stack))
120
+ if not suffix and last >= 0 and source[last] == ":" and allow & Allow.SUITE:
121
+ header = source[source.rfind("\n", 0, last) + 1 : last]
122
+ indent = header[: len(header) - len(header.lstrip(" \t"))]
123
+ suffix = "\n" + indent + " pass\n"
124
+ return suffix or None
125
+
126
+
127
+ def complete(source: str, allow: Allow = Allow.NONE) -> Completion:
128
+ """Complete a preview without altering or deleting interior source.
129
+
130
+ Defaults to the exact committed prefix. Opt-in completion is display-only:
131
+ STRING closes plain literals, BRACKET closes data/grouping delimiters, SUITE
132
+ adds ``pass`` to an empty final suite, and TAIL includes already valid but
133
+ unsealed source. Calls needing synthetic closers/arguments stay withheld.
134
+ """
135
+ if not isinstance(source, str):
136
+ raise TypeError("source must be str")
137
+ allow = Allow(allow)
138
+ if int(allow) & ~int(Allow.ALL):
139
+ raise ValueError("unknown preview flags")
140
+ parser = Parser()
141
+ parser.feed(source)
142
+ committed = parser.committed
143
+ pending = parser.pending
144
+ diagnostic = None
145
+ try:
146
+ _parse(pending, parser._flags, parser._line)
147
+ except (SyntaxError, UnicodeError) as error:
148
+ diagnostic = error
149
+ if isinstance(error, UnicodeEncodeError):
150
+ # Padding is only for line coordinates. Encoding diagnostics must
151
+ # retain the actual full input and its original character offsets.
152
+ # Encoding fails before grammar, so current flags cannot reinterpret history.
153
+ try:
154
+ _parse(source, parser._flags)
155
+ except (SyntaxError, UnicodeError) as original_error:
156
+ diagnostic = original_error
157
+ else:
158
+ if allow:
159
+ return Completion(source, "", "", committed)
160
+ suffix = _closures(pending, allow) if allow else None
161
+ if suffix:
162
+ try:
163
+ tree = _parse(pending + suffix, parser._flags, parser._line)
164
+ except (SyntaxError, ValueError, UnicodeError):
165
+ pass
166
+ else:
167
+ # Closing a call would guess its final argument list, even when the
168
+ # existing arguments happen to parse. Keep the entire pending unit.
169
+ normalized = pending.replace("\r\n", "\n").replace("\r", "\n")
170
+ boundary = (parser._line + normalized.count("\n"), len(normalized.rsplit("\n", 1)[-1].encode()))
171
+ closes_call = any(isinstance(node, ast.Call) and (node.end_lineno, node.end_col_offset) > boundary for node in ast.walk(tree))
172
+ if not closes_call:
173
+ return Completion(source, suffix, "", committed, diagnostic)
174
+ # Parser already checked each committed unit under its historical flags;
175
+ # a later future import must not reinterpret that accepted prefix.
176
+ return Completion(source[:committed], "", pending, committed, diagnostic)
File without changes
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: partial-python
3
+ Version: 0.1.0a1
4
+ Summary: Append-only Python parsing and incremental execution
5
+ Project-URL: Repository, https://github.com/promplate/partial-python
6
+ Project-URL: Documentation, https://github.com/promplate/partial-python/blob/main/docs/api.md
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # partial-python
11
+
12
+ Execute append-only Python while source is still arriving. Python 3.11+, no runtime dependencies. Experimental API.
13
+
14
+ ```sh
15
+ uv add 'partial-python==0.1.0a1'
16
+ ```
17
+
18
+ ## Input and completion
19
+
20
+ Use an `Executor` for each logical response. Feed source as it arrives, then explicitly finish or abort according to the provider's completion status.
21
+
22
+ ```python
23
+ from partial_python import Executor
24
+
25
+ executor = Executor({"foo": foo, "bar": bar, "baz": baz})
26
+ await executor.feed("await foo(1)\n") # Executes now.
27
+ await executor.feed("await bar(2)") # Waits: an operator or attribute access may follow.
28
+ await executor.feed("\nawait baz(3)") # Executes bar, buffers baz.
29
+ await executor.finish() # Successful EOF permits baz.
30
+ ```
31
+
32
+ **Units execute in source order, at most once each.** New input never reruns earlier units. A loop still performs its ordinary iterations, but its whole body must be sealed before execution starts.
33
+
34
+ `feed()` accepts new text deltas, not accumulated snapshots. `feed()` and `finish()` return completed `Execution` events. The executor retains `events`, `failed_unit`, sealed but unstarted `queued` units, and an immutable `parser_state` snapshot containing the unsealed `pending` tail. Read results from `executor.namespace`.
35
+
36
+ `finish()` declares successful source completion. On disconnects, token limits, cancellation, or failed responses, use `abort()` for an idle executor; cancel and await an active operation first. Failure is terminal: earlier effects remain, later units do not start, and nothing is retried. [Lifecycle and integration](docs/api.md).
37
+
38
+ ## Async text iterators
39
+
40
+ When your adapter already supplies a validated async text iterator, `incremental_exec()` is a convenience wrapper. `validated_deltas` must yield raw source and end normally **only after successful generation**. Its adapter must raise on truncation, failure, or a missing completion signal.
41
+
42
+ ```python
43
+ from partial_python import incremental_exec
44
+
45
+ namespace = await incremental_exec(validated_deltas, {"foo": foo, "bar": bar})
46
+ ```
47
+
48
+ Execution starts while source is arriving; the await completes after execution and returns the namespace used. To consume events as each unit completes, use `Executor.stream()` and retain the executor for diagnostics. Close a partially consumed event iterator with `aclosing` in its owning task/context. [Streaming adapter](docs/api.md#stateful-streaming).
49
+
50
+ ## Stable boundaries
51
+
52
+ | Input | Execution boundary |
53
+ | --- | --- |
54
+ | Top-level simple statement | A real logical newline or successful EOF. |
55
+ | `foo()` at temporary EOF | Wait; it can still become `foo() + bar()`. |
56
+ | `for`, `if`, `try`, `def`, and other compounds | Hold the entire statement until an unambiguous top-level successor or successful EOF. |
57
+ | `else`, `elif`, `except`, `finally`, or an ambiguous prefix such as `e` | Keep clauses attached; wait for ambiguous prefixes to resolve. |
58
+ | Blank lines and comments | Preserve them; they do not close a suite. |
59
+ | Decorators, brackets, line joins, and strings | Keep their complete logical statement together. |
60
+
61
+ `Parser.feed()` / `finish()` expose the same boundaries as immutable `CodeUnit` source slices without executing them. `pending` retains the uncommitted suffix. Every unit preserves original characters and source positions; no repaired text is executed. [Precise semantic contract](docs/semantics.md).
62
+
63
+ ## Display completion
64
+
65
+ ```python
66
+ from partial_python import Allow, complete
67
+
68
+ assert complete("ready = 1\nvalue = ['hel").source == "ready = 1\n"
69
+ preview = complete("value = ['hel", Allow.STRING | Allow.BRACKET)
70
+ assert preview.source == "value = ['hel']"
71
+ assert preview.provisional
72
+ ```
73
+
74
+ The default returns only the stable prefix. `Allow` flags opt into provisional display; `prefix`, `suffix`, and `withheld` distinguish original text from synthetic text. A preview never authorizes execution, even when its suffix is empty. [Preview options and limits](docs/preview.md).
75
+
76
+ ## Execution scope
77
+
78
+ Units share one namespace and support native top-level `await`. Execution requires an asyncio event loop; use the asyncio backend with AnyIO. `Parser` and `complete` are synchronous.
79
+
80
+ This uses sequential compilation units, with [explicit differences from whole-module execution](docs/semantics.md#execution-is-a-sequence-of-compilation-units). Future imports remain in the prologue, only the initial string sets the module docstring, and module-scope `global` declarations are rejected. On Python 3.14+, module annotations require an explicit `from __future__ import annotations`.
81
+
82
+ In-process execution provides no sandbox or rollback. Hosts own permissions, background tasks, source limits, and hard deadlines. The [integration examples](docs/api.md#host-examples) demonstrate transport completion, concurrent reception, and traceback source lifetime.
83
+
84
+ ## Development
85
+
86
+ ```sh
87
+ uv sync
88
+ uv run pytest
89
+ uv run basedpyright
90
+ uv run ruff check .
91
+ uv run ruff format --check .
92
+ uv run examples/streaming_tools.py
93
+ uv build
94
+ ```
95
+
96
+ [API and transport](docs/api.md) · [Design](docs/design.md) · [Validation](docs/validation.md) · [Related implementations](docs/prior-art.md)
@@ -0,0 +1,8 @@
1
+ partial_python/__init__.py,sha256=Xhc7Yuhm7ZLdLBe56Jcm6-84QN3Htwa5IskrJf640M8,425
2
+ partial_python/executor.py,sha256=157LxUn095Ge97LYaRq8dFtnWRbiZifWup60LIW8duk,15962
3
+ partial_python/parser.py,sha256=AX-dvWhmtwai8YcNz2DvgZpma--ELSatMIuctzJIJf8,18437
4
+ partial_python/preview.py,sha256=1NszfwFuGYGCHLMdEEaL5Mm-Tk8W39NsykWSI9CJzeQ,7324
5
+ partial_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ partial_python-0.1.0a1.dist-info/METADATA,sha256=NID8lC7Yw3k_PYUxsdaBGFBzh0OVZFqMw_M-Z1nqybg,5483
7
+ partial_python-0.1.0a1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ partial_python-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any