tidy3syntax 0.1.1__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,39 @@
1
+ """R syntax in notebook cells, rewritten to Python, shared by tidy3, tidy3a, plot3 and stats3.
2
+
3
+ Each package describes its syntax as a :class:`Dialect`; :func:`rewrite`
4
+ turns a cell into Python for all of them at once. Repairs (text that is not
5
+ Python) run by the call's name; reinterpretations (valid Python a package
6
+ reads differently) run only in the package's own calls. See
7
+ ``docs/design.md``.
8
+ """
9
+
10
+ from tidy3syntax.__version__ import __version__
11
+ from tidy3syntax.dialect import Dialect
12
+ from tidy3syntax.ownership import (
13
+ ModuleRef,
14
+ Owners,
15
+ imported_names,
16
+ module_of,
17
+ owned_calls,
18
+ )
19
+ from tidy3syntax.pipeline import reinterpret, rewrite
20
+ from tidy3syntax.placeholders import ASSIGN, NAME, RAssign, RName, runtime_values
21
+ from tidy3syntax.repairs import repair
22
+
23
+ __all__ = [
24
+ "ASSIGN",
25
+ "NAME",
26
+ "Dialect",
27
+ "ModuleRef",
28
+ "Owners",
29
+ "RAssign",
30
+ "RName",
31
+ "__version__",
32
+ "imported_names",
33
+ "module_of",
34
+ "owned_calls",
35
+ "reinterpret",
36
+ "repair",
37
+ "rewrite",
38
+ "runtime_values",
39
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
tidy3syntax/dialect.py ADDED
@@ -0,0 +1,94 @@
1
+ """A package's R syntax, described as data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass, field
8
+ from typing import Literal
9
+
10
+
11
+ def _names(values: Iterable[str]) -> frozenset[str]:
12
+ if isinstance(values, str):
13
+ raise TypeError(f"expected a collection of names, not the string {values!r}")
14
+ return frozenset(values)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Dialect:
19
+ """Which calls of one package take which piece of R syntax.
20
+
21
+ Fields under *repairs* name calls whose arguments may hold text that is
22
+ not Python; tidy3syntax rewrites those by the call's name, whoever owns
23
+ the call. Fields under *reinterpretations* name calls whose arguments
24
+ may hold valid Python that the package reads differently; tidy3syntax
25
+ rewrites those only in calls the package owns (see ``ownership``).
26
+ Names are the functions' names, without a module prefix.
27
+ """
28
+
29
+ package: str
30
+ # repairs
31
+ negation_calls: frozenset[str] = frozenset() # !x -> ~x
32
+ ordered_calls: frozenset[str] = frozenset() # name = value before a positional argument
33
+ settings: frozenset[str] = frozenset() # keywords of ordered calls that are never moved
34
+ rule_calls: frozenset[str] = frozenset() # left ~ right -> (left, right)
35
+ rule_after_column: frozenset[str] = frozenset() # rule calls whose first argument is a column
36
+ formula_calls: frozenset[str] = frozenset() # y ~ x -> "y ~ x"
37
+ formula_keywords: frozenset[str] = frozenset() # keywords whose value may be a formula (facets=)
38
+ formula_backticks: Literal["keep", "strip"] = "keep" # backtick names inside a quoted formula
39
+ stages: tuple[Callable[[str], str], ...] = () # the package's own repairs, run after the shared ones
40
+ # Which module prefixes make ``prefix.name(...)`` one of these calls: None for any
41
+ # (``ta.filter``), an empty set for bare calls only (so ``df.select(...)``, a method,
42
+ # is never rewritten), or the names allowed (``{"stats3"}``).
43
+ prefixes: frozenset[str] | None = None
44
+ # reinterpretations
45
+ power_calls: frozenset[str] = frozenset() # ^ -> **
46
+ unary_formula_calls: frozenset[str] = frozenset() # ~x -> "~x"
47
+ # The names the package exports; None reads the package's __all__ when first needed.
48
+ exports: frozenset[str] | None = field(default=None, compare=False)
49
+
50
+ def __post_init__(self) -> None:
51
+ for name in (
52
+ "negation_calls", "ordered_calls", "settings", "rule_calls", "rule_after_column",
53
+ "formula_calls", "formula_keywords", "power_calls", "unary_formula_calls",
54
+ ):
55
+ object.__setattr__(self, name, _names(getattr(self, name)))
56
+ if self.exports is not None:
57
+ object.__setattr__(self, "exports", _names(self.exports))
58
+ if self.prefixes is not None:
59
+ object.__setattr__(self, "prefixes", _names(self.prefixes))
60
+ if not self.rule_after_column <= self.rule_calls:
61
+ raise ValueError("rule_after_column must be a subset of rule_calls")
62
+ if self.formula_backticks not in ("keep", "strip"):
63
+ raise ValueError('formula_backticks is "keep" or "strip"')
64
+ if not self.package or "." in self.package:
65
+ raise ValueError("package is a top-level module name, such as 'tidy3a'")
66
+
67
+ def exported(self) -> frozenset[str]:
68
+ """The package's public names: ``exports``, or its ``__all__``."""
69
+ if self.exports is not None:
70
+ return self.exports
71
+ try:
72
+ module = importlib.import_module(self.package)
73
+ except Exception:
74
+ names: frozenset[str] = frozenset()
75
+ else:
76
+ names = frozenset(getattr(module, "__all__", ()) or (n for n in dir(module) if not n.startswith("_")))
77
+ object.__setattr__(self, "exports", names)
78
+ return names
79
+
80
+ def accepts_prefix(self, prefix: str | None) -> bool:
81
+ """Whether a call written ``prefix.name(...)`` (``None`` for a bare call) can be one of this dialect's."""
82
+ return prefix is None or self.prefixes is None or prefix in self.prefixes
83
+
84
+ def lists(self, field: str, name: str, prefix: str | None) -> bool:
85
+ """Whether the call ``prefix.name(...)`` is in this dialect's *field*."""
86
+ return name in getattr(self, field) and self.accepts_prefix(prefix)
87
+
88
+ def owns_module(self, module: str | None) -> bool:
89
+ """Whether a dotted module path belongs to this package."""
90
+ return module is not None and (module == self.package or module.startswith(self.package + "."))
91
+
92
+ @property
93
+ def reinterprets(self) -> bool:
94
+ return bool(self.power_calls or self.unary_formula_calls)
@@ -0,0 +1,207 @@
1
+ """Which package a call in a cell belongs to.
2
+
3
+ A reinterpretation changes the meaning of valid Python, so it runs only in
4
+ calls of the package that reads the text that way. The rule, as tidy3
5
+ settled it:
6
+
7
+ 1. A local variable of an enclosing function, lambda or comprehension is
8
+ the notebook's own.
9
+ 2. Otherwise the latest top-level binding of the name in the cell before
10
+ the call decides: an import gives that module (a star import gives a
11
+ package's exported names), and any other binding (``def``, assignment,
12
+ ``for`` target, ...) makes the name the notebook's own.
13
+ 3. Otherwise the context decides: in IPython, the module of the live value.
14
+ 4. A name the context does not have is the package's that exports it, when
15
+ exactly one does. Such a cell fails with a NameError whatever a rewrite
16
+ does, so this changes no result and keeps error messages as they were.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ast
22
+ from collections.abc import Iterable, Mapping, Sequence
23
+ from types import ModuleType
24
+ from typing import Any
25
+
26
+ from tidy3syntax.dialect import Dialect
27
+
28
+
29
+ class ModuleRef(str):
30
+ """A context value that only says which module a name came from, for contexts
31
+ without live values, such as an export that read the earlier cells' imports."""
32
+
33
+ __slots__ = ()
34
+
35
+
36
+ def module_of(value: Any) -> str | None:
37
+ """The dotted module a value comes from: a module's own name, a function's or
38
+ class's ``__module__``, or the module of an instance's type."""
39
+ if isinstance(value, ModuleRef):
40
+ return str(value)
41
+ if isinstance(value, ModuleType):
42
+ return value.__name__
43
+ if isinstance(value, type) or callable(value):
44
+ module = getattr(value, "__module__", None)
45
+ if isinstance(module, str):
46
+ return module
47
+ return type(value).__module__
48
+
49
+
50
+ _SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
51
+
52
+
53
+ class Owners:
54
+ """The bindings of one parsed cell, and the rule above applied to them."""
55
+
56
+ def __init__(self, tree: ast.Module, context: Mapping[str, Any] | None, dialects: Sequence[Dialect]):
57
+ self.context = context
58
+ self.dialects = tuple(dialects)
59
+ self.bindings: dict[str, list[tuple[tuple[int, int], str | None]]] = {}
60
+ stack: list[ast.AST] = list(tree.body)
61
+ while stack:
62
+ node = stack.pop()
63
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
64
+ self._bind(node.name, node, None)
65
+ continue
66
+ if isinstance(node, _SCOPES):
67
+ continue
68
+ if isinstance(node, ast.Import):
69
+ for alias in node.names:
70
+ if alias.asname:
71
+ self._bind(alias.asname, node, alias.name)
72
+ else:
73
+ top = alias.name.split(".")[0]
74
+ self._bind(top, node, top)
75
+ elif isinstance(node, ast.ImportFrom):
76
+ module = node.module if node.module and node.level == 0 else None
77
+ for alias in node.names:
78
+ if alias.name == "*":
79
+ for dialect in self.dialects:
80
+ if dialect.owns_module(module):
81
+ for name in dialect.exported():
82
+ self._bind(name, node, module)
83
+ else:
84
+ self._bind(alias.asname or alias.name, node, module)
85
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
86
+ self._bind(node.id, node, None)
87
+ elif isinstance(node, ast.ExceptHandler) and node.name:
88
+ self._bind(node.name, node, None)
89
+ stack.extend(ast.iter_child_nodes(node))
90
+ for found in self.bindings.values():
91
+ found.sort(key=lambda item: item[0])
92
+
93
+ def _bind(self, name: str, node: ast.AST, module: str | None) -> None:
94
+ end = (getattr(node, "end_lineno", None) or node.lineno, getattr(node, "end_col_offset", None) or 0)
95
+ self.bindings.setdefault(name, []).append((end, module))
96
+
97
+ def module(self, name: str, at: tuple[int, int]) -> str | None:
98
+ """The module *name* comes from when used at ``(line, byte column)`` *at*; ``None`` for the notebook's own."""
99
+ before = [module for end, module in self.bindings.get(name, ()) if end <= at]
100
+ if before:
101
+ return before[-1]
102
+ if self.context is not None and name in self.context:
103
+ return module_of(self.context[name])
104
+ exporting = [d.package for d in self.dialects if name in d.exported()]
105
+ return exporting[0] if len(exporting) == 1 else None
106
+
107
+ def owner(self, func: ast.AST, at: tuple[int, int], local: Iterable[str] = ()) -> str | None:
108
+ """The package of the dialects that a call's function belongs to, or ``None``."""
109
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
110
+ name = func.value.id
111
+ elif isinstance(func, ast.Name):
112
+ name = func.id
113
+ else:
114
+ return None
115
+ if name in set(local):
116
+ return None
117
+ module = self.module(name, at)
118
+ for dialect in self.dialects:
119
+ if dialect.owns_module(module):
120
+ return dialect.package
121
+ return None
122
+
123
+
124
+ def local_names(node: ast.AST) -> set[str]:
125
+ """Parameters and names bound in a function's, lambda's or comprehension's own scope."""
126
+ names: set[str] = set()
127
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
128
+ arguments = node.args
129
+ names.update(a.arg for a in [*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs])
130
+ names.update(a.arg for a in (arguments.vararg, arguments.kwarg) if a is not None)
131
+ body = node.body if isinstance(node.body, list) else [node.body]
132
+ else:
133
+ body = [generator.target for generator in getattr(node, "generators", [])]
134
+ stack = list(body)
135
+ while stack:
136
+ child = stack.pop()
137
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
138
+ names.add(child.name)
139
+ continue
140
+ if isinstance(child, _SCOPES):
141
+ continue
142
+ if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store):
143
+ names.add(child.id)
144
+ stack.extend(ast.iter_child_nodes(child))
145
+ return names
146
+
147
+
148
+ def owned_calls(
149
+ source: str,
150
+ tree: ast.Module,
151
+ dialects: Sequence[Dialect],
152
+ context: Mapping[str, Any] | None,
153
+ names: Iterable[str],
154
+ ) -> dict[int, str]:
155
+ """For each call to one of *names* that a dialect's package owns: the offset in *source*
156
+ just past the function's name, mapped to that package."""
157
+ owners = Owners(tree, context, dialects)
158
+ lines = source.split("\n")
159
+ starts = [0]
160
+ for line in lines:
161
+ starts.append(starts[-1] + len(line) + 1)
162
+ wanted = frozenset(names)
163
+ found: dict[int, str] = {}
164
+
165
+ def visit(node: ast.AST, local: frozenset[str]) -> None:
166
+ if isinstance(node, _SCOPES):
167
+ local = local | local_names(node)
168
+ if isinstance(node, ast.Call):
169
+ func = node.func
170
+ name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
171
+ if name in wanted:
172
+ package = owners.owner(func, (func.lineno, func.col_offset), local)
173
+ if package is not None and func.end_lineno is not None and func.end_col_offset is not None:
174
+ row = func.end_lineno
175
+ column = len(lines[row - 1].encode()[: func.end_col_offset].decode(errors="ignore"))
176
+ found[starts[row - 1] + column] = package
177
+ for child in ast.iter_child_nodes(node):
178
+ visit(child, local)
179
+
180
+ visit(tree, frozenset())
181
+ return found
182
+
183
+
184
+ def imported_names(*sources: str, dialects: Sequence[Dialect] = ()) -> dict[str, ModuleRef]:
185
+ """The names the cells' top-level imports bind, in order, as a context for a rewrite
186
+ that has no live namespace (an export reading a notebook's earlier cells). A star
187
+ import binds the exported names of the *dialects* whose package it imports."""
188
+ names: dict[str, ModuleRef] = {}
189
+ for source in sources:
190
+ try:
191
+ tree = ast.parse(source)
192
+ except SyntaxError:
193
+ continue
194
+ for node in tree.body:
195
+ if isinstance(node, ast.Import):
196
+ for alias in node.names:
197
+ target = alias.asname or alias.name.split(".")[0]
198
+ names[target] = ModuleRef(alias.name if alias.asname else alias.name.split(".")[0])
199
+ elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
200
+ for alias in node.names:
201
+ if alias.name != "*":
202
+ names[alias.asname or alias.name] = ModuleRef(node.module)
203
+ else:
204
+ for dialect in dialects:
205
+ if dialect.owns_module(node.module):
206
+ names.update(dict.fromkeys(dialect.exported(), ModuleRef(node.module)))
207
+ return names
@@ -0,0 +1,116 @@
1
+ """The rewrite of one cell: repairs, then ownership, then reinterpretations.
2
+
3
+ Phase A runs every repair by name. Phase B parses the result and reads who
4
+ owns each call that a reinterpretation would touch; a cell that does not
5
+ parse keeps Python's meaning. Phase C applies the reinterpretations whose
6
+ calls the dialect's package owns, and those of packages the caller listed in
7
+ ``always`` (an explicit scope the user turned on).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ from collections.abc import Collection, Mapping, Sequence
14
+ from typing import Any
15
+
16
+ from tidy3syntax.dialect import Dialect
17
+ from tidy3syntax.ownership import owned_calls
18
+ from tidy3syntax.repairs import formula_text, is_shell_cell, repair
19
+ from tidy3syntax.scanner import (
20
+ Call,
21
+ Spans,
22
+ apply,
23
+ calls,
24
+ keyword,
25
+ strip_span,
26
+ top_level_tildes,
27
+ )
28
+
29
+
30
+ def rewrite(
31
+ source: str,
32
+ dialects: Sequence[Dialect],
33
+ context: Mapping[str, Any] | None = None,
34
+ always: Collection[str] = (),
35
+ ) -> str:
36
+ """The cell *source* as Python, with every dialect's R syntax rewritten.
37
+
38
+ *context* answers who owns a name the cell does not bind: an IPython
39
+ namespace, or ``imported_names(...)`` of earlier cells. *always* lists
40
+ packages whose reinterpretations apply whoever owns the call.
41
+ """
42
+ if is_shell_cell(source):
43
+ return source
44
+ source = repair(source, dialects)
45
+ return reinterpret(source, dialects, context, always)
46
+
47
+
48
+ def reinterpret(
49
+ source: str,
50
+ dialects: Sequence[Dialect],
51
+ context: Mapping[str, Any] | None = None,
52
+ always: Collection[str] = (),
53
+ ) -> str:
54
+ """Phases B and C on text the repairs have already run on."""
55
+ active = [d for d in dialects if d.reinterprets]
56
+ if not active or ("^" not in source and "~" not in source):
57
+ return source
58
+ names = frozenset().union(*(d.power_calls | d.unary_formula_calls for d in active))
59
+ spans = Spans.of(source)
60
+ found = calls(source, names, spans)
61
+ if not found:
62
+ return source
63
+ try:
64
+ tree = ast.parse(source)
65
+ except (SyntaxError, ValueError):
66
+ owned: dict[int, str] = {}
67
+ else:
68
+ owned = owned_calls(source, tree, active, context, names)
69
+
70
+ def applies(call: Call, field: str) -> bool:
71
+ for dialect in active:
72
+ if dialect.lists(field, call.name, call.prefix) and (
73
+ owned.get(call.name_end) == dialect.package or dialect.package in always
74
+ ):
75
+ return True
76
+ return False
77
+
78
+ edits = _powers(source, spans, found, lambda call: applies(call, "power_calls"))
79
+ edits += _unary_formulas(source, active, found, lambda call: applies(call, "unary_formula_calls"))
80
+ return apply(source, edits)
81
+
82
+
83
+ def _powers(source: str, spans: Spans, found: list[Call], applies) -> list[tuple[int, int, str]]:
84
+ """``^`` → ``**`` where the nearest enclosing power call applies."""
85
+ edits = []
86
+ candidates = [call for call in found if "^" in source[call.open : call.close]]
87
+ for i in spans.code_indices():
88
+ if source[i] != "^" or source[i + 1 : i + 2] == "=":
89
+ continue
90
+ enclosing = [call for call in candidates if call.open < i < call.close]
91
+ if enclosing and applies(max(enclosing, key=lambda call: call.open)):
92
+ edits.append((i, i + 1, "**"))
93
+ return edits
94
+
95
+
96
+ def _unary_formulas(source: str, dialects: Sequence[Dialect], found: list[Call], applies) -> list[tuple[int, int, str]]:
97
+ """``facet_wrap(~cyl)`` → ``facet_wrap('~cyl')`` in unary-formula calls that apply."""
98
+ keywords = frozenset().union(*(d.formula_keywords for d in dialects))
99
+ edits = []
100
+ for call in found:
101
+ if not applies(call):
102
+ continue
103
+ strip = all(
104
+ d.formula_backticks == "strip" for d in dialects if call.name in d.unary_formula_calls
105
+ )
106
+ for a, b in call.arguments:
107
+ named = keyword(source[a:b])
108
+ if named is not None:
109
+ if named[0] not in keywords:
110
+ continue
111
+ a += named[1]
112
+ a, b = strip_span(source, a, b)
113
+ tildes = top_level_tildes(source[a:b])
114
+ if tildes and not any(is_binary for _, is_binary in tildes):
115
+ edits.append((a, b, repr(formula_text(source[a:b], strip))))
116
+ return edits
@@ -0,0 +1,47 @@
1
+ """The placeholders repairs leave in the text, and their values at run time.
2
+
3
+ A repair runs by the call's name, so its output must not depend on which
4
+ package owns the call. Where the reading does depend on the owner, the
5
+ repair leaves a neutral placeholder, and the owner's AST pass decides what
6
+ it means inside the owner's calls. Where no AST pass claims one, its
7
+ run-time value below is what Python sees.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ NAME = "__r_name__" # `First Name` -> __r_name__("First Name")
15
+ ASSIGN = "__r_assign__" # name = value before a positional argument -> __r_assign__("name", value)
16
+
17
+
18
+ class RName(str):
19
+ """A name written in backticks: a column name that need not be a Python identifier."""
20
+
21
+ __slots__ = ()
22
+
23
+ def __repr__(self) -> str:
24
+ return f"{NAME}({str.__repr__(self)})"
25
+
26
+
27
+ class RAssign:
28
+ """An assignment in the order written: ``target`` is a name, or a list of names; ``value`` is anything."""
29
+
30
+ __slots__ = ("target", "value")
31
+
32
+ def __init__(self, target: Any, value: Any):
33
+ self.target = target
34
+ self.value = value
35
+
36
+ def __repr__(self) -> str:
37
+ return f"{ASSIGN}({self.target!r}, {self.value!r})"
38
+
39
+ def __eq__(self, other: object) -> bool:
40
+ return isinstance(other, RAssign) and (self.target, self.value) == (other.target, other.value)
41
+
42
+ __hash__ = None # type: ignore[assignment]
43
+
44
+
45
+ def runtime_values() -> dict[str, Any]:
46
+ """The placeholders' values, for a namespace that runs rewritten cells."""
47
+ return {NAME: RName, ASSIGN: RAssign}
tidy3syntax/py.typed ADDED
File without changes
tidy3syntax/repairs.py ADDED
@@ -0,0 +1,308 @@
1
+ """Repairs: rewrites of text that is not Python.
2
+
3
+ Each repair changes only text that is a syntax error, so no valid program
4
+ can mean anything else by it, and it runs by the call's name, whoever owns
5
+ the call. Each one leaves a cell that parses unchanged, and each is
6
+ idempotent; the tests check both on every case and on the standard
7
+ library's own source.
8
+
9
+ Adapted from tidy3's ``masking.py`` (``rewrite_bang_not``,
10
+ ``rewrite_formula_rules``, ``rewrite_keywords_before_positional``,
11
+ ``rewrite_backtick_keyword_assigns``), tidy3a's ``jupyter.py``, plot3's
12
+ ``masking.py`` (facet formulas) and stats3's ``masking.py``
13
+ (``quote_formulas``), all MIT.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import re
20
+ from collections.abc import Iterable, Sequence
21
+
22
+ from tidy3syntax.dialect import Dialect
23
+ from tidy3syntax.placeholders import ASSIGN, NAME
24
+ from tidy3syntax.scanner import (
25
+ Call,
26
+ Spans,
27
+ apply,
28
+ calls,
29
+ keyword,
30
+ strip_span,
31
+ top_level_tildes,
32
+ )
33
+
34
+ _BACKTICK = re.compile(r"`([^`\n]+)`")
35
+ # Placeholders a package's own pass may have left before tidy3syntax ran (tidy3, plot3), and tidy3syntax's own.
36
+ _NAME_CALL = re.compile(r"__(?:r_name|tidy3_bt|plot3_bt)__\((['\"])(.*?)\1\)")
37
+
38
+
39
+ def _union(dialects: Iterable[Dialect], field: str) -> frozenset[str]:
40
+ return frozenset().union(*(getattr(d, field) for d in dialects))
41
+
42
+
43
+ def _listed(call: Call, dialects: Iterable[Dialect], field: str) -> bool:
44
+ """Whether some dialect lists *call*, with its prefix, in *field*."""
45
+ return any(d.lists(field, call.name, call.prefix) for d in dialects)
46
+
47
+
48
+ def is_shell_cell(source: str) -> bool:
49
+ """A cell of IPython shell lines (``!cmd``) and comments only; no repair touches it."""
50
+ lines = [line.strip() for line in source.splitlines() if line.strip()]
51
+ return bool(lines) and lines[0].startswith("!") and all(line.startswith(("!", "#")) for line in lines)
52
+
53
+
54
+ def _parses(text: str) -> bool:
55
+ try:
56
+ ast.parse(text.strip(), mode="eval")
57
+ except SyntaxError:
58
+ return False
59
+ return True
60
+
61
+
62
+ # ── formulas ─────────────────────────────────────────────────────────────────
63
+
64
+
65
+ def quote_formulas(source: str, dialects: Sequence[Dialect]) -> str:
66
+ """``lm(y ~ x)`` → ``lm('y ~ x')`` in formula calls; ``facet_wrap(~`my col`)`` → ``facet_wrap('~my col')``.
67
+
68
+ A formula with a left side (``y ~ x``, ``. ~ cyl``) is not Python, so it
69
+ is quoted in any formula call. A one-sided formula is quoted here only
70
+ when its text does not parse either (``` ~`my col` ```); ``~x`` is
71
+ bitwise not, and is left for the reinterpretation of unary-formula calls.
72
+ Keyword arguments are formulas only for the dialects' formula keywords.
73
+ """
74
+ binary = _union(dialects, "formula_calls")
75
+ unary = _union(dialects, "unary_formula_calls")
76
+ if "~" not in source or not (binary | unary):
77
+ return source
78
+ spans = Spans.of(source)
79
+ keywords = _union(dialects, "formula_keywords")
80
+ edits = []
81
+ for call in calls(source, binary | unary, spans):
82
+ for a, b in call.arguments:
83
+ named = keyword(source[a:b])
84
+ if named is not None:
85
+ if named[0] not in keywords:
86
+ continue
87
+ a += named[1]
88
+ a, b = strip_span(source, a, b)
89
+ text = source[a:b]
90
+ tildes = top_level_tildes(text)
91
+ if not tildes:
92
+ continue
93
+ has_left = any(is_binary for _, is_binary in tildes)
94
+ if has_left and not _listed(call, dialects, "formula_calls"):
95
+ continue
96
+ if not has_left and (not _listed(call, dialects, "unary_formula_calls") or _parses(text)):
97
+ continue
98
+ edits.append((a, b, repr(formula_text(text, _strips(dialects, call.name)))))
99
+ return _outermost_last(edits, source)
100
+
101
+
102
+ def _strips(dialects: Sequence[Dialect], name: str) -> bool:
103
+ listing = [d for d in dialects if name in d.formula_calls or name in d.unary_formula_calls]
104
+ return bool(listing) and all(d.formula_backticks == "strip" for d in listing)
105
+
106
+
107
+ def formula_text(text: str, strip_backticks: bool) -> str:
108
+ """A formula's text as a string: whitespace collapsed, backtick names kept or stripped."""
109
+ text = _NAME_CALL.sub(lambda m: f"`{m.group(2)}`", text)
110
+ if strip_backticks:
111
+ text = text.replace("`", "")
112
+ return " ".join(text.split())
113
+
114
+
115
+ def rule_pairs(source: str, dialects: Sequence[Dialect]) -> str:
116
+ """``case_when(x > 6 ~ "big")`` → ``case_when((x > 6, "big"))`` in rule calls.
117
+
118
+ ``~`` before a value stays negation (``~is_na(x) ~ 0`` is the rule
119
+ ``(~is_na(x), 0)``); keyword arguments and ``*args`` are left alone.
120
+ Raises SyntaxError, naming the function, for a rule that is not exactly
121
+ ``left ~ right``, and for a rule where a rule-after-column call expects
122
+ its column.
123
+ """
124
+ names = _union(dialects, "rule_calls")
125
+ if "~" not in source or not names:
126
+ return source
127
+ return _until_settled(lambda source: _rules_pass(source, names, dialects), source)
128
+
129
+
130
+ def _rules_pass(source: str, names: frozenset[str], dialects: Sequence[Dialect]) -> str:
131
+ spans = Spans.of(source)
132
+ edits = []
133
+ for call in calls(source, names, spans):
134
+ if not _listed(call, dialects, "rule_calls"):
135
+ continue
136
+ for position, (a, b) in enumerate(call.arguments):
137
+ text = source[a:b]
138
+ if keyword(text) or text.lstrip().startswith("*"):
139
+ continue
140
+ separators = [i for i, is_binary in top_level_tildes(text) if is_binary]
141
+ if not separators:
142
+ continue
143
+ shown = text.strip()
144
+ if position == 0 and _listed(call, dialects, "rule_after_column"):
145
+ raise SyntaxError(f"{call.name}() takes the column first, then rules: {shown!r} is a rule, not a column")
146
+ if len(separators) > 1:
147
+ raise SyntaxError(
148
+ f"{call.name}() rule {shown!r} has more than one ~; write one `left ~ right` per rule, "
149
+ "with parentheses around anything that itself contains ~"
150
+ )
151
+ left, right = text[: separators[0]].strip(), text[separators[0] + 1 :].strip()
152
+ if not left or not right:
153
+ raise SyntaxError(f"{call.name}() rule {shown!r} needs a value on both sides of ~")
154
+ a, b = strip_span(source, a, b)
155
+ edits.append((a, b, f"({left}, {right})"))
156
+ return _outermost_last(edits, source)
157
+
158
+
159
+ # ── negation ─────────────────────────────────────────────────────────────────
160
+
161
+
162
+ def negation(source: str, dialects: Sequence[Dialect]) -> str:
163
+ """``filter(!(x > 1))`` → ``filter(~(x > 1))`` inside negation calls.
164
+
165
+ Also at the start of a line inside the call. ``!=``, strings, comments,
166
+ and shell lines outside a call (``!pip install …``, ``x = !ls``) stay.
167
+ """
168
+ names = _union(dialects, "negation_calls")
169
+ if "!" not in source or not names:
170
+ return source
171
+ spans = Spans.of(source)
172
+ inside = [(call.open, call.close) for call in calls(source, names, spans) if _listed(call, dialects, "negation_calls")]
173
+ edits = [
174
+ (i, i + 1, "~")
175
+ for i in spans.code_indices()
176
+ if source[i] == "!" and source[i + 1 : i + 2] != "=" and any(a < i < b for a, b in inside)
177
+ ]
178
+ return apply(source, edits)
179
+
180
+
181
+ # ── assignments in the order written ─────────────────────────────────────────
182
+
183
+
184
+ def backtick_assignments(source: str) -> str:
185
+ """`` `new col` = value `` as a call argument → ``__r_assign__('new col', (value))``.
186
+
187
+ A backtick name is not a valid keyword, so the assignment becomes a
188
+ positional placeholder, valid next to other arguments. ``==``, ``<=``,
189
+ ``>=``, ``!=`` and ``=~`` are not assignments.
190
+ """
191
+ if "`" not in source or "=" not in source:
192
+ return source
193
+
194
+ def one_pass(source: str) -> str:
195
+ spans = Spans.of(source)
196
+ edits = []
197
+ for match in re.finditer(r"`([^`\n]+)`\s*=(?![=~])", source):
198
+ if match.start() not in spans or not re.search(r"[(,]\s*\Z", source[: match.start()]):
199
+ continue
200
+ end = _value_end(source, match.end(), spans)
201
+ value = source[match.end() : end].strip()
202
+ if end is not None and value:
203
+ edits.append((match.start(), end, f"{ASSIGN}({match.group(1)!r}, ({value}))"))
204
+ return _outermost_last(edits, source)
205
+
206
+ return _until_settled(one_pass, source)
207
+
208
+
209
+ def _outermost_last(edits: list[tuple[int, int, str]], source: str) -> str:
210
+ """Apply the edits that overlap no later one; an outer edit around an inner one waits for the next pass."""
211
+ kept: list[tuple[int, int, str]] = []
212
+ for edit in sorted(edits, reverse=True):
213
+ if all(edit[1] <= other[0] or edit[0] >= other[1] for other in kept):
214
+ kept.append(edit)
215
+ return apply(source, kept)
216
+
217
+
218
+ def _until_settled(one_pass, source: str) -> str:
219
+ for _ in range(100):
220
+ rewritten = one_pass(source)
221
+ if rewritten == source:
222
+ return source
223
+ source = rewritten
224
+ return source
225
+
226
+
227
+ def _value_end(source: str, start: int, spans: Spans) -> int | None:
228
+ """Where an argument's value that starts at *start* ends: the next top-level ``,`` or the call's ``)``."""
229
+ depth = 0
230
+ for i in spans.code_indices(start):
231
+ ch = source[i]
232
+ if ch in "([{":
233
+ depth += 1
234
+ elif ch in ")]}":
235
+ if depth == 0:
236
+ return i
237
+ depth -= 1
238
+ elif ch == "," and depth == 0:
239
+ return i
240
+ return None
241
+
242
+
243
+ def keywords_before_positional(source: str, dialects: Sequence[Dialect]) -> str:
244
+ """``group_by(w = bins(t), lat)`` → ``group_by(__r_assign__('w', (bins(t))), lat)`` in ordered calls.
245
+
246
+ R takes ``name = value`` before a positional argument and Python does
247
+ not, so each such keyword becomes the ordered placeholder. Keywords
248
+ after the last positional argument, ``*args`` and ``**kwargs``, and the
249
+ dialects' settings are left alone, so valid Python is unchanged.
250
+ """
251
+ names = _union(dialects, "ordered_calls")
252
+ if "=" not in source or not names:
253
+ return source
254
+ settings = _union(dialects, "settings")
255
+ return _until_settled(lambda source: _keywords_pass(source, names, settings, dialects), source)
256
+
257
+
258
+ def _keywords_pass(source: str, names: frozenset[str], settings: frozenset[str], dialects: Sequence[Dialect]) -> str:
259
+ edits = []
260
+ for call in calls(source, names):
261
+ if not _listed(call, dialects, "ordered_calls"):
262
+ continue
263
+ named = [keyword(source[a:b]) for a, b in call.arguments]
264
+ positional = [
265
+ i for i, ((a, b), kw) in enumerate(zip(call.arguments, named))
266
+ if kw is None and not source[a:b].lstrip().startswith("*")
267
+ ]
268
+ if not positional:
269
+ continue
270
+ for (a, b), kw in zip(call.arguments[: positional[-1]], named):
271
+ if kw is None or kw[0] in settings:
272
+ continue
273
+ value = source[a + kw[1] : b].strip()
274
+ a, b = strip_span(source, a, b)
275
+ edits.append((a, b, f"{ASSIGN}({kw[0]!r}, ({value}))"))
276
+ return _outermost_last(edits, source)
277
+
278
+
279
+ # ── backtick names ───────────────────────────────────────────────────────────
280
+
281
+
282
+ def backticks(source: str) -> str:
283
+ """`` `First Name` `` → ``__r_name__('First Name')``, outside strings and comments."""
284
+ if "`" not in source:
285
+ return source
286
+ spans = Spans.of(source)
287
+ edits = [
288
+ (m.start(), m.end(), f"{NAME}({m.group(1)!r})")
289
+ for m in _BACKTICK.finditer(source)
290
+ if m.start() in spans
291
+ ]
292
+ return apply(source, edits)
293
+
294
+
295
+ def repair(source: str, dialects: Sequence[Dialect]) -> str:
296
+ """Every repair, in order: formulas, rules, negation, backtick assignments, the dialects'
297
+ own stages, keywords before positional arguments, then backtick names."""
298
+ if is_shell_cell(source):
299
+ return source
300
+ source = quote_formulas(source, dialects)
301
+ source = rule_pairs(source, dialects)
302
+ source = negation(source, dialects)
303
+ source = backtick_assignments(source)
304
+ for dialect in dialects:
305
+ for stage in dialect.stages:
306
+ source = stage(source)
307
+ source = keywords_before_positional(source, dialects)
308
+ return backticks(source)
tidy3syntax/scanner.py ADDED
@@ -0,0 +1,220 @@
1
+ """Reading cells that are not yet Python: strings, comments, calls, arguments.
2
+
3
+ Every rewrite acts on text Python cannot parse yet (that is why it is
4
+ needed), so the scanner works on characters, not on ``tokenize`` or
5
+ ``ast``. It knows string literals (single, triple, with any prefix),
6
+ comments, and backtick names, and finds calls by name and their top-level
7
+ arguments outside them.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Iterable
14
+ from dataclasses import dataclass
15
+
16
+
17
+ def string_end(source: str, i: int) -> int:
18
+ """The index just past the string literal whose opening quote is at ``source[i]``."""
19
+ quote = source[i]
20
+ if source.startswith(quote * 3, i):
21
+ j = i + 3
22
+ while j < len(source):
23
+ if source[j] == "\\":
24
+ j += 2
25
+ continue
26
+ if source.startswith(quote * 3, j):
27
+ return j + 3
28
+ j += 1
29
+ return len(source)
30
+ j = i + 1
31
+ while j < len(source) and source[j] not in (quote, "\n"):
32
+ j += 2 if source[j] == "\\" else 1
33
+ return min(j + 1, len(source))
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Spans:
38
+ """Where a cell's code is: everything outside string literals and comments.
39
+
40
+ Backtick names count as code, so rewrites can find them, but a backtick
41
+ never opens a string: ```a b``` is one name, and a quote inside it ends
42
+ nothing.
43
+ """
44
+
45
+ code: tuple[tuple[int, int], ...]
46
+
47
+ @classmethod
48
+ def of(cls, source: str) -> Spans:
49
+ spans: list[tuple[int, int]] = []
50
+ start = i = 0
51
+ n = len(source)
52
+ while i < n:
53
+ ch = source[i]
54
+ if ch in "'\"":
55
+ spans.append((start, i))
56
+ i = start = string_end(source, i)
57
+ elif ch == "#":
58
+ spans.append((start, i))
59
+ newline = source.find("\n", i)
60
+ i = start = n if newline < 0 else newline
61
+ elif ch == "`":
62
+ close = source.find("`", i + 1)
63
+ newline = source.find("\n", i + 1)
64
+ i = n if close < 0 else close + 1 if newline < 0 or close < newline else i + 1
65
+ else:
66
+ i += 1
67
+ spans.append((start, n))
68
+ return cls(tuple((a, b) for a, b in spans if b > a))
69
+
70
+ def __contains__(self, index: int) -> bool:
71
+ return any(a <= index < b for a, b in self.code)
72
+
73
+ def code_indices(self, start: int = 0, end: int | None = None) -> Iterable[int]:
74
+ for a, b in self.code:
75
+ lo, hi = max(a, start), b if end is None else min(b, end)
76
+ yield from range(lo, hi)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Call:
81
+ """A call found by name: ``name`` as written (with any ``module.`` prefix), and where its parentheses are."""
82
+
83
+ name: str
84
+ prefix: str | None
85
+ name_end: int # just past the function name
86
+ open: int # index of "("
87
+ close: int # index of ")"
88
+ arguments: tuple[tuple[int, int], ...] # top-level argument spans, between the parentheses
89
+
90
+
91
+ def calls(source: str, names: Iterable[str], spans: Spans | None = None) -> list[Call]:
92
+ """Calls to any of *names*, bare or prefixed (``ta.filter(``), outside strings and comments.
93
+
94
+ A call whose closing parenthesis is missing is left out. Calls nested in
95
+ another call's arguments are found too, in source order.
96
+ """
97
+ wanted = sorted(set(names), key=len, reverse=True)
98
+ if not wanted:
99
+ return []
100
+ spans = spans or Spans.of(source)
101
+ pattern = re.compile(r"(?<![\w.])(?:([A-Za-z_]\w*)\s*\.\s*)?(" + "|".join(map(re.escape, wanted)) + r")\s*\(")
102
+ found = []
103
+ for match in pattern.finditer(source):
104
+ if match.start() not in spans:
105
+ continue
106
+ open_at = match.end() - 1
107
+ parsed = arguments(source, open_at, spans)
108
+ if parsed is None:
109
+ continue
110
+ close, args = parsed
111
+ found.append(Call(match.group(2), match.group(1), match.end(2), open_at, close, tuple(args)))
112
+ return found
113
+
114
+
115
+ def arguments(source: str, open_at: int, spans: Spans | None = None) -> tuple[int, list[tuple[int, int]]] | None:
116
+ """The index of the ``)`` matching ``source[open_at]``, and the top-level argument spans between them.
117
+
118
+ An empty call has no arguments; a trailing comma adds none.
119
+ """
120
+ spans = spans or Spans.of(source)
121
+ depth = 0
122
+ start = open_at + 1
123
+ found: list[tuple[int, int]] = []
124
+ in_name = False # inside a backtick name, where brackets and commas are text
125
+ for i in spans.code_indices(open_at):
126
+ ch = source[i]
127
+ if ch == "`":
128
+ in_name = not in_name
129
+ elif in_name:
130
+ continue
131
+ elif ch in "([{":
132
+ depth += 1
133
+ elif ch in ")]}":
134
+ depth -= 1
135
+ if depth == 0:
136
+ if source[start:i].strip():
137
+ found.append((start, i))
138
+ return i, found
139
+ elif ch == "," and depth == 1:
140
+ found.append((start, i))
141
+ start = i + 1
142
+ return None
143
+
144
+
145
+ # Words after which a "~" is unary ("a and ~b"), not a formula's separator.
146
+ _OPERATOR_WORDS = frozenset(
147
+ {"and", "or", "not", "in", "is", "if", "else", "lambda", "return", "yield", "await", "for"}
148
+ )
149
+
150
+
151
+ def top_level_tildes(text: str) -> list[tuple[int, bool]]:
152
+ """Each ``~`` at the top level of *text*, and whether it is binary.
153
+
154
+ Python's ``~`` is unary only, so a ``~`` after an operand (a name, a
155
+ number, a string, a backtick name, a closing bracket, or R's ``.``) can
156
+ only be a formula's separator: ``y ~ x``, ``. ~ cyl``. ``~x`` and
157
+ ``a and ~b`` are unary. Adapted from tidy3's ``_binary_tildes``.
158
+ """
159
+ found: list[tuple[int, bool]] = []
160
+ depth = 0
161
+ after_operand = False
162
+ i = 0
163
+ n = len(text)
164
+ while i < n:
165
+ c = text[i]
166
+ if c in "'\"":
167
+ i = string_end(text, i)
168
+ after_operand = True
169
+ continue
170
+ if c == "`":
171
+ close = text.find("`", i + 1)
172
+ i = n if close < 0 else close + 1
173
+ after_operand = True
174
+ continue
175
+ if c == "#":
176
+ newline = text.find("\n", i)
177
+ i = n if newline < 0 else newline
178
+ continue
179
+ if c.isalnum() or c == "_":
180
+ j = i
181
+ while j < n and (text[j].isalnum() or text[j] in "_."):
182
+ j += 1
183
+ after_operand = text[i:j] not in _OPERATOR_WORDS
184
+ i = j
185
+ continue
186
+ if c == ".":
187
+ after_operand = True # R's "." in ". ~ cyl"
188
+ elif c in "([{":
189
+ depth += 1
190
+ after_operand = False
191
+ elif c in ")]}":
192
+ depth -= 1
193
+ after_operand = True
194
+ elif c == "~":
195
+ if depth == 0:
196
+ found.append((i, after_operand))
197
+ after_operand = False
198
+ elif not c.isspace():
199
+ after_operand = False
200
+ i += 1
201
+ return found
202
+
203
+
204
+ def keyword(text: str) -> tuple[str, int] | None:
205
+ """``(name, index after "=")`` when an argument's text is ``name = value``; ``None`` otherwise."""
206
+ match = re.match(r"\s*([A-Za-z_]\w*)\s*=(?!=)", text)
207
+ return (match.group(1), match.end()) if match else None
208
+
209
+
210
+ def strip_span(source: str, a: int, b: int) -> tuple[int, int]:
211
+ """*a*..*b* without its leading and trailing whitespace."""
212
+ text = source[a:b]
213
+ return a + len(text) - len(text.lstrip()), b - (len(text) - len(text.rstrip()))
214
+
215
+
216
+ def apply(source: str, edits: Iterable[tuple[int, int, str]]) -> str:
217
+ """Replace each ``(start, end, text)`` span; the spans must not overlap."""
218
+ for start, end, text in sorted(edits, reverse=True):
219
+ source = source[:start] + text + source[end:]
220
+ return source
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: tidy3syntax
3
+ Version: 0.1.1
4
+ Summary: R syntax in notebook cells, rewritten to Python: shared by tidy3, tidy3a, plot3 and stats3
5
+ Author: Rigoberto Leyva Salmeron
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rleyvasal/tidy3syntax
8
+ Keywords: notebooks,ipython,jupyter,R,formulas,tidyverse
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: IPython
11
+ Classifier: Framework :: Jupyter
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7.0; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # tidy3syntax
21
+
22
+ R syntax in notebook cells, rewritten to Python. It is the shared notebook
23
+ layer of [tidy3](https://github.com/rleyvasal/tidy3),
24
+ [tidy3a](https://github.com/rleyvasal/tidy3a),
25
+ [plot3](https://github.com/rleyvasal/plot3) and
26
+ [stats3](https://github.com/rleyvasal/stats3), released on its own so a fix
27
+ reaches all four at once. It needs only the standard library.
28
+
29
+ ```python
30
+ import tidy3syntax as ts
31
+
32
+ stats3 = ts.Dialect("stats3", formula_calls={"t_test", "lm"})
33
+ tidy3 = ts.Dialect("tidy3", negation_calls={"filter"}, ordered_calls={"select"},
34
+ rule_calls={"case_when"})
35
+
36
+ ts.rewrite("penguins >> t_test(body_mass_g ~ sex)", [stats3, tidy3])
37
+ # "penguins >> t_test('body_mass_g ~ sex')"
38
+ ts.rewrite('df >> filter(!is_na(x)) >> select(Cus = NUM, Row)', [stats3, tidy3])
39
+ # "df >> filter(~is_na(x)) >> select(__r_assign__('Cus', (NUM)), Row)"
40
+ ```
41
+
42
+ ## Two kinds of rewrite
43
+
44
+ **Repairs** change only text that is a syntax error in Python, so no valid
45
+ program can mean anything else by it. They run by the call's name, whoever
46
+ owns the call:
47
+
48
+ | Written | Becomes | In calls listed as |
49
+ |---|---|---|
50
+ | `` `First Name` `` | `__r_name__('First Name')` | anywhere in code |
51
+ | `` f(`new col` = x) `` | `f(__r_assign__('new col', (x)))` | any call |
52
+ | `filter(!x)` | `filter(~x)` | `negation_calls` |
53
+ | `select(a = x, b)` | `select(__r_assign__('a', (x)), b)` | `ordered_calls` |
54
+ | `case_when(x > 6 ~ "big")` | `case_when((x > 6, "big"))` | `rule_calls` |
55
+ | `t_test(y ~ g)`, `facet_grid(drv ~ cyl)` | `t_test('y ~ g')` | `formula_calls` |
56
+
57
+ **Reinterpretations** change valid Python, so they run only in calls the
58
+ dialect's package owns:
59
+
60
+ | Written | Becomes | In calls listed as | Python's meaning |
61
+ |---|---|---|---|
62
+ | `geom_function(lambda x: x^2)` | `x**2` | `power_calls` | XOR |
63
+ | `facet_wrap(~cyl)` | `facet_wrap('~cyl')` | `unary_formula_calls` | bitwise not |
64
+
65
+ Who owns a call follows tidy3's rule: a function's local variable is the
66
+ notebook's; otherwise the latest top-level binding in the cell before the call
67
+ decides (an import gives its module, any other binding makes the name the
68
+ notebook's); otherwise the `context` does (an IPython namespace, or
69
+ `imported_names(...)` of earlier cells); a name the context lacks is the
70
+ exporting package's. A cell that does not parse gets no reinterpretation.
71
+ With `always={"plot3"}` a caller turns plot3's reinterpretations on everywhere.
72
+
73
+ ```python
74
+ plot3 = ts.Dialect("plot3", power_calls={"geom_function"}, exports={"geom_function"})
75
+
76
+ ts.rewrite("def geom_function(f):\n return f(3)\nr = geom_function(lambda x: x ^ 2)", [plot3])
77
+ # unchanged: the notebook's own geom_function keeps XOR
78
+ ts.rewrite("from plot3 import *\ngeom_function(lambda x: x ^ 2)", [plot3])
79
+ # "from plot3 import *\ngeom_function(lambda x: x ** 2)"
80
+ ```
81
+
82
+ The placeholders' run-time values are `RName` and `RAssign`
83
+ (`runtime_values()` gives both, for a namespace); each package's own AST pass
84
+ turns them into its column references inside its calls.
85
+
86
+ ## Status
87
+
88
+ Phases 1 to 5 of [the design](docs/design.md): the scanner, every repair,
89
+ the ownership rule and the two reinterpretations, used by tidy3 0.15.1, tidy3a
90
+ 0.16.1, plot3 0.8.1 and stats3 0.6.1. Not yet
91
+ here: the IPython coordinator that installs one transformer for all packages,
92
+ continuation lines (`>>` for tidy3, `+` for plot3), and entry points.
93
+
94
+ Until every package's AST pass reads the neutral placeholders, each package
95
+ calls only the repairs whose output the others already read, and tidy3 and
96
+ plot3 turn `__r_name__` and `__r_assign__` back into their own names
97
+ (`__tidy3_bt__`, `__tidy3_assign__`, `__plot3_bt__`). tidy3a writes
98
+ `__r_assign__` and hands it to tidy3 as `__tidy3_assign__` in tidy3's calls.
99
+
100
+ Where the output differs from a package's own rewrite before the switch:
101
+
102
+ - stats3: a multi-line formula keeps its indentation and trailing comma
103
+ (`t_test(\n 'y ~ g',\n)`), and backtick names in formulas are kept.
104
+ - tidy3: a keyword before a positional argument in a method call
105
+ (`df.select(a = x, b)`) is rewritten like the verb, as `!` in one already
106
+ was; backticks inside strings and comments stay; a backtick assignment is
107
+ rewritten only as a call argument.
108
+ - tidy3a: its placeholder is `__r_assign__` (it still reads `__tidy3a_assign__`).
109
+ - plot3: a placeholder another package left inside a one-sided facet formula
110
+ (`facet_wrap(~__tidy3_bt__('a b'))`) becomes the name again: `'~a b'`.
111
+
112
+ `scripts/family/` runs all five suites together on another machine: `sync.sh`
113
+ copies the working trees and starts `run.sh` in a tmux session there, and
114
+ `status.sh` reports each suite's result and failures.
115
+
116
+ Known limits: a Python 3.12 f-string that nests the same quote
117
+ (`f"{d["k"]}"`) is read as two strings, so a rewrite may see code inside it.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ python -m pip install -e ".[dev]"
123
+ python -m pytest -q
124
+ ```
125
+
126
+ The tests include properties: repairs leave every cell that parses unchanged,
127
+ `rewrite` leaves the standard library's own source unchanged with every
128
+ dialect registered, and rewriting is idempotent.
@@ -0,0 +1,14 @@
1
+ tidy3syntax/__init__.py,sha256=s4fpz-w9lRc0uh0nvbckKSdWwqa2aZxm37-4UL7UEHQ,1046
2
+ tidy3syntax/__version__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
3
+ tidy3syntax/dialect.py,sha256=nbs-aakLHV2AD8uCSkwliauR1tB8iuH5CE94yL6SYlc,4705
4
+ tidy3syntax/ownership.py,sha256=cxtCZKkNzrJ8QABgCTwtzoj6W-LQfwD8MIhXz5wZtUk,9225
5
+ tidy3syntax/pipeline.py,sha256=Eu8JeIW1fPstXHCayh0Imh80XvWr3QTaHKEP23kJ71E,4316
6
+ tidy3syntax/placeholders.py,sha256=u9vvAbvcPTLkaO69WXUX4VFXU3Bna0FJ3M3OtfcJ_Ts,1571
7
+ tidy3syntax/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ tidy3syntax/repairs.py,sha256=OF9tYgZUXLYNuVvWZ-X6MdHCiwdoP51cYn2tQILkU54,12817
9
+ tidy3syntax/scanner.py,sha256=Dn6ElrKpmi1zsORPnXci0-41IYhAuV-0QXQoyPe38NM,7702
10
+ tidy3syntax-0.1.1.dist-info/licenses/LICENSE,sha256=sjpfmtLhkcM74AL9VMpifL0CRdeiMoLyUO9OKZ_hg9I,1082
11
+ tidy3syntax-0.1.1.dist-info/METADATA,sha256=qIcBqbtkVBFMGOmN4xI1yNeasq7RvUDht4BB5fG6I2U,5714
12
+ tidy3syntax-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ tidy3syntax-0.1.1.dist-info/top_level.txt,sha256=EEXnC9-T915VwTE8D-CvWi9TH_bOZpkBTPSEmWhQD0E,12
14
+ tidy3syntax-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rigoberto Leyva Salmeron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ tidy3syntax