diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fc9f855 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of your workflow files + schedule: + interval: "weekly" # Options: daily, weekly, monthly diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..031ede2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "15 9 * * 3" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f4bda9b..f8c31fa 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,5 +1,7 @@ name: Validate Python Code +permissions: + contents: read on: push: @@ -17,12 +19,14 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9"] + python-version: ["3.11", "3.12", "3.13", "3.13t"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -53,20 +57,31 @@ jobs: flake8 matplotview --count --select=E9,F63,F7,F82 --show-source --statistics flake8 matplotview --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest + id: pytest run: | pytest + - name: Upload images on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() && steps.pytest.conclusion == 'failure' }} + with: + name: test-result-images + retention-days: 1 + path: result_images/ + test-windows: runs-on: windows-latest strategy: matrix: - python-version: [ "3.7", "3.8", "3.9" ] + python-version: ["3.11", "3.12", "3.13", "3.13t"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -76,5 +91,14 @@ jobs: pip install pytest pip install -r requirements.txt - name: Test with pytest + id: pytest run: | - pytest \ No newline at end of file + pytest + + - name: Upload images on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() && steps.pytest.conclusion == 'failure' }} + with: + name: test-result-images + retention-days: 1 + path: result_images/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..e88e6c7 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt diff --git a/README.md b/README.md index 0a03f53..788bca6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # matplotview -#### A library for creating lightweight views of matplotlib axes. +#### A small library for creating lightweight views of matplotlib axes. matplotview provides a simple interface for creating "views" of matplotlib axes, providing a simple way of displaying overviews and zoomed views of diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 7f5bc07..19c0c37 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -89,7 +89,7 @@ def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: Parameters ---------- view: Axes - The axes the is currently viewing the `axes_of_viewing`... + The axes that is currently viewing the `axes_of_viewing`... axes_of_viewing: Axes The axes that the view should stop viewing. diff --git a/matplotview/_docs.py b/matplotview/_docs.py index ec5a324..5753df5 100644 --- a/matplotview/_docs.py +++ b/matplotview/_docs.py @@ -1,12 +1,11 @@ import inspect -from inspect import signature def dynamic_doc_string(**kwargs): def convert(func): default_vals = { - k: v.default for k, v in signature(func).parameters.items() - if(v.default is not inspect.Parameter.empty) + k: v.default for k, v in inspect.signature(func).parameters.items() + if (v.default is not inspect.Parameter.empty) } default_vals.update(kwargs) func.__doc__ = func.__doc__.format(**default_vals) @@ -19,6 +18,6 @@ def convert(func): def get_interpolation_list_str(): from matplotlib.image import _interpd_ return ", ".join([ - f"'{k}'" if(i != len(_interpd_) - 1) else f"or '{k}'" + f"'{k}'" if (i != len(_interpd_) - 1) else f"or '{k}'" for i, k in enumerate(_interpd_) ]) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 8c6e747..4bfd180 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -105,7 +105,7 @@ def _scale_gc(self, gc: GraphicsContextBase) -> GraphicsContextBase: unit_box = transfer_transform.transform_bbox(unit_box) mult_factor = np.sqrt(unit_box.width * unit_box.height) - if(mult_factor == 0 or (not np.isfinite(mult_factor))): + if (mult_factor == 0 or (not np.isfinite(mult_factor))): return new_gc new_gc.set_linewidth(gc.get_linewidth() * mult_factor) @@ -218,18 +218,18 @@ def draw_path( # We check if the path intersects the axes box at all, if not don't # waste time drawing it. - if(not path.intersects_bbox(bbox, True)): + if (not path.intersects_bbox(bbox, True)): return - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) # Change the clip to the sub-axes box gc.set_clip_rectangle(bbox) - if(not isinstance(self.__bounding_axes.patch, Rectangle)): + if (not isinstance(self.__bounding_axes.patch, Rectangle)): gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) - rgbFace = tuple(rgbFace) if(rgbFace is not None) else None + rgbFace = tuple(rgbFace) if (rgbFace is not None) else None self.__renderer.draw_path(gc, path, IdentityTransform(), rgbFace) @@ -244,13 +244,83 @@ def _draw_text_as_path( ismath: bool ): # If the text field is empty, don't even try rendering it... - if((s is None) or (s.strip() == "")): + if ((s is None) or (s.strip() == "")): return # Call the super class instance, which works for all cases except one # checked above... (Above case causes error) super()._draw_text_as_path(gc, x, y, s, prop, angle, ismath) + def draw_markers( + self, + gc, + marker_path, + marker_trans, + path, + trans, + rgbFace=None, + ): + # If the markers need to be scaled accurately (such as in log scale), just use the fallback as each will need + # to be scaled separately. + if (self.__scale_widths): + super().draw_markers(gc, marker_path, marker_trans, path, trans, rgbFace) + return + + # Otherwise we transform just the marker offsets (not the marker patch), so they stay the same size. + path = path.deepcopy() + path.vertices = self._get_transfer_transform(trans).transform(path.vertices) + bbox = self._get_axes_display_box() + + # Change the clip to the sub-axes box + gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) + + rgbFace = tuple(rgbFace) if (rgbFace is not None) else None + self.__renderer.draw_markers(gc, marker_path, marker_trans, path, IdentityTransform(), rgbFace) + + def draw_path_collection( + self, + gc, + master_transform, + paths, + all_transforms, + offsets, + offset_trans, + facecolors, + edgecolors, + linewidths, + linestyles, + antialiaseds, + urls, + offset_position, + ): + # If we want accurate scaling for each marker (such as in log scale), just use superclass implementation... + if (self.__scale_widths): + super().draw_path_collection( + gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, + edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position + ) + return + + # Otherwise we transform just the offsets, and pass them to the backend. + print(offsets) + if (np.any(np.isnan(offsets))): + raise ValueError("???") + offsets = self._get_transfer_transform(offset_trans).transform(offsets) + print(offsets) + bbox = self._get_axes_display_box() + + # Change the clip to the sub-axes box + gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) + + self.__renderer.draw_path_collection( + gc, master_transform, paths, all_transforms, offsets, IdentityTransform(), facecolors, + edgecolors, linewidths, linestyles, antialiaseds, urls, None + ) + def draw_gouraud_triangle( self, gc: GraphicsContextBase, @@ -264,14 +334,14 @@ def draw_gouraud_triangle( path = Path(points, closed=True) bbox = self._get_axes_display_box() - if(not path.intersects_bbox(bbox, True)): + if (not path.intersects_bbox(bbox, True)): return - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) gc.set_clip_rectangle(bbox) - if(not isinstance(self.__bounding_axes.patch, Rectangle)): + if (not isinstance(self.__bounding_axes.patch, Rectangle)): gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) self.__renderer.draw_gouraud_triangle(gc, path.vertices, colors, @@ -299,7 +369,7 @@ def draw_image( out_box = img_bbox_disp.transformed(shift_data_transform) clipped_out_box = Bbox.intersection(out_box, axes_bbox) - if(clipped_out_box is None): + if (clipped_out_box is None): return # We compute what the dimensions of the final output image within the @@ -307,7 +377,7 @@ def draw_image( x, y, out_w, out_h = clipped_out_box.bounds out_w, out_h = int(np.ceil(out_w * mag)), int(np.ceil(out_h * mag)) - if((out_w <= 0) or (out_h <= 0)): + if ((out_w <= 0) or (out_h <= 0)): return # We can now construct the transform which converts between the @@ -330,16 +400,16 @@ def draw_image( alpha=1) out_arr[:, :, 3] = trans_msk - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) gc.set_clip_rectangle(clipped_out_box) - if(not isinstance(self.__bounding_axes.patch, Rectangle)): + if (not isinstance(self.__bounding_axes.patch, Rectangle)): gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) x, y = clipped_out_box.x0, clipped_out_box.y0 - if(self.option_scale_image()): + if (self.option_scale_image()): self.__renderer.draw_image(gc, x, y, out_arr, None) else: self.__renderer.draw_image(gc, x, y, out_arr) diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index 91c5e4b..f0b47a9 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -3,7 +3,6 @@ from typing import Type, List, Optional, Any, Set, Dict, Union from matplotlib.axes import Axes from matplotlib.transforms import Bbox -import matplotlib.docstring as docstring from matplotview._transform_renderer import _TransformRenderer from matplotlib.artist import Artist from matplotlib.backend_bases import RendererBase @@ -54,14 +53,14 @@ def draw(self, renderer: RendererBase): # If we are working with a 3D object, swap out it's axes with # this zoom axes (swapping out the 3d transform) and reproject it. - if(hasattr(self._artist, "do_3d_projection")): + if (hasattr(self._artist, "do_3d_projection")): self.do_3d_projection() # Check and see if the passed limiting box and extents of the # artist intersect, if not don't bother drawing this artist. # First 2 checks are a special case where we received a bad clip box. # (those can happen when we try to get the bounds of a map projection) - if( + if ( self._clip_box.width == 0 or self._clip_box.height == 0 or Bbox.intersection(full_extents, self._clip_box) is not None ): @@ -129,7 +128,7 @@ class ViewSpecification: def __post_init__(self): self.image_interpolation = str(self.image_interpolation) - if(self.filter_set is not None): + if (self.filter_set is not None): self.filter_set = set(self.filter_set) self.scale_lines = bool(self.scale_lines) @@ -139,7 +138,6 @@ class __ViewType: PRIVATE: A simple identifier class for identifying view types, a view will inherit from the axes class it is wrapping and this type... """ - ... # Cache classes so grabbing the same type twice leads to actually getting the @@ -163,10 +161,9 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: another axes contents... """ # If the passed class is a view, simply return it. - if(issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): + if (issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): return axes_class - @docstring.interpd class View(axes_class, __ViewType): """ An axes which automatically displays elements of another axes. Does not @@ -197,9 +194,7 @@ def __init__( **kwargs Other optional keyword arguments supported by the Axes - constructor this ViewAxes wraps: - - %(Axes:kwdoc)s + constructor this ViewAxes wraps. Returns ------- @@ -232,14 +227,14 @@ def get_children(self) -> List[Artist]: child_list = super().get_children() def filter_check(artist, filter_set): - if(filter_set is None): + if (filter_set is None): return True return ( (artist not in filter_set) and (type(artist) not in filter_set) ) - if(self.__renderer is not None): + if (self.__renderer is not None): for ax, spec in self.view_specifications.items(): mock_renderer = _TransformRenderer( self.__renderer, ax.transData, self.transData, @@ -257,7 +252,7 @@ def filter_check(artist, filter_set): for a in itertools.chain( ax._children, ax.child_axes - ) if(filter_check(a, spec.filter_set)) + ) if (filter_check(a, spec.filter_set)) ]) return child_list @@ -266,7 +261,7 @@ def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other # therefore we track the number of recursions and stop drawing # at a certain depth - if(self.figure._current_render_depth >= self.__max_render_depth): + if (self.figure._current_render_depth >= self.__max_render_depth): return self.figure._current_render_depth += 1 # Set the renderer, causing get_children to return the view's @@ -285,7 +280,7 @@ def __reduce__(self): cls = type(self) args = tuple( - arg if(arg != cls) else cls.__bases__[0] for arg in args + arg if (arg != cls) else cls.__bases__[0] for arg in args ) return ( @@ -322,7 +317,7 @@ def set_max_render_depth(self, val: int): allow. Zero and negative values are invalid, and will raise a ValueError. """ - if(val <= 0): + if (val <= 0): raise ValueError(f"Render depth must be positive, not {val}.") self.__max_render_depth = val @@ -380,12 +375,12 @@ def from_axes( If the provided axes to convert has an Axes type which does not match the axes class this view type wraps. """ - if(isinstance(axes, cls)): - if(render_depth is not None): + if (isinstance(axes, cls)): + if (render_depth is not None): axes.set_max_render_depth(render_depth) return axes - if(type(axes) != axes_class): + if (type(axes) is not axes_class): raise TypeError( f"Can't convert {type(axes).__name__} to {cls.__name__}" ) @@ -393,7 +388,7 @@ def from_axes( axes.__class__ = cls axes._init_vars( DEFAULT_RENDER_DEPTH - if(render_depth is None) + if (render_depth is None) else render_depth ) return axes diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py index 6210e63..c356664 100644 --- a/matplotview/tests/test_view_obj.py +++ b/matplotview/tests/test_view_obj.py @@ -8,6 +8,9 @@ def test_obj_comparison(): from matplotlib.axes import Subplot, Axes + import matplotlib + + mpl_version = tuple(int(v) for v in matplotlib.__version__.split(".")[:2]) view_class1 = view_wrapper(Subplot) view_class2 = view_wrapper(Subplot) @@ -15,7 +18,12 @@ def test_obj_comparison(): assert view_class1 is view_class2 assert view_class1 == view_class2 - assert view_class2 != view_class3 + if (mpl_version >= (3, 7)): + # As of 3.7.0, the subplot class no longer exists, and is an alias + # to the Axes class... + assert view_class2 == view_class3 + else: + assert view_class2 != view_class3 @check_figures_equal(tol=5.6) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py index 0e3ea66..05d44e1 100644 --- a/matplotview/tests/test_view_rendering.py +++ b/matplotview/tests/test_view_rendering.py @@ -1,7 +1,9 @@ +import sys + import numpy as np import matplotlib.pyplot as plt from matplotlib.testing.decorators import check_figures_equal -from matplotview import view, inset_zoom_axes +from matplotview import view, inset_zoom_axes, stop_viewing @check_figures_equal(tol=6) @@ -219,3 +221,98 @@ def test_double_view(fig_test, fig_ref): ax.set_aspect(1) ax.relim() ax.autoscale_view() + + +@check_figures_equal() +def test_stop_viewing(fig_test, fig_ref): + np.random.seed(1) + data = np.random.randint(0, 10, 10) + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.plot(data) + ax1_test.text(0.5, 0.5, "Hello") + + view(ax2_test, ax1_test) + stop_viewing(ax2_test, ax1_test) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.plot(data) + ax1_ref.text(0.5, 0.5, "Hello") + + +# On MacOS the results are off by an extremely tiny amount, can't even see in diff. It's close enough... +@check_figures_equal(tol=0.02 if sys.platform.startswith("darwin") else 0) +def test_log_line(fig_test, fig_ref): + data = [i for i in range(1, 10)] + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.plot(data, "-o") + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-1, 10) + ax2_test.set_ylim(-1, 10) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.plot(data, "-o") + ax2_ref.plot(data, "-o") + ax2_ref.set_xlim(-1, 10) + ax2_ref.set_ylim(-1, 10) + + +@check_figures_equal() +def test_log_scatter(fig_test, fig_ref): + data = [i for i in range(1, 11)] + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.scatter(data, data) + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-5, 15) + ax2_test.set_ylim(-5, 15) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.scatter(data, data) + ax2_ref.scatter(data, data) + ax2_ref.set_xlim(-5, 15) + ax2_ref.set_ylim(-5, 15) + + +@check_figures_equal() +def test_log_scatter_with_colors(fig_test, fig_ref): + data = [i for i in range(1, 11)] + colors = list("rgbrgbrgbr") + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.scatter(data, data, color=colors) + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-5, 15) + ax2_test.set_ylim(-5, 15) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.scatter(data, data, color=colors) + ax2_ref.scatter(data, data, color=colors) + ax2_ref.set_xlim(-5, 15) + ax2_ref.set_ylim(-5, 15) diff --git a/matplotview/tests/utils.py b/matplotview/tests/utils.py index 812eb84..8ca3f56 100644 --- a/matplotview/tests/utils.py +++ b/matplotview/tests/utils.py @@ -4,8 +4,8 @@ def figure_to_image(figure): figure.canvas.draw() - img = np.frombuffer(figure.canvas.tostring_rgb(), dtype=np.uint8) - return img.reshape(figure.canvas.get_width_height()[::-1] + (3,)) + img = np.frombuffer(figure.canvas.buffer_rgba(), dtype=np.uint8) + return img.reshape(figure.canvas.get_width_height()[::-1] + (4,))[..., :3] def matches_post_pickle(figure): diff --git a/setup.py b/setup.py index b62ee79..9e53965 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "1.0.0" +VERSION = "1.0.2" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy