Source code for simvx.core.scene_io.syntax

"""The subset of Python the scene editor can read, and how it refuses the rest.

A scene is an ordinary Python module and the engine runs whatever Python it
contains. The *editor* is the narrower surface: it reads and rewrites a scene
through parso, which trails CPython's grammar, so five constructs load and run
correctly and cannot be edited structurally.

Measured against parso 0.8.7, whose 3.13 and 3.14 grammars are byte-identical,
so this does not age out with the next Python:

* ``match`` / ``case`` (3.10)
* ``except*`` (3.11)
* ``type X = ...`` alias statements (3.12)
* an f-string quoted with the same character it nests, escaped, or written
  over several lines (PEP 701, 3.12)
* unparenthesised ``except A, B:`` (PEP 758, 3.14)

PEP 695 generic parameter lists (``class Foo[T]``, ``def f[T]()``) parse, and
so does the older f-string nesting that alternates quote characters.

None of the five is hard to avoid inside a scene's ``__init__``, which is
mostly node construction. What matters is that the editor says so: it opens the
file, shows it, diffs it and reads it, and refuses the edits it cannot make
without naming the construct and the line
(:class:`UnsupportedSceneSyntaxError`).

The classification is not a list of these five. It is read back from what parso
could not parse (:func:`syntax_issues`), so a file broken in any other way is
refused on the same terms with the parser's own message.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, NamedTuple

import parso

if TYPE_CHECKING:
    from parso.python.tree import Module
    from parso.tree import Leaf


__all__ = [
    "SyntaxIssue",
    "UnsupportedSceneSyntaxError",
    "explain_at",
    "first_unexplained",
    "issue_lines",
    "refusal",
    "syntax_issues",
]

#: Where the subset is written down, quoted at the end of every refusal so the
#: message points at the whole rule rather than only the line that broke it.
SUBSET_REFERENCE = "simvx.core.scene_io.syntax"


[docs] class UnsupportedSceneSyntaxError(ValueError): """The scene holds source the editor's parser cannot read. Raised by the structural edits on :class:`~simvx.core.scene_io.SceneClass` -- adding, removing or reordering a child -- when the lines the edit would rewrite sit inside a construct parso could not parse. The file still loads, still runs and still opens for reading: what is refused is the rewrite, because a removal inside a block the parser flattened emits a file that raises ``IndentationError``. :attr:`issues` carries the parse issues behind the refusal, first one first, for a caller that wants to place a marker rather than show a message. """ def __init__(self, message: str, issues: list[SyntaxIssue]) -> None: self.issues: list[SyntaxIssue] = list(issues) super().__init__(message)
[docs] class SyntaxIssue(NamedTuple): """One place parso could not read, and the construct that explains it.""" #: 1-indexed line, matching the editor's cursor convention. line: int #: 0-indexed column, as parso reports it. column: int #: The construct the line holds (``"a `match` statement"``), or ``None`` #: when the tokens on it match none of the known ones -- source that is #: simply malformed, which is refused on the same terms. construct: str | None #: parso's own message, kept so a refusal over unrecognised source still #: says something true about it. message: str
[docs] def describe(self) -> str: """One line naming the construct and where it is, for a refusal.""" what = self.construct or "source that does not parse" return f"{what} on line {self.line}"
[docs] def syntax_issues(module: Module) -> list[SyntaxIssue]: """Every place parso could not read ``module``, classified, in source order. Empty for a module that parses, which is the ordinary case and the one this is cheapest on: ``iter_errors`` walks the tree once and the classification runs only over the lines it reports. Requires a module parsed with ``error_recovery=True``; a strict parse raises before there is a tree to ask. """ grammar = parso.load_grammar() issues = list(grammar.iter_errors(module)) if not issues: return [] lines = {issue.start_pos[0] for issue in issues} constructs = _constructs_by_line(module, lines) return [ SyntaxIssue( line=issue.start_pos[0], column=issue.start_pos[1], construct=constructs.get(issue.start_pos[0]), message=issue.message, ) for issue in issues ]
[docs] def refusal(issues: list[SyntaxIssue], *, subject: str, consequence: str) -> UnsupportedSceneSyntaxError: """The error for a refusal, naming the first construct and its line. ``subject`` is what holds it (a path, ``"this scene"``) and ``consequence`` says what is being refused, in a whole sentence -- the two halves this cannot know. Source that is simply malformed gets the parser's own message and none of the subset talk: telling the author of a truncated file that it "loads and runs" would be a falsehood, and pointing them at a list of five constructs theirs is not on would send them looking in the wrong place. """ if not issues: # pragma: no cover - a refusal is only built over issues return UnsupportedSceneSyntaxError(f"{subject} does not parse", []) first = issues[0] if first.construct is None: return UnsupportedSceneSyntaxError( f"{subject} does not parse: {first.message} at line {first.line}, column {first.column}", issues, ) return UnsupportedSceneSyntaxError( f"{subject} holds {first.describe()}, which the scene editor's parser cannot read. " f"{consequence} The subset it reads is documented in {SUBSET_REFERENCE}.", issues, )
[docs] def issue_lines(issues: list[SyntaxIssue]) -> set[int]: """The line numbers ``issues`` covers, for a membership test.""" return {issue.line for issue in issues}
[docs] def first_unexplained(issues: list[SyntaxIssue]) -> SyntaxIssue | None: """The first issue no documented construct accounts for, or ``None``. ``None`` for a scene that is outside parso's grammar and inside the format's documented subset: one construct it cannot read, and then the indentation errors its recovery reports over the block body it lifted out. Such a file opens for reading and refuses its edits. Anything else -- a truncated file, an unclosed bracket -- has an issue with no classified construct at or above it, and stays the hard refusal at load that it has always been. There is nothing to show a reader of a file that is simply broken, and no subset rule to point them at. """ for issue in issues: explanation = explain_at(issues, issue.line) if explanation is None or explanation.construct is None: return issue return None
[docs] def explain_at(issues: list[SyntaxIssue], line: int) -> SyntaxIssue | None: """The issue that explains ``line``: the one on it, else the last one above it. A block the parser could not read reports its header (``match mode:``) and then an indentation error on each line of the body it lifted out, and the header is what a reader needs to be told. So a line inside the block is explained by the nearest issue at or before it, which is the header when the body's own line carries none. """ best: SyntaxIssue | None = None for issue in issues: if issue.line > line: break if issue.construct is not None or best is None: best = issue return best
# --------------------------------------------------------------------------- # Classification # --------------------------------------------------------------------------- def _constructs_by_line(module: Module, lines: set[int]) -> dict[int, str]: """Name the construct on each of ``lines``, from the tokens parso kept. Read off the leaves rather than the text: recovery keeps every token, including the ones it could not fit into a node, so the line's tokens are still there to be asked and no re-lexing or text matching is needed. """ tokens: dict[int, list[tuple[str, str]]] = {line: [] for line in lines} leaf: Leaf | None = module.get_first_leaf() while leaf is not None: bucket = tokens.get(leaf.start_pos[0]) if bucket is not None and leaf.value.strip(): bucket.append((leaf.type, leaf.value)) leaf = leaf.get_next_leaf() named: dict[int, str] = {} for line, line_tokens in tokens.items(): construct = _classify(line_tokens) if construct is not None: named[line] = construct return named def _classify(tokens: list[tuple[str, str]]) -> str | None: """Which unsupported construct these tokens spell, if any.""" if not tokens: return None values = [value for _kind, value in tokens] kinds = [kind for kind, _value in tokens] head = values[0] if head in ("match", "case") and values[-1] == ":": return "a `match` statement" if head == "except": if len(values) > 1 and values[1] == "*": return "an `except*` clause" if "," in values[1:]: return "an unparenthesised `except A, B:` clause" if head == "type" and len(values) > 2 and kinds[1] == "name" and values[2] == "=": return "a `type` alias statement" if "fstring_start" in kinds and "error_leaf" in kinds: return "an f-string quoted with the character it nests (PEP 701)" return None