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.
- partial_python/__init__.py +7 -0
- partial_python/executor.py +358 -0
- partial_python/parser.py +405 -0
- partial_python/preview.py +176 -0
- partial_python/py.typed +0 -0
- partial_python-0.1.0a1.dist-info/METADATA +96 -0
- partial_python-0.1.0a1.dist-info/RECORD +8 -0
- partial_python-0.1.0a1.dist-info/WHEEL +4 -0
|
@@ -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
|
partial_python/parser.py
ADDED
|
@@ -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)
|
partial_python/py.typed
ADDED
|
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,,
|