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.
- tidy3syntax/__init__.py +39 -0
- tidy3syntax/__version__.py +1 -0
- tidy3syntax/dialect.py +94 -0
- tidy3syntax/ownership.py +207 -0
- tidy3syntax/pipeline.py +116 -0
- tidy3syntax/placeholders.py +47 -0
- tidy3syntax/py.typed +0 -0
- tidy3syntax/repairs.py +308 -0
- tidy3syntax/scanner.py +220 -0
- tidy3syntax-0.1.1.dist-info/METADATA +128 -0
- tidy3syntax-0.1.1.dist-info/RECORD +14 -0
- tidy3syntax-0.1.1.dist-info/WHEEL +5 -0
- tidy3syntax-0.1.1.dist-info/licenses/LICENSE +21 -0
- tidy3syntax-0.1.1.dist-info/top_level.txt +1 -0
tidy3syntax/__init__.py
ADDED
|
@@ -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)
|
tidy3syntax/ownership.py
ADDED
|
@@ -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
|
tidy3syntax/pipeline.py
ADDED
|
@@ -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,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
|