@@ -92,6 +109,7 @@ a :ref:`FAQ
` in our :ref:`user guide `.
:hidden:
/tutorials/pyplot
+ /tutorials/coding_shortcuts
/tutorials/images
/tutorials/lifecycle
/tutorials/artists
diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py
index cf35dc1de7db..50077c6738fa 100644
--- a/lib/matplotlib/_mathtext.py
+++ b/lib/matplotlib/_mathtext.py
@@ -331,20 +331,15 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag
# Per-instance cache.
self._get_info = functools.cache(self._get_info) # type: ignore[method-assign]
self._fonts = {}
- self.fontmap: dict[str | int, str] = {}
+ self.fontmap: dict[str, str] = {}
filename = findfont(self.default_font_prop)
default_font = get_font(filename)
self._fonts['default'] = default_font
self._fonts['regular'] = default_font
- def _get_font(self, font: str | int) -> FT2Font:
- if font in self.fontmap:
- basename = self.fontmap[font]
- else:
- # NOTE: An int is only passed by subclasses which have placed int keys into
- # `self.fontmap`, so we must cast this to confirm it to typing.
- basename = T.cast(str, font)
+ def _get_font(self, font: str) -> FT2Font:
+ basename = self.fontmap.get(font, font)
cached_font = self._fonts.get(basename)
if cached_font is None and os.path.exists(basename):
cached_font = get_font(basename)
@@ -574,12 +569,13 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag
# include STIX sized alternatives for glyphs if fallback is STIX
if isinstance(self._fallback_font, StixFonts):
stixsizedaltfonts = {
- 0: 'STIXGeneral',
- 1: 'STIXSizeOneSym',
- 2: 'STIXSizeTwoSym',
- 3: 'STIXSizeThreeSym',
- 4: 'STIXSizeFourSym',
- 5: 'STIXSizeFiveSym'}
+ '0': 'STIXGeneral',
+ '1': 'STIXSizeOneSym',
+ '2': 'STIXSizeTwoSym',
+ '3': 'STIXSizeThreeSym',
+ '4': 'STIXSizeFourSym',
+ '5': 'STIXSizeFiveSym',
+ }
for size, name in stixsizedaltfonts.items():
fullpath = findfont(name)
@@ -637,7 +633,7 @@ def _get_glyph(self, fontname: str, font_class: str,
g = self._fallback_font._get_glyph(fontname, font_class, sym)
family = g[0].family_name
- if family in list(BakomaFonts._fontmap.values()):
+ if family in BakomaFonts._fontmap.values():
family = "Computer Modern"
_log.info("Substituting symbol %s from %s", sym, family)
return g
@@ -658,13 +654,12 @@ def _get_glyph(self, fontname: str, font_class: str,
def get_sized_alternatives_for_symbol(self, fontname: str,
sym: str) -> list[tuple[str, str]]:
if self._fallback_font:
- return self._fallback_font.get_sized_alternatives_for_symbol(
- fontname, sym)
+ return self._fallback_font.get_sized_alternatives_for_symbol(fontname, sym)
return [(fontname, sym)]
class DejaVuFonts(UnicodeFonts, metaclass=abc.ABCMeta):
- _fontmap: dict[str | int, str] = {}
+ _fontmap: dict[str, str] = {}
def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlags):
# This must come first so the backend's owner is set correctly
@@ -676,11 +671,11 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag
TruetypeFonts.__init__(self, default_font_prop, load_glyph_flags)
# Include Stix sized alternatives for glyphs
self._fontmap.update({
- 1: 'STIXSizeOneSym',
- 2: 'STIXSizeTwoSym',
- 3: 'STIXSizeThreeSym',
- 4: 'STIXSizeFourSym',
- 5: 'STIXSizeFiveSym',
+ '1': 'STIXSizeOneSym',
+ '2': 'STIXSizeTwoSym',
+ '3': 'STIXSizeThreeSym',
+ '4': 'STIXSizeFourSym',
+ '5': 'STIXSizeFiveSym',
})
for key, name in self._fontmap.items():
fullpath = findfont(name)
@@ -718,7 +713,7 @@ class DejaVuSerifFonts(DejaVuFonts):
'sf': 'DejaVu Sans',
'tt': 'DejaVu Sans Mono',
'ex': 'DejaVu Serif Display',
- 0: 'DejaVu Serif',
+ '0': 'DejaVu Serif',
}
@@ -736,7 +731,7 @@ class DejaVuSansFonts(DejaVuFonts):
'sf': 'DejaVu Sans',
'tt': 'DejaVu Sans Mono',
'ex': 'DejaVu Sans Display',
- 0: 'DejaVu Sans',
+ '0': 'DejaVu Sans',
}
@@ -752,7 +747,7 @@ class StixFonts(UnicodeFonts):
- handles sized alternative characters for the STIXSizeX fonts.
"""
- _fontmap: dict[str | int, str] = {
+ _fontmap = {
'rm': 'STIXGeneral',
'it': 'STIXGeneral:italic',
'bf': 'STIXGeneral:weight=bold',
@@ -760,12 +755,12 @@ class StixFonts(UnicodeFonts):
'nonunirm': 'STIXNonUnicode',
'nonuniit': 'STIXNonUnicode:italic',
'nonunibf': 'STIXNonUnicode:weight=bold',
- 0: 'STIXGeneral',
- 1: 'STIXSizeOneSym',
- 2: 'STIXSizeTwoSym',
- 3: 'STIXSizeThreeSym',
- 4: 'STIXSizeFourSym',
- 5: 'STIXSizeFiveSym',
+ '0': 'STIXGeneral',
+ '1': 'STIXSizeOneSym',
+ '2': 'STIXSizeTwoSym',
+ '3': 'STIXSizeThreeSym',
+ '4': 'STIXSizeFourSym',
+ '5': 'STIXSizeFiveSym',
}
_fallback_font = None
_sans = False
@@ -832,10 +827,8 @@ def _map_virtual_font(self, fontname: str, font_class: str,
return fontname, uniindex
@functools.cache
- def get_sized_alternatives_for_symbol( # type: ignore[override]
- self,
- fontname: str,
- sym: str) -> list[tuple[str, str]] | list[tuple[int, str]]:
+ def get_sized_alternatives_for_symbol(self, fontname: str,
+ sym: str) -> list[tuple[str, str]]:
fixes = {
'\\{': '{', '\\}': '}', '\\[': '[', '\\]': ']',
'<': '\N{MATHEMATICAL LEFT ANGLE BRACKET}',
@@ -846,8 +839,8 @@ def get_sized_alternatives_for_symbol( # type: ignore[override]
uniindex = get_unicode_index(sym)
except ValueError:
return [(fontname, sym)]
- alternatives = [(i, chr(uniindex)) for i in range(6)
- if self._get_font(i).get_char_index(uniindex) != 0]
+ alternatives = [(str(i), chr(uniindex)) for i in range(6)
+ if self._get_font(str(i)).get_char_index(uniindex) != 0]
# The largest size of the radical symbol in STIX has incorrect
# metrics that cause it to be disconnected from the stem.
if sym == r'\__sqrt__':
@@ -1542,7 +1535,7 @@ def __init__(self, c: str, height: float, depth: float, state: ParserState,
break
shift = 0.0
- if state.font != 0 or len(alternatives) == 1:
+ if state.font != '0' or len(alternatives) == 1:
if factor is None:
factor = target_total / (char.height + char.depth)
state.fontsize *= factor
@@ -2530,7 +2523,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any:
# Handle regular sub/superscripts
constants = _get_font_constant_set(state)
lc_height = last_char.height
- lc_baseline = 0
+ lc_baseline = 0.0
if self.is_dropsub(last_char):
lc_baseline = last_char.depth
diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py
index c87c789048c4..25515da77ed7 100644
--- a/lib/matplotlib/artist.py
+++ b/lib/matplotlib/artist.py
@@ -240,10 +240,12 @@ def remove(self):
# clear stale callback
self.stale_callback = None
_ax_flag = False
- if hasattr(self, 'axes') and self.axes:
+ ax = getattr(self, 'axes', None)
+ mouseover_set = getattr(ax, '_mouseover_set', None)
+ if mouseover_set is not None:
# remove from the mouse hit list
- self.axes._mouseover_set.discard(self)
- self.axes.stale = True
+ mouseover_set.discard(self)
+ ax.stale = True
self.axes = None # decouple the artist from the Axes
_ax_flag = True
diff --git a/lib/matplotlib/backend_tools.py b/lib/matplotlib/backend_tools.py
index 87ed794022a0..784f97c0a1e4 100644
--- a/lib/matplotlib/backend_tools.py
+++ b/lib/matplotlib/backend_tools.py
@@ -937,6 +937,7 @@ def trigger(self, *args, **kwargs):
self.toolmanager.message_event(message, self)
+#: The default tools to add to a tool manager.
default_tools = {'home': ToolHome, 'back': ToolBack, 'forward': ToolForward,
'zoom': ToolZoom, 'pan': ToolPan,
'subplots': ConfigureSubplotsBase,
@@ -956,12 +957,13 @@ def trigger(self, *args, **kwargs):
'copy': ToolCopyToClipboardBase,
}
+#: The default tools to add to a container.
default_toolbar_tools = [['navigation', ['home', 'back', 'forward']],
['zoompan', ['pan', 'zoom', 'subplots']],
['io', ['save', 'help']]]
-def add_tools_to_manager(toolmanager, tools=default_tools):
+def add_tools_to_manager(toolmanager, tools=None):
"""
Add multiple tools to a `.ToolManager`.
@@ -971,14 +973,16 @@ def add_tools_to_manager(toolmanager, tools=default_tools):
Manager to which the tools are added.
tools : {str: class_like}, optional
The tools to add in a {name: tool} dict, see
- `.backend_managers.ToolManager.add_tool` for more info.
+ `.backend_managers.ToolManager.add_tool` for more info. If not specified, then
+ defaults to `.default_tools`.
"""
-
+ if tools is None:
+ tools = default_tools
for name, tool in tools.items():
toolmanager.add_tool(name, tool)
-def add_tools_to_container(container, tools=default_toolbar_tools):
+def add_tools_to_container(container, tools=None):
"""
Add multiple tools to the container.
@@ -990,9 +994,11 @@ def add_tools_to_container(container, tools=default_toolbar_tools):
tools : list, optional
List in the form ``[[group1, [tool1, tool2 ...]], [group2, [...]]]``
where the tools ``[tool1, tool2, ...]`` will display in group1.
- See `.backend_bases.ToolContainerBase.add_tool` for details.
+ See `.backend_bases.ToolContainerBase.add_tool` for details. If not specified,
+ then defaults to `.default_toolbar_tools`.
"""
-
+ if tools is None:
+ tools = default_toolbar_tools
for group, grouptools in tools:
for position, tool in enumerate(grouptools):
container.add_tool(tool, group, position)
diff --git a/lib/matplotlib/backend_tools.pyi b/lib/matplotlib/backend_tools.pyi
index 32fe8c2f5a79..fa89e3d66871 100644
--- a/lib/matplotlib/backend_tools.pyi
+++ b/lib/matplotlib/backend_tools.pyi
@@ -112,10 +112,10 @@ class ToolHelpBase(ToolBase):
class ToolCopyToClipboardBase(ToolBase): ...
-default_tools: dict[str, ToolBase]
+default_tools: dict[str, type[ToolBase]]
default_toolbar_tools: list[list[str | list[str]]]
def add_tools_to_manager(
- toolmanager: ToolManager, tools: dict[str, type[ToolBase]] = ...
+ toolmanager: ToolManager, tools: dict[str, type[ToolBase]] | None = ...
) -> None: ...
-def add_tools_to_container(container: ToolContainerBase, tools: list[Any] = ...) -> None: ...
+def add_tools_to_container(container: ToolContainerBase, tools: list[Any] | None = ...) -> None: ...
diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py
index 48b6e8ac152c..2d2e24c3286c 100644
--- a/lib/matplotlib/backends/backend_pgf.py
+++ b/lib/matplotlib/backends/backend_pgf.py
@@ -281,7 +281,7 @@ def _setup_latex_process(self, *, expect_reply=True):
# it.
try:
self.latex = subprocess.Popen(
- [mpl.rcParams["pgf.texsystem"], "-halt-on-error"],
+ [mpl.rcParams["pgf.texsystem"], "-halt-on-error", "-no-shell-escape"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
encoding="utf-8", cwd=self.tmpdir)
except FileNotFoundError as err:
@@ -848,7 +848,7 @@ def print_pdf(self, fname_or_fh, *, metadata=None, **kwargs):
texcommand = mpl.rcParams["pgf.texsystem"]
cbook._check_and_log_subprocess(
[texcommand, "-interaction=nonstopmode", "-halt-on-error",
- "figure.tex"], _log, cwd=tmpdir)
+ "-no-shell-escape", "figure.tex"], _log, cwd=tmpdir)
with ((tmppath / "figure.pdf").open("rb") as orig,
cbook.open_file_cm(fname_or_fh, "wb") as dest):
shutil.copyfileobj(orig, dest) # copy file contents to target
@@ -965,7 +965,7 @@ def _run_latex(self):
tex_source.write_bytes(self._file.getvalue())
cbook._check_and_log_subprocess(
[texcommand, "-interaction=nonstopmode", "-halt-on-error",
- tex_source],
+ "-no-shell-escape", tex_source],
_log, cwd=tmpdir)
shutil.move(tex_source.with_suffix(".pdf"), self._output_name)
diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py
index f1f914ae5420..4dfdb2a6a095 100644
--- a/lib/matplotlib/backends/backend_ps.py
+++ b/lib/matplotlib/backends/backend_ps.py
@@ -1257,8 +1257,9 @@ def _convert_psfrags(tmppath, psfrags, paper_width, paper_height, orientation):
with TemporaryDirectory() as tmpdir:
psfile = os.path.join(tmpdir, "tmp.ps")
+ # -R1 is a security flag used to prevent shell command execution
cbook._check_and_log_subprocess(
- ['dvips', '-q', '-R0', '-o', psfile, dvifile], _log)
+ ['dvips', '-q', '-R1', '-o', psfile, dvifile], _log)
shutil.move(psfile, tmppath)
# check if the dvips created a ps in landscape paper. Somehow,
@@ -1302,7 +1303,7 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False):
cbook._check_and_log_subprocess(
[mpl._get_executable_info("gs").executable,
- "-dBATCH", "-dNOPAUSE", "-r%d" % dpi, "-sDEVICE=ps2write",
+ "-dBATCH", "-dNOPAUSE", "-dSAFER", "-r%d" % dpi, "-sDEVICE=ps2write",
*paper_option, f"-sOutputFile={psfile}", tmpfile],
_log)
@@ -1346,6 +1347,7 @@ def xpdf_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False):
# happy (https://ghostscript.com/doc/9.56.1/Use.htm#MS_Windows).
cbook._check_and_log_subprocess(
["ps2pdf",
+ "-dSAFER",
"-dAutoFilterColorImages#false",
"-dAutoFilterGrayImages#false",
"-sAutoRotatePages#None",
diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py
index 2193dc6b6cdc..7789ec2cd882 100644
--- a/lib/matplotlib/backends/backend_svg.py
+++ b/lib/matplotlib/backends/backend_svg.py
@@ -1132,7 +1132,8 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None):
font_style['font-style'] = prop.get_style()
if prop.get_variant() != 'normal':
font_style['font-variant'] = prop.get_variant()
- weight = fm.weight_dict[prop.get_weight()]
+ weight = prop.get_weight()
+ weight = fm.weight_dict.get(weight, weight) # convert to int
if weight != 400:
font_style['font-weight'] = f'{weight}'
diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py
index f7318d578121..4f3e594e9202 100644
--- a/lib/matplotlib/contour.py
+++ b/lib/matplotlib/contour.py
@@ -446,6 +446,8 @@ def add_label_near(self, x, y, inline=True, inline_spacing=5,
if transform is None:
transform = self.axes.transData
if transform:
+ x = self.axes.convert_xunits(x)
+ y = self.axes.convert_yunits(y)
x, y = transform.transform((x, y))
idx_level_min, idx_vtx_min, proj = self._find_nearest_contour(
diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py
index 089141727189..e5794954abb8 100644
--- a/lib/matplotlib/figure.py
+++ b/lib/matplotlib/figure.py
@@ -376,10 +376,17 @@ def _suplabels(self, t, info, **kwargs):
else:
suplab = self.text(x, y, t, **kwargs)
setattr(self, info['name'], suplab)
+ suplab._remove_method = functools.partial(self._remove_suplabel,
+ name=info['name'])
+
suplab._autopos = autopos
self.stale = True
return suplab
+ def _remove_suplabel(self, label, name):
+ self.texts.remove(label)
+ setattr(self, name, None)
+
@_docstring.Substitution(x0=0.5, y0=0.98, name='super title', ha='center',
va='top', rc='title')
@_docstring.copy(_suplabels)
diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py
index 9aa8dccde444..a44d5bd76b9a 100644
--- a/lib/matplotlib/font_manager.py
+++ b/lib/matplotlib/font_manager.py
@@ -744,7 +744,7 @@ def get_variant(self):
def get_weight(self):
"""
- Set the font weight. Options are: A numeric value in the
+ Get the font weight. Options are: A numeric value in the
range 0-1000 or one of 'light', 'normal', 'regular', 'book',
'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold',
'heavy', 'extra bold', 'black'
diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi
index 37281afeaafa..3c8b52a73b6b 100644
--- a/lib/matplotlib/ft2font.pyi
+++ b/lib/matplotlib/ft2font.pyi
@@ -194,7 +194,7 @@ class FT2Font(Buffer):
_kerning_factor: int = ...
) -> None: ...
if sys.version_info[:2] >= (3, 12):
- def __buffer__(self, flags: int) -> memoryview: ...
+ def __buffer__(self, /, flags: int) -> memoryview: ...
def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ...
def clear(self) -> None: ...
def draw_glyph_to_bitmap(
@@ -286,7 +286,7 @@ class FT2Image(Buffer):
def __init__(self, width: int, height: int) -> None: ...
def draw_rect_filled(self, x0: int, y0: int, x1: int, y1: int) -> None: ...
if sys.version_info[:2] >= (3, 12):
- def __buffer__(self, flags: int) -> memoryview: ...
+ def __buffer__(self, /, flags: int) -> memoryview: ...
@final
class Glyph:
diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py
index c23d9f818454..3a4da5f575fb 100644
--- a/lib/matplotlib/rcsetup.py
+++ b/lib/matplotlib/rcsetup.py
@@ -30,7 +30,7 @@
from matplotlib._enums import JoinStyle, CapStyle
# Don't let the original cycler collide with our validating cycler
-from cycler import Cycler, cycler as ccycler
+from cycler import Cycler, concat as cconcat, cycler as ccycler
@_api.caching_module_getattr
@@ -759,11 +759,62 @@ def cycler(*args, **kwargs):
return reduce(operator.add, (ccycler(k, v) for k, v in validated))
-class _DunderChecker(ast.NodeVisitor):
- def visit_Attribute(self, node):
- if node.attr.startswith("__") and node.attr.endswith("__"):
- raise ValueError("cycler strings with dunders are forbidden")
- self.generic_visit(node)
+def _parse_cycler_string(s):
+ """
+ Parse a string representation of a cycler into a Cycler object safely,
+ without using eval().
+
+ Accepts expressions like::
+
+ cycler('color', ['r', 'g', 'b'])
+ cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3])
+ cycler(c='rgb', lw=[1, 2, 3])
+ cycler('c', 'rgb') * cycler('linestyle', ['-', '--'])
+ """
+ try:
+ tree = ast.parse(s, mode='eval')
+ except SyntaxError as e:
+ raise ValueError(f"Could not parse {s!r}: {e}") from e
+ return _eval_cycler_expr(tree.body)
+
+
+def _eval_cycler_expr(node):
+ """Recursively evaluate an AST node to build a Cycler object."""
+ if isinstance(node, ast.BinOp):
+ left = _eval_cycler_expr(node.left)
+ right = _eval_cycler_expr(node.right)
+ if isinstance(node.op, ast.Add):
+ return left + right
+ if isinstance(node.op, ast.Mult):
+ return left * right
+ raise ValueError(f"Unsupported operator: {type(node.op).__name__}")
+ if isinstance(node, ast.Call):
+ if not (isinstance(node.func, ast.Name)
+ and node.func.id in ('cycler', 'concat')):
+ raise ValueError(
+ "only the 'cycler()' and 'concat()' functions are allowed")
+ func = cycler if node.func.id == 'cycler' else cconcat
+ args = [_eval_cycler_expr(a) for a in node.args]
+ kwargs = {kw.arg: _eval_cycler_expr(kw.value) for kw in node.keywords}
+ return func(*args, **kwargs)
+ if isinstance(node, ast.Subscript):
+ sl = node.slice
+ if not isinstance(sl, ast.Slice):
+ raise ValueError("only slicing is supported, not indexing")
+ s = slice(
+ ast.literal_eval(sl.lower) if sl.lower else None,
+ ast.literal_eval(sl.upper) if sl.upper else None,
+ ast.literal_eval(sl.step) if sl.step else None,
+ )
+ value = _eval_cycler_expr(node.value)
+ return value[s]
+ # Allow literal values (int, strings, lists, tuples) as arguments
+ # to cycler() and concat().
+ try:
+ return ast.literal_eval(node)
+ except (ValueError, TypeError):
+ raise ValueError(
+ f"Unsupported expression in cycler string: {ast.dump(node)}")
# A validator dedicated to the named legend loc
@@ -814,25 +865,11 @@ def _validate_legend_loc(loc):
def validate_cycler(s):
"""Return a Cycler object from a string repr or the object itself."""
if isinstance(s, str):
- # TODO: We might want to rethink this...
- # While I think I have it quite locked down, it is execution of
- # arbitrary code without sanitation.
- # Combine this with the possibility that rcparams might come from the
- # internet (future plans), this could be downright dangerous.
- # I locked it down by only having the 'cycler()' function available.
- # UPDATE: Partly plugging a security hole.
- # I really should have read this:
- # https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html
- # We should replace this eval with a combo of PyParsing and
- # ast.literal_eval()
try:
- _DunderChecker().visit(ast.parse(s))
- s = eval(s, {'cycler': cycler, '__builtins__': {}})
- except BaseException as e:
+ s = _parse_cycler_string(s)
+ except Exception as e:
raise ValueError(f"{s!r} is not a valid cycler construction: {e}"
) from e
- # Should make sure what comes from the above eval()
- # is a Cycler object.
if isinstance(s, Cycler):
cycler_inst = s
else:
@@ -1101,7 +1138,7 @@ def _convert_validator_spec(key, conv):
"axes.formatter.offset_threshold": validate_int,
"axes.unicode_minus": validate_bool,
# This entry can be either a cycler object or a string repr of a
- # cycler-object, which gets eval()'ed to create the object.
+ # cycler-object, which is parsed safely via AST.
"axes.prop_cycle": validate_cycler,
# If "data", axes limits are set close to the data.
# If "round_numbers" axes limits are set to the nearest round numbers.
diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py
index 19113d399626..73b1645468a6 100644
--- a/lib/matplotlib/testing/__init__.py
+++ b/lib/matplotlib/testing/__init__.py
@@ -164,7 +164,8 @@ def _check_for_pgf(texsystem):
""", encoding="utf-8")
try:
subprocess.check_call(
- [texsystem, "-halt-on-error", str(tex_path)], cwd=tmpdir,
+ [texsystem, "-halt-on-error", "-no-shell-escape",
+ str(tex_path)], cwd=tmpdir,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (OSError, subprocess.CalledProcessError):
return False
diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index 159fc70282b8..b9eb145b1410 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -2182,9 +2182,8 @@ def test_pcolor_regression(pd):
time_axis, y_axis = np.meshgrid(times, y_vals)
shape = (len(y_vals) - 1, len(times) - 1)
- z_data = np.arange(shape[0] * shape[1])
+ z_data = np.arange(shape[0] * shape[1]).reshape(shape)
- z_data.shape = shape
try:
register_matplotlib_converters()
diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py
index 509136fe323e..0f9f5f19afb5 100644
--- a/lib/matplotlib/tests/test_backend_svg.py
+++ b/lib/matplotlib/tests/test_backend_svg.py
@@ -73,7 +73,8 @@ def test_bold_font_output():
ax.plot(np.arange(10), np.arange(10))
ax.set_xlabel('nonbold-xlabel')
ax.set_ylabel('bold-ylabel', fontweight='bold')
- ax.set_title('bold-title', fontweight='bold')
+ # set weight as integer to assert it's handled properly
+ ax.set_title('bold-title', fontweight=600)
@image_comparison(['bold_font_output_with_none_fonttype.svg'])
@@ -83,7 +84,8 @@ def test_bold_font_output_with_none_fonttype():
ax.plot(np.arange(10), np.arange(10))
ax.set_xlabel('nonbold-xlabel')
ax.set_ylabel('bold-ylabel', fontweight='bold')
- ax.set_title('bold-title', fontweight='bold')
+ # set weight as integer to assert it's handled properly
+ ax.set_title('bold-title', fontweight=600)
@check_figures_equal(tol=20)
diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py
index 4e3c1bbc2bb5..594681391e61 100644
--- a/lib/matplotlib/tests/test_backends_interactive.py
+++ b/lib/matplotlib/tests/test_backends_interactive.py
@@ -648,7 +648,7 @@ def _impl_test_interactive_timers():
assert mock.call_count > 1
# Now turn it into a single shot timer and verify only one gets triggered
- mock.call_count = 0
+ mock.reset_mock()
timer.single_shot = True
timer.start()
plt.pause(pause_time)
diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py
index 7da4bab69c15..7ca9c0f77858 100644
--- a/lib/matplotlib/tests/test_colors.py
+++ b/lib/matplotlib/tests/test_colors.py
@@ -1590,7 +1590,7 @@ def test_norm_callback():
assert increment.call_count == 2
# We only want autoscale() calls to send out one update signal
- increment.call_count = 0
+ increment.reset_mock()
norm.autoscale([0, 1, 2])
assert increment.call_count == 1
diff --git a/lib/matplotlib/tests/test_datetime.py b/lib/matplotlib/tests/test_datetime.py
index 276056d044ae..9fc133549017 100644
--- a/lib/matplotlib/tests/test_datetime.py
+++ b/lib/matplotlib/tests/test_datetime.py
@@ -259,11 +259,22 @@ def test_bxp(self):
ax.xaxis.set_major_formatter(mpl.dates.DateFormatter("%Y-%m-%d"))
ax.set_title('Box plot with datetime data')
- @pytest.mark.xfail(reason="Test for clabel not written yet")
@mpl.style.context("default")
def test_clabel(self):
+ dates = [datetime.datetime(2023, 10, 1) + datetime.timedelta(days=i)
+ for i in range(10)]
+ x = np.arange(-10.0, 5.0, 0.5)
+ X, Y = np.meshgrid(x, dates)
+ Z = np.arange(X.size).reshape(X.shape)
+
fig, ax = plt.subplots()
- ax.clabel(...)
+ CS = ax.contour(X, Y, Z)
+ labels = ax.clabel(CS, manual=[(x[0], dates[0])])
+ assert len(labels) == 1
+ assert labels[0].get_text() == '0'
+ x_pos, y_pos = labels[0].get_position()
+ assert x_pos == pytest.approx(-10.0, abs=1e-3)
+ assert y_pos == pytest.approx(mpl.dates.date2num(dates[0]), abs=1e-3)
@mpl.style.context("default")
def test_contour(self):
diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py
index 3a4ced254091..487b6b9688ec 100644
--- a/lib/matplotlib/tests/test_figure.py
+++ b/lib/matplotlib/tests/test_figure.py
@@ -364,6 +364,28 @@ def test_get_suptitle_supxlabel_supylabel():
assert fig.get_supylabel() == 'supylabel'
+def test_remove_suptitle_supxlabel_supylabel():
+ fig = plt.figure()
+
+ title = fig.suptitle('suptitle')
+ xlabel = fig.supxlabel('supxlabel')
+ ylabel = fig.supylabel('supylabel')
+
+ assert len(fig.texts) == 3
+ assert fig._suptitle is not None
+ assert fig._supxlabel is not None
+ assert fig._supylabel is not None
+
+ title.remove()
+ assert fig._suptitle is None
+ xlabel.remove()
+ assert fig._supxlabel is None
+ ylabel.remove()
+ assert fig._supylabel is None
+
+ assert not fig.texts
+
+
@image_comparison(['alpha_background'],
# only test png and svg. The PDF output appears correct,
# but Ghostscript does not preserve the background color.
@@ -1551,6 +1573,7 @@ def test_subfigures_wspace_hspace():
def test_subfigure_remove():
fig = plt.figure()
sfs = fig.subfigures(2, 2)
+ sfs[1, 1].subplots()
sfs[1, 1].remove()
assert len(fig.subfigs) == 3
diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py
index bea5e90ea4e5..d800731ac53a 100644
--- a/lib/matplotlib/tests/test_rcparams.py
+++ b/lib/matplotlib/tests/test_rcparams.py
@@ -272,16 +272,23 @@ def generate_validator_testcases(valid):
cycler('linestyle', ['-', '--'])),
(cycler(mew=[2, 5]),
cycler('markeredgewidth', [2, 5])),
+ ("2 * cycler('color', 'rgb')", 2 * cycler('color', 'rgb')),
+ ("2 * cycler('color', 'r' + 'gb')", 2 * cycler('color', 'rgb')),
+ ("cycler(c='r' + 'gb', lw=[1, 2, 3])",
+ cycler('color', 'rgb') + cycler('linewidth', [1, 2, 3])),
+ ("cycler('color', 'rgb') * 2", cycler('color', 'rgb') * 2),
+ ("concat(cycler('color', 'rgb'), cycler('color', 'cmk'))",
+ cycler('color', list('rgbcmk'))),
+ ("cycler('color', 'rgbcmk')[:3]", cycler('color', list('rgb'))),
+ ("cycler('color', 'rgb')[::-1]", cycler('color', list('bgr'))),
),
- # This is *so* incredibly important: validate_cycler() eval's
- # an arbitrary string! I think I have it locked down enough,
- # and that is what this is testing.
- # TODO: Note that these tests are actually insufficient, as it may
- # be that they raised errors, but still did an action prior to
- # raising the exception. We should devise some additional tests
- # for that...
+ # validate_cycler() parses an arbitrary string using a safe
+ # AST-based parser (no eval). These tests verify that only valid
+ # cycler expressions are accepted.
'fail': ((4, ValueError), # Gotta be a string or Cycler object
('cycler("bleh, [])', ValueError), # syntax error
+ ("cycler('color', 'rgb') * * cycler('color', 'rgb')", # syntax error
+ ValueError),
('Cycler("linewidth", [1, 2, 3])',
ValueError), # only 'cycler()' function is allowed
# do not allow dunder in string literals
@@ -295,6 +302,9 @@ def generate_validator_testcases(valid):
ValueError),
("cycler('c', [j.__class__(j).lower() for j in ['r', 'b']])",
ValueError),
+ # list comprehensions are arbitrary code, even if "safe"
+ ("cycler('color', [x for x in ['r', 'g', 'b']])",
+ ValueError),
('1 + 2', ValueError), # doesn't produce a Cycler object
('os.system("echo Gotcha")', ValueError), # os not available
('import os', ValueError), # should not be able to import
diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py
index 0424aede16eb..6856329931ef 100644
--- a/lib/matplotlib/texmanager.py
+++ b/lib/matplotlib/texmanager.py
@@ -291,8 +291,8 @@ def make_dvi(cls, tex, fontsize):
Path(tmpdir, "file.tex").write_text(
cls._get_tex_source(tex, fontsize), encoding='utf-8')
cls._run_checked_subprocess(
- ["latex", "-interaction=nonstopmode", "--halt-on-error",
- "file.tex"], tex, cwd=tmpdir)
+ ["latex", "-interaction=nonstopmode", "-halt-on-error",
+ "-no-shell-escape", "file.tex"], tex, cwd=tmpdir)
Path(tmpdir, "file.dvi").replace(dvifile)
# Also move the tex source to the main cache directory, but
# only for backcompat.
diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py
index 6a3c0d684380..039d7133ded1 100644
--- a/lib/matplotlib/widgets.py
+++ b/lib/matplotlib/widgets.py
@@ -980,7 +980,7 @@ class CheckButtons(AxesWidget):
For the check buttons to remain responsive you must keep a
reference to this object.
- Connect to the CheckButtons with the `.on_clicked` method.
+ Connect to the CheckButtons with the `~._Buttons.on_clicked` method.
Attributes
----------
@@ -1545,7 +1545,7 @@ class RadioButtons(AxesWidget):
For the buttons to remain responsive you must keep a reference to this
object.
- Connect to the RadioButtons with the `.on_clicked` method.
+ Connect to the RadioButtons with the `~._Buttons.on_clicked` method.
Attributes
----------
diff --git a/pyproject.toml b/pyproject.toml
index 23c441b52c9c..96b69829e674 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,7 @@ requires-python = ">=3.10"
dev = [
"meson-python>=0.13.1,<0.17.0",
"pybind11>=2.13.2,!=2.13.3",
- "setuptools_scm>=7",
+ "setuptools_scm>=7,<10",
# Not required by us but setuptools_scm without a version, cso _if_
# installed, then setuptools_scm 8 requires at least this version.
# Unfortunately, we can't do a sort of minimum-if-instaled dependency, so
@@ -74,7 +74,7 @@ build-backend = "mesonpy"
requires = [
"meson-python>=0.13.1,<0.17.0",
"pybind11>=2.13.2,!=2.13.3",
- "setuptools_scm>=7",
+ "setuptools_scm>=7,<10",
]
[tool.meson-python.args]
diff --git a/requirements/dev/build-requirements.txt b/requirements/dev/build-requirements.txt
index 4d2a098c3c4f..372a7d669fb1 100644
--- a/requirements/dev/build-requirements.txt
+++ b/requirements/dev/build-requirements.txt
@@ -1,3 +1,3 @@
pybind11>=2.13.2,!=2.13.3
meson-python
-setuptools-scm
+setuptools-scm<10
diff --git a/requirements/testing/mypy.txt b/requirements/testing/mypy.txt
index 0cef979a34bf..4421560494d7 100644
--- a/requirements/testing/mypy.txt
+++ b/requirements/testing/mypy.txt
@@ -22,5 +22,5 @@ packaging>=20.0
pillow>=8
pyparsing>=3
python-dateutil>=2.7
-setuptools_scm>=7
+setuptools_scm>=7,<10
setuptools>=64
diff --git a/src/_backend_agg_wrapper.cpp b/src/_backend_agg_wrapper.cpp
index 269e2aaa9ee5..80bb14e8cfec 100644
--- a/src/_backend_agg_wrapper.cpp
+++ b/src/_backend_agg_wrapper.cpp
@@ -58,8 +58,8 @@ PyRendererAgg_draw_path(RendererAgg *self,
static void
PyRendererAgg_draw_text_image(RendererAgg *self,
py::array_t image_obj,
- std::variant vx,
- std::variant vy,
+ std::variant vx,
+ std::variant vy,
double angle,
GCAgg &gc)
{
diff --git a/src/_enums.h b/src/_enums.h
index 18f3d9aac9fa..e607b93f50f2 100644
--- a/src/_enums.h
+++ b/src/_enums.h
@@ -80,7 +80,7 @@ namespace p11x {
auto ival = PyLong_AsLong(tmp); \
value = decltype(value)(ival); \
Py_DECREF(tmp); \
- return !(ival == -1 && !PyErr_Occurred()); \
+ return !(ival == -1 && PyErr_Occurred()); \
} else { \
return false; \
} \
diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp
index 9b54721810d6..3008c0b08b2f 100644
--- a/src/ft2font_wrapper.cpp
+++ b/src/ft2font_wrapper.cpp
@@ -14,7 +14,7 @@ namespace py = pybind11;
using namespace pybind11::literals;
template
-using double_or_ = std::variant;
+using double_or_ = std::variant;
template
static T
diff --git a/subprojects/freetype-2.6.1.wrap b/subprojects/freetype-2.6.1.wrap
index 763362b84df0..270556f0d5d3 100644
--- a/subprojects/freetype-2.6.1.wrap
+++ b/subprojects/freetype-2.6.1.wrap
@@ -1,5 +1,5 @@
[wrap-file]
-source_url = https://download.savannah.gnu.org/releases/freetype/freetype-old/freetype-2.6.1.tar.gz
+source_url = https://download.savannah.nongnu.org/releases/freetype/freetype-old/freetype-2.6.1.tar.gz
source_fallback_url = https://downloads.sourceforge.net/project/freetype/freetype2/2.6.1/freetype-2.6.1.tar.gz
source_filename = freetype-2.6.1.tar.gz
source_hash = 0a3c7dfbda6da1e8fce29232e8e96d987ababbbf71ebc8c75659e4132c367014
diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py
index 07b67a3e04ee..2ee72c6a89fa 100644
--- a/tools/cache_zenodo_svg.py
+++ b/tools/cache_zenodo_svg.py
@@ -63,6 +63,7 @@ def _get_xdg_cache_dir():
if __name__ == "__main__":
data = {
+ "v3.10.8": "17595503",
"v3.10.7": "17298696",
"v3.10.6": "16999430",
"v3.10.5": "16644850",