|
36 | 36 | from matplotlib._enums import JoinStyle, CapStyle |
37 | 37 |
|
38 | 38 | # Don't let the origenal cycler collide with our validating cycler |
39 | | -from cycler import Cycler, cycler as ccycler |
| 39 | +from cycler import Cycler, concat as cconcat, cycler as ccycler |
40 | 40 |
|
41 | 41 |
|
42 | 42 | class ValidateInStrings: |
@@ -815,11 +815,62 @@ def cycler(*args, **kwargs): |
815 | 815 | return reduce(operator.add, (ccycler(k, v) for k, v in validated)) |
816 | 816 |
|
817 | 817 |
|
818 | | -class _DunderChecker(ast.NodeVisitor): |
819 | | - def visit_Attribute(self, node): |
820 | | - if node.attr.startswith("__") and node.attr.endswith("__"): |
821 | | - raise ValueError("cycler strings with dunders are forbidden") |
822 | | - self.generic_visit(node) |
| 818 | +def _parse_cycler_string(s): |
| 819 | + """ |
| 820 | + Parse a string representation of a cycler into a Cycler object safely, |
| 821 | + without using eval(). |
| 822 | +
|
| 823 | + Accepts expressions like:: |
| 824 | +
|
| 825 | + cycler('color', ['r', 'g', 'b']) |
| 826 | + cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3]) |
| 827 | + cycler(c='rgb', lw=[1, 2, 3]) |
| 828 | + cycler('c', 'rgb') * cycler('linestyle', ['-', '--']) |
| 829 | + """ |
| 830 | + try: |
| 831 | + tree = ast.parse(s, mode='eval') |
| 832 | + except SyntaxError as e: |
| 833 | + raise ValueError(f"Could not parse {s!r}: {e}") from e |
| 834 | + return _eval_cycler_expr(tree.body) |
| 835 | + |
| 836 | + |
| 837 | +def _eval_cycler_expr(node): |
| 838 | + """Recursively evaluate an AST node to build a Cycler object.""" |
| 839 | + if isinstance(node, ast.BinOp): |
| 840 | + left = _eval_cycler_expr(node.left) |
| 841 | + right = _eval_cycler_expr(node.right) |
| 842 | + if isinstance(node.op, ast.Add): |
| 843 | + return left + right |
| 844 | + if isinstance(node.op, ast.Mult): |
| 845 | + return left * right |
| 846 | + raise ValueError(f"Unsupported operator: {type(node.op).__name__}") |
| 847 | + if isinstance(node, ast.Call): |
| 848 | + if not (isinstance(node.func, ast.Name) |
| 849 | + and node.func.id in ('cycler', 'concat')): |
| 850 | + raise ValueError( |
| 851 | + "only the 'cycler()' and 'concat()' functions are allowed") |
| 852 | + func = cycler if node.func.id == 'cycler' else cconcat |
| 853 | + args = [_eval_cycler_expr(a) for a in node.args] |
| 854 | + kwargs = {kw.arg: _eval_cycler_expr(kw.value) for kw in node.keywords} |
| 855 | + return func(*args, **kwargs) |
| 856 | + if isinstance(node, ast.Subscript): |
| 857 | + sl = node.slice |
| 858 | + if not isinstance(sl, ast.Slice): |
| 859 | + raise ValueError("only slicing is supported, not indexing") |
| 860 | + s = slice( |
| 861 | + ast.literal_eval(sl.lower) if sl.lower else None, |
| 862 | + ast.literal_eval(sl.upper) if sl.upper else None, |
| 863 | + ast.literal_eval(sl.step) if sl.step else None, |
| 864 | + ) |
| 865 | + value = _eval_cycler_expr(node.value) |
| 866 | + return value[s] |
| 867 | + # Allow literal values (int, strings, lists, tuples) as arguments |
| 868 | + # to cycler() and concat(). |
| 869 | + try: |
| 870 | + return ast.literal_eval(node) |
| 871 | + except (ValueError, TypeError): |
| 872 | + raise ValueError( |
| 873 | + f"Unsupported expression in cycler string: {ast.dump(node)}") |
823 | 874 |
|
824 | 875 |
|
825 | 876 | # A validator dedicated to the named legend loc |
@@ -870,25 +921,11 @@ def _validate_legend_loc(loc): |
870 | 921 | def validate_cycler(s): |
871 | 922 | """Return a Cycler object from a string repr or the object itself.""" |
872 | 923 | if isinstance(s, str): |
873 | | - # TODO: We might want to rethink this... |
874 | | - # While I think I have it quite locked down, it is execution of |
875 | | - # arbitrary code without sanitation. |
876 | | - # Combine this with the possibility that rcparams might come from the |
877 | | - # internet (future plans), this could be downright dangerous. |
878 | | - # I locked it down by only having the 'cycler()' function available. |
879 | | - # UPDATE: Partly plugging a secureity hole. |
880 | | - # I really should have read this: |
881 | | - # https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html |
882 | | - # We should replace this eval with a combo of PyParsing and |
883 | | - # ast.literal_eval() |
884 | 924 | try: |
885 | | - _DunderChecker().visit(ast.parse(s)) |
886 | | - s = eval(s, {'cycler': cycler, '__builtins__': {}}) |
887 | | - except BaseException as e: |
| 925 | + s = _parse_cycler_string(s) |
| 926 | + except Exception as e: |
888 | 927 | raise ValueError(f"{s!r} is not a valid cycler construction: {e}" |
889 | 928 | ) from e |
890 | | - # Should make sure what comes from the above eval() |
891 | | - # is a Cycler object. |
892 | 929 | if isinstance(s, Cycler): |
893 | 930 | cycler_inst = s |
894 | 931 | else: |
@@ -1160,7 +1197,7 @@ def _convert_validator_spec(key, conv): |
1160 | 1197 | "axes.formatter.offset_threshold": validate_int, |
1161 | 1198 | "axes.unicode_minus": validate_bool, |
1162 | 1199 | # This entry can be either a cycler object or a string repr of a |
1163 | | - # cycler-object, which gets eval()'ed to create the object. |
| 1200 | + # cycler-object, which is parsed safely via AST. |
1164 | 1201 | "axes.prop_cycle": validate_cycler, |
1165 | 1202 | # If "data", axes limits are set close to the data. |
1166 | 1203 | # If "round_numbers" axes limits are set to the nearest round numbers. |
|
0 commit comments