augplot 0.1.0__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.
augplot/execution.py ADDED
@@ -0,0 +1,1285 @@
1
+ """Restricted generated-source validation and local execution (not an OS sandbox)."""
2
+
3
+ import ast
4
+ import builtins
5
+ import json
6
+ from contextlib import ExitStack
7
+
8
+ from .errors import GenerationError, ScopeError
9
+ from .profiling import copy_data
10
+
11
+ API_MANIFEST_VERSION = 2
12
+ MAX_SOURCE_CHARS = 50_000
13
+ MAX_AST_NODES = 4_000
14
+ MAX_LITERAL_ITEMS = 2_000
15
+ MAX_STATIC_RANGE = 10_000
16
+ MAX_STATIC_INTEGER = 10_000_000
17
+ MAX_LOOPS = 10
18
+ MAX_BOUNDED_ITERATION = 200
19
+ _IMPORT_ALIASES = {
20
+ "numpy": "np",
21
+ "pandas": "pd",
22
+ "matplotlib.pyplot": "plt",
23
+ "matplotlib.ticker": "ticker",
24
+ "matplotlib.dates": "dates",
25
+ "seaborn": "sns",
26
+ }
27
+ _BUILTINS = {
28
+ "abs",
29
+ "all",
30
+ "any",
31
+ "bool",
32
+ "dict",
33
+ "enumerate",
34
+ "filter",
35
+ "float",
36
+ "int",
37
+ "isinstance",
38
+ "issubclass",
39
+ "len",
40
+ "list",
41
+ "map",
42
+ "max",
43
+ "min",
44
+ "next",
45
+ "range",
46
+ "reversed",
47
+ "round",
48
+ "set",
49
+ "slice",
50
+ "sorted",
51
+ "str",
52
+ "sum",
53
+ "tuple",
54
+ "zip",
55
+ "ValueError",
56
+ "TypeError",
57
+ "KeyError",
58
+ "IndexError",
59
+ }
60
+
61
+ # Default-deny, versioned capability manifest. Deliberately absent: IO, serialization,
62
+ # environment, subprocess, configuration/backend APIs, dynamic execution and reflection.
63
+ _NUMPY = {
64
+ "abs",
65
+ "all",
66
+ "any",
67
+ "argmax",
68
+ "argmin",
69
+ "argsort",
70
+ "array",
71
+ "asarray",
72
+ "average",
73
+ "ceil",
74
+ "clip",
75
+ "corrcoef",
76
+ "count_nonzero",
77
+ "cumsum",
78
+ "diff",
79
+ "digitize",
80
+ "divide",
81
+ "exp",
82
+ "floor",
83
+ "isfinite",
84
+ "isinf",
85
+ "isnan",
86
+ "log",
87
+ "log10",
88
+ "max",
89
+ "mean",
90
+ "median",
91
+ "min",
92
+ "nanmax",
93
+ "nanmean",
94
+ "nanmedian",
95
+ "nanmin",
96
+ "nanpercentile",
97
+ "nanstd",
98
+ "nansum",
99
+ "percentile",
100
+ "quantile",
101
+ "ravel",
102
+ "reshape",
103
+ "round",
104
+ "sort",
105
+ "sqrt",
106
+ "std",
107
+ "sum",
108
+ "unique",
109
+ "var",
110
+ "where",
111
+ }
112
+ _RESOURCE_NUMPY = {
113
+ "arange",
114
+ "column_stack",
115
+ "concatenate",
116
+ "full",
117
+ "histogram",
118
+ "hstack",
119
+ "linspace",
120
+ "ones",
121
+ "repeat",
122
+ "vstack",
123
+ "zeros",
124
+ }
125
+ _PANDAS = {
126
+ "DataFrame",
127
+ "Series",
128
+ "crosstab",
129
+ "cut",
130
+ "qcut",
131
+ "get_dummies",
132
+ "infer_freq",
133
+ "isna",
134
+ "notna",
135
+ "pivot_table",
136
+ "to_datetime",
137
+ "to_numeric",
138
+ }
139
+ _RESOURCE_PANDAS = {"concat"}
140
+ _DYNAMIC_DISPATCH_METHODS = {"agg", "aggregate", "apply", "map", "transform"}
141
+ _DYNAMIC_BACKEND_METHODS = {"hist", "plot"}
142
+ _DATA_METHODS = {
143
+ "abs",
144
+ "all",
145
+ "any",
146
+ "append",
147
+ "astype",
148
+ "between",
149
+ "clip",
150
+ "combine_first",
151
+ "copy",
152
+ "corr",
153
+ "count",
154
+ "cov",
155
+ "cummax",
156
+ "cummin",
157
+ "cumprod",
158
+ "cumsum",
159
+ "describe",
160
+ "diff",
161
+ "drop",
162
+ "drop_duplicates",
163
+ "dropna",
164
+ "duplicated",
165
+ "eq",
166
+ "explode",
167
+ "extend",
168
+ "ffill",
169
+ "fillna",
170
+ "first_valid_index",
171
+ "ge",
172
+ "groupby",
173
+ "gt",
174
+ "head",
175
+ "idxmax",
176
+ "idxmin",
177
+ "infer_objects",
178
+ "interpolate",
179
+ "isin",
180
+ "isna",
181
+ "items",
182
+ "keys",
183
+ "kurt",
184
+ "last",
185
+ "le",
186
+ "lt",
187
+ "max",
188
+ "mean",
189
+ "median",
190
+ "melt",
191
+ "min",
192
+ "mode",
193
+ "nlargest",
194
+ "notna",
195
+ "nsmallest",
196
+ "nunique",
197
+ "pct_change",
198
+ "pivot",
199
+ "quantile",
200
+ "rank",
201
+ "reindex",
202
+ "rename",
203
+ "reset_index",
204
+ "resample",
205
+ "reverse",
206
+ "rolling",
207
+ "round",
208
+ "sample",
209
+ "select_dtypes",
210
+ "sem",
211
+ "set_index",
212
+ "shift",
213
+ "skew",
214
+ "sort",
215
+ "sort_index",
216
+ "sort_values",
217
+ "squeeze",
218
+ "std",
219
+ "sum",
220
+ "tail",
221
+ "to_dict",
222
+ "to_frame",
223
+ "to_list",
224
+ "to_numpy",
225
+ "tolist",
226
+ "transpose",
227
+ "truediv",
228
+ "unique",
229
+ "unstack",
230
+ "value_counts",
231
+ "var",
232
+ "where",
233
+ "xs",
234
+ }
235
+ _DATA_PROPERTIES = {
236
+ "T",
237
+ "at",
238
+ "columns",
239
+ "dtypes",
240
+ "empty",
241
+ "iat",
242
+ "iloc",
243
+ "index",
244
+ "loc",
245
+ "name",
246
+ "ndim",
247
+ "shape",
248
+ "size",
249
+ "str",
250
+ "values",
251
+ }
252
+ _BOUND_PRESERVING_DATA_METHODS = {
253
+ "abs",
254
+ "astype",
255
+ "between",
256
+ "clip",
257
+ "copy",
258
+ "cummax",
259
+ "cummin",
260
+ "cumprod",
261
+ "cumsum",
262
+ "diff",
263
+ "drop",
264
+ "drop_duplicates",
265
+ "dropna",
266
+ "duplicated",
267
+ "eq",
268
+ "ffill",
269
+ "fillna",
270
+ "ge",
271
+ "gt",
272
+ "head",
273
+ "isin",
274
+ "isna",
275
+ "le",
276
+ "lt",
277
+ "notna",
278
+ "pct_change",
279
+ "rank",
280
+ "rename",
281
+ "reset_index",
282
+ "round",
283
+ "select_dtypes",
284
+ "shift",
285
+ "sort_index",
286
+ "sort_values",
287
+ "squeeze",
288
+ "tail",
289
+ "to_frame",
290
+ "to_list",
291
+ "to_numpy",
292
+ "tolist",
293
+ "unique",
294
+ "where",
295
+ }
296
+ _PYPLOT = {
297
+ "Rectangle",
298
+ "bar",
299
+ "barh",
300
+ "boxplot",
301
+ "contour",
302
+ "contourf",
303
+ "errorbar",
304
+ "eventplot",
305
+ "figure",
306
+ "fill",
307
+ "fill_between",
308
+ "hexbin",
309
+ "hist",
310
+ "hlines",
311
+ "imshow",
312
+ "pie",
313
+ "plot",
314
+ "scatter",
315
+ "stackplot",
316
+ "stem",
317
+ "step",
318
+ "subplots",
319
+ "subplot_mosaic",
320
+ "violinplot",
321
+ "vlines",
322
+ }
323
+ _SEABORN = {
324
+ "barplot",
325
+ "boxenplot",
326
+ "boxplot",
327
+ "catplot",
328
+ "countplot",
329
+ "displot",
330
+ "ecdfplot",
331
+ "factorplot",
332
+ "heatmap",
333
+ "histplot",
334
+ "jointplot",
335
+ "kdeplot",
336
+ "lineplot",
337
+ "lmplot",
338
+ "pairplot",
339
+ "pointplot",
340
+ "regplot",
341
+ "relplot",
342
+ "residplot",
343
+ "rugplot",
344
+ "scatterplot",
345
+ "stripplot",
346
+ "swarmplot",
347
+ "violinplot",
348
+ }
349
+ _TICKER = {
350
+ "AutoLocator",
351
+ "AutoMinorLocator",
352
+ "FixedFormatter",
353
+ "FixedLocator",
354
+ "FormatStrFormatter",
355
+ "FuncFormatter",
356
+ "LinearLocator",
357
+ "LogFormatter",
358
+ "LogFormatterMathtext",
359
+ "LogLocator",
360
+ "MaxNLocator",
361
+ "MultipleLocator",
362
+ "NullFormatter",
363
+ "NullLocator",
364
+ "PercentFormatter",
365
+ "ScalarFormatter",
366
+ "StrMethodFormatter",
367
+ }
368
+ _DATES = {
369
+ "AutoDateFormatter",
370
+ "AutoDateLocator",
371
+ "ConciseDateFormatter",
372
+ "DateFormatter",
373
+ "DayLocator",
374
+ "HourLocator",
375
+ "MicrosecondLocator",
376
+ "MinuteLocator",
377
+ "MonthLocator",
378
+ "RRuleLocator",
379
+ "SecondLocator",
380
+ "WeekdayLocator",
381
+ "YearLocator",
382
+ "date2num",
383
+ "num2date",
384
+ }
385
+ _FIGURE = {
386
+ "add_axes",
387
+ "add_gridspec",
388
+ "add_subplot",
389
+ "align_labels",
390
+ "autofmt_xdate",
391
+ "colorbar",
392
+ "delaxes",
393
+ "legend",
394
+ "subplots",
395
+ "suptitle",
396
+ "supxlabel",
397
+ "supylabel",
398
+ "text",
399
+ "tight_layout",
400
+ }
401
+ _AXES = {
402
+ "acorr",
403
+ "add_patch",
404
+ "annotate",
405
+ "arrow",
406
+ "axhline",
407
+ "axhspan",
408
+ "axline",
409
+ "axvline",
410
+ "axvspan",
411
+ "bar",
412
+ "bar_label",
413
+ "barbs",
414
+ "barh",
415
+ "boxplot",
416
+ "broken_barh",
417
+ "clabel",
418
+ "contour",
419
+ "contourf",
420
+ "errorbar",
421
+ "eventplot",
422
+ "fill",
423
+ "fill_between",
424
+ "fill_betweenx",
425
+ "get_figure",
426
+ "get_legend",
427
+ "get_legend_handles_labels",
428
+ "grid",
429
+ "hexbin",
430
+ "hist",
431
+ "hist2d",
432
+ "hlines",
433
+ "imshow",
434
+ "inset_axes",
435
+ "legend",
436
+ "margins",
437
+ "pcolor",
438
+ "pcolormesh",
439
+ "pie",
440
+ "plot",
441
+ "plot_date",
442
+ "quiver",
443
+ "scatter",
444
+ "secondary_xaxis",
445
+ "secondary_yaxis",
446
+ "set",
447
+ "set_aspect",
448
+ "set_axis_off",
449
+ "set_axis_on",
450
+ "set_box_aspect",
451
+ "set_facecolor",
452
+ "set_frame_on",
453
+ "set_prop_cycle",
454
+ "set_title",
455
+ "set_xlabel",
456
+ "set_xlim",
457
+ "set_xscale",
458
+ "set_xticks",
459
+ "set_ylabel",
460
+ "set_ylim",
461
+ "set_yscale",
462
+ "set_yticks",
463
+ "sharex",
464
+ "sharey",
465
+ "specgram",
466
+ "spy",
467
+ "stackplot",
468
+ "stem",
469
+ "step",
470
+ "streamplot",
471
+ "table",
472
+ "text",
473
+ "tick_params",
474
+ "tricontour",
475
+ "tricontourf",
476
+ "tripcolor",
477
+ "triplot",
478
+ "twinx",
479
+ "twiny",
480
+ "violinplot",
481
+ "vlines",
482
+ "xaxis_date",
483
+ "yaxis_date",
484
+ }
485
+ _SAFE_AXES_SET_KEYWORDS = {
486
+ "aspect",
487
+ "box_aspect",
488
+ "facecolor",
489
+ "frame_on",
490
+ "title",
491
+ "xlabel",
492
+ "xlim",
493
+ "xscale",
494
+ "ylabel",
495
+ "ylim",
496
+ "yscale",
497
+ }
498
+ _ARTIST = {
499
+ "get_figure",
500
+ "remove",
501
+ "set_alpha",
502
+ "set_color",
503
+ "set_edgecolor",
504
+ "set_facecolor",
505
+ "set_label",
506
+ "set_linestyle",
507
+ "set_linewidth",
508
+ "set_marker",
509
+ "set_markersize",
510
+ "set_visible",
511
+ }
512
+ _SEQUENCE_METHODS = {"count", "index"}
513
+ _AXIS = {
514
+ "grid",
515
+ "set_label",
516
+ "set_label_position",
517
+ "set_major_formatter",
518
+ "set_major_locator",
519
+ "set_minor_formatter",
520
+ "set_minor_locator",
521
+ "set_tick_params",
522
+ "set_ticks",
523
+ "set_ticks_position",
524
+ "set_ticklabels",
525
+ }
526
+ _DANGEROUS_KEYWORDS = {
527
+ "backend",
528
+ "file",
529
+ "filename",
530
+ "filepath",
531
+ "fname",
532
+ "font",
533
+ "fontproperties",
534
+ "path",
535
+ "picker",
536
+ "url",
537
+ "urls",
538
+ "usetex",
539
+ }
540
+
541
+
542
+ def parse_response(response: str) -> tuple[str, str]:
543
+ text = response.strip()
544
+ if text.startswith("```") and text.endswith("```"):
545
+ text = "\n".join(text.splitlines()[1:-1])
546
+ try:
547
+ result = json.loads(text)
548
+ except (ValueError, TypeError):
549
+ raise GenerationError("Expected a JSON object with code and explanation strings.") from None
550
+ if not isinstance(result, dict) or set(result) != {"status", "code", "explanation"}:
551
+ raise GenerationError("Response does not match the Augplot response schema.")
552
+ status, code, explanation = result["status"], result["code"], result["explanation"]
553
+ if (
554
+ status not in {"ok", "out_of_scope"}
555
+ or not isinstance(code, str)
556
+ or not isinstance(explanation, str)
557
+ or not explanation.strip()
558
+ ):
559
+ raise GenerationError("Response has invalid code or explanation fields.")
560
+ if status == "out_of_scope":
561
+ if code.strip():
562
+ raise GenerationError("Out-of-scope responses must have an empty code field.")
563
+ raise ScopeError(explanation.strip()[:1000])
564
+ if not code.strip():
565
+ raise GenerationError("Successful responses must contain code.")
566
+ if len(code) > MAX_SOURCE_CHARS:
567
+ raise GenerationError("Generated code exceeds the source-size limit.")
568
+ return code.strip() + "\n", explanation.strip()
569
+
570
+
571
+ def allowed_imports(backend: str) -> set[str]:
572
+ result = {"numpy", "pandas", "matplotlib.pyplot", "matplotlib.ticker", "matplotlib.dates"}
573
+ if backend in ("auto", "seaborn"):
574
+ result.add("seaborn")
575
+ return result
576
+
577
+
578
+ class _Validator:
579
+ def __init__(self, code, backend):
580
+ self.code = code
581
+ self.backend = backend
582
+ self.env = {"data": "data", "title": "value", "figsize": "value"}
583
+ self.imports = set()
584
+ self.figures = set()
585
+ self.invalid_provenance = set()
586
+ self.loops = 0
587
+ self.loop_depth = 0
588
+
589
+ def fail(self, rule, message):
590
+ raise GenerationError(
591
+ message,
592
+ code=self.code,
593
+ violations=[{"rule": rule, "message": message}],
594
+ )
595
+
596
+ def validate_keywords(self, node):
597
+ for keyword in node.keywords:
598
+ if keyword.arg is None:
599
+ self.fail("star_args", "*args and **kwargs are not allowed.")
600
+ if keyword.arg == "regex" and not (
601
+ isinstance(keyword.value, ast.Constant) and keyword.value.value is False
602
+ ):
603
+ self.fail(
604
+ "resource_limit",
605
+ "Regex evaluation is outside the bounded plotting subset.",
606
+ )
607
+ if keyword.arg.casefold() in _DANGEROUS_KEYWORDS:
608
+ self.fail(
609
+ "dangerous_keyword",
610
+ f"Keyword {keyword.arg!r} grants an external or active-content capability.",
611
+ )
612
+
613
+ def merge_branches(self, before, body, otherwise):
614
+ merged = {}
615
+ for name in before.keys() | body.keys() | otherwise.keys():
616
+ body_kind = body.get(name)
617
+ otherwise_kind = otherwise.get(name)
618
+ if body_kind == otherwise_kind and body_kind is not None:
619
+ merged[name] = body_kind
620
+ else:
621
+ self.invalid_provenance.add(name)
622
+ return merged
623
+
624
+ def assigned_names(self, target):
625
+ if isinstance(target, ast.Name):
626
+ return {target.id}
627
+ if isinstance(target, (ast.Tuple, ast.List)):
628
+ return set().union(*(self.assigned_names(item) for item in target.elts))
629
+ return set()
630
+
631
+ def static_integer(self, node):
632
+ if isinstance(node, ast.Constant) and isinstance(node.value, int):
633
+ return node.value
634
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
635
+ value = self.static_integer(node.operand)
636
+ if value is None:
637
+ return None
638
+ return value if isinstance(node.op, ast.UAdd) else -value
639
+ if isinstance(node, ast.BinOp) and isinstance(
640
+ node.op, (ast.Add, ast.Sub, ast.Mult, ast.Pow)
641
+ ):
642
+ left = self.static_integer(node.left)
643
+ right = self.static_integer(node.right)
644
+ if left is None or right is None:
645
+ return None
646
+ if isinstance(node.op, ast.Pow) and (right < 0 or right > 16):
647
+ self.fail("resource_limit", "Static exponent is outside the safe limit.")
648
+ try:
649
+ if isinstance(node.op, ast.Add):
650
+ return left + right
651
+ if isinstance(node.op, ast.Sub):
652
+ return left - right
653
+ if isinstance(node.op, ast.Mult):
654
+ return left * right
655
+ return left**right
656
+ except (ArithmeticError, OverflowError):
657
+ self.fail("resource_limit", "Static arithmetic exceeds the safe limit.")
658
+ return None
659
+
660
+ def literal_number(self, node):
661
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
662
+ return node.value
663
+ return None
664
+
665
+ def bounded_count(self, node, *, default=None):
666
+ if node is None:
667
+ return default
668
+ value = self.static_integer(node)
669
+ if value is not None and 0 <= value <= MAX_BOUNDED_ITERATION:
670
+ return value
671
+ return None
672
+
673
+ def bounded_head_or_tail(self, node):
674
+ count_keywords = [keyword for keyword in node.keywords if keyword.arg == "n"]
675
+ if (
676
+ len(node.args) > 1
677
+ or len(count_keywords) > 1
678
+ or any(keyword.arg != "n" for keyword in node.keywords)
679
+ or (node.args and count_keywords)
680
+ ):
681
+ return False
682
+ count_node = count_keywords[0].value if count_keywords else None
683
+ if count_node is None and node.args:
684
+ count_node = node.args[0]
685
+ return self.bounded_count(count_node, default=5) is not None
686
+
687
+ def bounded_range(self, node):
688
+ if not 1 <= len(node.args) <= 3 or node.keywords:
689
+ return False
690
+ values = [self.static_integer(argument) for argument in node.args]
691
+ if any(value is None or abs(value) > MAX_STATIC_RANGE for value in values):
692
+ return False
693
+ try:
694
+ return len(range(*values)) <= MAX_BOUNDED_ITERATION
695
+ except (TypeError, ValueError, OverflowError):
696
+ return False
697
+
698
+ def keyword_value(self, node, name):
699
+ return next((item.value for item in node.keywords if item.arg == name), None)
700
+
701
+ def validate_resources(self, base, attr, node):
702
+ if base in {"module:matplotlib.pyplot", "figure"} and attr == "subplots":
703
+ rows_node = self.keyword_value(node, "nrows")
704
+ columns_node = self.keyword_value(node, "ncols")
705
+ if rows_node is None and node.args:
706
+ rows_node = node.args[0]
707
+ if columns_node is None and len(node.args) > 1:
708
+ columns_node = node.args[1]
709
+ rows = self.literal_number(rows_node) if rows_node is not None else 1
710
+ columns = self.literal_number(columns_node) if columns_node is not None else 1
711
+ if rows is not None and columns is not None and rows * columns > 100:
712
+ self.fail("resource_limit", "Plot creates too many Axes.")
713
+
714
+ if base == "module:matplotlib.pyplot" and attr in {"figure", "subplots"}:
715
+ figsize = self.keyword_value(node, "figsize")
716
+ if isinstance(figsize, (ast.Tuple, ast.List)) and len(figsize.elts) == 2:
717
+ dimensions = [self.literal_number(item) for item in figsize.elts]
718
+ if all(value is not None for value in dimensions) and (
719
+ max(dimensions) > 100 or dimensions[0] * dimensions[1] > 10_000
720
+ ):
721
+ self.fail("resource_limit", "Figure dimensions exceed the safe limit.")
722
+
723
+ if base in {"axes", "module:matplotlib.pyplot"} and attr in {"hist", "hist2d"}:
724
+ bins_node = self.keyword_value(node, "bins")
725
+ if bins_node is None and len(node.args) > 1:
726
+ bins_node = node.args[1]
727
+ bins = self.literal_number(bins_node) if bins_node is not None else None
728
+ if bins is not None and bins > 10_000:
729
+ self.fail("resource_limit", "Histogram bin count exceeds the safe limit.")
730
+
731
+ def validate(self, tree):
732
+ if len(list(ast.walk(tree))) > MAX_AST_NODES:
733
+ self.fail("ast_size", "Generated source is too complex.")
734
+ if len(tree.body) != 1 or not isinstance(tree.body[0], ast.FunctionDef):
735
+ self.fail(
736
+ "module_shape", "Source must contain exactly one function, with imports inside it."
737
+ )
738
+ fn = tree.body[0]
739
+ a = fn.args
740
+ if fn.name != "plot_data" or fn.decorator_list or fn.returns:
741
+ self.fail("function_contract", "Expected undecorated plot_data with no annotations.")
742
+ if (
743
+ [x.arg for x in a.args] != ["data"]
744
+ or a.posonlyargs
745
+ or a.defaults
746
+ or a.vararg
747
+ or a.kwarg
748
+ or [x.arg for x in a.kwonlyargs] != ["title", "figsize"]
749
+ or any(not isinstance(x, ast.Constant) or x.value is not None for x in a.kw_defaults)
750
+ or any(x.annotation for x in a.args + a.kwonlyargs)
751
+ ):
752
+ self.fail(
753
+ "function_signature",
754
+ "Use the signature plot_data(data, *, title=None, figsize=None).",
755
+ )
756
+ if not fn.body or not isinstance(fn.body[-1], ast.Return):
757
+ self.fail("return", "plot_data must end by returning its approved Figure.")
758
+ for stmt in fn.body:
759
+ self.stmt(stmt)
760
+ if not isinstance(fn.body[-1].value, ast.Name) or fn.body[-1].value.id not in self.figures:
761
+ self.fail(
762
+ "return", "plot_data must return a Figure created by an approved plotting call."
763
+ )
764
+
765
+ def stmt(self, n):
766
+ if isinstance(n, ast.Import):
767
+ for x in n.names:
768
+ if x.name not in allowed_imports(self.backend) or x.asname != _IMPORT_ALIASES.get(
769
+ x.name
770
+ ):
771
+ self.fail("import", "Import is not in the approved manifest.")
772
+ if x.asname in self.env:
773
+ self.fail(
774
+ "alias_shadowing", "Imported aliases cannot be reassigned or shadowed."
775
+ )
776
+ self.env[x.asname] = "module:" + x.name
777
+ self.imports.add(x.asname)
778
+ elif isinstance(n, ast.Assign):
779
+ if len(n.targets) != 1:
780
+ self.fail("assignment", "Only one assignment target is allowed.")
781
+ self.assign(n.targets[0], self.expr(n.value))
782
+ elif isinstance(n, (ast.AnnAssign, ast.AugAssign)):
783
+ self.fail("assignment", "Annotated and augmented assignments are not allowed.")
784
+ elif isinstance(n, ast.Expr):
785
+ self.expr(n.value)
786
+ elif isinstance(n, ast.Return):
787
+ self.expr(n.value)
788
+ elif isinstance(n, ast.Raise):
789
+ if (
790
+ not isinstance(n.exc, ast.Call)
791
+ or not isinstance(n.exc.func, ast.Name)
792
+ or n.exc.func.id not in {"ValueError", "TypeError", "KeyError", "IndexError"}
793
+ or n.cause is not None
794
+ ):
795
+ self.fail("raise", "Only approved local validation errors may be raised.")
796
+ self.expr(n.exc)
797
+ elif isinstance(n, ast.If):
798
+ self.expr(n.test)
799
+ before = self.env.copy()
800
+ before_figures = self.figures.copy()
801
+ for s in n.body:
802
+ self.stmt(s)
803
+ body = self.env.copy()
804
+ body_figures = self.figures.copy()
805
+ self.env = before.copy()
806
+ self.figures = before_figures.copy()
807
+ for s in n.orelse:
808
+ self.stmt(s)
809
+ otherwise = self.env.copy()
810
+ otherwise_figures = self.figures.copy()
811
+ self.env = self.merge_branches(before, body, otherwise)
812
+ self.figures = body_figures & otherwise_figures
813
+ elif isinstance(n, ast.For):
814
+ self.loops += 1
815
+ if self.loops > MAX_LOOPS:
816
+ self.fail("loop_limit", "Generated source contains too many loops.")
817
+ kind = self.expr(n.iter)
818
+ if kind not in {"axes_sequence", "bounded_column", "bounded_sequence"}:
819
+ self.fail(
820
+ "resource_limit",
821
+ "Loops may iterate only over statically or explicitly bounded sequences.",
822
+ )
823
+ if self.loop_depth:
824
+ self.fail("resource_limit", "Nested loops are not allowed.")
825
+ self.assign(n.target, "axes_sequence" if kind == "axes_sequence" else "value")
826
+ self.loop_depth += 1
827
+ try:
828
+ for s in n.body + n.orelse:
829
+ self.stmt(s)
830
+ finally:
831
+ self.loop_depth -= 1
832
+ elif isinstance(n, (ast.Pass, ast.Break, ast.Continue)):
833
+ pass
834
+ else:
835
+ self.fail("statement", "Unsupported Python construct in generated source.")
836
+
837
+ def assign(self, target, kind):
838
+ if isinstance(target, ast.Name):
839
+ if target.id.startswith("_") or target.id in self.imports or target.id == "data":
840
+ self.fail(
841
+ "alias_shadowing", "Reserved names and imported aliases cannot be reassigned."
842
+ )
843
+ if target.id in self.figures and kind != "figure":
844
+ self.fail("figure_identity", "An approved Figure name cannot be replaced.")
845
+ self.env[target.id] = kind
846
+ self.invalid_provenance.discard(target.id)
847
+ if kind == "figure":
848
+ self.figures.add(target.id)
849
+ elif isinstance(target, (ast.Tuple, ast.List)):
850
+ if kind not in {
851
+ "figure_axes",
852
+ "bounded_sequence",
853
+ "sequence",
854
+ "axes",
855
+ "axes_sequence",
856
+ "value",
857
+ }:
858
+ self.fail("assignment", "This value cannot be unpacked.")
859
+ for i, elt in enumerate(target.elts):
860
+ self.assign(
861
+ elt,
862
+ "figure"
863
+ if i == 0 and kind == "figure_axes"
864
+ else "axes"
865
+ if kind in {"figure_axes", "axes"} or (kind == "axes_sequence" and i == 0)
866
+ else "value",
867
+ )
868
+ elif isinstance(target, ast.Subscript):
869
+ # A local, data-derived frame may be reshaped in memory; the caller's
870
+ # original `data` argument and all attribute/module mutation remain blocked.
871
+ if (
872
+ isinstance(target.value, ast.Name)
873
+ and target.value.id != "data"
874
+ and self.expr(target.value) == "data"
875
+ ):
876
+ self.expr(target.slice)
877
+ return
878
+ self.fail("mutation", "Attribute and subscript assignment are not allowed.")
879
+ else:
880
+ self.fail("mutation", "Attribute and subscript assignment are not allowed.")
881
+
882
+ def expr(self, n):
883
+ if isinstance(n, (ast.operator, ast.boolop, ast.unaryop, ast.cmpop, ast.expr_context)):
884
+ return "value"
885
+ if isinstance(n, ast.Constant):
886
+ if isinstance(n.value, (str, bytes)) and len(n.value) > MAX_LITERAL_ITEMS:
887
+ self.fail("literal_size", "Generated source contains an oversized literal.")
888
+ if isinstance(n.value, int) and abs(n.value) > MAX_STATIC_INTEGER:
889
+ self.fail("resource_limit", "Integer literal exceeds the safe limit.")
890
+ return "value"
891
+ if isinstance(n, ast.Name):
892
+ if n.id in _BUILTINS:
893
+ return "builtin:" + n.id
894
+ if n.id in self.invalid_provenance:
895
+ self.fail("provenance", "Name does not have one capability on every path.")
896
+ if n.id.startswith("_") or n.id not in self.env:
897
+ self.fail("name", "Source references an unknown capability or name.")
898
+ return self.env[n.id]
899
+ if isinstance(n, (ast.List, ast.Tuple, ast.Set)):
900
+ if len(n.elts) > MAX_LITERAL_ITEMS:
901
+ self.fail("literal_size", "Generated source contains an oversized literal.")
902
+ kinds = [self.expr(x) for x in n.elts]
903
+ if "axes" in kinds or "axes_sequence" in kinds:
904
+ return "axes_sequence"
905
+ return "bounded_sequence" if len(n.elts) <= MAX_BOUNDED_ITERATION else "sequence"
906
+ if isinstance(n, ast.Dict):
907
+ if len(n.keys) > MAX_LITERAL_ITEMS or any(k is None for k in n.keys):
908
+ self.fail(
909
+ "literal_size", "Generated source contains an unsupported dictionary literal."
910
+ )
911
+ for k, v in zip(n.keys, n.values, strict=True):
912
+ self.expr(k)
913
+ self.expr(v)
914
+ return "value"
915
+ if isinstance(n, ast.Subscript):
916
+ base = self.expr(n.value)
917
+ self.expr(n.slice)
918
+ if base in {"axes", "axes_sequence"}:
919
+ return "axes"
920
+ if base == "bounded_data" and isinstance(n.slice, ast.Constant):
921
+ return "bounded_column"
922
+ if base == "bounded_column" and isinstance(n.slice, ast.Slice):
923
+ return "bounded_column"
924
+ if base == "bounded_sequence" and isinstance(n.slice, ast.Slice):
925
+ return "bounded_sequence"
926
+ if base in {"bounded_column", "bounded_data"}:
927
+ return "bounded_data"
928
+ if base == "bounded_sequence":
929
+ return "value"
930
+ return "data"
931
+ if isinstance(n, ast.Slice):
932
+ for x in (n.lower, n.upper, n.step):
933
+ if x:
934
+ self.expr(x)
935
+ return "value"
936
+ if isinstance(n, ast.IfExp):
937
+ self.expr(n.test)
938
+ kinds = {self.expr(n.body), self.expr(n.orelse)}
939
+ if kinds <= {"axes", "axes_sequence"}:
940
+ return "axes_sequence"
941
+ if len(kinds) == 1:
942
+ return kinds.pop()
943
+ if kinds <= {
944
+ "bounded_column",
945
+ "bounded_data",
946
+ "bounded_sequence",
947
+ "data",
948
+ "value",
949
+ "sequence",
950
+ }:
951
+ return "data"
952
+ self.fail("provenance", "Conditional expression has incompatible capabilities.")
953
+ if isinstance(n, (ast.BinOp, ast.BoolOp, ast.Compare, ast.UnaryOp)):
954
+ for c in ast.iter_child_nodes(n):
955
+ self.expr(c)
956
+ static_value = self.static_integer(n)
957
+ if static_value is not None and abs(static_value) > MAX_STATIC_INTEGER:
958
+ self.fail("resource_limit", "Static arithmetic exceeds the safe limit.")
959
+ return "data"
960
+ if isinstance(n, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)):
961
+ if len(n.generators) != 1:
962
+ self.fail("resource_limit", "Nested comprehensions are not allowed.")
963
+ before = self.env.copy()
964
+ generator = n.generators[0]
965
+ iterable_kind = self.expr(generator.iter)
966
+ if generator.is_async or iterable_kind not in {
967
+ "bounded_data",
968
+ "bounded_column",
969
+ "bounded_sequence",
970
+ "data",
971
+ "sequence",
972
+ "axes_sequence",
973
+ }:
974
+ self.fail("resource_limit", "Comprehensions require an approved local sequence.")
975
+ self.assign(generator.target, "value")
976
+ for condition in generator.ifs:
977
+ self.expr(condition)
978
+ if isinstance(n, ast.DictComp):
979
+ self.expr(n.key)
980
+ self.expr(n.value)
981
+ else:
982
+ self.expr(n.elt)
983
+ self.env = before
984
+ self.invalid_provenance.update(self.assigned_names(generator.target))
985
+ return (
986
+ "bounded_sequence"
987
+ if iterable_kind in {"bounded_column", "bounded_sequence", "axes_sequence"}
988
+ else "sequence"
989
+ )
990
+ if isinstance(n, ast.JoinedStr):
991
+ if (
992
+ sum(
993
+ len(value.value)
994
+ for value in n.values
995
+ if isinstance(value, ast.Constant) and isinstance(value.value, str)
996
+ )
997
+ > MAX_LITERAL_ITEMS
998
+ ):
999
+ self.fail("literal_size", "Generated source contains an oversized literal.")
1000
+ for value in n.values:
1001
+ self.expr(value)
1002
+ return "value"
1003
+ if isinstance(n, ast.FormattedValue):
1004
+ if n.conversion != -1 or n.format_spec is not None:
1005
+ self.fail(
1006
+ "format_string",
1007
+ "Formatted labels cannot use conversions or format specifications.",
1008
+ )
1009
+ self.expr(n.value)
1010
+ return "value"
1011
+ if isinstance(n, ast.Call):
1012
+ return self.call(n)
1013
+ if isinstance(n, ast.Attribute):
1014
+ return self.attribute(n)
1015
+ if isinstance(n, ast.Starred):
1016
+ self.fail("star_args", "Starred arguments are not allowed.")
1017
+ self.fail("expression", "Unsupported Python expression in generated source.")
1018
+
1019
+ def attribute(self, n):
1020
+ if n.attr.startswith("_"):
1021
+ self.fail("private_access", "Private and dunder attributes are not allowed.")
1022
+ base = self.expr(n.value)
1023
+ if (
1024
+ base
1025
+ in {
1026
+ "bounded_column",
1027
+ "bounded_data",
1028
+ "bounded_sequence",
1029
+ "data",
1030
+ "value",
1031
+ "sequence",
1032
+ }
1033
+ and n.attr in _DATA_PROPERTIES
1034
+ ):
1035
+ return (
1036
+ "bounded_column"
1037
+ if base == "bounded_column"
1038
+ else "bounded_data"
1039
+ if base in {"bounded_data", "bounded_sequence"}
1040
+ else "data"
1041
+ )
1042
+ if base == "axes" and n.attr in {"xaxis", "yaxis"}:
1043
+ return "axis"
1044
+ if base == "artist" and n.attr in {"figure", "fig"}:
1045
+ return "figure"
1046
+ self.fail("attribute", "Attribute access is not an approved capability.")
1047
+
1048
+ def call(self, n):
1049
+ if any(isinstance(a, ast.Starred) for a in n.args):
1050
+ self.fail("star_args", "*args and **kwargs are not allowed.")
1051
+ if isinstance(n.func, ast.Name):
1052
+ self.validate_keywords(n)
1053
+ arg_kinds = [self.expr(x) for x in n.args]
1054
+ for keyword in n.keywords:
1055
+ self.expr(keyword.value)
1056
+ if n.func.id not in _BUILTINS:
1057
+ self.fail("call", "Calls must resolve to an approved capability.")
1058
+ if n.func.id == "range" and (
1059
+ len(n.args) > 3
1060
+ or any(
1061
+ not isinstance(x, ast.Constant)
1062
+ or not isinstance(x.value, int)
1063
+ or abs(x.value) > MAX_STATIC_RANGE
1064
+ for x in n.args
1065
+ )
1066
+ ):
1067
+ self.fail("loop_bound", "range() must use small static integer bounds.")
1068
+ if n.func.id == "zip" and arg_kinds and arg_kinds[0] in {"axes", "axes_sequence"}:
1069
+ return "axes_sequence"
1070
+ if n.func.id == "range" and self.bounded_range(n):
1071
+ return "bounded_sequence"
1072
+ bounded_kinds = {"bounded_column", "bounded_sequence"}
1073
+ if n.func.id == "zip" and any(kind in bounded_kinds for kind in arg_kinds):
1074
+ return "bounded_sequence"
1075
+ if n.func.id in {
1076
+ "dict",
1077
+ "enumerate",
1078
+ "filter",
1079
+ "list",
1080
+ "map",
1081
+ "reversed",
1082
+ "set",
1083
+ "sorted",
1084
+ "tuple",
1085
+ } and any(kind in bounded_kinds for kind in arg_kinds):
1086
+ return "bounded_sequence"
1087
+ return (
1088
+ "sequence"
1089
+ if n.func.id
1090
+ in {
1091
+ "list",
1092
+ "tuple",
1093
+ "set",
1094
+ "dict",
1095
+ "range",
1096
+ "zip",
1097
+ "map",
1098
+ "filter",
1099
+ "sorted",
1100
+ "reversed",
1101
+ "enumerate",
1102
+ }
1103
+ else "value"
1104
+ )
1105
+ if not isinstance(n.func, ast.Attribute):
1106
+ self.fail("call", "Indirect call targets are not allowed.")
1107
+ attr, base = n.func.attr, self.expr(n.func.value)
1108
+ if attr.startswith("_"):
1109
+ self.fail("private_access", "Private and dunder attributes are not allowed.")
1110
+ active_keywords = {
1111
+ keyword.arg.casefold()
1112
+ for keyword in n.keywords
1113
+ if keyword.arg is not None and keyword.arg.casefold() != "backend"
1114
+ } & _DANGEROUS_KEYWORDS
1115
+ if (
1116
+ base in {"bounded_column", "bounded_data", "data"}
1117
+ and attr in _DYNAMIC_BACKEND_METHODS
1118
+ and active_keywords
1119
+ ):
1120
+ self.validate_keywords(n)
1121
+ if base in {"bounded_column", "bounded_data", "data"} and attr in _DYNAMIC_BACKEND_METHODS:
1122
+ self.fail(
1123
+ "dynamic_backend",
1124
+ "Pandas plotting dispatch is not allowed; use Matplotlib or Seaborn directly.",
1125
+ )
1126
+ if base in {"bounded_column", "bounded_data", "data"} and attr in _DYNAMIC_DISPATCH_METHODS:
1127
+ self.fail(
1128
+ "dynamic_dispatch",
1129
+ "Pandas callable and string dispatch methods are not allowed.",
1130
+ )
1131
+ self.validate_keywords(n)
1132
+ for argument in n.args:
1133
+ self.expr(argument)
1134
+ for keyword in n.keywords:
1135
+ self.expr(keyword.value)
1136
+ self.validate_resources(base, attr, n)
1137
+ if base == "module:numpy" and attr in _RESOURCE_NUMPY:
1138
+ self.fail(
1139
+ "resource_limit",
1140
+ "This NumPy allocation or expansion API is outside the safe plotting subset.",
1141
+ )
1142
+ if base == "module:pandas" and attr in _RESOURCE_PANDAS:
1143
+ self.fail(
1144
+ "resource_limit",
1145
+ "This Pandas expansion API is outside the safe plotting subset.",
1146
+ )
1147
+ if base == "module:numpy" and attr in _NUMPY:
1148
+ return "data"
1149
+ if base == "module:pandas" and attr in _PANDAS:
1150
+ return "data"
1151
+ if base == "builtin:dict" and attr == "fromkeys":
1152
+ return "data"
1153
+ if base == "module:matplotlib.ticker" and attr in _TICKER:
1154
+ return "artist"
1155
+ if base == "module:matplotlib.dates" and attr in _DATES:
1156
+ return "artist"
1157
+ if base == "module:matplotlib.pyplot" and attr in _PYPLOT:
1158
+ return (
1159
+ "figure_axes"
1160
+ if attr in {"subplots", "subplot_mosaic"}
1161
+ else "figure"
1162
+ if attr == "figure"
1163
+ else "artist"
1164
+ )
1165
+ if base == "module:seaborn" and attr in _SEABORN:
1166
+ return "artist"
1167
+ if base in {"bounded_column", "bounded_data", "data"} and attr in _DATA_METHODS:
1168
+ if attr in {"head", "tail"} and self.bounded_head_or_tail(n):
1169
+ return "bounded_column" if base == "bounded_column" else "bounded_data"
1170
+ if base in {"bounded_column", "bounded_data"} and attr in {
1171
+ "to_list",
1172
+ "to_numpy",
1173
+ "tolist",
1174
+ "unique",
1175
+ }:
1176
+ return "bounded_sequence"
1177
+ if base in {"bounded_column", "bounded_data"} and attr in (
1178
+ _BOUND_PRESERVING_DATA_METHODS
1179
+ ):
1180
+ return base
1181
+ return "data"
1182
+ if base == "figure" and attr in _FIGURE:
1183
+ return "axes" if attr in {"add_axes", "add_subplot", "subplots"} else "artist"
1184
+ if base == "axes" and attr == "set":
1185
+ names = {keyword.arg for keyword in n.keywords}
1186
+ if n.args or None in names or not names <= _SAFE_AXES_SET_KEYWORDS:
1187
+ self.fail(
1188
+ "dangerous_keyword",
1189
+ "Axes.set accepts only explicitly approved visual properties.",
1190
+ )
1191
+ return "artist"
1192
+ if base == "axes" and attr in _AXES:
1193
+ return (
1194
+ "axes"
1195
+ if attr
1196
+ in {
1197
+ "inset_axes",
1198
+ "secondary_xaxis",
1199
+ "secondary_yaxis",
1200
+ "sharex",
1201
+ "sharey",
1202
+ "twinx",
1203
+ "twiny",
1204
+ }
1205
+ else "figure"
1206
+ if attr == "get_figure"
1207
+ else "sequence"
1208
+ if attr == "get_legend_handles_labels"
1209
+ else "artist"
1210
+ )
1211
+ if base == "artist" and attr in _ARTIST:
1212
+ return "figure" if attr == "get_figure" else "artist"
1213
+ if base == "axis" and attr in _AXIS:
1214
+ return "artist"
1215
+ if base in {"bounded_sequence", "sequence"} and attr in _SEQUENCE_METHODS:
1216
+ return "value"
1217
+ self.fail("call", "Call target is not in the approved capability manifest.")
1218
+
1219
+
1220
+ def validate_code(code: str, backend: str) -> ast.Module:
1221
+ if not isinstance(code, str) or len(code) > MAX_SOURCE_CHARS:
1222
+ raise GenerationError(
1223
+ "Generated source exceeds the source-size limit.",
1224
+ code=code,
1225
+ violations=[{"rule": "source_size", "message": "Source exceeds size limit."}],
1226
+ )
1227
+ try:
1228
+ tree = ast.parse(code)
1229
+ except SyntaxError:
1230
+ raise GenerationError(
1231
+ "Generated source has invalid Python syntax.",
1232
+ code=code,
1233
+ violations=[{"rule": "syntax", "message": "Invalid Python syntax."}],
1234
+ ) from None
1235
+ _Validator(code, backend).validate(tree)
1236
+ return tree
1237
+
1238
+
1239
+ def execute(code, data, *, backend, title=None, figsize=None):
1240
+ tree = validate_code(code, backend)
1241
+ import matplotlib as mpl
1242
+ import matplotlib.pyplot as plt
1243
+ from matplotlib.figure import Figure
1244
+
1245
+ permitted = allowed_imports(backend)
1246
+
1247
+ def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
1248
+ if level or name not in permitted:
1249
+ raise ImportError("Import is not permitted.")
1250
+ return builtins.__import__(name, globals, locals, fromlist, level)
1251
+
1252
+ namespace = {"__builtins__": {name: getattr(builtins, name) for name in _BUILTINS}}
1253
+ namespace["__builtins__"]["__import__"] = guarded_import
1254
+ previous_figures = set(plt.get_fignums())
1255
+ try:
1256
+ with ExitStack() as stack:
1257
+ stack.enter_context(mpl.rc_context())
1258
+ if backend in ("auto", "seaborn"):
1259
+ import seaborn as sns
1260
+
1261
+ stack.enter_context(sns.axes_style("whitegrid"))
1262
+ stack.enter_context(sns.plotting_context("notebook"))
1263
+ exec(compile(tree, "<augplot-generated>", "exec"), namespace)
1264
+ figure = namespace["plot_data"](copy_data(data), title=title, figsize=figsize)
1265
+ if not isinstance(figure, Figure) or not figure.axes:
1266
+ raise GenerationError(
1267
+ "Function must return a nonempty Figure for the backend.", code=code
1268
+ )
1269
+ return figure
1270
+ except GenerationError:
1271
+ raise
1272
+ except Exception as exc:
1273
+ trace, line = exc.__traceback__, None
1274
+ while trace is not None:
1275
+ if trace.tb_frame.f_code.co_filename == "<augplot-generated>":
1276
+ line = trace.tb_lineno
1277
+ trace = trace.tb_next
1278
+ raise GenerationError(
1279
+ f"Plot execution failed ({type(exc).__name__})"
1280
+ + (f" at generated line {line}." if line else "."),
1281
+ code=code,
1282
+ ) from None
1283
+ finally:
1284
+ for number in set(plt.get_fignums()) - previous_figures:
1285
+ plt.close(number)