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/__init__.py +25 -0
- augplot/core.py +307 -0
- augplot/datasets.py +51 -0
- augplot/errors.py +45 -0
- augplot/execution.py +1285 -0
- augplot/exporting.py +105 -0
- augplot/history.py +193 -0
- augplot/profiling.py +273 -0
- augplot/prompts.py +208 -0
- augplot/provider.py +57 -0
- augplot-0.1.0.dist-info/METADATA +157 -0
- augplot-0.1.0.dist-info/RECORD +15 -0
- augplot-0.1.0.dist-info/WHEEL +4 -0
- augplot-0.1.0.dist-info/licenses/LICENSE +201 -0
- augplot-0.1.0.dist-info/licenses/NOTICE +3 -0
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)
|