plot3 0.6.8__tar.gz → 0.8.0__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.
- {plot3-0.6.8 → plot3-0.8.0}/PKG-INFO +2 -1
- plot3-0.8.0/plot3/__version__.py +1 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/build.py +39 -16
- {plot3-0.6.8 → plot3-0.8.0}/plot3/encode.py +8 -4
- {plot3-0.6.8 → plot3-0.8.0}/plot3/geoms.py +1 -1
- {plot3-0.6.8 → plot3-0.8.0}/plot3/jupyter.py +9 -3
- plot3-0.8.0/plot3/kde.py +124 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/masking.py +172 -55
- {plot3-0.6.8 → plot3-0.8.0}/plot3/scales.py +13 -3
- {plot3-0.6.8 → plot3-0.8.0}/plot3/stat2d.py +6 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/PKG-INFO +2 -1
- {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/SOURCES.txt +2 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/requires.txt +1 -0
- {plot3-0.6.8 → plot3-0.8.0}/pyproject.toml +3 -0
- plot3-0.8.0/tests/test_fast_paths.py +106 -0
- plot3-0.8.0/tests/test_masking.py +437 -0
- plot3-0.6.8/plot3/__version__.py +0 -1
- plot3-0.6.8/tests/test_masking.py +0 -240
- {plot3-0.6.8 → plot3-0.8.0}/LICENSE +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/README.md +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/__init__.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/aesexpr.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/calculus.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/compose.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/contour.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/craft.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/expr.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/flip.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/function.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/ggplot.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/io.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/latexin.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/mathtext.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/payload.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/remote.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/scaling.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/special.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/static.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/stats3d.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/table.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/themes.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3/viewer.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/dependency_links.txt +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/top_level.txt +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/setup.cfg +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_2d_density.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_3d.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_animation.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_annotate.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_api_public.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_array_backend.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_arrow.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_build_contract.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_contour.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_everyday_ggplot2.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_expr.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_facets_compose.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_geom_function.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_geoms_ds.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_ggplot2_extras2.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_ggplot2_parity.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_ggplot_extras.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_grammar.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_latex_input.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_lidar_scene.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_math_layers.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_mathtext.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_payload.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_payload_display.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_point_cloud.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_r_oracle_parity.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_readme.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_remote_bridge.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_review_fixes.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_save_menu.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_scales_api.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_scales_themes.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_special.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_stat2d.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_static_save.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_stats_semantic.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_step3_geoms.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_table_backends.py +0 -0
- {plot3-0.6.8 → plot3-0.8.0}/tests/test_titles_theme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plot3
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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: tidysyntax<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.0"
|
|
@@ -10,6 +10,7 @@ import pandas as pd
|
|
|
10
10
|
import copy
|
|
11
11
|
import os
|
|
12
12
|
|
|
13
|
+
from plot3.kde import EXACT_WORK, gaussian_1d
|
|
13
14
|
from plot3.encode import encode_codes, encode_norm, pack_u16, pack_u32, pack_u8
|
|
14
15
|
from plot3.mathtext import split_math
|
|
15
16
|
from plot3.geoms import (
|
|
@@ -100,6 +101,11 @@ def _kde_1d(
|
|
|
100
101
|
if hi <= lo:
|
|
101
102
|
hi = lo + 1.0
|
|
102
103
|
grid = np.linspace(lo, hi, n)
|
|
104
|
+
if x.size * n > EXACT_WORK:
|
|
105
|
+
# Large inputs: linear binning and an FFT, as R's density() does.
|
|
106
|
+
sums = gaussian_1d(x, lo, hi, n, bw)
|
|
107
|
+
if sums is not None:
|
|
108
|
+
return grid, sums / x.size
|
|
103
109
|
# (n_grid, n_obs)
|
|
104
110
|
u = (grid[:, None] - x[None, :]) / bw
|
|
105
111
|
dens = np.exp(-0.5 * u * u).sum(axis=1) / (
|
|
@@ -1522,6 +1528,26 @@ def _rug_series(rug, g):
|
|
|
1522
1528
|
return out
|
|
1523
1529
|
|
|
1524
1530
|
|
|
1531
|
+
def _remap_codes(codes, remap: dict, default) -> np.ndarray:
|
|
1532
|
+
"""``remap.get(int(c), default)`` for every code, as one table lookup."""
|
|
1533
|
+
c = np.asarray(codes)
|
|
1534
|
+
if c.dtype.kind not in "biuf" or (c.dtype.kind == "f" and not np.isfinite(c).all()):
|
|
1535
|
+
# Same per-value rule, errors included (int() of NaN raises).
|
|
1536
|
+
return np.array([remap.get(int(x), default) for x in c.ravel()]).reshape(c.shape)
|
|
1537
|
+
ints = c.astype(np.int64)
|
|
1538
|
+
out = np.full(ints.shape, default)
|
|
1539
|
+
if remap and ints.size:
|
|
1540
|
+
keys = np.fromiter(remap.keys(), dtype=np.int64, count=len(remap))
|
|
1541
|
+
values = np.array(list(remap.values()))
|
|
1542
|
+
order = np.argsort(keys)
|
|
1543
|
+
keys, values = keys[order], values[order]
|
|
1544
|
+
at = np.clip(np.searchsorted(keys, ints), 0, len(keys) - 1)
|
|
1545
|
+
hit = keys[at] == ints
|
|
1546
|
+
out = out.astype(np.result_type(out, values))
|
|
1547
|
+
out[hit] = values[at[hit]]
|
|
1548
|
+
return out
|
|
1549
|
+
|
|
1550
|
+
|
|
1525
1551
|
def _rug_positions(scale, series) -> np.ndarray:
|
|
1526
1552
|
"""Values in the scale's own units; NaN where they have no place."""
|
|
1527
1553
|
kind, pos, cats = col_values(series)
|
|
@@ -1531,6 +1557,13 @@ def _rug_positions(scale, series) -> np.ndarray:
|
|
|
1531
1557
|
lookup = np.array([index.get(c, np.nan) for c in cats] + [np.nan], dtype=np.float64)
|
|
1532
1558
|
codes = np.where(np.isfinite(pos), pos, len(cats)).astype(np.int64)
|
|
1533
1559
|
return lookup[codes]
|
|
1560
|
+
values = np.asarray(series)
|
|
1561
|
+
# float32 prints differently as a NumPy scalar than as a float.
|
|
1562
|
+
if values.dtype.kind in "biu" or values.dtype == np.float64:
|
|
1563
|
+
# One text lookup per distinct value (NaN included), not per row.
|
|
1564
|
+
codes, uniques = pd.factorize(values, use_na_sentinel=False)
|
|
1565
|
+
lookup = np.array([index.get(str(u), np.nan) for u in uniques.tolist()], dtype=np.float64)
|
|
1566
|
+
return lookup[codes] if len(codes) else np.array([], dtype=np.float64)
|
|
1534
1567
|
return np.array([index.get(str(v), np.nan) for v in series], dtype=np.float64)
|
|
1535
1568
|
if kind == "cat":
|
|
1536
1569
|
return np.full(len(pos), np.nan)
|
|
@@ -2211,10 +2244,8 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
2211
2244
|
}
|
|
2212
2245
|
out = np.full(np.size(v), np.nan, dtype=np.float64)
|
|
2213
2246
|
flat = np.asarray(v, dtype=np.float64).ravel()
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
continue
|
|
2217
|
-
out[i] = remap.get(int(c), -1)
|
|
2247
|
+
finite = np.isfinite(flat)
|
|
2248
|
+
out[finite] = _remap_codes(flat[finite], remap, -1)
|
|
2218
2249
|
v = out.reshape(np.shape(v))
|
|
2219
2250
|
sc.cats = merged
|
|
2220
2251
|
else:
|
|
@@ -3089,9 +3120,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3089
3120
|
i: color_scale[1].index(c)
|
|
3090
3121
|
for i, c in enumerate(local)
|
|
3091
3122
|
}
|
|
3092
|
-
codes =
|
|
3093
|
-
[remap.get(int(c), 0) for c in cv], dtype="<u2"
|
|
3094
|
-
)
|
|
3123
|
+
codes = _remap_codes(cv, remap, 0).astype("<u2")
|
|
3095
3124
|
payloads.append((pid, pack_u16(codes, g.compress)))
|
|
3096
3125
|
spec_l["ocolor"] = {
|
|
3097
3126
|
"id": pid, "dtype": "u16", "kind": "cat"
|
|
@@ -3121,8 +3150,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3121
3150
|
# remap onto the global cat list
|
|
3122
3151
|
local = vals["color"][2]
|
|
3123
3152
|
remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
|
|
3124
|
-
codes =
|
|
3125
|
-
dtype="<u2")
|
|
3153
|
+
codes = _remap_codes(cv[order], remap, 0).astype("<u2")
|
|
3126
3154
|
payloads.append((pid, pack_u16(codes, g.compress)))
|
|
3127
3155
|
spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
|
|
3128
3156
|
else:
|
|
@@ -3140,9 +3168,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3140
3168
|
if ckind == "cat":
|
|
3141
3169
|
local = vals["color"][2]
|
|
3142
3170
|
remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
|
|
3143
|
-
codes =
|
|
3144
|
-
[remap.get(int(c), 0) for c in cv[order]], dtype="<u2"
|
|
3145
|
-
)
|
|
3171
|
+
codes = _remap_codes(cv[order], remap, 0).astype("<u2")
|
|
3146
3172
|
payloads.append((pid, pack_u16(codes, g.compress)))
|
|
3147
3173
|
spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
|
|
3148
3174
|
else:
|
|
@@ -3322,10 +3348,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3322
3348
|
flat = np.full(raw.shape, 65535, dtype=np.uint16)
|
|
3323
3349
|
ok = np.isfinite(raw) & (raw >= 0)
|
|
3324
3350
|
if ok.any():
|
|
3325
|
-
mapped = np.
|
|
3326
|
-
[remap.get(int(c), 0) for c in raw[ok]],
|
|
3327
|
-
dtype=np.uint16,
|
|
3328
|
-
)
|
|
3351
|
+
mapped = _remap_codes(raw[ok], remap, 0).astype(np.uint16)
|
|
3329
3352
|
flat[np.flatnonzero(ok)] = mapped
|
|
3330
3353
|
enc = encode_codes(flat, g.compress)
|
|
3331
3354
|
pid = f"p{li}fc"
|
|
@@ -7,6 +7,10 @@ import gzip as _gzip
|
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
|
|
10
|
+
# gzip is lossless at every level. Level 1 builds large payloads several
|
|
11
|
+
# times faster than 6 for a few percent more bytes.
|
|
12
|
+
_LEVEL = 1
|
|
13
|
+
|
|
10
14
|
def delta_u16(a: np.ndarray) -> np.ndarray:
|
|
11
15
|
"""Column-wise delta mod 2^16 (lossless; tiny values on ordered data)."""
|
|
12
16
|
d = a.astype(np.int32)
|
|
@@ -21,7 +25,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
|
|
|
21
25
|
raw = _gzip.compress(
|
|
22
26
|
np.ascontiguousarray(d[:, 0]).tobytes()
|
|
23
27
|
+ np.ascontiguousarray(d[:, 1]).tobytes(),
|
|
24
|
-
|
|
28
|
+
compresslevel=_LEVEL, mtime=0,
|
|
25
29
|
)
|
|
26
30
|
else:
|
|
27
31
|
raw = np.ascontiguousarray(q, dtype="<u2").tobytes()
|
|
@@ -31,7 +35,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
|
|
|
31
35
|
def pack_f32(v: np.ndarray, compress: bool) -> str:
|
|
32
36
|
raw = np.ascontiguousarray(v, dtype="<f4").tobytes()
|
|
33
37
|
if compress:
|
|
34
|
-
raw = _gzip.compress(raw,
|
|
38
|
+
raw = _gzip.compress(raw, _LEVEL, mtime=0)
|
|
35
39
|
return base64.b64encode(raw).decode("ascii")
|
|
36
40
|
|
|
37
41
|
|
|
@@ -55,7 +59,7 @@ def pack_u8(values: np.ndarray, compress: bool) -> str:
|
|
|
55
59
|
"""uint8 array -> base64 (optional gzip; no delta)."""
|
|
56
60
|
raw = np.ascontiguousarray(values, dtype=np.uint8).tobytes()
|
|
57
61
|
if compress:
|
|
58
|
-
raw = _gzip.compress(raw,
|
|
62
|
+
raw = _gzip.compress(raw, _LEVEL, mtime=0)
|
|
59
63
|
return base64.b64encode(raw).decode("ascii")
|
|
60
64
|
|
|
61
65
|
|
|
@@ -63,6 +67,6 @@ def pack_u32(values: np.ndarray, compress: bool) -> str:
|
|
|
63
67
|
"""uint32 array -> base64 (optional gzip; no delta)."""
|
|
64
68
|
raw = np.ascontiguousarray(values, dtype="<u4").tobytes()
|
|
65
69
|
if compress:
|
|
66
|
-
raw = _gzip.compress(raw,
|
|
70
|
+
raw = _gzip.compress(raw, _LEVEL, mtime=0)
|
|
67
71
|
return base64.b64encode(raw).decode("ascii")
|
|
68
72
|
|
|
@@ -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:
|
|
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
|
-
|
|
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.
|
plot3-0.8.0/plot3/kde.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Binned Gaussian kernel density sums (linear binning + FFT convolution).
|
|
2
|
+
|
|
3
|
+
R's ``density()`` bins too. Values are spread onto a fine grid (at least
|
|
4
|
+
four points per output interval, at most 1/20 of a bandwidth apart in
|
|
5
|
+
1D and 1/4 in 2D),
|
|
6
|
+
convolved with the kernel by FFT, and read back at the output points. The
|
|
7
|
+
relative error is far below what a plot shows. Values more than
|
|
8
|
+
``_REACH`` bandwidths past the output range add nothing in float64 and are
|
|
9
|
+
left out. The callers keep the exact sum for small inputs, and these
|
|
10
|
+
helpers return None when the fine grid would be too large.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
# Below this many (observations x output points) the exact sum is cheap.
|
|
18
|
+
EXACT_WORK = 2_000_000
|
|
19
|
+
# The fine spacing is at most this fraction of the bandwidth (1D, 2D), and
|
|
20
|
+
# the fine grid has at least four points per output interval. Linear
|
|
21
|
+
# binning's error grows with the square of that fraction.
|
|
22
|
+
_MAX_STEP_1D = 0.05
|
|
23
|
+
_MAX_STEP_2D = 0.25
|
|
24
|
+
_MIN_FINE = 4
|
|
25
|
+
# Past this many bandwidths a kernel underflows to zero in float64.
|
|
26
|
+
_REACH = 40.0
|
|
27
|
+
# Largest fine grid (points, or cells in 2D) worth binning onto.
|
|
28
|
+
_MAX_CELLS = 1 << 22
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fine_axis(values: np.ndarray, lo: float, hi: float, n: int, h: float, max_step: float):
|
|
32
|
+
"""``(origin, step, size, first, fine)`` of a fine grid through the output points.
|
|
33
|
+
|
|
34
|
+
The output point ``k`` sits at fine index ``first + k * fine``. The grid
|
|
35
|
+
covers every value within ``_REACH`` bandwidths of the output range;
|
|
36
|
+
values farther out add nothing to the sums.
|
|
37
|
+
"""
|
|
38
|
+
if n < 2 or not hi > lo or not h > 0 or not values.size:
|
|
39
|
+
return None
|
|
40
|
+
fine = max(_MIN_FINE, int(np.ceil((hi - lo) / ((n - 1) * max_step * h))))
|
|
41
|
+
step = (hi - lo) / ((n - 1) * fine)
|
|
42
|
+
low = max(float(values.min()), lo - _REACH * h)
|
|
43
|
+
high = min(float(values.max()), hi + _REACH * h)
|
|
44
|
+
below = max(0, int(np.ceil((lo - low) / step)))
|
|
45
|
+
above = max(0, int(np.ceil((high - hi) / step)))
|
|
46
|
+
size = (n - 1) * fine + 1 + below + above
|
|
47
|
+
if size > _MAX_CELLS:
|
|
48
|
+
return None
|
|
49
|
+
return lo - below * step, step, size, below, fine
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _within(values: np.ndarray, origin: float, step: float, size: int) -> np.ndarray:
|
|
53
|
+
return (values >= origin) & (values <= origin + (size - 1) * step)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _linear_bins(values: np.ndarray, origin: float, step: float, size: int):
|
|
57
|
+
"""Indices and weights of the two fine points around each value."""
|
|
58
|
+
pos = (values - origin) / step
|
|
59
|
+
left = np.clip(np.floor(pos).astype(np.int64), 0, size - 2)
|
|
60
|
+
frac = np.clip(pos - left, 0.0, 1.0)
|
|
61
|
+
return left, frac
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _kernel_fft(step: float, size: int, h: float, length: int) -> np.ndarray:
|
|
65
|
+
offsets = np.arange(-(size - 1), size) * step
|
|
66
|
+
kernel = np.exp(-0.5 * (offsets / h) ** 2) / (h * np.sqrt(2.0 * np.pi))
|
|
67
|
+
return np.fft.rfft(kernel, length)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fft_length(size: int) -> int:
|
|
71
|
+
return 1 << int(np.ceil(np.log2(3 * size - 2)))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _convolve(weights: np.ndarray, step: float, size: int, h: float, axis: int) -> np.ndarray:
|
|
75
|
+
"""``sum_i w[i] K((j - i) step)`` for every fine index ``j`` along *axis*."""
|
|
76
|
+
length = _fft_length(size)
|
|
77
|
+
spectrum = np.fft.rfft(weights, length, axis=axis)
|
|
78
|
+
shape = [1] * weights.ndim
|
|
79
|
+
shape[axis] = -1
|
|
80
|
+
full = np.fft.irfft(spectrum * _kernel_fft(step, size, h, length).reshape(shape), length, axis=axis)
|
|
81
|
+
return np.take(full, np.arange(size - 1, 2 * size - 1), axis=axis)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def gaussian_1d(x: np.ndarray, lo: float, hi: float, n: int, h: float) -> np.ndarray | None:
|
|
85
|
+
"""``sum_i phi((g - x_i) / h) / h`` at ``linspace(lo, hi, n)``, or None."""
|
|
86
|
+
axis = _fine_axis(x, lo, hi, n, h, _MAX_STEP_1D)
|
|
87
|
+
if axis is None:
|
|
88
|
+
return None
|
|
89
|
+
origin, step, size, first, fine = axis
|
|
90
|
+
x = x[_within(x, origin, step, size)]
|
|
91
|
+
left, frac = _linear_bins(x, origin, step, size)
|
|
92
|
+
weights = np.bincount(left, 1.0 - frac, size) + np.bincount(left + 1, frac, size)
|
|
93
|
+
sums = _convolve(weights, step, size, h, axis=0)
|
|
94
|
+
return np.maximum(sums[first:first + (n - 1) * fine + 1:fine], 0.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def gaussian_2d(
|
|
98
|
+
xs: np.ndarray, ys: np.ndarray, x_lims: tuple[float, float], y_lims: tuple[float, float],
|
|
99
|
+
n: int, hx: float, hy: float,
|
|
100
|
+
) -> np.ndarray | None:
|
|
101
|
+
"""``sum_i phi_x(gx - x_i) phi_y(gy - y_i)`` as an ``(n, n)`` (y, x) grid, or None."""
|
|
102
|
+
ax = _fine_axis(xs, x_lims[0], x_lims[1], n, hx, _MAX_STEP_2D)
|
|
103
|
+
ay = _fine_axis(ys, y_lims[0], y_lims[1], n, hy, _MAX_STEP_2D)
|
|
104
|
+
if ax is None or ay is None:
|
|
105
|
+
return None
|
|
106
|
+
(x_origin, x_step, x_size, x_first, x_fine), (y_origin, y_step, y_size, y_first, y_fine) = ax, ay
|
|
107
|
+
# Two FFT passes over the fine grid must beat n exponentials per value
|
|
108
|
+
# on each axis; a grid stretched over wide limits does not.
|
|
109
|
+
if x_size * y_size > _MAX_CELLS or x_size * y_size * 16 > xs.size * n:
|
|
110
|
+
return None
|
|
111
|
+
keep = _within(xs, x_origin, x_step, x_size) & _within(ys, y_origin, y_step, y_size)
|
|
112
|
+
xs, ys = xs[keep], ys[keep]
|
|
113
|
+
xl, xf = _linear_bins(xs, x_origin, x_step, x_size)
|
|
114
|
+
yl, yf = _linear_bins(ys, y_origin, y_step, y_size)
|
|
115
|
+
cells = y_size * x_size
|
|
116
|
+
weights = np.zeros(cells)
|
|
117
|
+
for dy, wy in ((0, 1.0 - yf), (1, yf)):
|
|
118
|
+
for dx, wx in ((0, 1.0 - xf), (1, xf)):
|
|
119
|
+
weights += np.bincount((yl + dy) * x_size + xl + dx, wy * wx, cells)
|
|
120
|
+
weights = weights.reshape(y_size, x_size)
|
|
121
|
+
sums = _convolve(_convolve(weights, x_step, x_size, hx, axis=1), y_step, y_size, hy, axis=0)
|
|
122
|
+
rows = slice(y_first, y_first + (n - 1) * y_fine + 1, y_fine)
|
|
123
|
+
cols = slice(x_first, x_first + (n - 1) * x_fine + 1, x_fine)
|
|
124
|
+
return np.maximum(sums[rows, cols], 0.0)
|
|
@@ -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 tidysyntax
|
|
40
|
+
from tidysyntax import repairs
|
|
41
|
+
from tidysyntax.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
|
-
|
|
116
|
+
_FACETS = frozenset({"facet_wrap", "facet_grid"})
|
|
117
|
+
|
|
118
|
+
# plot3's part of the notebook syntax tidy3, tidy3a, plot3 and stats3 share
|
|
119
|
+
# (tidysyntax). ``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 = tidysyntax.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
|
+
"""tidysyntax's ``__r_name__`` placeholder as plot3's ``__plot3_bt__``, outside strings and comments."""
|
|
151
|
+
if tidysyntax.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(tidysyntax.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
|
|
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 tidysyntax.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 tidysyntax.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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 tidysyntax.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
|
|
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
|
|
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 tidysyntax.reinterpret(_repair(source), [DIALECT], _session_namespace(namespace))
|
|
164
215
|
|
|
165
216
|
|
|
166
|
-
def
|
|
167
|
-
"""
|
|
168
|
-
if
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 = tidysyntax.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(tidysyntax.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 =
|
|
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__", "")
|
|
756
|
+
and getattr(obj, "__name__", "")
|
|
757
|
+
in {"plot3_backtick_transform", "plot3_formula_transform"}
|
|
641
758
|
)
|
|
642
759
|
|
|
643
760
|
|