diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 8548d16d43de..6a4e6b81ddf8 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3050,7 +3050,7 @@ def broken_barh(self, xranges, yrange, align="bottom", **kwargs): @_docstring.interpd def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing=0, tick_labels=None, labels=None, orientation="vertical", colors=None, - **kwargs): + hatch=None, **kwargs): """ Make a grouped bar plot. @@ -3190,6 +3190,15 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing If not specified, the colors from the Axes property cycle will be used. + hatch : sequence of :mpltype:`hatch` or None, optional + Hatch pattern(s) to apply per dataset. + + - If ``None`` (default), no hatching is applied. + - If a sequence of strings is provided (e.g., ``['//', 'xx', '..']``), + the patterns are cycled across datasets. + - If the sequence contains a single element (e.g., ``['//']``), + the same pattern is repeated for all datasets. + **kwargs : `.Rectangle` properties %(Rectangle:kwdoc)s @@ -3318,6 +3327,38 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # TODO: do we want to be more restrictive and check lengths? colors = itertools.cycle(colors) + if hatch is None: + # No hatch specified: disable hatching entirely by cycling [None]. + hatches = itertools.cycle([None]) + + elif isinstance(hatch, str): + raise ValueError("'hatch' must be a sequence of strings " + "(e.g., ['//']) or None; " + "a single string like '//' is not allowed." + ) + + else: + try: + hatch_list = list(hatch) + except TypeError: + raise ValueError("'hatch' must be a sequence of strings" + "(e.g., ['//']) or None") from None + + if not hatch_list: + # Empty sequence is invalid → raise instead of treating as no hatch. + raise ValueError( + "'hatch' must be a non-empty sequence of strings or None; " + "use hatch=None for no hatching." + ) + + elif not all(h is None or isinstance(h, str) for h in hatch_list): + raise TypeError("All entries in 'hatch' must be strings or None") + + else: + # Sequence of hatch patterns: cycle through them as needed. + # Example: hatch=['//', 'xx', '..'] → patterns repeat across datasets. + hatches = itertools.cycle(hatch_list) + bar_width = (group_distance / (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) bar_spacing_abs = bar_spacing * bar_width @@ -3331,15 +3372,19 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # place the bars, but only use numerical positions, categorical tick labels # are handled separately below bar_containers = [] - for i, (hs, label, color) in enumerate(zip(heights, labels, colors)): + + + for i, (hs, label, color, hatch_pattern) in enumerate( + zip(heights, labels, colors, hatches) + ): lefts = (group_centers - 0.5 * group_distance + margin_abs + i * (bar_width + bar_spacing_abs)) if orientation == "vertical": bc = self.bar(lefts, hs, width=bar_width, align="edge", - label=label, color=color, **kwargs) + label=label, color=color, hatch=hatch_pattern, **kwargs) else: bc = self.barh(lefts, hs, height=bar_width, align="edge", - label=label, color=color, **kwargs) + label=label, color=color, hatch=hatch_pattern,**kwargs) bar_containers.append(bc) if tick_labels is not None: diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 69d251aa21f7..d1b54f3fd263 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -287,6 +287,7 @@ class Axes(_AxesBase): bar_spacing: float | None = ..., orientation: Literal["vertical", "horizontal"] = ..., colors: Iterable[ColorType] | None = ..., + hatch: Iterable[str] | None = ..., **kwargs ) -> list[BarContainer]: ... def stem( diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 25aa1a1b2821..c78ef89fd998 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -3536,6 +3536,7 @@ def grouped_bar( labels: Sequence[str] | None = None, orientation: Literal["vertical", "horizontal"] = "vertical", colors: Iterable[ColorType] | None = None, + hatch: Iterable[str] | None = None, **kwargs, ) -> list[BarContainer]: return gca().grouped_bar( @@ -3547,6 +3548,7 @@ def grouped_bar( labels=labels, orientation=orientation, colors=colors, + hatch=hatch, **kwargs, ) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 7307951595cb..60d2dcb76ef2 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2267,6 +2267,121 @@ def test_grouped_bar_return_value(): assert bc not in ax.containers +def test_grouped_bar_single_hatch_str_raises(): + """Passing a single string for hatch should raise a ValueError.""" + fig, ax = plt.subplots() + x = np.arange(3) + heights = [np.array([1, 2, 3]), np.array([2, 1, 2])] + with pytest.raises(ValueError, match="must be a sequence of strings"): + ax.grouped_bar(heights, positions=x, hatch='//') + + +def test_grouped_bar_hatch_non_iterable_raises(): + """Non-iterable hatch values should raise a ValueError.""" + fig, ax = plt.subplots() + heights = [np.array([1, 2]), np.array([2, 3])] + with pytest.raises(ValueError, match="must be a sequence of strings"): + ax.grouped_bar(heights, hatch=123) # invalid non-iterable + + +def test_grouped_bar_hatch_sequence(): + """Each dataset should receive its own hatch pattern when a sequence is passed.""" + fig, ax = plt.subplots() + x = np.arange(2) + heights = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4])] + hatches = ['//', 'xx', '..'] + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + + # Verify each dataset gets the corresponding hatch + for hatch, c in zip(hatches, containers.bar_containers): + for rect in c: + assert rect.get_hatch() == hatch + + +def test_grouped_bar_hatch_cycles_when_shorter_than_datasets(): + """When the hatch list is shorter than the number of datasets, + patterns should cycle. + """ + + fig, ax = plt.subplots() + x = np.arange(2) + heights = [ + np.array([1, 2]), + np.array([2, 3]), + np.array([3, 4]), + ] + hatches = ['//', 'xx'] # shorter than number of datasets → should cycle + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + + expected_hatches = ['//', 'xx', '//'] # cycle repeats + for gi, c in enumerate(containers.bar_containers): + for rect in c: + assert rect.get_hatch() == expected_hatches[gi] + + +def test_grouped_bar_hatch_none(): + """Passing hatch=None should result in bars with no hatch.""" + fig, ax = plt.subplots() + x = np.arange(2) + heights = [np.array([1, 2]), np.array([2, 3])] + containers = ax.grouped_bar(heights, positions=x, hatch=None) + + # All bars should have no hatch applied + for c in containers.bar_containers: + for rect in c: + assert rect.get_hatch() in (None, ''), \ + f"Expected no hatch, got {rect.get_hatch()!r}" + + +def test_grouped_bar_empty_string_disables_hatch(): + """ + Empty strings or None in the hatch list should result in no hatch + for the corresponding dataset, while valid strings should apply + the hatch pattern normally. + """ + fig, ax = plt.subplots() + x = np.arange(3) + heights = [np.array([1, 2, 3]), np.array([2, 1, 2]), np.array([3, 2, 1])] + hatches = ["", "xx", None] + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + # Collect the hatch pattern for each bar in each dataset + counts = [[rect.get_hatch() for rect in bc] for bc in containers.bar_containers] + # First dataset: empty string disables hatch + assert all(h in ("", None) for h in counts[0]) + # Second dataset: hatch pattern applied + assert all(h == "xx" for h in counts[1]) + # Third dataset: None disables hatch + assert all(h in ("", None) for h in counts[2]) + + +def test_grouped_bar_empty_hatch_sequence_raises(): + """An empty hatch sequence should raise a ValueError.""" + fig, ax = plt.subplots() + heights = [np.array([1, 2]), np.array([2, 3])] + with pytest.raises( + ValueError, + match="must be a non-empty sequence of strings or None" + ): + ax.grouped_bar(heights, hatch=[]) + + +def test_grouped_bar_dict_with_labels_forbidden(): + """Passing labels along with dict input should raise an error.""" + fig, ax = plt.subplots() + data = {"a": [1, 2], "b": [2, 1]} + with pytest.raises(ValueError, match="cannot be used if 'heights' is a mapping"): + ax.grouped_bar(data, labels=["x", "y"]) + + +def test_grouped_bar_positions_not_equidistant(): + """Passing non-equidistant positions should raise an error.""" + fig, ax = plt.subplots() + x = np.array([0, 1, 3]) + heights = [np.array([1, 2, 3]), np.array([2, 1, 2])] + with pytest.raises(ValueError, match="must be equidistant"): + ax.grouped_bar(heights, positions=x) + + def test_boxplot_dates_pandas(pd): # smoke test for boxplot and dates in pandas data = np.random.rand(5, 2)