plot3 0.7.0__tar.gz → 0.8.1__tar.gz

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.
Files changed (84) hide show
  1. {plot3-0.7.0 → plot3-0.8.1}/PKG-INFO +2 -1
  2. plot3-0.8.1/plot3/__version__.py +1 -0
  3. {plot3-0.7.0 → plot3-0.8.1}/plot3/geoms.py +1 -1
  4. {plot3-0.7.0 → plot3-0.8.1}/plot3/jupyter.py +9 -3
  5. {plot3-0.7.0 → plot3-0.8.1}/plot3/masking.py +172 -55
  6. {plot3-0.7.0 → plot3-0.8.1}/plot3.egg-info/PKG-INFO +2 -1
  7. {plot3-0.7.0 → plot3-0.8.1}/plot3.egg-info/requires.txt +1 -0
  8. {plot3-0.7.0 → plot3-0.8.1}/pyproject.toml +3 -0
  9. plot3-0.8.1/tests/test_masking.py +437 -0
  10. plot3-0.7.0/plot3/__version__.py +0 -1
  11. plot3-0.7.0/tests/test_masking.py +0 -240
  12. {plot3-0.7.0 → plot3-0.8.1}/LICENSE +0 -0
  13. {plot3-0.7.0 → plot3-0.8.1}/README.md +0 -0
  14. {plot3-0.7.0 → plot3-0.8.1}/plot3/__init__.py +0 -0
  15. {plot3-0.7.0 → plot3-0.8.1}/plot3/aesexpr.py +0 -0
  16. {plot3-0.7.0 → plot3-0.8.1}/plot3/build.py +0 -0
  17. {plot3-0.7.0 → plot3-0.8.1}/plot3/calculus.py +0 -0
  18. {plot3-0.7.0 → plot3-0.8.1}/plot3/compose.py +0 -0
  19. {plot3-0.7.0 → plot3-0.8.1}/plot3/contour.py +0 -0
  20. {plot3-0.7.0 → plot3-0.8.1}/plot3/craft.py +0 -0
  21. {plot3-0.7.0 → plot3-0.8.1}/plot3/encode.py +0 -0
  22. {plot3-0.7.0 → plot3-0.8.1}/plot3/expr.py +0 -0
  23. {plot3-0.7.0 → plot3-0.8.1}/plot3/flip.py +0 -0
  24. {plot3-0.7.0 → plot3-0.8.1}/plot3/function.py +0 -0
  25. {plot3-0.7.0 → plot3-0.8.1}/plot3/ggplot.py +0 -0
  26. {plot3-0.7.0 → plot3-0.8.1}/plot3/io.py +0 -0
  27. {plot3-0.7.0 → plot3-0.8.1}/plot3/kde.py +0 -0
  28. {plot3-0.7.0 → plot3-0.8.1}/plot3/latexin.py +0 -0
  29. {plot3-0.7.0 → plot3-0.8.1}/plot3/mathtext.py +0 -0
  30. {plot3-0.7.0 → plot3-0.8.1}/plot3/payload.py +0 -0
  31. {plot3-0.7.0 → plot3-0.8.1}/plot3/remote.py +0 -0
  32. {plot3-0.7.0 → plot3-0.8.1}/plot3/scales.py +0 -0
  33. {plot3-0.7.0 → plot3-0.8.1}/plot3/scaling.py +0 -0
  34. {plot3-0.7.0 → plot3-0.8.1}/plot3/special.py +0 -0
  35. {plot3-0.7.0 → plot3-0.8.1}/plot3/stat2d.py +0 -0
  36. {plot3-0.7.0 → plot3-0.8.1}/plot3/static.py +0 -0
  37. {plot3-0.7.0 → plot3-0.8.1}/plot3/stats3d.py +0 -0
  38. {plot3-0.7.0 → plot3-0.8.1}/plot3/table.py +0 -0
  39. {plot3-0.7.0 → plot3-0.8.1}/plot3/themes.py +0 -0
  40. {plot3-0.7.0 → plot3-0.8.1}/plot3/viewer.py +0 -0
  41. {plot3-0.7.0 → plot3-0.8.1}/plot3.egg-info/SOURCES.txt +0 -0
  42. {plot3-0.7.0 → plot3-0.8.1}/plot3.egg-info/dependency_links.txt +0 -0
  43. {plot3-0.7.0 → plot3-0.8.1}/plot3.egg-info/top_level.txt +0 -0
  44. {plot3-0.7.0 → plot3-0.8.1}/setup.cfg +0 -0
  45. {plot3-0.7.0 → plot3-0.8.1}/tests/test_2d_density.py +0 -0
  46. {plot3-0.7.0 → plot3-0.8.1}/tests/test_3d.py +0 -0
  47. {plot3-0.7.0 → plot3-0.8.1}/tests/test_animation.py +0 -0
  48. {plot3-0.7.0 → plot3-0.8.1}/tests/test_annotate.py +0 -0
  49. {plot3-0.7.0 → plot3-0.8.1}/tests/test_api_public.py +0 -0
  50. {plot3-0.7.0 → plot3-0.8.1}/tests/test_array_backend.py +0 -0
  51. {plot3-0.7.0 → plot3-0.8.1}/tests/test_arrow.py +0 -0
  52. {plot3-0.7.0 → plot3-0.8.1}/tests/test_build_contract.py +0 -0
  53. {plot3-0.7.0 → plot3-0.8.1}/tests/test_contour.py +0 -0
  54. {plot3-0.7.0 → plot3-0.8.1}/tests/test_everyday_ggplot2.py +0 -0
  55. {plot3-0.7.0 → plot3-0.8.1}/tests/test_expr.py +0 -0
  56. {plot3-0.7.0 → plot3-0.8.1}/tests/test_facets_compose.py +0 -0
  57. {plot3-0.7.0 → plot3-0.8.1}/tests/test_fast_paths.py +0 -0
  58. {plot3-0.7.0 → plot3-0.8.1}/tests/test_geom_function.py +0 -0
  59. {plot3-0.7.0 → plot3-0.8.1}/tests/test_geoms_ds.py +0 -0
  60. {plot3-0.7.0 → plot3-0.8.1}/tests/test_ggplot2_extras2.py +0 -0
  61. {plot3-0.7.0 → plot3-0.8.1}/tests/test_ggplot2_parity.py +0 -0
  62. {plot3-0.7.0 → plot3-0.8.1}/tests/test_ggplot_extras.py +0 -0
  63. {plot3-0.7.0 → plot3-0.8.1}/tests/test_grammar.py +0 -0
  64. {plot3-0.7.0 → plot3-0.8.1}/tests/test_latex_input.py +0 -0
  65. {plot3-0.7.0 → plot3-0.8.1}/tests/test_lidar_scene.py +0 -0
  66. {plot3-0.7.0 → plot3-0.8.1}/tests/test_math_layers.py +0 -0
  67. {plot3-0.7.0 → plot3-0.8.1}/tests/test_mathtext.py +0 -0
  68. {plot3-0.7.0 → plot3-0.8.1}/tests/test_payload.py +0 -0
  69. {plot3-0.7.0 → plot3-0.8.1}/tests/test_payload_display.py +0 -0
  70. {plot3-0.7.0 → plot3-0.8.1}/tests/test_point_cloud.py +0 -0
  71. {plot3-0.7.0 → plot3-0.8.1}/tests/test_r_oracle_parity.py +0 -0
  72. {plot3-0.7.0 → plot3-0.8.1}/tests/test_readme.py +0 -0
  73. {plot3-0.7.0 → plot3-0.8.1}/tests/test_remote_bridge.py +0 -0
  74. {plot3-0.7.0 → plot3-0.8.1}/tests/test_review_fixes.py +0 -0
  75. {plot3-0.7.0 → plot3-0.8.1}/tests/test_save_menu.py +0 -0
  76. {plot3-0.7.0 → plot3-0.8.1}/tests/test_scales_api.py +0 -0
  77. {plot3-0.7.0 → plot3-0.8.1}/tests/test_scales_themes.py +0 -0
  78. {plot3-0.7.0 → plot3-0.8.1}/tests/test_special.py +0 -0
  79. {plot3-0.7.0 → plot3-0.8.1}/tests/test_stat2d.py +0 -0
  80. {plot3-0.7.0 → plot3-0.8.1}/tests/test_static_save.py +0 -0
  81. {plot3-0.7.0 → plot3-0.8.1}/tests/test_stats_semantic.py +0 -0
  82. {plot3-0.7.0 → plot3-0.8.1}/tests/test_step3_geoms.py +0 -0
  83. {plot3-0.7.0 → plot3-0.8.1}/tests/test_table_backends.py +0 -0
  84. {plot3-0.7.0 → plot3-0.8.1}/tests/test_titles_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plot3
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: ggplot2 grammar of graphics for Python: interactive WebGL figures and journal-ready PNG, SVG, and PDF
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -17,6 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: numpy>=1.24
19
19
  Requires-Dist: pandas>=2.0
20
+ Requires-Dist: tidy3syntax<0.2,>=0.1
20
21
  Provides-Extra: pdf
21
22
  Requires-Dist: cairosvg>=2.7; extra == "pdf"
22
23
  Provides-Extra: dev
@@ -0,0 +1 @@
1
+ __version__ = "0.8.1"
@@ -1798,7 +1798,7 @@ class facet_wrap:
1798
1798
  # Accept "~cyl", ". ~ cyl", "cyl ~ ."
1799
1799
  parts = [p.strip() for p in name.split("~")]
1800
1800
  candidates = [p for p in parts if p and p != "."]
1801
- if len(candidates) != 1:
1801
+ if len(candidates) != 1 or "+" in candidates[0]:
1802
1802
  raise ValueError(
1803
1803
  "facet_wrap() currently accepts a single facet column "
1804
1804
  f"(got {facets!r})"
@@ -22,6 +22,7 @@ from plot3.masking import (
22
22
  is_mask_transformer,
23
23
  is_tidy3_backtick_transformer,
24
24
  plot3_backtick_transform,
25
+ plot3_formula_transform,
25
26
  plot3_layer_transform,
26
27
  )
27
28
 
@@ -62,16 +63,21 @@ def enable_r_style(ipython: Any | None = None) -> bool:
62
63
  # When tidy3 is not loaded, leave tidy3 sentinel unset; plot3 AST
63
64
  # still rewrites __tidy3_bt__ if tidy3's preparser ran first.
64
65
 
65
- # Source preparser: only install plot3's if tidy3 did not already provide one.
66
+ # Source preparser: when tidy3 already rewrites backticks, run only
67
+ # plot3's own formula rewrites (^ in geom_function, ~ in facets), after it.
66
68
  for attr in ("input_transformers_cleanup", "input_transformers_post"):
67
69
  transformers = getattr(ipython, attr, None)
68
70
  if not isinstance(transformers, list):
69
71
  continue
70
- has_tidy3_bt = any(is_tidy3_backtick_transformer(t) for t in transformers)
71
72
  transformers[:] = [
72
73
  t for t in transformers if not is_backtick_transformer(t)
73
74
  ]
74
- if not has_tidy3_bt:
75
+ tidy3_at = [
76
+ i for i, t in enumerate(transformers) if is_tidy3_backtick_transformer(t)
77
+ ]
78
+ if tidy3_at:
79
+ transformers.insert(tidy3_at[-1] + 1, plot3_formula_transform)
80
+ else:
75
81
  transformers.insert(0, plot3_backtick_transform)
76
82
 
77
83
  # AST pass for bare names inside aes / facet_wrap.
@@ -30,11 +30,16 @@ from __future__ import annotations
30
30
 
31
31
  import ast
32
32
  import builtins
33
+ import dataclasses
33
34
  import io
34
35
  import re
35
36
  import tokenize
36
37
  from typing import Any, Iterable
37
38
 
39
+ import tidy3syntax
40
+ from tidy3syntax import repairs
41
+ from tidy3syntax.scanner import Spans, apply
42
+
38
43
  # Sentinels (plot3 own + tidy3 compatibility).
39
44
  BT_NAME = "__plot3_bt__"
40
45
  TIDY3_BT_NAME = "__tidy3_bt__"
@@ -108,69 +113,121 @@ _PASSTHROUGH_KW = frozenset(
108
113
 
109
114
  _FORMULA_CALLS = frozenset({"geom_function", "geom_vector_field"})
110
115
 
111
- _BT_RE = re.compile(r"`([^`\n]+)`")
116
+ _FACETS = frozenset({"facet_wrap", "facet_grid"})
117
+
118
+ # plot3's part of the notebook syntax tidy3, tidy3a, plot3 and stats3 share
119
+ # (tidy3syntax). ``drv ~ cyl`` and ``~`my col``` are not Python, so they are
120
+ # quoted in any facet call; ``^`` in geom_function (XOR in Python) and ``~cyl``
121
+ # in a facet (bitwise not) are valid Python, so they are rewritten only where
122
+ # the call is plot3's: a notebook's own geom_function keeps XOR.
123
+ DIALECT = tidy3syntax.Dialect(
124
+ "plot3",
125
+ formula_calls=_FACETS,
126
+ formula_keywords={"facets"},
127
+ formula_backticks="strip",
128
+ power_calls=_FORMULA_CALLS,
129
+ unary_formula_calls=_FACETS,
130
+ )
131
+ _POWER_ONLY = dataclasses.replace(DIALECT, formula_calls=(), formula_keywords=(), unary_formula_calls=())
132
+ _FACETS_ONLY = dataclasses.replace(DIALECT, power_calls=())
133
+
134
+ _SESSION = object() # default: the running IPython's namespace, if any
135
+
136
+
137
+ def _session_namespace(namespace: Any) -> dict[str, Any] | None:
138
+ if namespace is not _SESSION:
139
+ return namespace
140
+ try:
141
+ from IPython import get_ipython
142
+
143
+ ip = get_ipython()
144
+ except Exception:
145
+ return None
146
+ return getattr(ip, "user_ns", None) if ip is not None else None
147
+
148
+
149
+ def _plot3_bt(source: str) -> str:
150
+ """tidy3syntax's ``__r_name__`` placeholder as plot3's ``__plot3_bt__``, outside strings and comments."""
151
+ if tidy3syntax.NAME not in source:
152
+ return source
153
+ spans = Spans.of(source)
154
+ edits = [
155
+ (m.start(), m.end(), BT_NAME)
156
+ for m in re.finditer(re.escape(tidy3syntax.NAME) + r"(?=\s*\()", source)
157
+ if m.start() in spans
158
+ ]
159
+ return apply(source, edits)
160
+
161
+
162
+ def _repair(source: str) -> str:
163
+ """The rewrites of text that is not Python: ``drv ~ cyl``, ``~`my col``` and backticks."""
164
+ return _plot3_bt(repairs.backticks(repairs.quote_formulas(source, [DIALECT])))
112
165
 
113
166
 
114
- def rewrite_formula_carets(source: str) -> str:
167
+ def rewrite_facet_formulas(source: str, *, unary: str = "owned", namespace: Any = _SESSION) -> str:
168
+ """Quote R formulas inside ``facet_wrap`` / ``facet_grid`` before parsing.
169
+
170
+ ``facet_grid(drv ~ cyl)`` → ``facet_grid('drv ~ cyl')``: ``~`` with a left
171
+ side is not Python, so it is quoted in any facet call, as is a ``~`` formula
172
+ holding a backtick name. ``facet_wrap(~cyl)`` → ``facet_wrap('~cyl')``
173
+ only where the facet call is plot3's, since ``~cyl`` is Python's bitwise
174
+ not (``unary="owned"``); ``unary="never"`` leaves such formulas for a
175
+ later pass. Backtick names inside the formula lose their backticks
176
+ (``~`my col``` → ``'~my col'``).
177
+ """
178
+ source = repairs.quote_formulas(source, [DIALECT])
179
+ if unary == "never":
180
+ return source
181
+ return tidy3syntax.reinterpret(source, [_FACETS_ONLY], _session_namespace(namespace))
182
+
183
+
184
+ def rewrite_formulas(source: str, namespace: Any = _SESSION) -> str:
185
+ """The text rewrites plot3 needs even when tidy3 handles backticks:
186
+ ``^`` inside ``geom_function`` and ``~`` formulas inside facets.
187
+
188
+ ``drv ~ cyl`` is quoted first, since the cell must parse before the
189
+ ownership of ``geom_function`` and ``facet_wrap`` can be read."""
190
+ source = repairs.quote_formulas(source, [DIALECT])
191
+ return tidy3syntax.reinterpret(source, [DIALECT], _session_namespace(namespace))
192
+
193
+
194
+ def rewrite_formula_carets(source: str, namespace: Any = _SESSION) -> str:
115
195
  """Inside ``geom_function(...)``, turn ``^`` into ``**`` before Python parses.
116
196
 
117
197
  ``2*x^3`` and ``(2*x)^3`` are the same tree once Python has parsed ``^``
118
- as xor, so the rewrite has to happen on the text.
198
+ as xor, so the rewrite has to happen on the text. Only plot3's own
199
+ ``geom_function`` is rewritten: in a notebook's own function of that
200
+ name, ``^`` stays XOR.
119
201
  """
120
- if "^" not in source or not any(name in source for name in _FORMULA_CALLS):
121
- return source
122
- try:
123
- tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
124
- except (tokenize.TokenError, SyntaxError):
125
- return source
126
- starts = [0]
127
- for line in source.splitlines(keepends=True):
128
- starts.append(starts[-1] + len(line))
129
- hits: list[int] = []
130
- depth = 0 # paren depth inside the current geom_function call
131
- armed = False # just saw the name geom_function
132
- for tok in tokens:
133
- if depth == 0:
134
- if tok.type == tokenize.NAME and tok.string in _FORMULA_CALLS:
135
- armed = True
136
- continue
137
- if armed and tok.type == tokenize.OP and tok.string == "(":
138
- depth = 1
139
- armed = False
140
- continue
141
- if tok.type == tokenize.OP:
142
- if tok.string in "([{":
143
- depth += 1
144
- elif tok.string in ")]}":
145
- depth -= 1
146
- elif tok.string == "^":
147
- row, col = tok.start
148
- hits.append(starts[row - 1] + col)
149
- for pos in reversed(hits):
150
- source = source[:pos] + "**" + source[pos + 1 :]
151
- return source
152
-
153
-
154
- def rewrite_backticks(source: str) -> str:
155
- """Preparse backticks, and ``^`` inside ``geom_function``, before parsing.
202
+ return tidy3syntax.reinterpret(source, [_POWER_ONLY], _session_namespace(namespace))
203
+
204
+
205
+ def rewrite_backticks(source: str, namespace: Any = _SESSION) -> str:
206
+ """Preparse backticks, ``^`` inside ``geom_function``, and facet formulas.
156
207
 
157
208
  `` `col name` `` → ``__plot3_bt__("col name")``. ``^`` → ``**`` only
158
- inside a ``geom_function(...)`` call, where it means power.
209
+ inside plot3's ``geom_function(...)``, where it means power. Backticks
210
+ inside string literals and comments stay as written. The text that is
211
+ not Python goes first, so the cell parses when the ownership of
212
+ ``geom_function`` and ``facet_wrap`` is read.
159
213
  """
160
- source = rewrite_formula_carets(source)
161
- if "`" not in source:
162
- return source
163
- return _BT_RE.sub(lambda m: f"{BT_NAME}({m.group(1)!r})", source)
214
+ return tidy3syntax.reinterpret(_repair(source), [DIALECT], _session_namespace(namespace))
164
215
 
165
216
 
166
- def plot3_backtick_transform(lines: list[str]) -> list[str]:
167
- """IPython input transformer: backticks, and ``^`` inside geom_function."""
168
- if not lines:
169
- return lines
170
- src = "".join(lines)
171
- if "`" not in src and "^" not in src:
172
- return lines
173
- out = rewrite_backticks(src)
217
+ def _parse_dedented(source: str) -> tuple[ast.Module, list[int]] | None:
218
+ """Parse *source* with indented ``+`` lines moved to column 0 (they are
219
+ joined later); the shift per line maps positions back. ``None`` if the
220
+ cell does not parse, in which case nothing valid is reinterpreted."""
221
+ raw = source.split("\n")
222
+ shift = [len(line) - len(line.lstrip()) if line.lstrip().startswith("+") else 0 for line in raw]
223
+ try:
224
+ tree = ast.parse("\n".join(line[n:] for line, n in zip(raw, shift)))
225
+ except (SyntaxError, ValueError):
226
+ return None
227
+ return tree, shift
228
+
229
+
230
+ def _as_lines(src: str, out: str, lines: list[str]) -> list[str]:
174
231
  if out == src:
175
232
  return lines
176
233
  if out.endswith("\n"):
@@ -179,6 +236,28 @@ def plot3_backtick_transform(lines: list[str]) -> list[str]:
179
236
  return parts or [out]
180
237
 
181
238
 
239
+ def plot3_backtick_transform(lines: list[str]) -> list[str]:
240
+ """IPython input transformer: backticks, ``^`` inside geom_function, and
241
+ ``~`` formulas inside facets."""
242
+ if not lines:
243
+ return lines
244
+ src = "".join(lines)
245
+ if "`" not in src and "^" not in src and "~" not in src:
246
+ return lines
247
+ return _as_lines(src, rewrite_backticks(src), lines)
248
+
249
+
250
+ def plot3_formula_transform(lines: list[str]) -> list[str]:
251
+ """IPython input transformer used beside tidy3's backtick preparser:
252
+ ``^`` inside geom_function and ``~`` formulas inside facets only."""
253
+ if not lines:
254
+ return lines
255
+ src = "".join(lines)
256
+ if "^" not in src and "~" not in src:
257
+ return lines
258
+ return _as_lines(src, rewrite_formulas(src), lines)
259
+
260
+
182
261
  _OPENERS = frozenset("([{")
183
262
  _CLOSERS = frozenset(")]}")
184
263
  _SKIP_TOKENS = frozenset({
@@ -229,7 +308,7 @@ def _value_start(tokens: list[tokenize.TokenInfo]) -> tuple[int, int]:
229
308
  return tokens[0].start
230
309
 
231
310
 
232
- def join_layer_lines(source: str) -> str | None:
311
+ def join_layer_lines(source: str, namespace: Any = _SESSION) -> str | None:
233
312
  """Join ``+`` lines that add plot3 layers to the statement above them.
234
313
 
235
314
  In a notebook, ``ggplot(df, aes(...))`` followed by lines that start
@@ -240,6 +319,12 @@ def join_layer_lines(source: str) -> str | None:
240
319
  lines; the expression gets parentheses on the lines as typed, so error
241
320
  line numbers still match. Returns ``None`` when nothing needs joining,
242
321
  or when the result would not parse. Pipes (``>>``) are tidy3's.
322
+
323
+ An indented ``+`` line is not Python, so it joins by name. An unindented
324
+ one is a valid unary-plus statement, so it joins only when the name after
325
+ ``+`` is plot3's and the statement above starts with a plot3 call or a
326
+ value not known to be something else: after ``x = 2``, or with the
327
+ notebook's own ``geom_point``, ``+ geom_point()`` stays a statement.
243
328
  """
244
329
  if not source or "+" not in source:
245
330
  return None
@@ -253,6 +338,36 @@ def join_layer_lines(source: str) -> str | None:
253
338
  logical = _logical_lines(analysed)
254
339
  if not logical:
255
340
  return None
341
+ parsed = _parse_dedented(_repair(source))
342
+ owners = tidy3syntax.Owners(parsed[0], _session_namespace(namespace), [DIALECT]) if parsed is not None else None
343
+
344
+ def plot3_name(name: str, row: int) -> bool:
345
+ return owners is not None and DIALECT.owns_module(owners.module(name, (row, 0)))
346
+
347
+ def plot3_head(tokens: list[tokenize.TokenInfo]) -> bool:
348
+ start = _value_start(tokens)
349
+ rest = [t for t in tokens if t.start >= start]
350
+ while rest and rest[0].string == "(":
351
+ rest = rest[1:]
352
+ if owners is None or not rest or rest[0].type != tokenize.NAME:
353
+ return False
354
+ name, row = rest[0].string, rest[0].start[0]
355
+ if len(rest) > 1 and rest[1].string in ("(", "."):
356
+ return plot3_name(name, row) # ggplot(...), p3.ggplot(...)
357
+ # A variable: refused only when its value is known not to be plot3's.
358
+ bound = [package for end, package in owners.bindings.get(name, ()) if end <= (row, 0)]
359
+ if bound:
360
+ return bound[-1] is None or DIALECT.owns_module(bound[-1])
361
+ if owners.context is not None and name in owners.context:
362
+ return DIALECT.owns_module(tidy3syntax.module_of(owners.context[name]))
363
+ return True
364
+
365
+ def may_join(tokens: list[tokenize.TokenInfo], head: list[tokenize.TokenInfo]) -> bool:
366
+ """Whether joining this ``+`` line changes valid Python, and may: see above."""
367
+ row = tokens[0].start[0]
368
+ if shift[row - 1]:
369
+ return True # indented: not Python on its own
370
+ return plot3_name(tokens[1].string, row) and plot3_head(head)
256
371
 
257
372
  def is_layer(tokens: list[tokenize.TokenInfo]) -> bool:
258
373
  return (
@@ -273,6 +388,7 @@ def join_layer_lines(source: str) -> str | None:
273
388
  and head.start[1] == 0
274
389
  and head.type != tokenize.OP
275
390
  and head.string not in {"if", "for", "while", "with", "def", "class", "try", "elif", "else", "except", "finally", "return", "import", "from"}
391
+ and may_join(tokens, previous[0])
276
392
  ):
277
393
  previous.append(tokens)
278
394
  else:
@@ -618,7 +734,7 @@ def apply_masking(
618
734
  backticks: bool = True,
619
735
  ) -> str:
620
736
  """Apply backtick rewrite + AST masking; return unparsed source (tests)."""
621
- text = rewrite_formula_carets(source)
737
+ text = rewrite_formulas(source)
622
738
  if backticks:
623
739
  text = rewrite_backticks(text)
624
740
  tree = ast.parse(text)
@@ -637,7 +753,8 @@ def is_mask_transformer(obj: Any) -> bool:
637
753
  def is_backtick_transformer(obj: Any) -> bool:
638
754
  return (
639
755
  getattr(obj, "__module__", "") in {"plot3.masking", "plot3.jupyter"}
640
- and getattr(obj, "__name__", "") == "plot3_backtick_transform"
756
+ and getattr(obj, "__name__", "")
757
+ in {"plot3_backtick_transform", "plot3_formula_transform"}
641
758
  )
642
759
 
643
760
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plot3
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: ggplot2 grammar of graphics for Python: interactive WebGL figures and journal-ready PNG, SVG, and PDF
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -17,6 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: numpy>=1.24
19
19
  Requires-Dist: pandas>=2.0
20
+ Requires-Dist: tidy3syntax<0.2,>=0.1
20
21
  Provides-Extra: pdf
21
22
  Requires-Dist: cairosvg>=2.7; extra == "pdf"
22
23
  Provides-Extra: dev
@@ -1,5 +1,6 @@
1
1
  numpy>=1.24
2
2
  pandas>=2.0
3
+ tidy3syntax<0.2,>=0.1
3
4
 
4
5
  [dev]
5
6
  pytest>=7.0
@@ -22,6 +22,9 @@ classifiers = [
22
22
  dependencies = [
23
23
  "numpy>=1.24",
24
24
  "pandas>=2.0",
25
+ # The notebook syntax tidy3, tidy3a, plot3 and stats3 share (facet
26
+ # formulas, ^ in geom_function, backtick names). Standard library only.
27
+ "tidy3syntax>=0.1,<0.2",
25
28
  ]
26
29
 
27
30
  [project.optional-dependencies]