From 5ba80f37aae55922dc7901a29823f1b98035c39e Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 12 May 2023 14:32:59 -0700 Subject: [PATCH 001/105] starting graph layer implementation from points --- examples/add_graph.py | 29 ++++++++++++++++ .../_qt/layer_controls/qt_graph_controls.py | 5 +++ .../qt_layer_controls_container.py | 3 ++ napari/_tests/utils.py | 3 +- napari/_vispy/layers/graph.py | 33 +++++++++++++++++++ napari/_vispy/layers/points.py | 5 +-- napari/_vispy/utils/visual.py | 3 ++ napari/_vispy/visuals/graph.py | 9 +++++ napari/components/viewer_model.py | 1 + napari/layers/__init__.py | 2 ++ napari/layers/graph/__init__.py | 1 + napari/layers/graph/graph.py | 4 +++ napari/types.py | 1 + napari/view_layers.py | 4 +++ 14 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 examples/add_graph.py create mode 100644 napari/_qt/layer_controls/qt_graph_controls.py create mode 100644 napari/_vispy/layers/graph.py create mode 100644 napari/_vispy/visuals/graph.py create mode 100644 napari/layers/graph/__init__.py create mode 100644 napari/layers/graph/graph.py diff --git a/examples/add_graph.py b/examples/add_graph.py new file mode 100644 index 00000000000..b176d1b616b --- /dev/null +++ b/examples/add_graph.py @@ -0,0 +1,29 @@ +import numpy as np +import pandas as pd +import napari +from napari.layers import Graph +from napari_graph import UndirectedGraph + + +def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: + neighbors = np.random.randint(n_nodes, size=(n_nodes * n_neighbors)) + edges = np.stack([np.repeat(np.arange(n_nodes), n_neighbors), neighbors], axis=1) + + nodes_df = pd.DataFrame( + 400 * np.random.uniform(size=(n_nodes, 4)), + columns=["t", "z", "y", "x"], + ) + graph = UndirectedGraph(edges=edges, coords=nodes_df[["t", "z", "y", "x"]]) + + return graph + + +if __name__ == "__main__": + + viewer = napari.Viewer() + n_nodes = 1000000 + graph = build_graph(n_nodes, 5).get_coordinates() + layer = Graph(graph, out_of_slice_display=True) + viewer.add_layer(layer) + + napari.run() diff --git a/napari/_qt/layer_controls/qt_graph_controls.py b/napari/_qt/layer_controls/qt_graph_controls.py new file mode 100644 index 00000000000..2421226f82c --- /dev/null +++ b/napari/_qt/layer_controls/qt_graph_controls.py @@ -0,0 +1,5 @@ +from .qt_points_controls import QtPointsControls + + +class QtGraphControls(QtPointsControls): + pass diff --git a/napari/_qt/layer_controls/qt_layer_controls_container.py b/napari/_qt/layer_controls/qt_layer_controls_container.py index 8c236d273a5..f2c9bf74b6c 100644 --- a/napari/_qt/layer_controls/qt_layer_controls_container.py +++ b/napari/_qt/layer_controls/qt_layer_controls_container.py @@ -1,5 +1,6 @@ from qtpy.QtWidgets import QFrame, QStackedWidget +from napari._qt.layer_controls.qt_graph_controls import QtGraphControls from napari._qt.layer_controls.qt_image_controls import QtImageControls from napari._qt.layer_controls.qt_labels_controls import QtLabelsControls from napari._qt.layer_controls.qt_points_controls import QtPointsControls @@ -9,6 +10,7 @@ from napari._qt.layer_controls.qt_vectors_controls import QtVectorsControls from napari.layers import ( Image, + Graph, Labels, Points, Shapes, @@ -21,6 +23,7 @@ layer_to_controls = { Labels: QtLabelsControls, + Graph: QtGraphControls, Image: QtImageControls, Points: QtPointsControls, Shapes: QtShapesControls, diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index 305b7b51fe3..505c9dc6948 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -10,6 +10,7 @@ from napari import Viewer from napari.layers import ( + Graph, Image, Labels, Points, @@ -91,7 +92,7 @@ p = [ts.array(np.random.random(s)) for s in [(40, 20), (20, 10), (10, 5)]] layer_test_data.extend([(Image, m, 2), (Image, p, 2)]) -classes = [Labels, Points, Vectors, Shapes, Surface, Tracks, Image] +classes = [Graph, Labels, Points, Vectors, Shapes, Surface, Tracks, Image] names = [cls.__name__.lower() for cls in classes] layer2addmethod = { cls: getattr(Viewer, 'add_' + name) for cls, name in zip(classes, names) diff --git a/napari/_vispy/layers/graph.py b/napari/_vispy/layers/graph.py new file mode 100644 index 00000000000..fd475ee2b64 --- /dev/null +++ b/napari/_vispy/layers/graph.py @@ -0,0 +1,33 @@ +from vispy import gloo + +from ..visuals.graph import GraphVisual +from .points import VispyPointsLayer + + +class VispyGraphLayer(VispyPointsLayer): + _visual = GraphVisual + + def _on_data_change(self): + # self._set_graph_edges_data() + super()._on_data_change() + + def _set_graph_edges_data(self): + """Sets the LineVisual (subvisual[4]) with the graph edges data""" + subvisual = self.node._subvisuals[4] + edges = self.layer._view_edges_coordinates + + if len(edges) == 0: + subvisual.visible = False + return + + subvisual.visible = True + flat_edges = edges.reshape((-1, edges.shape[-1])) # (N x 2, D) + flat_edges = flat_edges[:, ::-1] + + # clearing up buffer, there was a vispy error otherwise + subvisual._line_visual._pos_vbo = gloo.VertexBuffer() + subvisual.set_data( + flat_edges, + color='white', + width=1, + ) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index 1f2a70338f6..49f0892c4f8 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -12,11 +12,12 @@ class VispyPointsLayer(VispyBaseLayer): _highlight_color = (0, 0.6, 1) _highlight_width = None + _visual = PointsVisual def __init__(self, layer) -> None: self._highlight_width = get_settings().appearance.highlight_thickness - node = PointsVisual() + node = self._visual() super().__init__(layer, node) self.layer.events.symbol.connect(self._on_data_change) @@ -137,7 +138,7 @@ def _update_text(self, *, update_node=True): def _get_text_node(self): """Function to get the text node from the Compound visual""" - text_node = self.node._subvisuals[-1] + text_node = self.node._subvisuals[3] return text_node def _on_text_change(self, event=None): diff --git a/napari/_vispy/utils/visual.py b/napari/_vispy/utils/visual.py index 5eda51da49f..c23b990a686 100644 --- a/napari/_vispy/utils/visual.py +++ b/napari/_vispy/utils/visual.py @@ -6,6 +6,7 @@ from vispy.scene.widgets.viewbox import ViewBox from napari._vispy.layers.base import VispyBaseLayer +from napari._vispy.layers.graph import VispyGraphLayer from napari._vispy.layers.image import VispyImageLayer from napari._vispy.layers.labels import VispyLabelsLayer from napari._vispy.layers.points import VispyPointsLayer @@ -34,6 +35,7 @@ TransformBoxOverlay, ) from napari.layers import ( + Graph, Image, Labels, Layer, @@ -47,6 +49,7 @@ from napari.utils.translations import trans layer_to_visual = { + Graph: VispyGraphLayer, Image: VispyImageLayer, Labels: VispyLabelsLayer, Points: VispyPointsLayer, diff --git a/napari/_vispy/visuals/graph.py b/napari/_vispy/visuals/graph.py new file mode 100644 index 00000000000..7d6d1f03027 --- /dev/null +++ b/napari/_vispy/visuals/graph.py @@ -0,0 +1,9 @@ +from vispy.visuals import LineVisual + +from .points import PointsVisual + + +class GraphVisual(PointsVisual): + def __init__(self): + super().__init__() + self.add_subvisual(LineVisual(connect='segments')) diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py index 895c9278994..24d5c4cc0ef 100644 --- a/napari/components/viewer_model.py +++ b/napari/components/viewer_model.py @@ -1608,6 +1608,7 @@ def valid_add_kwargs() -> Dict[str, Set[str]]: for _layer in ( + layers.Graph, layers.Labels, layers.Points, layers.Shapes, diff --git a/napari/layers/__init__.py b/napari/layers/__init__.py index ab180bd5b61..382e6ad64e9 100644 --- a/napari/layers/__init__.py +++ b/napari/layers/__init__.py @@ -7,6 +7,7 @@ import inspect as _inspect from napari.layers.base import Layer +from napari.layers.graph import Graph from napari.layers.image import Image from napari.layers.labels import Labels from napari.layers.points import Points @@ -24,6 +25,7 @@ } __all__ = [ + 'Graph', 'Image', 'Labels', 'Layer', diff --git a/napari/layers/graph/__init__.py b/napari/layers/graph/__init__.py new file mode 100644 index 00000000000..f2f720b3844 --- /dev/null +++ b/napari/layers/graph/__init__.py @@ -0,0 +1 @@ +from napari.layers.graph.graph import Graph \ No newline at end of file diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py new file mode 100644 index 00000000000..b7be790a59a --- /dev/null +++ b/napari/layers/graph/graph.py @@ -0,0 +1,4 @@ +from napari.layers.points import Points + +class Graph(Points): + pass diff --git a/napari/types.py b/napari/types.py index a5682f282f5..5586127c530 100644 --- a/napari/types.py +++ b/napari/types.py @@ -79,6 +79,7 @@ class SampleDict(TypedDict): ArrayBase: Type[np.ndarray] = np.ndarray +GraphData = NewType("GraphData", tuple) # FIXME ImageData = NewType("ImageData", np.ndarray) LabelsData = NewType("LabelsData", np.ndarray) PointsData = NewType("PointsData", np.ndarray) diff --git a/napari/view_layers.py b/napari/view_layers.py index c3994b3948c..e4bca50c09f 100644 --- a/napari/view_layers.py +++ b/napari/view_layers.py @@ -22,6 +22,7 @@ def view_(*args, **kwargs): from napari.viewer import Viewer __all__ = [ + 'view_graph', 'view_image', 'view_labels', 'view_path', @@ -173,6 +174,9 @@ def _make_viewer_then( # viewer.add_image(*args, **kwargs) # return viewer +@_merge_layer_viewer_sigs_docs +def view_graph(*args, **kwargs): + return _make_viewer_then('add_graph', *args, **kwargs)[0] @_merge_layer_viewer_sigs_docs def view_image(*args, **kwargs): From e95b3d09ced2abdc1c5c240389757d975d178f06 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 12 May 2023 15:32:57 -0700 Subject: [PATCH 002/105] added _basepoints class --- napari/layers/points/points.py | 675 ++++++++++++++++++--------------- 1 file changed, 372 insertions(+), 303 deletions(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 5661c6df89d..e57764e4697 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -1,11 +1,13 @@ import numbers import warnings +from abc import abstractmethod from copy import copy, deepcopy from itertools import cycle from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import numpy as np import pandas as pd +from numpy.typing import ArrayLike from psygnal.containers import Selection from scipy.stats import gmean @@ -49,8 +51,12 @@ DEFAULT_COLOR_CYCLE = np.array([[1, 0, 1, 1], [0, 1, 0, 1]]) -class Points(Layer): - """Points layer. +class _BasePoints(Layer): + """ + Implements the basic functionality of spatially distributed coordinates. + Used by to display points and graph nodes. + + TODO: update documentation and typing Parameters ---------- @@ -520,85 +526,17 @@ def __init__( self.refresh() @property - def data(self) -> np.ndarray: - """(N, D) array: coordinates for N points in D dimensions.""" - return self._data - - @data.setter - def data(self, data: Optional[np.ndarray]): - data, _ = fix_data_points(data, self.ndim) - cur_npoints = len(self._data) - self._data = data - - # Add/remove property and style values based on the number of new points. - with self.events.blocker_all(), self._edge.events.blocker_all(), self._face.events.blocker_all(): - self._feature_table.resize(len(data)) - self.text.apply(self.features) - if len(data) < cur_npoints: - # If there are now fewer points, remove the size and colors of the - # extra ones - if len(self._edge.colors) > len(data): - self._edge._remove( - np.arange(len(data), len(self._edge.colors)) - ) - if len(self._face.colors) > len(data): - self._face._remove( - np.arange(len(data), len(self._face.colors)) - ) - self._shown = self._shown[: len(data)] - self._size = self._size[: len(data)] - self._edge_width = self._edge_width[: len(data)] - self._symbol = self._symbol[: len(data)] - - elif len(data) > cur_npoints: - # If there are now more points, add the size and colors of the - # new ones - adding = len(data) - cur_npoints - if len(self._size) > 0: - new_size = copy(self._size[-1]) - for i in self._slice_input.displayed: - new_size[i] = self.current_size - else: - # Add the default size, with a value for each dimension - new_size = np.repeat( - self.current_size, self._size.shape[1] - ) - size = np.repeat([new_size], adding, axis=0) - - if len(self._edge_width) > 0: - new_edge_width = copy(self._edge_width[-1]) - else: - new_edge_width = self.current_edge_width - edge_width = np.repeat([new_edge_width], adding, axis=0) - - if len(self._symbol) > 0: - new_symbol = copy(self._symbol[-1]) - else: - new_symbol = self.current_symbol - symbol = np.repeat([new_symbol], adding, axis=0) - - # Add new colors, updating the current property value before - # to handle any in-place modification of feature_defaults. - # Also see: https://github.com/napari/napari/issues/5634 - current_properties = self._feature_table.currents() - self._edge._update_current_properties(current_properties) - self._edge._add(n_colors=adding) - self._face._update_current_properties(current_properties) - self._face._add(n_colors=adding) - - shown = np.repeat([True], adding, axis=0) - self._shown = np.concatenate((self._shown, shown), axis=0) + def _points_data(self) -> np.ndarray: + """Spatialy distributed coordinates.""" + raise NotImplementedError - self.size = np.concatenate((self._size, size), axis=0) - self.edge_width = np.concatenate( - (self._edge_width, edge_width), axis=0 - ) - self.symbol = np.concatenate((self._symbol, symbol), axis=0) - self.selected_data = set(np.arange(cur_npoints, len(data))) + @property + def data(self) -> Any: + raise NotImplementedError - self._update_dims() - self.events.data(value=self.data) - self._reset_editable() + @data.setter + def data(self, data: Any) -> None: + raise NotImplementedError def _on_selection(self, selected): if selected: @@ -1891,7 +1829,8 @@ def _update_thumbnail(self): colormapped[..., 3] *= self.opacity self.thumbnail = colormapped - def add(self, coords): + @abstractmethod + def add(self, coords: ArrayLike) -> None: """Adds points at coordinates. Parameters @@ -1899,36 +1838,11 @@ def add(self, coords): coords : array Point or points to add to the layer data. """ - self.data = np.append(self.data, np.atleast_2d(coords), axis=0) + raise NotImplementedError - def remove_selected(self): + def remove_selected(self) -> None: """Removes selected points if any.""" - index = list(self.selected_data) - index.sort() - if len(index): - self._shown = np.delete(self._shown, index, axis=0) - self._size = np.delete(self._size, index, axis=0) - self._symbol = np.delete(self._symbol, index, axis=0) - self._edge_width = np.delete(self._edge_width, index, axis=0) - with self._edge.events.blocker_all(): - self._edge._remove(indices_to_remove=index) - with self._face.events.blocker_all(): - self._face._remove(indices_to_remove=index) - self._feature_table.remove(index) - self.text.remove(index) - if self._value in self.selected_data: - self._value = None - else: - if self._value is not None: - # update the index of self._value to account for the - # data being removed - indices_removed = np.array(index) < self._value - offset = np.sum(indices_removed) - self._value -= offset - self._value_stored -= offset - - self.data = np.delete(self.data, index, axis=0) - self.selected_data = set() + raise NotImplementedError def _move( self, @@ -1948,14 +1862,28 @@ def _move( selection_indices = list(selection_indices) disp = list(self._slice_input.displayed) self._set_drag_start(selection_indices, position) - center = self.data[np.ix_(selection_indices, disp)].mean(axis=0) + ixgrid = np.ix_(selection_indices, disp) + center = self.data[ixgrid].mean(axis=0) shift = np.array(position)[disp] - center - self._drag_start - self.data[np.ix_(selection_indices, disp)] = ( - self.data[np.ix_(selection_indices, disp)] + shift - ) + self._move_points(ixgrid, shift) self.refresh() self.events.data(value=self.data) + @abstractmethod + def _move_points( + self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray + ) -> None: + """Move points along a set a coordinates given a shift. + + Parameters + ---------- + ixgrid : Tuple[np.ndarray, np.ndarray] + Crossproduct indexing grid of node indices and dimensions, see `np.ix_` + shift : np.ndarray + Selected coordinates shift + """ + raise NotImplementedError + def _set_drag_start( self, selection_indices: Sequence[int], @@ -1984,60 +1912,6 @@ def _set_drag_start( ].mean(axis=0) self._drag_start -= center - def _paste_data(self): - """Paste any point from clipboard and select them.""" - npoints = len(self._view_data) - totpoints = len(self.data) - - if len(self._clipboard.keys()) > 0: - not_disp = self._slice_input.not_displayed - data = deepcopy(self._clipboard['data']) - offset = [ - self._slice_indices[i] - self._clipboard['indices'][i] - for i in not_disp - ] - data[:, not_disp] = data[:, not_disp] + np.array(offset) - self._data = np.append(self.data, data, axis=0) - self._shown = np.append( - self.shown, deepcopy(self._clipboard['shown']), axis=0 - ) - self._size = np.append( - self.size, deepcopy(self._clipboard['size']), axis=0 - ) - self._symbol = np.append( - self.symbol, deepcopy(self._clipboard['symbol']), axis=0 - ) - - self._feature_table.append(self._clipboard['features']) - - self.text._paste(**self._clipboard['text']) - - self._edge_width = np.append( - self.edge_width, - deepcopy(self._clipboard['edge_width']), - axis=0, - ) - self._edge._paste( - colors=self._clipboard['edge_color'], - properties=_features_to_properties( - self._clipboard['features'] - ), - ) - self._face._paste( - colors=self._clipboard['face_color'], - properties=_features_to_properties( - self._clipboard['features'] - ), - ) - - self._selected_view = list( - range(npoints, npoints + len(self._clipboard['data'])) - ) - self._selected_data = set( - range(totpoints, totpoints + len(self._clipboard['data'])) - ) - self.refresh() - def _copy_data(self): """Copy selected points to clipboard.""" if len(self.selected_data) > 0: @@ -2057,150 +1931,61 @@ def _copy_data(self): else: self._clipboard = {} - def to_mask( + def get_status( self, + position: Optional[Tuple] = None, *, - shape: tuple, - data_to_world: Optional[Affine] = None, - isotropic_output: bool = True, - ): - """Return a binary mask array of all the points as balls. + view_direction: Optional[np.ndarray] = None, + dims_displayed: Optional[List[int]] = None, + world: bool = False, + ) -> dict: + """Status message information of the data at a coordinate position. - Parameters - ---------- - shape : tuple - The shape of the mask to be generated. - data_to_world : Optional[Affine] - The data-to-world transform of the output mask image. This likely comes from a reference image. - If None, then this is the same as this layer's data-to-world transform. - isotropic_output : bool - If True, then force the output mask to always contain isotropic balls in data/pixel coordinates. - Otherwise, allow the anisotropy in the data-to-world transform to squash the balls in certain dimensions. - By default this is True, but you should set it to False if you are going to create a napari image - layer from the result with the same data-to-world transform and want the visualized balls to be - roughly isotropic. + # Parameters + # ---------- + # position : tuple + # Position in either data or world coordinates. + # view_direction : Optional[np.ndarray] + # A unit vector giving the direction of the ray in nD world coordinates. + # The default value is None. + # dims_displayed : Optional[List[int]] + # A list of the dimensions currently being displayed in the viewer. + # The default value is None. + # world : bool + # If True the position is taken to be in world coordinates + # and converted into data coordinates. False by default. - Returns - ------- - np.ndarray - The output binary mask array of the given shape containing this layer's points as balls. - """ - if data_to_world is None: - data_to_world = self._data_to_world - mask = np.zeros(shape, dtype=bool) - mask_world_to_data = data_to_world.inverse - points_data_to_mask_data = self._data_to_world.compose( - mask_world_to_data + # Returns + # ------- + # source_info : dict + # Dict containing information that can be used in a status update. + #""" + if position is not None: + value = self.get_value( + position, + view_direction=view_direction, + dims_displayed=dims_displayed, + world=world, + ) + else: + value = None + + source_info = self._get_source_info() + source_info['coordinates'] = generate_layer_coords_status( + position[-self.ndim :], value ) - points_in_mask_data_coords = np.atleast_2d( - points_data_to_mask_data(self.data) + + # if this points layer has properties + properties = self._get_properties( + position, + view_direction=view_direction, + dims_displayed=dims_displayed, + world=world, ) + if properties: + source_info['coordinates'] += "; " + ", ".join(properties) - # Calculating the radii of the output points in the mask is complex. - - # Points.size tells the size of the points in pixels in each dimension, - # so we take the arithmetic mean across dimensions to define a scalar size - # per point, which is consistent with visualization. - mean_radii = np.mean(self.size, axis=1, keepdims=True) / 2 - - # Scale each radius by the geometric mean scale of the Points layer to - # keep the balls isotropic when visualized in world coordinates. - # Then scale each radius by the scale of the output image mask - # using the geometric mean if isotropic output is desired. - # The geometric means are used instead of the arithmetic mean - # to maintain the volume scaling factor of the transforms. - point_data_to_world_scale = gmean(np.abs(self._data_to_world.scale)) - mask_world_to_data_scale = ( - gmean(np.abs(mask_world_to_data.scale)) - if isotropic_output - else np.abs(mask_world_to_data.scale) - ) - radii_scale = point_data_to_world_scale * mask_world_to_data_scale - - output_data_radii = mean_radii * np.atleast_2d(radii_scale) - - for coords, radii in zip( - points_in_mask_data_coords, output_data_radii - ): - # Define a minimal set of coordinates where the mask could be present - # by defining an inclusive lower and exclusive upper bound for each dimension. - lower_coords = np.maximum(np.floor(coords - radii), 0).astype(int) - upper_coords = np.minimum( - np.ceil(coords + radii) + 1, shape - ).astype(int) - # Generate every possible coordinate within the bounds defined above - # in a grid of size D1 x D2 x ... x Dd x D (e.g. for D=2, this might be 4x5x2). - submask_coords = [ - range(lower_coords[i], upper_coords[i]) - for i in range(self.ndim) - ] - submask_grids = np.stack( - np.meshgrid(*submask_coords, copy=False, indexing='ij'), - axis=-1, - ) - # Update the mask coordinates based on the normalized square distance - # using a logical or to maintain any existing positive mask locations. - normalized_square_distances = np.sum( - ((submask_grids - coords) / radii) ** 2, axis=-1 - ) - mask[np.ix_(*submask_coords)] |= normalized_square_distances <= 1 - return mask - - def get_status( - self, - position: Optional[Tuple] = None, - *, - view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, - world: bool = False, - ) -> dict: - """Status message information of the data at a coordinate position. - - # Parameters - # ---------- - # position : tuple - # Position in either data or world coordinates. - # view_direction : Optional[np.ndarray] - # A unit vector giving the direction of the ray in nD world coordinates. - # The default value is None. - # dims_displayed : Optional[List[int]] - # A list of the dimensions currently being displayed in the viewer. - # The default value is None. - # world : bool - # If True the position is taken to be in world coordinates - # and converted into data coordinates. False by default. - - # Returns - # ------- - # source_info : dict - # Dict containing information that can be used in a status update. - #""" - if position is not None: - value = self.get_value( - position, - view_direction=view_direction, - dims_displayed=dims_displayed, - world=world, - ) - else: - value = None - - source_info = self._get_source_info() - source_info['coordinates'] = generate_layer_coords_status( - position[-self.ndim :], value - ) - - # if this points layer has properties - properties = self._get_properties( - position, - view_direction=view_direction, - dims_displayed=dims_displayed, - world=world, - ) - if properties: - source_info['coordinates'] += "; " + ", ".join(properties) - - return source_info + return source_info def _get_tooltip_text( self, @@ -2270,3 +2055,287 @@ def _get_properties( and v[value] is not None and not (isinstance(v[value], float) and np.isnan(v[value])) ] + + +class Points(_BasePoints): + @property + def _points_data(self) -> np.ndarray: + """Spatialy distributed coordinates.""" + return self.data + + @property + def data(self) -> np.ndarray: + """(N, D) array: coordinates for N points in D dimensions.""" + return self._data + + @data.setter + def data(self, data: Optional[np.ndarray]): + data, _ = fix_data_points(data, self.ndim) + cur_npoints = len(self._data) + self._data = data + + # Add/remove property and style values based on the number of new points. + with self.events.blocker_all(), self._edge.events.blocker_all(), self._face.events.blocker_all(): + self._feature_table.resize(len(data)) + self.text.apply(self.features) + if len(data) < cur_npoints: + # If there are now fewer points, remove the size and colors of the + # extra ones + if len(self._edge.colors) > len(data): + self._edge._remove( + np.arange(len(data), len(self._edge.colors)) + ) + if len(self._face.colors) > len(data): + self._face._remove( + np.arange(len(data), len(self._face.colors)) + ) + self._shown = self._shown[: len(data)] + self._size = self._size[: len(data)] + self._edge_width = self._edge_width[: len(data)] + self._symbol = self._symbol[: len(data)] + + elif len(data) > cur_npoints: + # If there are now more points, add the size and colors of the + # new ones + adding = len(data) - cur_npoints + if len(self._size) > 0: + new_size = copy(self._size[-1]) + for i in self._slice_input.displayed: + new_size[i] = self.current_size + else: + # Add the default size, with a value for each dimension + new_size = np.repeat( + self.current_size, self._size.shape[1] + ) + size = np.repeat([new_size], adding, axis=0) + + if len(self._edge_width) > 0: + new_edge_width = copy(self._edge_width[-1]) + else: + new_edge_width = self.current_edge_width + edge_width = np.repeat([new_edge_width], adding, axis=0) + + if len(self._symbol) > 0: + new_symbol = copy(self._symbol[-1]) + else: + new_symbol = self.current_symbol + symbol = np.repeat([new_symbol], adding, axis=0) + + # Add new colors, updating the current property value before + # to handle any in-place modification of feature_defaults. + # Also see: https://github.com/napari/napari/issues/5634 + current_properties = self._feature_table.currents() + self._edge._update_current_properties(current_properties) + self._edge._add(n_colors=adding) + self._face._update_current_properties(current_properties) + self._face._add(n_colors=adding) + + shown = np.repeat([True], adding, axis=0) + self._shown = np.concatenate((self._shown, shown), axis=0) + + self.size = np.concatenate((self._size, size), axis=0) + self.edge_width = np.concatenate( + (self._edge_width, edge_width), axis=0 + ) + self.symbol = np.concatenate((self._symbol, symbol), axis=0) + self.selected_data = set(np.arange(cur_npoints, len(data))) + + self._update_dims() + self.events.data(value=self.data) + self._reset_editable() + + def add(self, coords): + """Adds points at coordinates. + + Parameters + ---------- + coords : array + Point or points to add to the layer data. + """ + self.data = np.append(self.data, np.atleast_2d(coords), axis=0) + + def remove_selected(self): + """Removes selected points if any.""" + index = list(self.selected_data) + index.sort() + if len(index): + self._shown = np.delete(self._shown, index, axis=0) + self._size = np.delete(self._size, index, axis=0) + self._symbol = np.delete(self._symbol, index, axis=0) + self._edge_width = np.delete(self._edge_width, index, axis=0) + with self._edge.events.blocker_all(): + self._edge._remove(indices_to_remove=index) + with self._face.events.blocker_all(): + self._face._remove(indices_to_remove=index) + self._feature_table.remove(index) + self.text.remove(index) + if self._value in self.selected_data: + self._value = None + else: + if self._value is not None: + # update the index of self._value to account for the + # data being removed + indices_removed = np.array(index) < self._value + offset = np.sum(indices_removed) + self._value -= offset + self._value_stored -= offset + + self.data = np.delete(self.data, index, axis=0) + self.selected_data = set() + + def _move_points( + self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray + ) -> None: + """Move points along a set a coordinates given a shift. + + Parameters + ---------- + ixgrid : Tuple[np.ndarray, np.ndarray] + Crossproduct indexing grid of node indices and dimensions, see `np.ix_` + shift : np.ndarray + Selected coordinates shift + """ + self._points_data[ixgrid] = self._points_data[ixgrid] + shift + + def _paste_data(self): + """Paste any point from clipboard and select them.""" + npoints = len(self._view_data) + totpoints = len(self.data) + + if len(self._clipboard.keys()) > 0: + not_disp = self._slice_input.not_displayed + data = deepcopy(self._clipboard['data']) + offset = [ + self._slice_indices[i] - self._clipboard['indices'][i] + for i in not_disp + ] + data[:, not_disp] = data[:, not_disp] + np.array(offset) + self._data = np.append(self.data, data, axis=0) + self._shown = np.append( + self.shown, deepcopy(self._clipboard['shown']), axis=0 + ) + self._size = np.append( + self.size, deepcopy(self._clipboard['size']), axis=0 + ) + self._symbol = np.append( + self.symbol, deepcopy(self._clipboard['symbol']), axis=0 + ) + + self._feature_table.append(self._clipboard['features']) + + self.text._paste(**self._clipboard['text']) + + self._edge_width = np.append( + self.edge_width, + deepcopy(self._clipboard['edge_width']), + axis=0, + ) + self._edge._paste( + colors=self._clipboard['edge_color'], + properties=_features_to_properties( + self._clipboard['features'] + ), + ) + self._face._paste( + colors=self._clipboard['face_color'], + properties=_features_to_properties( + self._clipboard['features'] + ), + ) + + self._selected_view = list( + range(npoints, npoints + len(self._clipboard['data'])) + ) + self._selected_data = set( + range(totpoints, totpoints + len(self._clipboard['data'])) + ) + self.refresh() + + def to_mask( + self, + *, + shape: tuple, + data_to_world: Optional[Affine] = None, + isotropic_output: bool = True, + ): + """Return a binary mask array of all the points as balls. + + Parameters + ---------- + shape : tuple + The shape of the mask to be generated. + data_to_world : Optional[Affine] + The data-to-world transform of the output mask image. This likely comes from a reference image. + If None, then this is the same as this layer's data-to-world transform. + isotropic_output : bool + If True, then force the output mask to always contain isotropic balls in data/pixel coordinates. + Otherwise, allow the anisotropy in the data-to-world transform to squash the balls in certain dimensions. + By default this is True, but you should set it to False if you are going to create a napari image + layer from the result with the same data-to-world transform and want the visualized balls to be + roughly isotropic. + + Returns + ------- + np.ndarray + The output binary mask array of the given shape containing this layer's points as balls. + """ + if data_to_world is None: + data_to_world = self._data_to_world + mask = np.zeros(shape, dtype=bool) + mask_world_to_data = data_to_world.inverse + points_data_to_mask_data = self._data_to_world.compose( + mask_world_to_data + ) + points_in_mask_data_coords = np.atleast_2d( + points_data_to_mask_data(self.data) + ) + + # Calculating the radii of the output points in the mask is complex. + + # Points.size tells the size of the points in pixels in each dimension, + # so we take the arithmetic mean across dimensions to define a scalar size + # per point, which is consistent with visualization. + mean_radii = np.mean(self.size, axis=1, keepdims=True) / 2 + + # Scale each radius by the geometric mean scale of the Points layer to + # keep the balls isotropic when visualized in world coordinates. + # Then scale each radius by the scale of the output image mask + # using the geometric mean if isotropic output is desired. + # The geometric means are used instead of the arithmetic mean + # to maintain the volume scaling factor of the transforms. + point_data_to_world_scale = gmean(np.abs(self._data_to_world.scale)) + mask_world_to_data_scale = ( + gmean(np.abs(mask_world_to_data.scale)) + if isotropic_output + else np.abs(mask_world_to_data.scale) + ) + radii_scale = point_data_to_world_scale * mask_world_to_data_scale + + output_data_radii = mean_radii * np.atleast_2d(radii_scale) + + for coords, radii in zip( + points_in_mask_data_coords, output_data_radii + ): + # Define a minimal set of coordinates where the mask could be present + # by defining an inclusive lower and exclusive upper bound for each dimension. + lower_coords = np.maximum(np.floor(coords - radii), 0).astype(int) + upper_coords = np.minimum( + np.ceil(coords + radii) + 1, shape + ).astype(int) + # Generate every possible coordinate within the bounds defined above + # in a grid of size D1 x D2 x ... x Dd x D (e.g. for D=2, this might be 4x5x2). + submask_coords = [ + range(lower_coords[i], upper_coords[i]) + for i in range(self.ndim) + ] + submask_grids = np.stack( + np.meshgrid(*submask_coords, copy=False, indexing='ij'), + axis=-1, + ) + # Update the mask coordinates based on the normalized square distance + # using a logical or to maintain any existing positive mask locations. + normalized_square_distances = np.sum( + ((submask_grids - coords) / radii) ** 2, axis=-1 + ) + mask[np.ix_(*submask_coords)] |= normalized_square_distances <= 1 + return mask From c45d2ecbc98579c960061c12b53662c390b37281 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 12 May 2023 15:49:36 -0700 Subject: [PATCH 003/105] 1st replacement of self.data to self._points_data on _BasePoints --- napari/layers/points/points.py | 77 ++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index e57764e4697..9454890bc20 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -679,7 +679,7 @@ def refresh_text(self): def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" - return self.data.shape[1] + raise NotImplementedError @property def _extent_data(self) -> np.ndarray: @@ -692,8 +692,8 @@ def _extent_data(self) -> np.ndarray: if len(self.data) == 0: extrema = np.full((2, self.ndim), np.nan) else: - maxs = np.max(self.data, axis=0) - mins = np.min(self.data, axis=0) + maxs = np.max(self._points_data, axis=0) + mins = np.min(self._points_data, axis=0) extrema = np.vstack([mins, maxs]) return extrema @@ -727,7 +727,7 @@ def symbol(self) -> np.ndarray: @symbol.setter def symbol(self, symbol: Union[str, np.ndarray, list]) -> None: - symbol = np.broadcast_to(symbol, self.data.shape[0]) + symbol = np.broadcast_to(symbol, len(self.data)) self._symbol = coerce_symbols(symbol) self.events.symbol() self.events.highlight() @@ -754,11 +754,11 @@ def size(self) -> np.ndarray: @size.setter def size(self, size: Union[int, float, np.ndarray, list]) -> None: try: - self._size = np.broadcast_to(size, self.data.shape).copy() + self._size = np.broadcast_to(size, self._points_data.shape).copy() except ValueError as e: try: self._size = np.broadcast_to( - size, self.data.shape[::-1] + size, self._points_data.shape[::-1] ).T.copy() except ValueError: raise ValueError( @@ -847,7 +847,7 @@ def shown(self): @shown.setter def shown(self, shown): - self._shown = np.broadcast_to(shown, self.data.shape[0]).astype(bool) + self._shown = np.broadcast_to(shown, len(self.data)).astype(bool) self.refresh() @property @@ -860,7 +860,7 @@ def edge_width( self, edge_width: Union[int, float, np.ndarray, list] ) -> None: # broadcast to np.array - edge_width = np.broadcast_to(edge_width, self.data.shape[0]).copy() + edge_width = np.broadcast_to(edge_width, len(self.data)).copy() # edge width cannot be negative if np.any(edge_width < 0): @@ -1179,22 +1179,21 @@ def _get_state(self): state : dict Dictionary of layer state. """ + not_empty = len(self.data) > 0 state = self._get_base_state() state.update( { - 'symbol': self.symbol - if self.data.size - else [self.current_symbol], + 'symbol': self.symbol if not_empty else [self.current_symbol], 'edge_width': self.edge_width, 'edge_width_is_relative': self.edge_width_is_relative, 'face_color': self.face_color - if self.data.size + if not_empty else [self.current_face_color], 'face_color_cycle': self.face_color_cycle, 'face_colormap': self.face_colormap.name, 'face_contrast_limits': self.face_contrast_limits, 'edge_color': self.edge_color - if self.data.size + if not_empty else [self.current_edge_color], 'edge_color_cycle': self.edge_color_cycle, 'edge_colormap': self.edge_colormap.name, @@ -1349,7 +1348,7 @@ def _view_data(self) -> np.ndarray: Array of coordinates for the N points in view """ if len(self._indices_view) > 0: - data = self.data[ + data = self._points_data[ np.ix_(self._indices_view, self._slice_input.displayed) ] else: @@ -1863,7 +1862,7 @@ def _move( disp = list(self._slice_input.displayed) self._set_drag_start(selection_indices, position) ixgrid = np.ix_(selection_indices, disp) - center = self.data[ixgrid].mean(axis=0) + center = self._points_data[ixgrid].mean(axis=0) shift = np.array(position)[disp] - center - self._drag_start self._move_points(ixgrid, shift) self.refresh() @@ -1907,30 +1906,11 @@ def _set_drag_start( if self._drag_start is None: self._drag_start = np.array(position, dtype=float)[dims_displayed] if len(selection_indices) > 0 and center_by_data: - center = self.data[ + center = self._points_data[ np.ix_(selection_indices, dims_displayed) ].mean(axis=0) self._drag_start -= center - def _copy_data(self): - """Copy selected points to clipboard.""" - if len(self.selected_data) > 0: - index = list(self.selected_data) - self._clipboard = { - 'data': deepcopy(self.data[index]), - 'edge_color': deepcopy(self.edge_color[index]), - 'face_color': deepcopy(self.face_color[index]), - 'shown': deepcopy(self.shown[index]), - 'size': deepcopy(self.size[index]), - 'symbol': deepcopy(self.symbol[index]), - 'edge_width': deepcopy(self.edge_width[index]), - 'features': deepcopy(self.features.iloc[index]), - 'indices': self._slice_indices, - 'text': self.text._copy(index), - } - else: - self._clipboard = {} - def get_status( self, position: Optional[Tuple] = None, @@ -2044,7 +2024,7 @@ def _get_properties( world=world, ) # if the cursor is not outside the image or on the background - if value is None or value > self.data.shape[0]: + if value is None or value > len(self.data): return [] return [ @@ -2144,6 +2124,10 @@ def data(self, data: Optional[np.ndarray]): self.events.data(value=self.data) self._reset_editable() + def _get_ndim(self) -> int: + """Determine number of dimensions of the layer.""" + return self.data.shape[1] + def add(self, coords): """Adds points at coordinates. @@ -2195,7 +2179,7 @@ def _move_points( shift : np.ndarray Selected coordinates shift """ - self._points_data[ixgrid] = self._points_data[ixgrid] + shift + self.data[ixgrid] = self.data[ixgrid] + shift def _paste_data(self): """Paste any point from clipboard and select them.""" @@ -2251,6 +2235,25 @@ def _paste_data(self): ) self.refresh() + def _copy_data(self): + """Copy selected points to clipboard.""" + if len(self.selected_data) > 0: + index = list(self.selected_data) + self._clipboard = { + 'data': deepcopy(self.data[index]), + 'edge_color': deepcopy(self.edge_color[index]), + 'face_color': deepcopy(self.face_color[index]), + 'shown': deepcopy(self.shown[index]), + 'size': deepcopy(self.size[index]), + 'symbol': deepcopy(self.symbol[index]), + 'edge_width': deepcopy(self.edge_width[index]), + 'features': deepcopy(self.features.iloc[index]), + 'indices': self._slice_indices, + 'text': self.text._copy(index), + } + else: + self._clipboard = {} + def to_mask( self, *, From 92d9dd9111a0621dd11bbc9abad38b1401640cff Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 12 May 2023 16:13:08 -0700 Subject: [PATCH 004/105] init graph layer implementation --- napari/layers/graph/graph.py | 188 ++++++++++++++++++++++++++++++++- napari/layers/points/points.py | 99 +++++++++++++++-- 2 files changed, 275 insertions(+), 12 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index b7be790a59a..3878c942c55 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,4 +1,186 @@ -from napari.layers.points import Points +from typing import Optional, Tuple -class Graph(Points): - pass +import numpy as np +from napari_graph import BaseGraph, UndirectedGraph +from numpy.typing import ArrayLike + +from napari.layers.points.points import _BasePoints +from napari.utils.translations import trans + + +class Graph(_BasePoints): + def __init__( + self, + data=None, + *, + ndim=None, + features=None, + feature_defaults=None, + properties=None, + text=None, + symbol='o', + size=10, + edge_width=0.05, + edge_width_is_relative=True, + edge_color='dimgray', + edge_color_cycle=None, + edge_colormap='viridis', + edge_contrast_limits=None, + face_color='white', + face_color_cycle=None, + face_colormap='viridis', + face_contrast_limits=None, + out_of_slice_display=False, + n_dimensional=None, + name=None, + metadata=None, + scale=None, + translate=None, + rotate=None, + shear=None, + affine=None, + opacity=1, + blending='translucent', + visible=True, + cache=True, + property_choices=None, + experimental_clipping_planes=None, + shading='none', + canvas_size_limits=..., + antialiasing=1, + shown=True, + ) -> None: + self._data = self._fix_data(data) + + super().__init__( + data, + ndim=self._data.ndim, + features=features, + feature_defaults=feature_defaults, + properties=properties, + text=text, + symbol=symbol, + size=size, + edge_width=edge_width, + edge_width_is_relative=edge_width_is_relative, + edge_color=edge_color, + edge_color_cycle=edge_color_cycle, + edge_colormap=edge_colormap, + edge_contrast_limits=edge_contrast_limits, + face_color=face_color, + face_color_cycle=face_color_cycle, + face_colormap=face_colormap, + face_contrast_limits=face_contrast_limits, + out_of_slice_display=out_of_slice_display, + n_dimensional=n_dimensional, + name=name, + metadata=metadata, + scale=scale, + translate=translate, + rotate=rotate, + shear=shear, + affine=affine, + opacity=opacity, + blending=blending, + visible=visible, + cache=cache, + property_choices=property_choices, + experimental_clipping_planes=experimental_clipping_planes, + shading=shading, + canvas_size_limits=canvas_size_limits, + antialiasing=antialiasing, + shown=shown, + ) + + @staticmethod + def _fix_data(data: Optional[BaseGraph] = None) -> BaseGraph: + """Checks input data and return a empty graph if is None.""" + if data is None: + return UndirectedGraph(n_nodes=100, ndim=3, n_edges=200) + + if isinstance(data, BaseGraph): + return data + + raise NotImplementedError + + @property + def _points_data(self) -> np.ndarray: + return self._data._coords + + @property + def data(self) -> BaseGraph: + return self._data + + @data.setter + def data(self, data: Optional[BaseGraph]) -> None: + # FIXME: might be missing data changed call + self._data = self._fix_data(data) + + def _get_ndim(self) -> int: + """Determine number of dimensions of the layer.""" + return self.data.ndim + + def add( + self, coords: ArrayLike, indices: Optional[ArrayLike] = None + ) -> None: + """Adds nodes at coordinates. + Parameters + ---------- + coords : sequence of indices to add point at + indices : optional indices of the newly inserted nodes. + """ + coords = np.atleast_2d(coords) + if indices is None: + new_starting_idx = self.data._buffer2world.max() + 1 + indices = np.arange( + new_starting_idx, new_starting_idx + len(coords) + ) + + if len(coords) != len(indices): + raise ValueError( + trans._( + 'coordinates and indices must have the same length. Found {coords_size} and {idx_size}', + coords_size=len(coords), + idx_size=len(indices), + ) + ) + + # FIXME: prev_size = self.data.n_allocated_nodes + + for idx, coord in zip(indices, coords): + self.data.add_nodes(idx, coord) + + # FIXME: self._data_changed(prev_size) + + def remove_selected(self): + """Removes selected points if any.""" + if len(self.selected_data): + indices = self.data._buffer2world[list(self.selected_data)] + self.remove(indices) + self.selected_data = set() + + def remove(self, indices: ArrayLike) -> None: + """Removes nodes given their indices.""" + # FIXME: prev_size = self.data.n_allocated_nodes + if isinstance(indices, np.ndarray): + indices = indices.tolist() + + indices.sort(reverse=True) + for idx in indices: + self.data.remove_node(idx) + + # FIXME: self._data_changed(prev_size) + + def _move_points( + self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray + ) -> None: + """Move points along a set a coordinates given a shift. + + Parameters + ---------- + ixgrid : Tuple[np.ndarray, np.ndarray] + Crossproduct indexing grid of node indices and dimensions, see `np.ix_` + shift : np.ndarray + Selected coordinates shift + """ + self.data._coords[ixgrid] = self.data._coords[ixgrid] + shift diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 9454890bc20..aca7d8c224d 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -368,11 +368,6 @@ def __init__( antialiasing=1, shown=True, ) -> None: - if ndim is None and scale is not None: - ndim = len(scale) - - data, ndim = fix_data_points(data, ndim) - # Indices of selected points self._selected_data = set() self._selected_data_stored = set() @@ -440,9 +435,6 @@ def __init__( feature_defaults=Event, ) - # Save the point coordinates - self._data = np.asarray(data) - self._feature_table = _FeatureTable.from_layer( features=features, feature_defaults=feature_defaults, @@ -486,7 +478,7 @@ def __init__( color_properties = ( self._feature_table.properties() - if self._data.size > 0 + if len(self.data) else self._feature_table.currents() ) self._edge = ColorManager._from_layer_kwargs( @@ -2038,6 +2030,95 @@ def _get_properties( class Points(_BasePoints): + def __init__( + self, + data=None, + *, + ndim=None, + features=None, + feature_defaults=None, + properties=None, + text=None, + symbol='o', + size=10, + edge_width=0.05, + edge_width_is_relative=True, + edge_color='dimgray', + edge_color_cycle=None, + edge_colormap='viridis', + edge_contrast_limits=None, + face_color='white', + face_color_cycle=None, + face_colormap='viridis', + face_contrast_limits=None, + out_of_slice_display=False, + n_dimensional=None, + name=None, + metadata=None, + scale=None, + translate=None, + rotate=None, + shear=None, + affine=None, + opacity=1, + blending='translucent', + visible=True, + cache=True, + property_choices=None, + experimental_clipping_planes=None, + shading='none', + canvas_size_limits=(2, 10000), + antialiasing=1, + shown=True, + ) -> None: + if ndim is None and scale is not None: + ndim = len(scale) + + data, ndim = fix_data_points(data, ndim) + + # Save the point coordinates + self._data = np.asarray(data) + + super().__init__( + data, + ndim=ndim, + features=features, + feature_defaults=feature_defaults, + properties=properties, + text=text, + symbol=symbol, + size=size, + edge_width=edge_width, + edge_width_is_relative=edge_width_is_relative, + edge_color=edge_color, + edge_color_cycle=edge_color_cycle, + edge_colormap=edge_colormap, + edge_contrast_limits=edge_contrast_limits, + face_color=face_color, + face_color_cycle=face_color_cycle, + face_colormap=face_colormap, + face_contrast_limits=face_contrast_limits, + out_of_slice_display=out_of_slice_display, + n_dimensional=n_dimensional, + name=name, + metadata=metadata, + scale=scale, + translate=translate, + rotate=rotate, + shear=shear, + affine=affine, + opacity=opacity, + blending=blending, + visible=visible, + cache=cache, + property_choices=property_choices, + experimental_clipping_planes=experimental_clipping_planes, + shading=shading, + canvas_size_limits=canvas_size_limits, + antialiasing=antialiasing, + shown=shown, + ) + @property def _points_data(self) -> np.ndarray: """Spatialy distributed coordinates.""" From 891217b5aa07b3a5e1e8d840367489f8eaa6351b Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 16 May 2023 16:19:29 -0700 Subject: [PATCH 005/105] graph slicing request / response and vispy visuals --- examples/add_graph.py | 5 +- napari/_vispy/layers/graph.py | 6 +- napari/_vispy/layers/points.py | 4 ++ napari/layers/graph/_slice.py | 102 +++++++++++++++++++++++++++++++++ napari/layers/graph/graph.py | 41 +++++++++---- napari/layers/points/_slice.py | 10 +++- napari/layers/points/points.py | 26 ++++++--- 7 files changed, 166 insertions(+), 28 deletions(-) create mode 100644 napari/layers/graph/_slice.py diff --git a/examples/add_graph.py b/examples/add_graph.py index b176d1b616b..51916861359 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -1,8 +1,9 @@ import numpy as np import pandas as pd +from napari_graph import UndirectedGraph + import napari from napari.layers import Graph -from napari_graph import UndirectedGraph def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: @@ -22,7 +23,7 @@ def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: viewer = napari.Viewer() n_nodes = 1000000 - graph = build_graph(n_nodes, 5).get_coordinates() + graph = build_graph(n_nodes, 5) layer = Graph(graph, out_of_slice_display=True) viewer.add_layer(layer) diff --git a/napari/_vispy/layers/graph.py b/napari/_vispy/layers/graph.py index fd475ee2b64..3e49cc1c524 100644 --- a/napari/_vispy/layers/graph.py +++ b/napari/_vispy/layers/graph.py @@ -1,14 +1,14 @@ from vispy import gloo -from ..visuals.graph import GraphVisual -from .points import VispyPointsLayer +from napari._vispy.layers.points import VispyPointsLayer +from napari._vispy.visuals.graph import GraphVisual class VispyGraphLayer(VispyPointsLayer): _visual = GraphVisual def _on_data_change(self): - # self._set_graph_edges_data() + self._set_graph_edges_data() super()._on_data_change() def _set_graph_edges_data(self): diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index 49f0892c4f8..494cc9cef2a 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -1,4 +1,5 @@ import numpy as np +from vispy import gloo from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.utils.gl import BLENDING_MODES @@ -116,6 +117,9 @@ def _on_highlight_change(self): pos = self.layer._highlight_box width = settings.appearance.highlight_thickness + # FIXME: vispy bug? LineVisual error when going from 2d to 3d (or the opposite) + self.node._subvisuals[2]._line_visual._pos_vbo = gloo.VertexBuffer() + self.node._subvisuals[2].set_data( pos=pos[:, ::-1], color=self._highlight_color, diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py new file mode 100644 index 00000000000..7295379f2c8 --- /dev/null +++ b/napari/layers/graph/_slice.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from napari_graph import BaseGraph + +from napari.layers.points._slice import _PointSliceRequest +from napari.layers.utils._slice_input import _SliceInput + + +@dataclass(frozen=True) +class _GraphSliceResponse: + """Contains all the output data of slicing an graph layer. + + Attributes + ---------- + indices : array like + Indices of the sliced *nodes* data. + edge_indices : array like + Indices of the slice nodes for each *edge*. + scale: array like or none + Used to scale the sliced points for visualization. + Should be broadcastable to indices. + dims : _SliceInput + Describes the slicing plane or bounding box in the layer's dimensions. + """ + + indices: np.ndarray = field(repr=False) + edges_indices: np.ndarray = field(repr=False) + scale: Any = field(repr=False) + dims: _SliceInput + + +class _GraphSliceRequest(_PointSliceRequest): + data: BaseGraph = field(repr=False) # updating typing + + @property + def _points_data(self) -> np.ndarray: + return self.data._coords + + def _edge_indices(self, node_indices: np.ndarray) -> np.ndarray: + """ + Node indices of pair nodes for each valid edge. + An edge is valid when both nodes are present. + + NOTE: + this could be computed in a single shot by rewriting + _get_out_of_display_slice_data + _get_slice_data + """ + mask = np.zeros(len(self.data), dtype=bool) + mask[node_indices] = True + _, edges = self.data.get_edges_buffers(is_buffer_domain=True) + edges_view = edges[ + np.logical_and(mask[edges[:, 0]], mask[edges[:, 1]]) + ] + return edges_view + + def __call__(self) -> _GraphSliceResponse: + # Return early if no data + if len(self.data) == 0: + return _GraphSliceResponse( + indices=[], + edges_indices=[], + edge_scale=np.empty(0), + dims=self.dims, + ) + + not_disp = list(self.dims.not_displayed) + if not not_disp: + # If we want to display everything, then use all indices. + # scale is only impacted by not displayed data, therefore 1 + node_indices = np.arange(len(self.data)) + return _GraphSliceResponse( + indices=node_indices, + edges_indices=self._edge_indices(node_indices), + scale=1, + dims=self.dims, + ) + + # We want a numpy array so we can use fancy indexing with the non-displayed + # indices, but as self.dims_indices can (and often/always does) contain slice + # objects, the array has dtype=object which is then very slow for the + # arithmetic below. As Points._round_index is always False, we can safely + # convert to float to get a major performance improvement. + not_disp_indices = np.array(self.dims_indices)[not_disp].astype(float) + + if self.out_of_slice_display and self.dims.ndim > 2: + slice_indices, scale = self._get_out_of_display_slice_data( + not_disp, not_disp_indices + ) + else: + slice_indices, scale = self._get_slice_data( + not_disp, not_disp_indices + ) + + return _GraphSliceResponse( + indices=slice_indices, + edges_indices=self._edge_indices(slice_indices), + scale=scale, + dims=self.dims, + ) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 3878c942c55..1e8e4d5cd86 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -4,7 +4,9 @@ from napari_graph import BaseGraph, UndirectedGraph from numpy.typing import ArrayLike +from napari.layers.graph._slice import _GraphSliceRequest, _GraphSliceResponse from napari.layers.points.points import _BasePoints +from napari.layers.utils._slice_input import _SliceInput from napari.utils.translations import trans @@ -46,11 +48,12 @@ def __init__( property_choices=None, experimental_clipping_planes=None, shading='none', - canvas_size_limits=..., + canvas_size_limits=(2, 10000), antialiasing=1, shown=True, ) -> None: - self._data = self._fix_data(data) + self._data = self._fix_data(data, ndim) + self._edges_indices_view = [] super().__init__( data, @@ -93,10 +96,12 @@ def __init__( ) @staticmethod - def _fix_data(data: Optional[BaseGraph] = None) -> BaseGraph: + def _fix_data( + data: Optional[BaseGraph] = None, ndim: int = 3 + ) -> BaseGraph: """Checks input data and return a empty graph if is None.""" if data is None: - return UndirectedGraph(n_nodes=100, ndim=3, n_edges=200) + return UndirectedGraph(n_nodes=100, ndim=ndim, n_edges=200) if isinstance(data, BaseGraph): return data @@ -120,6 +125,27 @@ def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" return self.data.ndim + def _make_slice_request_internal( + self, slice_input: _SliceInput, dims_indices: ArrayLike + ) -> _GraphSliceRequest: + return _GraphSliceRequest( + dims=slice_input, + data=self.data, + dims_indices=dims_indices, + out_of_slice_display=self.out_of_slice_display, + size=self.size, + ) + + def _update_slice_response(self, response: _GraphSliceResponse) -> None: + super()._update_slice_response(response) + self._edges_indices_view = response.edges_indices + + @property + def _view_edges_coordinates(self) -> np.ndarray: + return self.data._coords[self._edges_indices_view][ + ..., self._slice_input.displayed + ] + def add( self, coords: ArrayLike, indices: Optional[ArrayLike] = None ) -> None: @@ -145,13 +171,9 @@ def add( ) ) - # FIXME: prev_size = self.data.n_allocated_nodes - for idx, coord in zip(indices, coords): self.data.add_nodes(idx, coord) - # FIXME: self._data_changed(prev_size) - def remove_selected(self): """Removes selected points if any.""" if len(self.selected_data): @@ -161,7 +183,6 @@ def remove_selected(self): def remove(self, indices: ArrayLike) -> None: """Removes nodes given their indices.""" - # FIXME: prev_size = self.data.n_allocated_nodes if isinstance(indices, np.ndarray): indices = indices.tolist() @@ -169,8 +190,6 @@ def remove(self, indices: ArrayLike) -> None: for idx in indices: self.data.remove_node(idx) - # FIXME: self._data_changed(prev_size) - def _move_points( self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray ) -> None: diff --git a/napari/layers/points/_slice.py b/napari/layers/points/_slice.py index 076baf7f7b1..77cad770cd5 100644 --- a/napari/layers/points/_slice.py +++ b/napari/layers/points/_slice.py @@ -8,7 +8,7 @@ @dataclass(frozen=True) class _PointSliceResponse: - """Contains all the output data of slicing an image layer. + """Contains all the output data of slicing an points layer. Attributes ---------- @@ -57,6 +57,10 @@ class _PointSliceRequest: size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) + @property + def _points_data(self) -> np.ndarray: + return self.data + def __call__(self) -> _PointSliceResponse: # Return early if no data if len(self.data) == 0: @@ -96,7 +100,7 @@ def __call__(self) -> _PointSliceResponse: def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): """This method slices in the out-of-display case.""" - distances = abs(self.data[:, not_disp] - not_disp_indices) + distances = abs(self._points_data[:, not_disp] - not_disp_indices) sizes = self.size[:, not_disp] / 2 matches = np.all(distances <= sizes, axis=1) size_match = sizes[matches] @@ -109,7 +113,7 @@ def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): def _get_slice_data(self, not_disp, not_disp_indices): """This method slices in the simpler case.""" - data = self.data[:, not_disp] + data = self._points_data[:, not_disp] distances = np.abs(data - not_disp_indices) matches = np.all(distances <= 0.5, axis=1) slice_indices = np.where(matches)[0].astype(int) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 19e8e80e69e..f03114e6a70 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -668,6 +668,7 @@ def refresh_text(self): """ self.text.refresh(self.features) + @abstractmethod def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" raise NotImplementedError @@ -1653,7 +1654,7 @@ def _set_view_slice(self): response = request() self._update_slice_response(response) - def _make_slice_request(self, dims) -> _PointSliceRequest: + def _make_slice_request(self, dims) -> Any: """Make a Points slice request based on the given dims and these data.""" slice_input = self._make_slice_input( dims.point, dims.ndisplay, dims.order @@ -1670,16 +1671,11 @@ def _make_slice_request(self, dims) -> _PointSliceRequest: ) return self._make_slice_request_internal(slice_input, slice_indices) + @abstractmethod def _make_slice_request_internal( - self, slice_input: _SliceInput, dims_indices + self, slice_input: _SliceInput, dims_indices: ArrayLike ): - return _PointSliceRequest( - dims=slice_input, - data=self.data, - dims_indices=dims_indices, - out_of_slice_display=self.out_of_slice_display, - size=self.size, - ) + raise NotImplementedError def _update_slice_response(self, response: _PointSliceResponse): """Handle a slicing response.""" @@ -1832,6 +1828,7 @@ def add(self, coords: ArrayLike) -> None: """ raise NotImplementedError + @abstractmethod def remove_selected(self) -> None: """Removes selected points if any.""" raise NotImplementedError @@ -2210,6 +2207,17 @@ def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" return self.data.shape[1] + def _make_slice_request_internal( + self, slice_input: _SliceInput, dims_indices: ArrayLike + ) -> _PointSliceRequest: + return _PointSliceRequest( + dims=slice_input, + data=self.data, + dims_indices=dims_indices, + out_of_slice_display=self.out_of_slice_display, + size=self.size, + ) + def add(self, coords): """Adds points at coordinates. From 37ddd9654962e81eafce9731401c84d7af54bde9 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 19 May 2023 10:30:53 -0700 Subject: [PATCH 006/105] add graph layer testing --- napari/layers/graph/_slice.py | 2 +- napari/layers/graph/_tests/test_graph.py | 178 +++++++++++++++++++++++ napari/layers/graph/graph.py | 60 +++++++- 3 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 napari/layers/graph/_tests/test_graph.py diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 7295379f2c8..e566d5898b2 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -62,7 +62,7 @@ def __call__(self) -> _GraphSliceResponse: return _GraphSliceResponse( indices=[], edges_indices=[], - edge_scale=np.empty(0), + scale=np.empty(0), dims=self.dims, ) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py new file mode 100644 index 00000000000..39b584f6405 --- /dev/null +++ b/napari/layers/graph/_tests/test_graph.py @@ -0,0 +1,178 @@ +from typing import Type + +import numpy as np +import pytest +from napari_graph import BaseGraph, DirectedGraph, UndirectedGraph + +from napari.layers import Graph + + +def test_empty_graph() -> None: + graph = Graph() + assert len(graph.data) == 0 + + +def test_1_dim_array_graph() -> None: + shape = (2,) + + graph = Graph(np.random.random(shape)) + + assert len(graph.data) == 1 + assert graph.ndim == shape[0] + + +def test_2_dim_array_graph() -> None: + shape = (5, 2) + + graph = Graph(np.random.random(shape)) + + assert len(graph.data) == shape[0] + assert graph.ndim == shape[1] + + +def test_3_dim_array_graph() -> None: + shape = (5, 2, 2) + + with pytest.raises(ValueError): + Graph(np.random.random(shape)) + + +def test_incompatible_data_graph() -> None: + dict_graph = {0: [], 1: [1], 2: [0, 1]} + + with pytest.raises(TypeError): + Graph(dict_graph) + + +def test_non_spatial_graph() -> None: + non_spatial_graph = UndirectedGraph(edges=[[0, 0], [0, 1], [1, 1]]) + with pytest.raises(ValueError): + Graph(non_spatial_graph) + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_changing_graph(graph_class: Type[BaseGraph]) -> None: + graph_a = graph_class(edges=[[0, 1]], coords=[[0, 0], [1, 1]]) + graph_b = graph_class(coords=[[0, 0, 0]]) + layer = Graph(graph_a) + assert len(layer.data) == graph_a.n_nodes + assert layer.ndim == graph_a.ndim + layer.data = graph_b + assert len(layer.data) == graph_b.n_nodes + assert layer.ndim == graph_b.ndim + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_move(graph_class: Type[BaseGraph]) -> None: + start_coords = np.asarray([[0, 0], [1, 1], [2, 2]]) + graph = graph_class(edges=[[0, 1], [1, 2]], coords=start_coords) + + layer = Graph(graph) + assert len(layer.data) == len(start_coords) + + # move one points relative to initial drag start location + layer._move([0], [0, 0]) + layer._move([0], [10, 10]) + layer._drag_start = None + + assert np.all(layer._points_data[0] == start_coords[0] + [10, 10]) + assert np.all(layer._points_data[1:] == start_coords[1:]) + + # move other two points + layer._move([1, 2], [2, 2]) + layer._move([1, 2], np.add([2, 2], [-3, 4])) + assert np.all(layer._points_data[0] == start_coords[0] + [10, 10]) + assert np.all(layer._points_data[1:2] == start_coords[1:2] + [-3, 4]) + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_add_nodes(graph_class: Type[BaseGraph]) -> None: + # it also tests if original graph object is changed inplace. + coords = np.asarray([[0, 0], [1, 1]]) + + graph = graph_class(edges=[[0, 1]], coords=coords) + layer = Graph(graph) + + assert len(layer.data) == coords.shape[0] + + # adding without indexing + layer.add([2, 2]) + assert len(layer.data) == coords.shape[0] + 1 + assert graph.n_nodes == coords.shape[0] + 1 + + # adding with index + layer.add([3, 3], 13) + assert len(layer.data) == coords.shape[0] + 2 + assert graph.n_nodes == coords.shape[0] + 2 + + # adding multiple with indices + layer.add([[4, 4], [5, 5]], [24, 25]) + assert len(layer.data) == coords.shape[0] + 4 + assert graph.n_nodes == coords.shape[0] + 4 + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_remove_selected_nodes(graph_class: Type[BaseGraph]) -> None: + # it also tests if original graph object is changed inplace. + coords = np.asarray([[0, 0], [1, 1], [2, 2]]) + + graph = graph_class(edges=[[0, 1], [1, 2]], coords=coords) + layer = Graph(graph) + + # With nothing selected no points should be removed + layer.remove_selected() + assert len(layer.data) == coords.shape[0] + assert graph.n_nodes == coords.shape[0] + + # select nodes and remove then + layer.selected_data = {0, 2} + layer.remove_selected() + assert len(layer.data) == coords.shape[0] - 2 + assert graph.n_nodes == coords.shape[0] - 2 + + # on this test, coordinates match index + assert np.all(graph.get_coordinates() == 1) + + # remove last nodes, note that node id is not zero + layer.selected_data = {1} + layer.remove_selected() + assert len(layer.data) == 0 + assert graph.n_nodes == 0 + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_remove_nodes(graph_class: Type[BaseGraph]) -> None: + # it also tests if original graph object is changed inplace. + coords = np.asarray([[0, 0], [1, 1], [2, 2]]) + + graph = graph_class(edges=[[0, 1], [1, 2]], coords=coords) + layer = Graph(graph) + + # note that their index doesn't change with removals + layer.remove(1) + assert len(layer.data) == coords.shape[0] - 1 + assert graph.n_nodes == coords.shape[0] - 1 + + # on this test, coordinates match index + assert not np.any(graph.get_coordinates() == 1) + + layer.remove([0, 2]) + assert len(layer.data) == 0 + assert graph.n_nodes == 0 + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_graph_out_of_slice_display(graph_class: Type[BaseGraph]) -> None: + coords = np.asarray([[0, 0, 0, 0], [1, 1, 1, 1], [2, 2, 2, 2]]) + + graph = graph_class(edges=[[0, 1], [1, 2]], coords=coords) + layer = Graph(graph, out_of_slice_display=True) + assert layer.out_of_slice_display + + +def test_graph_from_data_tuple() -> None: + layer = Graph(name="graph") + new_layer = Graph.create(*layer.as_layer_data_tuple()) + assert layer.name == new_layer.name + assert len(layer.data) == len(new_layer.data) + assert layer.ndim == new_layer.ndim diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 1e8e4d5cd86..7aca897c12c 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import Optional, Tuple, Union import numpy as np from napari_graph import BaseGraph, UndirectedGraph @@ -56,7 +56,7 @@ def __init__( self._edges_indices_view = [] super().__init__( - data, + self._data, ndim=self._data.ndim, features=features, feature_defaults=feature_defaults, @@ -97,16 +97,51 @@ def __init__( @staticmethod def _fix_data( - data: Optional[BaseGraph] = None, ndim: int = 3 + data: Optional[Union[BaseGraph, ArrayLike]] = None, + ndim: Optional[int] = None, ) -> BaseGraph: """Checks input data and return a empty graph if is None.""" + if ndim is None: + ndim = 3 + if data is None: return UndirectedGraph(n_nodes=100, ndim=ndim, n_edges=200) if isinstance(data, BaseGraph): + if data._coords is None: + raise ValueError( + trans._( + "Graph layer must be a spatial graph, have the `coords` attribute." + ) + ) return data - raise NotImplementedError + try: + arr_data = np.atleast_2d(data) + except ValueError as err: + raise NotImplementedError( + trans._( + "Could not convert to {data} to a napari graph.", + data=data, + ) + ) from err + + if not issubclass(arr_data.dtype.type, np.number): + raise TypeError( + trans._( + "Expected numeric type. Found{dtype}.", + dtype=arr_data.dtype, + ) + ) + + if arr_data.ndim > 2: + raise ValueError( + trans._( + "Graph layer only supports 2-dim arrays. Found {ndim}.", + ndim=arr_data.ndim, + ) + ) + return UndirectedGraph(coords=arr_data) @property def _points_data(self) -> np.ndarray: @@ -120,6 +155,7 @@ def data(self) -> BaseGraph: def data(self, data: Optional[BaseGraph]) -> None: # FIXME: might be missing data changed call self._data = self._fix_data(data) + self._update_dims() def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" @@ -162,6 +198,8 @@ def add( new_starting_idx, new_starting_idx + len(coords) ) + indices = np.atleast_1d(indices) + if len(coords) != len(indices): raise ValueError( trans._( @@ -183,10 +221,18 @@ def remove_selected(self): def remove(self, indices: ArrayLike) -> None: """Removes nodes given their indices.""" - if isinstance(indices, np.ndarray): - indices = indices.tolist() + indices = np.atleast_1d(indices) + if indices.ndim > 1: + raise ValueError( + trans._( + "Indices for removal must be 1-dim. Found {ndim}", + ndim=indices.ndim, + ) + ) + + # descending order + indices = np.flip(np.sort(indices)) - indices.sort(reverse=True) for idx in indices: self.data.remove_node(idx) From 89fb3cfb4bb5823a35c3d8ae470713eba5f48fd9 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 19 May 2023 13:51:37 -0700 Subject: [PATCH 007/105] add add_graph example description --- examples/add_graph.py | 9 ++++++ .../_vispy/_tests/test_vispy_graph_layer.py | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 napari/_vispy/_tests/test_vispy_graph_layer.py diff --git a/examples/add_graph.py b/examples/add_graph.py index 51916861359..166bf1a33b9 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -1,3 +1,12 @@ +""" +Add graph +=================== + +Display a random undirected graph using the graph layer. + +.. tags:: visualization-basic +""" + import numpy as np import pandas as pd from napari_graph import UndirectedGraph diff --git a/napari/_vispy/_tests/test_vispy_graph_layer.py b/napari/_vispy/_tests/test_vispy_graph_layer.py new file mode 100644 index 00000000000..4753f8b0141 --- /dev/null +++ b/napari/_vispy/_tests/test_vispy_graph_layer.py @@ -0,0 +1,30 @@ +from typing import Type + +import numpy as np +import pytest +from napari_graph import BaseGraph, DirectedGraph, UndirectedGraph + +from napari._vispy.layers.graph import VispyGraphLayer +from napari.layers import Graph + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_vispy_graph_layer(graph_class: Type[BaseGraph]) -> None: + edges = np.asarray([[0, 1], [1, 2]]) + coords = np.asarray([[0, 0, 0, -1], [0, 0, 1, 2], [1, 0, 2, 3]]) + + graph = graph_class(edges=edges, coords=coords) + + layer = Graph(graph) + visual = VispyGraphLayer(layer) + + # checking nodes positions + assert np.all( + coords[:2, 1:] + == np.flip(visual.node._subvisuals[0]._data["a_position"], axis=-1) + ) + + # checking edges positions + assert np.all( + coords[:2, 2:] == np.flip(visual.node._subvisuals[4]._pos, axis=-1) + ) From b5a0b9418b0822ffea3ea194cbdbfde2ee77c9cd Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 22 May 2023 10:53:30 -0700 Subject: [PATCH 008/105] refactored graph class to always operate on the cluster directly --- napari/layers/graph/_slice.py | 135 ++++++++++++++++++----- napari/layers/graph/_tests/test_graph.py | 41 +++++++ napari/layers/graph/graph.py | 62 ++++++++++- napari/layers/points/_slice.py | 8 +- napari/layers/points/points.py | 34 ++++-- 5 files changed, 230 insertions(+), 50 deletions(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index e566d5898b2..5a0d79d6405 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -1,10 +1,10 @@ from dataclasses import dataclass, field -from typing import Any +from typing import Any, Sequence, Tuple import numpy as np from napari_graph import BaseGraph +from napari_graph.base_graph import _NODE_EMPTY_PTR -from napari.layers.points._slice import _PointSliceRequest from napari.layers.utils._slice_input import _SliceInput @@ -31,30 +31,36 @@ class _GraphSliceResponse: dims: _SliceInput -class _GraphSliceRequest(_PointSliceRequest): - data: BaseGraph = field(repr=False) # updating typing +@dataclass(frozen=True) +class _GraphSliceRequest: + """A callable that stores all the input data needed to slice a graph layer. - @property - def _points_data(self) -> np.ndarray: - return self.data._coords + This should be treated a deeply immutable structure, even though some + fields can be modified in place. It is like a function that has captured + all its inputs already. - def _edge_indices(self, node_indices: np.ndarray) -> np.ndarray: - """ - Node indices of pair nodes for each valid edge. - An edge is valid when both nodes are present. + In general, the calling an instance of this may take a long time, so you may + want to run it off the main thread. - NOTE: - this could be computed in a single shot by rewriting - _get_out_of_display_slice_data - _get_slice_data - """ - mask = np.zeros(len(self.data), dtype=bool) - mask[node_indices] = True - _, edges = self.data.get_edges_buffers(is_buffer_domain=True) - edges_view = edges[ - np.logical_and(mask[edges[:, 0]], mask[edges[:, 1]]) - ] - return edges_view + Attributes + ---------- + dims : _SliceInput + Describes the slicing plane or bounding box in the layer's dimensions. + data : BaseGraph + The layer's data field, which is the main input to slicing. + dims_indices : tuple of ints or slices + The slice indices in the layer's data space. + size : array like + Size of each node. This is used in calculating visibility. + others + See the corresponding attributes in `Layer` and `Image`. + """ + + dims: _SliceInput + data: BaseGraph = field(repr=False) + dims_indices: Any = field(repr=False) + size: Any = field(repr=False) + out_of_slice_display: bool = field(repr=False) def __call__(self) -> _GraphSliceResponse: # Return early if no data @@ -71,9 +77,10 @@ def __call__(self) -> _GraphSliceResponse: # If we want to display everything, then use all indices. # scale is only impacted by not displayed data, therefore 1 node_indices = np.arange(len(self.data)) + _, edges = self.data.get_edges_buffers(is_buffer_domain=True) return _GraphSliceResponse( indices=node_indices, - edges_indices=self._edge_indices(node_indices), + edges_indices=edges, scale=1, dims=self.dims, ) @@ -86,17 +93,85 @@ def __call__(self) -> _GraphSliceResponse: not_disp_indices = np.array(self.dims_indices)[not_disp].astype(float) if self.out_of_slice_display and self.dims.ndim > 2: - slice_indices, scale = self._get_out_of_display_slice_data( - not_disp, not_disp_indices - ) + ( + node_indices, + edges_indices, + scale, + ) = self._get_out_of_display_slice_data(not_disp, not_disp_indices) else: - slice_indices, scale = self._get_slice_data( + node_indices, edges_indices, scale = self._get_slice_data( not_disp, not_disp_indices ) return _GraphSliceResponse( - indices=slice_indices, - edges_indices=self._edge_indices(slice_indices), + indices=node_indices, + edges_indices=edges_indices, scale=scale, dims=self.dims, ) + + def _get_out_of_display_slice_data( + self, + not_disp: Sequence[int], + not_disp_indices: np.ndarray, + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Slices data according to non displayed indices + and compute scaling factor for out-slice display + while ignoring not initialized nodes from graph. + """ + valid_nodes = self.data._buffer2world != _NODE_EMPTY_PTR + ixgrid = np.ix_(valid_nodes, not_disp) + data = self.data._coords[ixgrid] + sizes = self.size[ixgrid] / 2 + distances = abs(data - not_disp_indices) + matches = np.all(distances <= sizes, axis=1) + size_match = sizes[matches] + size_match[size_match == 0] = 1 + scale_per_dim = (size_match - distances[matches]) / size_match + scale_per_dim[size_match == 0] = 1 + scale = np.prod(scale_per_dim, axis=1) + valid_nodes[valid_nodes] = matches + slice_indices = np.where(valid_nodes)[0].astype(int) + edge_indices = self._valid_edges(valid_nodes) + return slice_indices, edge_indices, scale + + def _get_slice_data( + self, + not_disp: Sequence[int], + not_disp_indices: np.ndarray, + ) -> Tuple[np.ndarray, np.ndarray, int]: + """ + Slices data according to non displayed indices + while ignoring not initialized nodes from graph. + """ + valid_nodes = self.data._buffer2world != _NODE_EMPTY_PTR + data = self.data._coords[np.ix_(valid_nodes, not_disp)] + distances = np.abs(data - not_disp_indices) + matches = np.all(distances <= 0.5, axis=1) + valid_nodes[valid_nodes] = matches + slice_indices = np.where(valid_nodes)[0].astype(int) + edge_indices = self._valid_edges(valid_nodes) + return slice_indices, edge_indices, 1 + + def _valid_edges( + self, + nodes_mask: np.ndarray, + ) -> np.ndarray: + """Compute edges (node pair) where both nodes are presents. + + Parameters + ---------- + nodes_mask : np.ndarray + Binary mask of available nodes. + + Returns + ------- + np.ndarray + (N x 2) array of nodes indices, where N is the number of valid edges. + """ + _, edges = self.data.get_edges_buffers(is_buffer_domain=True) + valid_edges = edges[ + np.logical_and(nodes_mask[edges[:, 0]], nodes_mask[edges[:, 1]]) + ] + return valid_edges diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 39b584f6405..e58ef6f64b4 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -1,6 +1,7 @@ from typing import Type import numpy as np +import pandas as pd import pytest from napari_graph import BaseGraph, DirectedGraph, UndirectedGraph @@ -161,6 +162,31 @@ def test_remove_nodes(graph_class: Type[BaseGraph]) -> None: assert graph.n_nodes == 0 +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_remove_nodes_non_sequential_indexing( + graph_class: Type[BaseGraph], +) -> None: + # it also tests if original graph object is changed inplace. + indices = np.asarray([5, 3, 1]) + coords = np.asarray([[0, 0], [1, 1], [2, 2]]) + coords = pd.DataFrame(coords, index=indices) + + graph = graph_class(edges=[[5, 3], [3, 1]], coords=coords) + layer = Graph(graph) + + # note that their index doesn't change with removals + layer.remove(indices[1]) + assert len(layer.data) == coords.shape[0] - 1 + assert graph.n_nodes == coords.shape[0] - 1 + + # node 3 (remove above) coordinates are all 1 + assert not np.any(graph.get_coordinates() == 1) + + layer.remove(indices[[0, 2]]) + assert len(layer.data) == 0 + assert graph.n_nodes == 0 + + @pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) def test_graph_out_of_slice_display(graph_class: Type[BaseGraph]) -> None: coords = np.asarray([[0, 0, 0, 0], [1, 1, 1, 1], [2, 2, 2, 2]]) @@ -176,3 +202,18 @@ def test_graph_from_data_tuple() -> None: assert layer.name == new_layer.name assert len(layer.data) == len(new_layer.data) assert layer.ndim == new_layer.ndim + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_graph_from_data_tuple_non_empty(graph_class: Type[BaseGraph]) -> None: + indices = np.asarray([5, 3, 1]) + coords = np.asarray([[0, 0], [1, 1], [2, 2]]) + coords = pd.DataFrame(coords, index=indices) + + graph = graph_class(edges=[[5, 3], [3, 1]], coords=coords) + layer = Graph(graph) + + new_layer = Graph.create(*layer.as_layer_data_tuple()) + assert layer.name == new_layer.name + assert len(layer.data) == len(new_layer.data) + assert layer.ndim == new_layer.ndim diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 7aca897c12c..8179c885a85 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -153,9 +153,9 @@ def data(self) -> BaseGraph: @data.setter def data(self, data: Optional[BaseGraph]) -> None: - # FIXME: might be missing data changed call + prev_size = self.data.n_allocated_nodes self._data = self._fix_data(data) - self._update_dims() + self._data_changed(prev_size) def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" @@ -209,9 +209,13 @@ def add( ) ) + prev_size = self.data.n_allocated_nodes + for idx, coord in zip(indices, coords): self.data.add_nodes(idx, coord) + self._data_changed(prev_size) + def remove_selected(self): """Removes selected points if any.""" if len(self.selected_data): @@ -230,12 +234,15 @@ def remove(self, indices: ArrayLike) -> None: ) ) + prev_size = self.data.n_allocated_nodes # descending order indices = np.flip(np.sort(indices)) for idx in indices: self.data.remove_node(idx) + self._data_changed(prev_size) + def _move_points( self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray ) -> None: @@ -249,3 +256,54 @@ def _move_points( Selected coordinates shift """ self.data._coords[ixgrid] = self.data._coords[ixgrid] + shift + + def _update_props_and_style(self, data_size: int, prev_size: int) -> None: + # Add/remove property and style values based on the number of new points. + with self.events.blocker_all(), self._edge.events.blocker_all(), self._face.events.blocker_all(): + self._feature_table.resize(data_size) + self.text.apply(self.features) + if data_size < prev_size: + # If there are now fewer points, remove the size and colors of the + # extra ones + if len(self._edge.colors) > data_size: + self._edge._remove( + np.arange(data_size, len(self._edge.colors)) + ) + if len(self._face.colors) > data_size: + self._face._remove( + np.arange(data_size, len(self._face.colors)) + ) + self._shown = self._shown[:data_size] + self._size = self._size[:data_size] + self._edge_width = self._edge_width[:data_size] + self._symbol = self._symbol[:data_size] + + elif data_size > prev_size: + adding = data_size - prev_size + + current_properties = self._feature_table.currents() + self._edge._update_current_properties(current_properties) + self._edge._add(n_colors=adding) + self._face._update_current_properties(current_properties) + self._face._add(n_colors=adding) + + for attribute in ("shown", "edge_width", "symbol"): + if attribute == "shown": + default_value = True + else: + default_value = getattr(self, f"current_{attribute}") + new_values = np.repeat([default_value], adding, axis=0) + values = np.concatenate( + (getattr(self, f"_{attribute}"), new_values), axis=0 + ) + setattr(self, attribute, values) + + new_sizes = np.broadcast_to( + self.current_size, (adding, self._size.shape[1]) + ) + self.size = np.concatenate((self._size, new_sizes), axis=0) + + def _data_changed(self, prev_size: int) -> None: + self._update_props_and_style(self.data.n_allocated_nodes, prev_size) + self._update_dims() + self.events.data(value=self.data) diff --git a/napari/layers/points/_slice.py b/napari/layers/points/_slice.py index 77cad770cd5..48eefbc0dc8 100644 --- a/napari/layers/points/_slice.py +++ b/napari/layers/points/_slice.py @@ -57,10 +57,6 @@ class _PointSliceRequest: size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) - @property - def _points_data(self) -> np.ndarray: - return self.data - def __call__(self) -> _PointSliceResponse: # Return early if no data if len(self.data) == 0: @@ -100,7 +96,7 @@ def __call__(self) -> _PointSliceResponse: def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): """This method slices in the out-of-display case.""" - distances = abs(self._points_data[:, not_disp] - not_disp_indices) + distances = abs(self.data[:, not_disp] - not_disp_indices) sizes = self.size[:, not_disp] / 2 matches = np.all(distances <= sizes, axis=1) size_match = sizes[matches] @@ -113,7 +109,7 @@ def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): def _get_slice_data(self, not_disp, not_disp_indices): """This method slices in the simpler case.""" - data = self._points_data[:, not_disp] + data = self.data[:, not_disp] distances = np.abs(data - not_disp_indices) matches = np.all(distances <= 0.5, axis=1) slice_indices = np.where(matches)[0].astype(int) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index f03114e6a70..8b91d7bf652 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -439,7 +439,7 @@ def __init__( feature_defaults=feature_defaults, properties=properties, property_choices=property_choices, - num_data=len(self.data), + num_data=len(self._points_data), ) self._text = TextManager._from_layer( @@ -477,7 +477,7 @@ def __init__( color_properties = ( self._feature_table.properties() - if len(self.data) + if len(self._points_data) else self._feature_table.currents() ) self._edge = ColorManager._from_layer_kwargs( @@ -560,7 +560,9 @@ def features( self, features: Union[Dict[str, np.ndarray], pd.DataFrame], ) -> None: - self._feature_table.set_values(features, num_data=len(self.data)) + self._feature_table.set_values( + features, num_data=len(self._points_data) + ) self._update_color_manager( self._face, self._feature_table, "face_color" ) @@ -681,7 +683,7 @@ def _extent_data(self) -> np.ndarray: ------- extent_data : array, shape (2, D) """ - if len(self.data) == 0: + if len(self._points_data) == 0: extrema = np.full((2, self.ndim), np.nan) else: maxs = np.max(self._points_data, axis=0) @@ -719,7 +721,7 @@ def symbol(self) -> np.ndarray: @symbol.setter def symbol(self, symbol: Union[str, np.ndarray, list]) -> None: - symbol = np.broadcast_to(symbol, len(self.data)) + symbol = np.broadcast_to(symbol, len(self._points_data)) self._symbol = coerce_symbols(symbol) self.events.symbol() self.events.highlight() @@ -755,7 +757,10 @@ def size(self, size: Union[int, float, np.ndarray, list]) -> None: except ValueError: raise ValueError( trans._( - "Size is not compatible for broadcasting", + "Size of shape {size_shape} is not compatible for broadcasting " + "with shape {points_shape}", + size_shape=size.shape, + points_shape=self._points_data.shape, deferred=True, ) ) from e @@ -839,7 +844,9 @@ def shown(self): @shown.setter def shown(self, shown): - self._shown = np.broadcast_to(shown, len(self.data)).astype(bool) + self._shown = np.broadcast_to(shown, len(self._points_data)).astype( + bool + ) self.refresh() @property @@ -852,7 +859,7 @@ def edge_width( self, edge_width: Union[int, float, np.ndarray, list] ) -> None: # broadcast to np.array - edge_width = np.broadcast_to(edge_width, len(self.data)).copy() + edge_width = np.broadcast_to(edge_width, len(self._points_data)).copy() # edge width cannot be negative if np.any(edge_width < 0): @@ -917,7 +924,7 @@ def edge_color(self) -> np.ndarray: def edge_color(self, edge_color): self._edge._set_color( color=edge_color, - n_colors=len(self.data), + n_colors=len(self._points_data), properties=self.properties, current_properties=self.current_properties, ) @@ -1004,7 +1011,7 @@ def face_color(self) -> np.ndarray: def face_color(self, face_color): self._face._set_color( color=face_color, - n_colors=len(self.data), + n_colors=len(self._points_data), properties=self.properties, current_properties=self.current_properties, ) @@ -1171,6 +1178,9 @@ def _get_state(self): state : dict Dictionary of layer state. """ + + # must be self.data and not self._points_data + # self._points_data includes invalid nodes from graph buffer. not_empty = len(self.data) > 0 state = self._get_base_state() state.update( @@ -2014,7 +2024,7 @@ def _get_properties( world=world, ) # if the cursor is not outside the image or on the background - if value is None or value > len(self.data): + if value is None or value > len(self._points_data): return [] return [ @@ -2274,7 +2284,7 @@ def _move_points( def _paste_data(self): """Paste any point from clipboard and select them.""" npoints = len(self._view_data) - totpoints = len(self.data) + totpoints = len(self._points_data) if len(self._clipboard.keys()) > 0: not_disp = self._slice_input.not_displayed From d278b5a9ab779ef2719cc089c37079bd52d74503 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 22 May 2023 11:11:22 -0700 Subject: [PATCH 009/105] add new delaunay graph example --- examples/nuclei_segmentation_graph.py | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 examples/nuclei_segmentation_graph.py diff --git a/examples/nuclei_segmentation_graph.py b/examples/nuclei_segmentation_graph.py new file mode 100644 index 00000000000..c9ef08fc092 --- /dev/null +++ b/examples/nuclei_segmentation_graph.py @@ -0,0 +1,42 @@ +""" +Nuclei Segmentation Graph +=============== + +Creates a delaunay graph from maxima of cell nuclei. + +.. tags:: visualization-nD +""" +from itertools import combinations + +import numpy as np +from napari_graph import UndirectedGraph +from scipy.spatial import Delaunay +from skimage import data, feature, filters + +import napari + + +def delaunay_edges(points: np.ndarray) -> np.ndarray: + delaunay = Delaunay(points) + edges = [] + for simplex in delaunay.simplices: + edges += list(combinations(simplex, 2)) + + return edges + + +cells = data.cells3d() + +nuclei = cells[:, 1] +smooth = filters.gaussian(nuclei, sigma=10) +nodes_coords = feature.peak_local_max(smooth) +edges = delaunay_edges(nodes_coords) +graph = UndirectedGraph(edges, nodes_coords) +viewer = napari.view_image( + cells, channel_axis=1, name=['membranes', 'nuclei'], ndisplay=3 +) +viewer.add_graph(graph) +viewer.camera.angles = (10, -20, 130) + +if __name__ == '__main__': + napari.run() From 9c8a3900f66d6f19bbc12e3ad396838f1523a947 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 22 May 2023 11:12:00 -0700 Subject: [PATCH 010/105] update add_graph to follow other examples --- examples/add_graph.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/add_graph.py b/examples/add_graph.py index 166bf1a33b9..472ffa3f934 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -28,12 +28,13 @@ def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: return graph -if __name__ == "__main__": +viewer = napari.Viewer() +n_nodes = 1000000 +graph = build_graph(n_nodes, 5) +layer = Graph(graph, out_of_slice_display=True) +viewer.add_layer(layer) + - viewer = napari.Viewer() - n_nodes = 1000000 - graph = build_graph(n_nodes, 5) - layer = Graph(graph, out_of_slice_display=True) - viewer.add_layer(layer) +if __name__ == "__main__": napari.run() From 66057a431df535fde4bf5b965876b80250fd839c Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 22 May 2023 11:55:50 -0700 Subject: [PATCH 011/105] graph slicing bug fix --- .../_vispy/_tests/test_vispy_graph_layer.py | 41 +++++++++++++++++++ napari/layers/graph/_slice.py | 7 +++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/napari/_vispy/_tests/test_vispy_graph_layer.py b/napari/_vispy/_tests/test_vispy_graph_layer.py index 4753f8b0141..234984b4105 100644 --- a/napari/_vispy/_tests/test_vispy_graph_layer.py +++ b/napari/_vispy/_tests/test_vispy_graph_layer.py @@ -28,3 +28,44 @@ def test_vispy_graph_layer(graph_class: Type[BaseGraph]) -> None: assert np.all( coords[:2, 2:] == np.flip(visual.node._subvisuals[4]._pos, axis=-1) ) + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_vispy_graph_layer_removal(graph_class: Type[BaseGraph]) -> None: + edges = np.asarray([[0, 1], [1, 2]]) + coords = np.asarray([[0, 0, 0, -1], [0, 0, 1, 2], [0, 0, 2, 3]]) + + graph = graph_class(edges=edges, coords=coords) + + layer = Graph(graph) + visual = VispyGraphLayer(layer) + + # checking nodes positions + assert np.all( + coords[:, 1:] + == np.flip(visual.node._subvisuals[0]._data["a_position"], axis=-1) + ) + + # checking first edge + assert np.all( + coords[:2, 2:] == np.flip(visual.node._subvisuals[4]._pos[:2], axis=-1) + ) + + # checking second edge + assert np.all( + coords[1:3, 2:] + == np.flip(visual.node._subvisuals[4]._pos[2:], axis=-1) + ) + + layer.remove(0) + + # checking remaining nodes positions + assert np.all( + coords[1:, 1:] + == np.flip(visual.node._subvisuals[0]._data["a_position"], axis=-1) + ) + + # checking single edge + assert np.all( + coords[1:3, 2:] == np.flip(visual.node._subvisuals[4]._pos, axis=-1) + ) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 5a0d79d6405..5f76ea7a28f 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -64,7 +64,7 @@ class _GraphSliceRequest: def __call__(self) -> _GraphSliceResponse: # Return early if no data - if len(self.data) == 0: + if self.data.n_nodes == 0: return _GraphSliceResponse( indices=[], edges_indices=[], @@ -76,7 +76,10 @@ def __call__(self) -> _GraphSliceResponse: if not not_disp: # If we want to display everything, then use all indices. # scale is only impacted by not displayed data, therefore 1 - node_indices = np.arange(len(self.data)) + node_indices = np.arange(self.data.n_allocated_nodes) + node_indices = node_indices[ + self.data._buffer2world != _NODE_EMPTY_PTR + ] _, edges = self.data.get_edges_buffers(is_buffer_domain=True) return _GraphSliceResponse( indices=node_indices, From 149c0d80ecc602c485ee6d59a618fc9bfffc0f55 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 May 2023 20:34:14 +0000 Subject: [PATCH 012/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- napari/_qt/layer_controls/qt_graph_controls.py | 2 +- napari/_qt/layer_controls/qt_layer_controls_container.py | 2 +- napari/_vispy/visuals/graph.py | 2 +- napari/layers/graph/__init__.py | 1 - napari/view_layers.py | 2 ++ 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/napari/_qt/layer_controls/qt_graph_controls.py b/napari/_qt/layer_controls/qt_graph_controls.py index 2421226f82c..85676eeec1f 100644 --- a/napari/_qt/layer_controls/qt_graph_controls.py +++ b/napari/_qt/layer_controls/qt_graph_controls.py @@ -1,4 +1,4 @@ -from .qt_points_controls import QtPointsControls +from napari._qt.layer_controls.qt_points_controls import QtPointsControls class QtGraphControls(QtPointsControls): diff --git a/napari/_qt/layer_controls/qt_layer_controls_container.py b/napari/_qt/layer_controls/qt_layer_controls_container.py index f2c9bf74b6c..7fd7063b480 100644 --- a/napari/_qt/layer_controls/qt_layer_controls_container.py +++ b/napari/_qt/layer_controls/qt_layer_controls_container.py @@ -9,8 +9,8 @@ from napari._qt.layer_controls.qt_tracks_controls import QtTracksControls from napari._qt.layer_controls.qt_vectors_controls import QtVectorsControls from napari.layers import ( - Image, Graph, + Image, Labels, Points, Shapes, diff --git a/napari/_vispy/visuals/graph.py b/napari/_vispy/visuals/graph.py index 7d6d1f03027..d97b7a06a94 100644 --- a/napari/_vispy/visuals/graph.py +++ b/napari/_vispy/visuals/graph.py @@ -1,6 +1,6 @@ from vispy.visuals import LineVisual -from .points import PointsVisual +from napari._vispy.visuals.points import PointsVisual class GraphVisual(PointsVisual): diff --git a/napari/layers/graph/__init__.py b/napari/layers/graph/__init__.py index f2f720b3844..e69de29bb2d 100644 --- a/napari/layers/graph/__init__.py +++ b/napari/layers/graph/__init__.py @@ -1 +0,0 @@ -from napari.layers.graph.graph import Graph \ No newline at end of file diff --git a/napari/view_layers.py b/napari/view_layers.py index e4bca50c09f..b240a984e7d 100644 --- a/napari/view_layers.py +++ b/napari/view_layers.py @@ -174,10 +174,12 @@ def _make_viewer_then( # viewer.add_image(*args, **kwargs) # return viewer + @_merge_layer_viewer_sigs_docs def view_graph(*args, **kwargs): return _make_viewer_then('add_graph', *args, **kwargs)[0] + @_merge_layer_viewer_sigs_docs def view_image(*args, **kwargs): return _make_viewer_then('add_image', *args, **kwargs)[0] From a6564c7911b69695b56b299f6b0d05b04c9c3d9d Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 22 May 2023 14:01:11 -0700 Subject: [PATCH 013/105] fix change from precommit --- napari/layers/graph/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/napari/layers/graph/__init__.py b/napari/layers/graph/__init__.py index e69de29bb2d..debd8251c3f 100644 --- a/napari/layers/graph/__init__.py +++ b/napari/layers/graph/__init__.py @@ -0,0 +1,3 @@ +from napari.layers.graph.graph import Graph + +__all__ = ['Graph'] From 3c97c56f6271fa73d3413d03921171a848a5006b Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 25 May 2023 15:12:01 -0700 Subject: [PATCH 014/105] updated graphdata type --- napari/_tests/test_magicgui.py | 2 +- napari/_vispy/_tests/test_vispy_graph_layer.py | 9 ++++++++- napari/layers/graph/_slice.py | 10 ++++++++-- napari/layers/graph/_tests/test_graph.py | 11 +++++++++-- napari/layers/graph/graph.py | 15 ++++++++++++++- napari/types.py | 7 ++++++- setup.cfg | 3 +++ 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/napari/_tests/test_magicgui.py b/napari/_tests/test_magicgui.py index a6e7b2c3756..09df3e764a7 100644 --- a/napari/_tests/test_magicgui.py +++ b/napari/_tests/test_magicgui.py @@ -308,7 +308,7 @@ def func_returns(v: Viewer) -> bool: MGUI_EXPORTS = ['napari.layers.Layer', 'napari.Viewer'] MGUI_EXPORTS += [f'napari.types.{nm.title()}Data' for nm in layers.NAMES] -NAMES = ('Image', 'Labels', 'Layer', 'Points', 'Shapes', 'Surface') +NAMES = ('Graph', 'Image', 'Labels', 'Layer', 'Points', 'Shapes', 'Surface') @pytest.mark.parametrize('name', sorted(MGUI_EXPORTS)) diff --git a/napari/_vispy/_tests/test_vispy_graph_layer.py b/napari/_vispy/_tests/test_vispy_graph_layer.py index 234984b4105..adb93692c5a 100644 --- a/napari/_vispy/_tests/test_vispy_graph_layer.py +++ b/napari/_vispy/_tests/test_vispy_graph_layer.py @@ -2,11 +2,18 @@ import numpy as np import pytest -from napari_graph import BaseGraph, DirectedGraph, UndirectedGraph from napari._vispy.layers.graph import VispyGraphLayer from napari.layers import Graph +pytest.importorskip("napari_graph") + +from napari_graph import ( # noqa: E402 + BaseGraph, + DirectedGraph, + UndirectedGraph, +) + @pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) def test_vispy_graph_layer(graph_class: Type[BaseGraph]) -> None: diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 5f76ea7a28f..8c5c2a16f08 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -2,11 +2,17 @@ from typing import Any, Sequence, Tuple import numpy as np -from napari_graph import BaseGraph -from napari_graph.base_graph import _NODE_EMPTY_PTR from napari.layers.utils._slice_input import _SliceInput +try: + from napari_graph import BaseGraph + from napari_graph.base_graph import _NODE_EMPTY_PTR + +except ModuleNotFoundError: + BaseGraph = None + _NODE_EMPTY_PTR = None + @dataclass(frozen=True) class _GraphSliceResponse: diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index e58ef6f64b4..3a908da5e22 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -3,9 +3,16 @@ import numpy as np import pandas as pd import pytest -from napari_graph import BaseGraph, DirectedGraph, UndirectedGraph -from napari.layers import Graph +pytest.importorskip("napari_graph") + +from napari_graph import ( # noqa: E402 + BaseGraph, + DirectedGraph, + UndirectedGraph, +) + +from napari.layers import Graph # noqa: E402 def test_empty_graph() -> None: diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 8179c885a85..39a272881be 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,7 +1,6 @@ from typing import Optional, Tuple, Union import numpy as np -from napari_graph import BaseGraph, UndirectedGraph from numpy.typing import ArrayLike from napari.layers.graph._slice import _GraphSliceRequest, _GraphSliceResponse @@ -9,6 +8,13 @@ from napari.layers.utils._slice_input import _SliceInput from napari.utils.translations import trans +try: + from napari_graph import BaseGraph, UndirectedGraph + +except ModuleNotFoundError: + BaseGraph = None + UndirectedGraph = None + class Graph(_BasePoints): def __init__( @@ -52,6 +58,13 @@ def __init__( antialiasing=1, shown=True, ) -> None: + if BaseGraph is None: + raise ImportError( + trans._( + "`napari-graph` module is required by the graph layer." + ) + ) + self._data = self._fix_data(data, ndim) self._edges_indices_view = [] diff --git a/napari/types.py b/napari/types.py index bdc318b12a2..999ea22b139 100644 --- a/napari/types.py +++ b/napari/types.py @@ -29,6 +29,11 @@ from magicgui.widgets import FunctionGui from qtpy.QtWidgets import QWidget # type: ignore [attr-defined] +try: + from napari_graph import BaseGraph + +except ModuleNotFoundError: + BaseGraph = None # This is a WOEFULLY inadequate stub for a duck-array type. # Mostly, just a placeholder for the concept of needing an ArrayLike type. @@ -83,7 +88,7 @@ class SampleDict(TypedDict): ArrayBase: Type[np.ndarray] = np.ndarray -GraphData = NewType("GraphData", tuple) # FIXME +GraphData = NewType("GraphData", BaseGraph) ImageData = NewType("ImageData", np.ndarray) LabelsData = NewType("LabelsData", np.ndarray) PointsData = NewType("PointsData", np.ndarray) diff --git a/setup.cfg b/setup.cfg index f9f1d6ebb37..63e047e4827 100644 --- a/setup.cfg +++ b/setup.cfg @@ -130,6 +130,7 @@ testing = IPython>=7.25.0 qtconsole>=4.5.1 rich>=12.0.0 + napari-graph release = PyGithub>=1.44.1 twine>=3.1.1 @@ -146,6 +147,8 @@ build = black ruff pyqt5 +graph = + napari-graph [options.entry_points] console_scripts = From 4a6a08c6061c31be79079075a4306150c52ef7de Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 25 May 2023 16:24:55 -0700 Subject: [PATCH 015/105] replaced edges_ of points and graph layer with border_ --- examples/add_points_with_features.py | 14 +- examples/add_points_with_multicolor_text.py | 14 +- examples/add_points_with_text.py | 14 +- examples/mgui_with_threadpoolexec_.py | 4 +- examples/mgui_with_threadworker_.py | 4 +- examples/nD_points_with_features.py | 14 +- examples/spherical_points.py | 2 +- .../_qt/layer_controls/qt_points_controls.py | 44 +- napari/_vispy/layers/points.py | 36 +- napari/components/_tests/test_prune_kwargs.py | 2 - napari/layers/graph/graph.py | 40 +- napari/layers/points/_tests/test_points.py | 136 +++--- napari/layers/points/points.py | 388 +++++++++--------- tools/string_list.json | 14 +- 14 files changed, 368 insertions(+), 358 deletions(-) diff --git a/examples/add_points_with_features.py b/examples/add_points_with_features.py index cc9e86a81ee..99b1ecc8478 100644 --- a/examples/add_points_with_features.py +++ b/examples/add_points_with_features.py @@ -29,22 +29,22 @@ face_color_cycle = ['blue', 'green'] # create a points layer where the face_color is set by the good_point feature -# and the edge_color is set via a color map (grayscale) on the confidence +# and the border_color is set via a color map (grayscale) on the confidence # feature. points_layer = viewer.add_points( points, features=features, size=20, - edge_width=7, - edge_width_is_relative=False, - edge_color='confidence', - edge_colormap='gray', + border_width=7, + border_width_is_relative=False, + border_color='confidence', + border_colormap='gray', face_color='good_point', face_color_cycle=face_color_cycle ) -# set the edge_color mode to colormap -points_layer.edge_color_mode = 'colormap' +# set the border_color mode to colormap +points_layer.border_color_mode = 'colormap' # bind a function to toggle the good_point annotation of the selected points diff --git a/examples/add_points_with_multicolor_text.py b/examples/add_points_with_multicolor_text.py index 4f488616fda..2f16d144213 100644 --- a/examples/add_points_with_multicolor_text.py +++ b/examples/add_points_with_multicolor_text.py @@ -33,23 +33,23 @@ } # create a points layer where the face_color is set by the good_point feature -# and the edge_color is set via a color map (grayscale) on the confidence +# and the border_color is set via a color map (grayscale) on the confidence # feature points_layer = viewer.add_points( points, features=features, text=text, size=20, - edge_width=7, - edge_width_is_relative=False, - edge_color='confidence', - edge_colormap='gray', + border_width=7, + border_width_is_relative=False, + border_color='confidence', + border_colormap='gray', face_color='good_point', face_color_cycle=color_cycle, ) -# set the edge_color mode to colormap -points_layer.edge_color_mode = 'colormap' +# set the border_color mode to colormap +points_layer.border_color_mode = 'colormap' if __name__ == '__main__': napari.run() diff --git a/examples/add_points_with_text.py b/examples/add_points_with_text.py index 5403d2bd11e..3b4a2ece7fc 100644 --- a/examples/add_points_with_text.py +++ b/examples/add_points_with_text.py @@ -34,23 +34,23 @@ } # create a points layer where the face_color is set by the good_point feature -# and the edge_color is set via a color map (grayscale) on the confidence +# and the border_color is set via a color map (grayscale) on the confidence # feature. points_layer = viewer.add_points( points, features=features, text=text, size=20, - edge_width=7, - edge_width_is_relative=False, - edge_color='confidence', - edge_colormap='gray', + border_width=7, + border_width_is_relative=False, + border_color='confidence', + border_colormap='gray', face_color='good_point', face_color_cycle=face_color_cycle, ) -# set the edge_color mode to colormap -points_layer.edge_color_mode = 'colormap' +# set the border_color mode to colormap +points_layer.border_color_mode = 'colormap' if __name__ == '__main__': napari.run() diff --git a/examples/mgui_with_threadpoolexec_.py b/examples/mgui_with_threadpoolexec_.py index 3889162fd29..0f4c4f333df 100644 --- a/examples/mgui_with_threadpoolexec_.py +++ b/examples/mgui_with_threadpoolexec_.py @@ -53,8 +53,8 @@ def _make_blob(): data = blobs[:, : image.ndim] kwargs = { "size": blobs[:, -1], - "edge_color": "red", - "edge_width": 2, + "border_color": "red", + "border_width": 2, "face_color": "transparent", } return (data, kwargs, 'points') diff --git a/examples/mgui_with_threadworker_.py b/examples/mgui_with_threadworker_.py index 2b75f2689fb..bd3257ca5ca 100644 --- a/examples/mgui_with_threadworker_.py +++ b/examples/mgui_with_threadworker_.py @@ -36,8 +36,8 @@ def detect_blobs() -> LayerDataTuple: points = blobs[:, : image.ndim] meta = { "size": blobs[:, -1], - "edge_color": "red", - "edge_width": 2, + "border_color": "red", + "border_width": 2, "face_color": "transparent", } # return a "LayerDataTuple" diff --git a/examples/nD_points_with_features.py b/examples/nD_points_with_features.py index edc833d03c5..6c0220af7bc 100644 --- a/examples/nD_points_with_features.py +++ b/examples/nD_points_with_features.py @@ -29,20 +29,20 @@ [True, True, True, True, False, False, False, False] * int(blobs.shape[0] / 2) ) -edge_feature = np.array(['A', 'B', 'C', 'D', 'E'] * int(len(points) / 5)) +border_feature = np.array(['A', 'B', 'C', 'D', 'E'] * int(len(points) / 5)) features = { 'face_feature': face_feature, - 'edge_feature': edge_feature, + 'border_feature': border_feature, } points_layer = viewer.add_points( points, features=features, size=3, - edge_width=5, - edge_width_is_relative=False, - edge_color='edge_feature', + border_width=5, + border_width_is_relative=False, + border_color='border_feature', face_color='face_feature', out_of_slice_display=False, ) @@ -50,9 +50,9 @@ # change the face color cycle points_layer.face_color_cycle = ['white', 'black'] -# change the edge_color cycle. +# change the border_color cycle. # there are 4 colors for 5 categories, so 'c' will be recycled -points_layer.edge_color_cycle = ['c', 'm', 'y', 'k'] +points_layer.border_color_cycle = ['c', 'm', 'y', 'k'] if __name__ == '__main__': napari.run() diff --git a/examples/spherical_points.py b/examples/spherical_points.py index b17364c9ab8..3b847a39a3e 100644 --- a/examples/spherical_points.py +++ b/examples/spherical_points.py @@ -20,7 +20,7 @@ face_color=colors, size=sizes, shading='spherical', - edge_width=0, + border_width=0, ) # antialiasing is currently a bit broken, this is especially bad in 3D so diff --git a/napari/_qt/layer_controls/qt_points_controls.py b/napari/_qt/layer_controls/qt_points_controls.py index 188f44fa5fb..5839f09c0bb 100644 --- a/napari/_qt/layer_controls/qt_points_controls.py +++ b/napari/_qt/layer_controls/qt_points_controls.py @@ -45,10 +45,10 @@ class QtPointsControls(QtLayerControls): Button group of points layer modes (ADD, PAN_ZOOM, SELECT). delete_button : qtpy.QtWidgets.QtModePushButton Button to delete points from layer. - edgeColorEdit : QColorSwatchEdit - Widget to select display color for shape edges. + borderColorEdit : QColorSwatchEdit + Widget to select display color for points borders. faceColorEdit : QColorSwatchEdit - Widget to select display color for shape faces. + Widget to select display color for points faces. layer : napari.layers.Points An instance of a napari Points layer. outOfSliceCheckBox : qtpy.QtWidgets.QCheckBox @@ -80,11 +80,11 @@ def __init__(self, layer) -> None: ) self.layer.events.symbol.connect(self._on_current_symbol_change) self.layer.events.size.connect(self._on_current_size_change) - self.layer.events.current_edge_color.connect( - self._on_current_edge_color_change + self.layer.events.current_border_color.connect( + self._on_current_border_color_change ) - self.layer._edge.events.current_color.connect( - self._on_current_edge_color_change + self.layer._border.events.current_color.connect( + self._on_current_border_color_change ) self.layer.events.current_face_color.connect( self._on_current_face_color_change @@ -122,12 +122,14 @@ def __init__(self, layer) -> None: initial_color=self.layer.current_face_color, tooltip=trans._('click to set current face color'), ) - self.edgeColorEdit = QColorSwatchEdit( - initial_color=self.layer.current_edge_color, - tooltip=trans._('click to set current edge color'), + self.borderColorEdit = QColorSwatchEdit( + initial_color=self.layer.current_border_color, + tooltip=trans._('click to set current border color'), ) self.faceColorEdit.color_changed.connect(self.changeCurrentFaceColor) - self.edgeColorEdit.color_changed.connect(self.changeCurrentEdgeColor) + self.borderColorEdit.color_changed.connect( + self.changeCurrentBorderColor + ) sym_cb = QComboBox() sym_cb.setToolTip( @@ -215,7 +217,7 @@ def __init__(self, layer) -> None: self.layout().addRow(trans._('blending:'), self.blendComboBox) self.layout().addRow(trans._('symbol:'), self.symbolComboBox) self.layout().addRow(trans._('face color:'), self.faceColorEdit) - self.layout().addRow(trans._('edge color:'), self.edgeColorEdit) + self.layout().addRow(trans._('border color:'), self.borderColorEdit) self.layout().addRow(trans._('display text:'), self.textDispCheckBox) self.layout().addRow(trans._('out of slice:'), self.outOfSliceCheckBox) @@ -338,22 +340,22 @@ def changeCurrentFaceColor(self, color: np.ndarray): self.layer.current_face_color = color @Slot(np.ndarray) - def changeCurrentEdgeColor(self, color: np.ndarray): - """Update edge color of layer model from color picker user input.""" - with self.layer.events.current_edge_color.blocker( - self._on_current_edge_color_change + def changeCurrentBorderColor(self, color: np.ndarray): + """Update border color of layer model from color picker user input.""" + with self.layer.events.current_border_color.blocker( + self._on_current_border_color_change ): - self.layer.current_edge_color = color + self.layer.current_border_color = color def _on_current_face_color_change(self): """Receive layer.current_face_color() change event and update view.""" with qt_signals_blocked(self.faceColorEdit): self.faceColorEdit.setColor(self.layer.current_face_color) - def _on_current_edge_color_change(self): - """Receive layer.current_edge_color() change event and update view.""" - with qt_signals_blocked(self.edgeColorEdit): - self.edgeColorEdit.setColor(self.layer.current_edge_color) + def _on_current_border_color_change(self): + """Receive layer.current_border_color() change event and update view.""" + with qt_signals_blocked(self.borderColorEdit): + self.borderColorEdit.setColor(self.layer.current_border_color) def _on_ndisplay_changed(self): self.layer.editable = not (self.layer.ndim == 2 and self.ndisplay == 3) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index 494cc9cef2a..4b081c8cf6b 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -22,11 +22,15 @@ def __init__(self, layer) -> None: super().__init__(layer, node) self.layer.events.symbol.connect(self._on_data_change) - self.layer.events.edge_width.connect(self._on_data_change) - self.layer.events.edge_width_is_relative.connect(self._on_data_change) - self.layer.events.edge_color.connect(self._on_data_change) - self.layer._edge.events.colors.connect(self._on_data_change) - self.layer._edge.events.color_properties.connect(self._on_data_change) + self.layer.events.border_width.connect(self._on_data_change) + self.layer.events.border_width_is_relative.connect( + self._on_data_change + ) + self.layer.events.border_color.connect(self._on_data_change) + self.layer._border.events.colors.connect(self._on_data_change) + self.layer._border.events.color_properties.connect( + self._on_data_change + ) self.layer.events.face_color.connect(self._on_data_change) self.layer._face.events.colors.connect(self._on_data_change) self.layer._face.events.color_properties.connect(self._on_data_change) @@ -48,28 +52,28 @@ def _on_data_change(self): # always pass one invisible point to avoid issues data = np.zeros((1, self.layer._slice_input.ndisplay)) size = [0] - edge_color = np.array([[0.0, 0.0, 0.0, 1.0]], dtype=np.float32) + border_color = np.array([[0.0, 0.0, 0.0, 1.0]], dtype=np.float32) face_color = np.array([[1.0, 1.0, 1.0, 1.0]], dtype=np.float32) - edge_width = [0] + border_width = [0] symbol = ['o'] else: data = self.layer._view_data size = self.layer._view_size - edge_color = self.layer._view_edge_color + border_color = self.layer._view_border_color face_color = self.layer._view_face_color - edge_width = self.layer._view_edge_width + border_width = self.layer._view_border_width symbol = self.layer._view_symbol set_data = self.node._subvisuals[0].set_data - if self.layer.edge_width_is_relative: - edge_kw = { + if self.layer.border_width_is_relative: + border_kw = { 'edge_width': None, - 'edge_width_rel': edge_width, + 'edge_width_rel': border_width, } else: - edge_kw = { - 'edge_width': edge_width, + border_kw = { + 'edge_width': border_width, 'edge_width_rel': None, } @@ -77,9 +81,9 @@ def _on_data_change(self): data[:, ::-1], size=size, symbol=symbol, - edge_color=edge_color, + edge_color=border_color, face_color=face_color, - **edge_kw, + **border_kw, ) self.reset() diff --git a/napari/components/_tests/test_prune_kwargs.py b/napari/components/_tests/test_prune_kwargs.py index 0737bac0fd5..22c7784dea2 100644 --- a/napari/components/_tests/test_prune_kwargs.py +++ b/napari/components/_tests/test_prune_kwargs.py @@ -40,8 +40,6 @@ { 'scale': (0.75, 1), 'blending': 'translucent', - 'edge_color': 'red', - 'edge_width': 2, 'face_color': 'white', 'name': 'name', }, diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 39a272881be..ca7954ce0db 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -28,12 +28,12 @@ def __init__( text=None, symbol='o', size=10, - edge_width=0.05, - edge_width_is_relative=True, - edge_color='dimgray', - edge_color_cycle=None, - edge_colormap='viridis', - edge_contrast_limits=None, + border_width=0.05, + border_width_is_relative=True, + border_color='dimgray', + border_color_cycle=None, + border_colormap='viridis', + border_contrast_limits=None, face_color='white', face_color_cycle=None, face_colormap='viridis', @@ -77,12 +77,12 @@ def __init__( text=text, symbol=symbol, size=size, - edge_width=edge_width, - edge_width_is_relative=edge_width_is_relative, - edge_color=edge_color, - edge_color_cycle=edge_color_cycle, - edge_colormap=edge_colormap, - edge_contrast_limits=edge_contrast_limits, + border_width=border_width, + border_width_is_relative=border_width_is_relative, + border_color=border_color, + border_color_cycle=border_color_cycle, + border_colormap=border_colormap, + border_contrast_limits=border_contrast_limits, face_color=face_color, face_color_cycle=face_color_cycle, face_colormap=face_colormap, @@ -272,15 +272,15 @@ def _move_points( def _update_props_and_style(self, data_size: int, prev_size: int) -> None: # Add/remove property and style values based on the number of new points. - with self.events.blocker_all(), self._edge.events.blocker_all(), self._face.events.blocker_all(): + with self.events.blocker_all(), self._border.events.blocker_all(), self._face.events.blocker_all(): self._feature_table.resize(data_size) self.text.apply(self.features) if data_size < prev_size: # If there are now fewer points, remove the size and colors of the # extra ones - if len(self._edge.colors) > data_size: - self._edge._remove( - np.arange(data_size, len(self._edge.colors)) + if len(self._border.colors) > data_size: + self._border._remove( + np.arange(data_size, len(self._border.colors)) ) if len(self._face.colors) > data_size: self._face._remove( @@ -288,19 +288,19 @@ def _update_props_and_style(self, data_size: int, prev_size: int) -> None: ) self._shown = self._shown[:data_size] self._size = self._size[:data_size] - self._edge_width = self._edge_width[:data_size] + self._border_width = self._border_width[:data_size] self._symbol = self._symbol[:data_size] elif data_size > prev_size: adding = data_size - prev_size current_properties = self._feature_table.currents() - self._edge._update_current_properties(current_properties) - self._edge._add(n_colors=adding) + self._border._update_current_properties(current_properties) + self._border._add(n_colors=adding) self._face._update_current_properties(current_properties) self._face._add(n_colors=adding) - for attribute in ("shown", "edge_width", "symbol"): + for attribute in ("shown", "border_width", "symbol"): if attribute == "shown": default_value = True else: diff --git a/napari/layers/points/_tests/test_points.py b/napari/layers/points/_tests/test_points.py index 5cbd92f75e9..020cf6cf78e 100644 --- a/napari/layers/points/_tests/test_points.py +++ b/napari/layers/points/_tests/test_points.py @@ -135,27 +135,27 @@ def test_empty_layer_with_face_colormap(): np.testing.assert_allclose(layer._face.current_color, face_color) -def test_empty_layer_with_edge_colormap(): +def test_empty_layer_with_border_colormap(): """Test creating an empty layer where the face color is a colormap See: https://github.com/napari/napari/pull/1069 """ default_properties = {'point_type': np.array([1.5], dtype=float)} layer = Points( property_choices=default_properties, - edge_color='point_type', - edge_colormap='gray', + border_color='point_type', + border_colormap='gray', ) - assert layer.edge_color_mode == 'colormap' + assert layer.border_color_mode == 'colormap' # verify the current_face_color is correct - edge_color = np.array([1, 1, 1, 1]) - np.testing.assert_allclose(layer._edge.current_color, edge_color) + border_color = np.array([1, 1, 1, 1]) + np.testing.assert_allclose(layer._border.current_color, border_color) -@pytest.mark.parametrize('feature_name', ('edge', 'face')) +@pytest.mark.parametrize('feature_name', ('border', 'face')) def test_set_current_properties_on_empty_layer_with_color_cycle(feature_name): - """Test setting current_properties an empty layer where the face/edge color + """Test setting current_properties an empty layer where the face/border color is a color cycle. See: https://github.com/napari/napari/pull/3110 @@ -469,11 +469,11 @@ def test_remove_selected_removes_corresponding_attributes(): layer = Points( data, size=size, - edge_width=size, + border_width=size, symbol=symbol, features={'feature': feature}, face_color=color, - edge_color=color, + border_color=color, text=text, shown=shown, ) @@ -482,11 +482,11 @@ def test_remove_selected_removes_corresponding_attributes(): data[1:], size=size[1:], symbol=symbol[1:], - edge_width=size[1:], + border_width=size[1:], features={'feature': feature[1:]}, feature_defaults={'feature': feature[0]}, face_color=color[1:], - edge_color=color[1:], + border_color=color[1:], text=text, # computed from feature shown=shown[1:], ) @@ -676,7 +676,7 @@ def test_properties(properties): assert layer.get_status(data[1])['coordinates'].endswith("point_type: A") -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_adding_properties(attribute): """Test adding properties to an existing layer""" shape = (10, 2) @@ -924,61 +924,63 @@ def test_points_errors(): Points(data, properties=copy(annotations)) -def test_edge_width(): - """Test setting edge width.""" +def test_border_width(): + """Test setting border width.""" shape = (10, 2) np.random.seed(0) data = 20 * np.random.random(shape) layer = Points(data) - np.testing.assert_array_equal(layer.edge_width, 0.05) + np.testing.assert_array_equal(layer.border_width, 0.05) - layer.edge_width = 0.5 - np.testing.assert_array_equal(layer.edge_width, 0.5) + layer.border_width = 0.5 + np.testing.assert_array_equal(layer.border_width, 0.5) # fail outside of range 0, 1 if relative is enabled (default) with pytest.raises(ValueError): - layer.edge_width = 2 + layer.border_width = 2 - layer.edge_width_is_relative = False - layer.edge_width = 2 - np.testing.assert_array_equal(layer.edge_width, 2) + layer.border_width_is_relative = False + layer.border_width = 2 + np.testing.assert_array_equal(layer.border_width, 2) # fail if we try to come back again with pytest.raises(ValueError): - layer.edge_width_is_relative = True + layer.border_width_is_relative = True # all should work on instantiation too - layer = Points(data, edge_width=3, edge_width_is_relative=False) - np.testing.assert_array_equal(layer.edge_width, 3) - assert layer.edge_width_is_relative is False + layer = Points(data, border_width=3, border_width_is_relative=False) + np.testing.assert_array_equal(layer.border_width, 3) + assert layer.border_width_is_relative is False with pytest.raises(ValueError): - layer.edge_width = -2 + layer.border_width = -2 @pytest.mark.parametrize( - "edge_width", + "border_width", [int(1), float(1), np.array([1, 2, 3, 4, 5]), [1, 2, 3, 4, 5]], ) -def test_edge_width_types(edge_width): - """Test edge_width dtypes with valid values""" +def test_border_width_types(border_width): + """Test border_width dtypes with valid values""" shape = (5, 2) np.random.seed(0) data = 20 * np.random.random(shape) - layer = Points(data, edge_width=edge_width, edge_width_is_relative=False) - np.testing.assert_array_equal(layer.edge_width, edge_width) + layer = Points( + data, border_width=border_width, border_width_is_relative=False + ) + np.testing.assert_array_equal(layer.border_width, border_width) @pytest.mark.parametrize( - "edge_width", + "border_width", [int(-1), float(-1), np.array([-1, 2, 3, 4, 5]), [-1, 2, 3, 4, 5]], ) -def test_edge_width_types_negative(edge_width): - """Test negative values in all edge_width dtypes""" +def test_border_width_types_negative(border_width): + """Test negative values in all border_width dtypes""" shape = (5, 2) np.random.seed(0) data = 20 * np.random.random(shape) with pytest.raises(ValueError): - Points(data, edge_width=edge_width, edge_width_is_relative=False) + Points(data, border_width=border_width, border_width_is_relative=False) def test_out_of_slice_display(): @@ -1007,7 +1009,7 @@ def test_out_of_slice_display(): assert layer.out_of_slice_display is True -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_switch_color_mode(attribute): """Test switching between color modes""" shape = (10, 2) @@ -1039,13 +1041,13 @@ def test_switch_color_mode(attribute): layer_color, np.repeat([initial_color], shape[0], axis=0) ) - # there should not be an edge_color_property + # there should not be an border_color_property color_manager = getattr(layer, f'_{attribute}') color_property = color_manager.color_properties assert color_property is None # transitioning to colormap should raise a warning - # because there isn't an edge color property yet and + # because there isn't an border color property yet and # the first property in points.properties is being automatically selected with pytest.warns(UserWarning): setattr(layer, f'{attribute}_color_mode', 'colormap') @@ -1062,13 +1064,13 @@ def test_switch_color_mode(attribute): layer_color = transform_color(color_cycle * int(shape[0] / 2)) np.testing.assert_allclose(color, layer_color) - # switch back to direct, edge_colors shouldn't change + # switch back to direct, border_colors shouldn't change setattr(layer, f'{attribute}_color_mode', 'direct') - new_edge_color = getattr(layer, f'{attribute}_color') - np.testing.assert_allclose(new_edge_color, color) + new_border_color = getattr(layer, f'{attribute}_color') + np.testing.assert_allclose(new_border_color, color) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_colormap_without_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 2) @@ -1080,7 +1082,7 @@ def test_colormap_without_properties(attribute): setattr(layer, f'{attribute}_color_mode', 'colormap') -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_colormap_with_categorical_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 2) @@ -1093,7 +1095,7 @@ def test_colormap_with_categorical_properties(attribute): setattr(layer, f'{attribute}_color_mode', 'colormap') -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_add_colormap(attribute): """Test directly adding a vispy Colormap object""" shape = (10, 2) @@ -1110,7 +1112,7 @@ def test_add_colormap(attribute): assert 'unnamed colormap' in layer_colormap.name -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_add_point_direct(attribute: str): """Test adding points to layer directly""" layer = Points() @@ -1123,7 +1125,7 @@ def test_add_point_direct(attribute: str): ) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_color_direct(attribute: str): """Test setting colors directly""" shape = (10, 2) @@ -1135,7 +1137,7 @@ def test_color_direct(attribute: str): current_color = getattr(layer, f'current_{attribute}_color') layer_color = getattr(layer, f'{attribute}_color') assert current_color == 'black' - assert len(layer.edge_color) == shape[0] + assert len(layer.border_color) == shape[0] np.testing.assert_allclose(color_array, layer_color) # With no data selected changing color has no effect @@ -1144,7 +1146,7 @@ def test_color_direct(attribute: str): assert current_color == 'blue' np.testing.assert_allclose(color_array, layer_color) - # Select data and change edge color of selection + # Select data and change border color of selection selected_data = {0, 1} layer.selected_data = {0, 1} current_color = getattr(layer, f'current_{attribute}_color') @@ -1183,13 +1185,13 @@ def test_color_direct(attribute: str): color_cycle_rgba = [[1, 0, 0, 1], [0, 0, 1, 1]] -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) @pytest.mark.parametrize( "color_cycle", [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(attribute, color_cycle): - """Test setting edge/face color with a color cycle list""" + """Test setting border/face color with a color cycle list""" # create Points using list color cycle shape = (10, 2) np.random.seed(0) @@ -1248,9 +1250,9 @@ def test_color_cycle(attribute, color_cycle): ) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_color_cycle_dict(attribute): - """Test setting edge/face color with a color cycle dict""" + """Test setting border/face color with a color cycle dict""" data = np.array([[0, 0], [100, 0], [0, 100]]) properties = {'my_colors': [2, 6, 3]} points_kwargs = { @@ -1267,9 +1269,9 @@ def test_color_cycle_dict(attribute): np.testing.assert_allclose(color_cycle_map[6], [1, 1, 1, 1]) # 6 is white -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_add_color_cycle_to_empty_layer(attribute): - """Test adding a point to an empty layer when edge/face color is a color cycle + """Test adding a point to an empty layer when border/face color is a color cycle See: https://github.com/napari/napari/pull/1069 """ @@ -1282,7 +1284,7 @@ def test_add_color_cycle_to_empty_layer(attribute): } layer = Points(**points_kwargs) - # verify the current_edge_color is correct + # verify the current_border_color is correct expected_color = transform_color(color_cycle[0])[0] color_manager = getattr(layer, f'_{attribute}') current_color = color_manager.current_color @@ -1308,11 +1310,11 @@ def test_add_color_cycle_to_empty_layer(attribute): np.testing.assert_equal(layer.properties, new_properties) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_adding_value_color_cycle(attribute): """Test that adding values to properties used to set a color cycle and then calling Points.refresh_colors() performs the update and adds the - new value to the face/edge_color_cycle_map. + new value to the face/border_color_cycle_map. See: https://github.com/napari/napari/issues/988 """ @@ -1341,9 +1343,9 @@ def test_adding_value_color_cycle(attribute): assert 'C' in color_map_keys -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize("attribute", ['border', 'face']) def test_color_colormap(attribute): - """Test setting edge/face color with a colormap""" + """Test setting border/face color with a colormap""" # create Points using with a colormap shape = (10, 2) np.random.seed(0) @@ -1803,23 +1805,23 @@ def test_view_colors(): face_color = np.array( [[1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1], [0, 0, 1, 1]] ) - edge_color = np.array( + border_color = np.array( [[0, 0, 1, 1], [1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]] ) - layer = Points(coords, face_color=face_color, edge_color=edge_color) + layer = Points(coords, face_color=face_color, border_color=border_color) layer._slice_dims([0, slice(None), slice(None)]) assert np.all(layer._view_face_color == face_color[[0, 1]]) - assert np.all(layer._view_edge_color == edge_color[[0, 1]]) + assert np.all(layer._view_border_color == border_color[[0, 1]]) layer._slice_dims([1, slice(None), slice(None)]) assert np.all(layer._view_face_color == face_color[[2]]) - assert np.all(layer._view_edge_color == edge_color[[2]]) + assert np.all(layer._view_border_color == border_color[[2]]) # view colors should return empty array if there are no points layer._slice_dims([2, slice(None), slice(None)]) assert len(layer._view_face_color) == 0 - assert len(layer._view_edge_color) == 0 + assert len(layer._view_border_color) == 0 def test_interaction_box(): @@ -2443,8 +2445,8 @@ def test_empty_data_from_tuple(): [ ("size", [20, 20]), ("face_color", np.asarray([0.0, 0.0, 1.0, 1.0])), - ("edge_color", np.asarray([0.0, 0.0, 1.0, 1.0])), - ("edge_width", np.asarray([0.2])), + ("border_color", np.asarray([0.0, 0.0, 1.0, 1.0])), + ("border_width", np.asarray([0.2])), ], ) def test_new_point_size_editable(attribute, new_value): diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 92f452fd14d..67487f26f45 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -90,18 +90,18 @@ class _BasePoints(Layer): Size of the point marker in data pixels. If given as a scalar, all points are made the same size. If given as an array, size must be the same or broadcastable to the same shape as the data. - edge_width : float, array - Width of the symbol edge in pixels. - edge_width_is_relative : bool - If enabled, edge_width is interpreted as a fraction of the point size. - edge_color : str, array-like, dict + border_width : float, array + Width of the symbol border in pixels. + border_width_is_relative : bool + If enabled, border_width is interpreted as a fraction of the point size. + border_color : str, array-like, dict Color of the point marker border. Numeric color values should be RGB(A). - edge_color_cycle : np.ndarray, list - Cycle of colors (provided as string name, RGB, or RGBA) to map to edge_color if a + border_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a categorical attribute is used color the vectors. - edge_colormap : str, napari.utils.Colormap - Colormap to set edge_color if a continuous attribute is used to set face_color. - edge_contrast_limits : None, (float, float) + border_colormap : str, napari.utils.Colormap + Colormap to set border_color if a continuous attribute is used to set face_color. + border_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to @@ -194,18 +194,18 @@ class _BasePoints(Layer): size : array (N, D) Array of sizes for each point in each dimension. Must have the same shape as the layer `data`. - edge_width : array (N,) - Width of the marker edges in pixels for all points - edge_width : array (N,) - Width of the marker edges for all points as a fraction of their size. - edge_color : Nx4 numpy array - Array of edge color RGBA values, one for each point. - edge_color_cycle : np.ndarray, list - Cycle of colors (provided as string name, RGB, or RGBA) to map to edge_color if a + border_width : array (N,) + Width of the marker borders in pixels for all points + border_width : array (N,) + Width of the marker borders for all points as a fraction of their size. + border_color : Nx4 numpy array + Array of border color RGBA values, one for each point. + border_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a categorical attribute is used color the vectors. - edge_colormap : str, napari.utils.Colormap - Colormap to set edge_color if a continuous attribute is used to set face_color. - edge_contrast_limits : None, (float, float) + border_colormap : str, napari.utils.Colormap + Colormap to set border_color if a continuous attribute is used to set face_color. + border_contrast_limits : None, (float, float) clims for mapping the property to a color map. These are the min and max value of the specified property that are mapped to 0 and 1, respectively. The default value is None. If set the none, the clims will be set to @@ -227,14 +227,14 @@ class _BasePoints(Layer): current_size : float Size of the marker for the next point to be added or the currently selected point. - current_edge_width : float - Edge width of the marker for the next point to be added or the currently + current_border_width : float + border width of the marker for the next point to be added or the currently selected point. - current_edge_color : str - Edge color of the marker edge for the next point to be added or the currently + current_border_color : str + border color of the marker border for the next point to be added or the currently selected point. current_face_color : str - Face color of the marker edge for the next point to be added or the currently + Face color of the marker border for the next point to be added or the currently selected point. out_of_slice_display : bool If True, renders points not just in central plane but also slightly out of slice @@ -258,8 +258,8 @@ class _BasePoints(Layer): CYCLE allows the color to be set via a color cycle over an attribute COLORMAP allows color to be set via a color map over an attribute - edge_color_mode : str - Edge color setting mode. + border_color_mode : str + border color setting mode. DIRECT (default mode) allows each point to be set arbitrarily @@ -283,8 +283,8 @@ class _BasePoints(Layer): Size of the point markers in the currently viewed slice. _view_symbol : array (M, ) Symbols of the point markers in the currently viewed slice. - _view_edge_width : array (M, ) - Edge width of the point markers in the currently viewed slice. + _view_border_width : array (M, ) + border width of the point markers in the currently viewed slice. _indices_view : array (M, ) Integer indices of the points in the currently viewed slice and are shown. _selected_view : @@ -321,7 +321,7 @@ class _BasePoints(Layer): Mode.SELECT: 'standard', } - # TODO write better documentation for edge_color and face_color + # TODO write better documentation for border_color and face_color # The max number of points that will ever be used to render the thumbnail # If more points are present then they are randomly subsampled @@ -338,12 +338,12 @@ def __init__( text=None, symbol='o', size=10, - edge_width=0.05, - edge_width_is_relative=True, - edge_color='dimgray', - edge_color_cycle=None, - edge_colormap='viridis', - edge_contrast_limits=None, + border_width=0.05, + border_width_is_relative=True, + border_color='dimgray', + border_color_cycle=None, + border_colormap='viridis', + border_contrast_limits=None, face_color='white', face_color_cycle=None, face_colormap='viridis', @@ -413,13 +413,13 @@ def __init__( self.events.add( size=Event, current_size=Event, - edge_width=Event, - current_edge_width=Event, - edge_width_is_relative=Event, + border_width=Event, + current_border_width=Event, + border_width_is_relative=Event, face_color=Event, current_face_color=Event, - edge_color=Event, - current_edge_color=Event, + border_color=Event, + current_border_color=Event, properties=Event, current_properties=Event, symbol=Event, @@ -447,7 +447,7 @@ def __init__( features=self.features, ) - self._edge_width_is_relative = False + self._border_width_is_relative = False self._shown = np.empty(0).astype(bool) # Indices of selected points @@ -462,8 +462,8 @@ def __init__( # constructor so each point gets its own value then the default # value is used when adding new points self._current_size = np.asarray(size) if np.isscalar(size) else 10 - self._current_edge_width = ( - np.asarray(edge_width) if np.isscalar(edge_width) else 0.1 + self._current_border_width = ( + np.asarray(border_width) if np.isscalar(border_width) else 0.1 ) self.current_symbol = ( np.asarray(symbol) if np.isscalar(symbol) else 'o' @@ -480,12 +480,12 @@ def __init__( if len(self._points_data) else self._feature_table.currents() ) - self._edge = ColorManager._from_layer_kwargs( + self._border = ColorManager._from_layer_kwargs( n_colors=len(data), - colors=edge_color, - continuous_colormap=edge_colormap, - contrast_limits=edge_contrast_limits, - categorical_colormap=edge_color_cycle, + colors=border_color, + continuous_colormap=border_colormap, + contrast_limits=border_contrast_limits, + categorical_colormap=border_color_cycle, properties=color_properties, ) self._face = ColorManager._from_layer_kwargs( @@ -506,8 +506,8 @@ def __init__( self.size = size self.shown = shown self.symbol = symbol - self.edge_width = edge_width - self.edge_width_is_relative = edge_width_is_relative + self.border_width = border_width + self.border_width_is_relative = border_width_is_relative self.canvas_size_limits = canvas_size_limits self.shading = shading @@ -567,7 +567,7 @@ def features( self._face, self._feature_table, "face_color" ) self._update_color_manager( - self._edge, self._feature_table, "edge_color" + self._border, self._feature_table, "border_color" ) self.text.refresh(self.features) self.events.properties() @@ -587,7 +587,7 @@ def feature_defaults( ) -> None: self._feature_table.set_defaults(defaults) current_properties = self.current_properties - self._edge._update_current_properties(current_properties) + self._border._update_current_properties(current_properties) self._face._update_current_properties(current_properties) self.events.current_properties() self.events.feature_defaults() @@ -643,7 +643,7 @@ def current_properties(self, current_properties): current_properties, update_indices=update_indices ) current_properties = self.current_properties - self._edge._update_current_properties(current_properties) + self._border._update_current_properties(current_properties) self._face._update_current_properties(current_properties) self.events.current_properties() self.events.feature_defaults() @@ -865,145 +865,149 @@ def shown(self, shown): self.refresh() @property - def edge_width(self) -> np.ndarray: - """(N, D) array: edge_width of all N points.""" - return self._edge_width + def border_width(self) -> np.ndarray: + """(N, D) array: border_width of all N points.""" + return self._border_width - @edge_width.setter - def edge_width( - self, edge_width: Union[int, float, np.ndarray, list] + @border_width.setter + def border_width( + self, border_width: Union[int, float, np.ndarray, list] ) -> None: # broadcast to np.array - edge_width = np.broadcast_to(edge_width, len(self._points_data)).copy() + border_width = np.broadcast_to( + border_width, len(self._points_data) + ).copy() - # edge width cannot be negative - if np.any(edge_width < 0): + # border width cannot be negative + if np.any(border_width < 0): raise ValueError( trans._( - 'All edge_width must be > 0', + 'All border_width must be > 0', deferred=True, ) ) - # if relative edge width is enabled, edge_width must be between 0 and 1 - if self.edge_width_is_relative and np.any(edge_width > 1): + # if relative border width is enabled, border_width must be between 0 and 1 + if self.border_width_is_relative and np.any(border_width > 1): raise ValueError( trans._( - 'All edge_width must be between 0 and 1 if edge_width_is_relative is enabled', + 'All border_width must be between 0 and 1 if border_width_is_relative is enabled', deferred=True, ) ) - self._edge_width = edge_width + self._border_width = border_width self.refresh() @property - def edge_width_is_relative(self) -> bool: - """bool: treat edge_width as a fraction of point size.""" - return self._edge_width_is_relative - - @edge_width_is_relative.setter - def edge_width_is_relative(self, edge_width_is_relative: bool) -> None: - if edge_width_is_relative and np.any( - (self.edge_width > 1) | (self.edge_width < 0) + def border_width_is_relative(self) -> bool: + """bool: treat border_width as a fraction of point size.""" + return self._border_width_is_relative + + @border_width_is_relative.setter + def border_width_is_relative(self, border_width_is_relative: bool) -> None: + if border_width_is_relative and np.any( + (self.border_width > 1) | (self.border_width < 0) ): raise ValueError( trans._( - 'edge_width_is_relative can only be enabled if edge_width is between 0 and 1', + 'border_width_is_relative can only be enabled if border_width is between 0 and 1', deferred=True, ) ) - self._edge_width_is_relative = edge_width_is_relative - self.events.edge_width_is_relative() + self._border_width_is_relative = border_width_is_relative + self.events.border_width_is_relative() @property - def current_edge_width(self) -> Union[int, float]: - """float: edge_width of marker for the next added point.""" - return self._current_edge_width + def current_border_width(self) -> Union[int, float]: + """float: border_width of marker for the next added point.""" + return self._current_border_width - @current_edge_width.setter - def current_edge_width(self, edge_width: Union[None, float]) -> None: - self._current_edge_width = edge_width + @current_border_width.setter + def current_border_width(self, border_width: Union[None, float]) -> None: + self._current_border_width = border_width if self._update_properties and len(self.selected_data) > 0: for i in self.selected_data: - self.edge_width[i] = (self.edge_width[i] > 0) * edge_width + self.border_width[i] = ( + self.border_width[i] > 0 + ) * border_width self.refresh() - self.events.edge_width() - self.events.current_edge_width() + self.events.border_width() + self.events.current_border_width() @property - def edge_color(self) -> np.ndarray: - """(N x 4) np.ndarray: Array of RGBA edge colors for each point""" - return self._edge.colors - - @edge_color.setter - def edge_color(self, edge_color): - self._edge._set_color( - color=edge_color, + def border_color(self) -> np.ndarray: + """(N x 4) np.ndarray: Array of RGBA border colors for each point""" + return self._border.colors + + @border_color.setter + def border_color(self, border_color): + self._border._set_color( + color=border_color, n_colors=len(self._points_data), properties=self.properties, current_properties=self.current_properties, ) - self.events.edge_color() + self.events.border_color() @property - def edge_color_cycle(self) -> np.ndarray: - """Union[list, np.ndarray] : Color cycle for edge_color. + def border_color_cycle(self) -> np.ndarray: + """Union[list, np.ndarray] : Color cycle for border_color. Can be a list of colors defined by name, RGB or RGBA """ - return self._edge.categorical_colormap.fallback_color.values + return self._border.categorical_colormap.fallback_color.values - @edge_color_cycle.setter - def edge_color_cycle(self, edge_color_cycle: Union[list, np.ndarray]): - self._edge.categorical_colormap = edge_color_cycle + @border_color_cycle.setter + def border_color_cycle(self, border_color_cycle: Union[list, np.ndarray]): + self._border.categorical_colormap = border_color_cycle @property - def edge_colormap(self) -> Colormap: - """Return the colormap to be applied to a property to get the edge color. + def border_colormap(self) -> Colormap: + """Return the colormap to be applied to a property to get the border color. Returns ------- colormap : napari.utils.Colormap The Colormap object. """ - return self._edge.continuous_colormap + return self._border.continuous_colormap - @edge_colormap.setter - def edge_colormap(self, colormap: ValidColormapArg): - self._edge.continuous_colormap = colormap + @border_colormap.setter + def border_colormap(self, colormap: ValidColormapArg): + self._border.continuous_colormap = colormap @property - def edge_contrast_limits(self) -> Tuple[float, float]: + def border_contrast_limits(self) -> Tuple[float, float]: """None, (float, float): contrast limits for mapping - the edge_color colormap property to 0 and 1 + the border_color colormap property to 0 and 1 """ - return self._edge.contrast_limits + return self._border.contrast_limits - @edge_contrast_limits.setter - def edge_contrast_limits( + @border_contrast_limits.setter + def border_contrast_limits( self, contrast_limits: Union[None, Tuple[float, float]] ): - self._edge.contrast_limits = contrast_limits + self._border.contrast_limits = contrast_limits @property - def current_edge_color(self) -> str: - """str: Edge color of marker for the next added point or the selected point(s).""" - hex_ = rgb_to_hex(self._edge.current_color)[0] + def current_border_color(self) -> str: + """str: border color of marker for the next added point or the selected point(s).""" + hex_ = rgb_to_hex(self._border.current_color)[0] return hex_to_name.get(hex_, hex_) - @current_edge_color.setter - def current_edge_color(self, edge_color: ColorType) -> None: + @current_border_color.setter + def current_border_color(self, border_color: ColorType) -> None: if self._update_properties and len(self.selected_data) > 0: update_indices = list(self.selected_data) else: update_indices = [] - self._edge._update_current_color( - edge_color, update_indices=update_indices + self._border._update_current_color( + border_color, update_indices=update_indices ) - self.events.current_edge_color() + self.events.current_border_color() @property - def edge_color_mode(self) -> str: - """str: Edge color setting mode + def border_color_mode(self) -> str: + """str: border color setting mode DIRECT (default mode) allows each point to be set arbitrarily @@ -1011,11 +1015,11 @@ def edge_color_mode(self) -> str: COLORMAP allows color to be set via a color map over an attribute """ - return self._edge.color_mode + return self._border.color_mode - @edge_color_mode.setter - def edge_color_mode(self, edge_color_mode: Union[str, ColorMode]): - self._set_color_mode(edge_color_mode, 'edge') + @border_color_mode.setter + def border_color_mode(self, border_color_mode: Union[str, ColorMode]): + self._set_color_mode(border_color_mode, 'border') @property def face_color(self) -> np.ndarray: @@ -1107,16 +1111,16 @@ def face_color_mode(self, face_color_mode): def _set_color_mode( self, color_mode: Union[ColorMode, str], attribute: str ): - """Set the face_color_mode or edge_color_mode property + """Set the face_color_mode or border_color_mode property Parameters ---------- color_mode : str, ColorMode - The value for setting edge or face_color_mode. If color_mode is a string, + The value for setting border or face_color_mode. If color_mode is a string, it should be one of: 'direct', 'cycle', or 'colormap' - attribute : str in {'edge', 'face'} + attribute : str in {'border', 'face'} The name of the attribute to set the color of. - Should be 'edge' for edge_color_mode or 'face' for face_color_mode. + Should be 'border' for border_color_mode or 'face' for face_color_mode. """ color_mode = ColorMode(color_mode) color_manager = getattr(self, f'_{attribute}') @@ -1169,7 +1173,7 @@ def _set_color_mode( color_manager.color_mode = color_mode def refresh_colors(self, update_color_mapping: bool = False): - """Calculate and update face and edge colors if using a cycle or color map + """Calculate and update face and border colors if using a cycle or color map Parameters ---------- @@ -1182,7 +1186,7 @@ def refresh_colors(self, update_color_mapping: bool = False): the color cycle map or colormap), set ``update_color_mapping=False``. Default value is False. """ - self._edge._refresh_colors(self.properties, update_color_mapping) + self._border._refresh_colors(self.properties, update_color_mapping) self._face._refresh_colors(self.properties, update_color_mapping) def _get_state(self): @@ -1201,20 +1205,20 @@ def _get_state(self): state.update( { 'symbol': self.symbol if not_empty else [self.current_symbol], - 'edge_width': self.edge_width, - 'edge_width_is_relative': self.edge_width_is_relative, + 'border_width': self.border_width, + 'border_width_is_relative': self.border_width_is_relative, 'face_color': self.face_color if not_empty else [self.current_face_color], 'face_color_cycle': self.face_color_cycle, 'face_colormap': self.face_colormap.name, 'face_contrast_limits': self.face_contrast_limits, - 'edge_color': self.edge_color + 'border_color': self.border_color if not_empty - else [self.current_edge_color], - 'edge_color_cycle': self.edge_color_cycle, - 'edge_colormap': self.edge_colormap.name, - 'edge_contrast_limits': self.edge_contrast_limits, + else [self.current_border_color], + 'border_color_cycle': self.border_color_cycle, + 'border_colormap': self.border_colormap.name, + 'border_contrast_limits': self.border_contrast_limits, 'properties': self.properties, 'property_choices': self.property_choices, 'text': self.text.dict(), @@ -1256,10 +1260,10 @@ def selected_data(self, selected_data: Sequence[int]) -> None: return index = list(self._selected_data) if ( - unique_edge_color := _unique_element(self.edge_color[index]) + unique_border_color := _unique_element(self.border_color[index]) ) is not None: with self.block_update_properties(): - self.current_edge_color = unique_edge_color + self.current_border_color = unique_border_color if ( unique_face_color := _unique_element(self.face_color[index]) @@ -1277,10 +1281,10 @@ def selected_data(self, selected_data: Sequence[int]) -> None: self.current_size = unique_size if ( - unique_edge_width := _unique_element(self.edge_width[index]) + unique_border_width := _unique_element(self.border_width[index]) ) is not None: with self.block_update_properties(): - self.current_edge_width = unique_edge_width + self.current_border_width = unique_border_width if (unique_symbol := _unique_element(self.symbol[index])) is not None: with self.block_update_properties(): self.current_symbol = unique_symbol @@ -1448,15 +1452,15 @@ def _view_symbol(self) -> np.ndarray: return self.symbol[self._indices_view] @property - def _view_edge_width(self) -> np.ndarray: - """Get the edge_width of the points in view + def _view_border_width(self) -> np.ndarray: + """Get the border_width of the points in view Returns ------- - view_edge_width : (N,) np.ndarray - Array of edge_widths for the N points in view + view_border_width : (N,) np.ndarray + Array of border_widths for the N points in view """ - return self.edge_width[self._indices_view] + return self.border_width[self._indices_view] @property def _view_face_color(self) -> np.ndarray: @@ -1471,16 +1475,16 @@ def _view_face_color(self) -> np.ndarray: return self.face_color[self._indices_view] @property - def _view_edge_color(self) -> np.ndarray: - """Get the edge colors of the points in view + def _view_border_color(self) -> np.ndarray: + """Get the border colors of the points in view Returns ------- - view_edge_color : (N x 4) np.ndarray - RGBA color array for the edge colors of the N points in view. + view_border_color : (N x 4) np.ndarray + RGBA color array for the border colors of the N points in view. If there are no points in view, returns array of length 0. """ - return self.edge_color[self._indices_view] + return self.border_color[self._indices_view] def _reset_editable(self) -> None: """Set editable mode based on layer properties.""" @@ -2048,12 +2052,12 @@ def __init__( text=None, symbol='o', size=10, - edge_width=0.05, - edge_width_is_relative=True, - edge_color='dimgray', - edge_color_cycle=None, - edge_colormap='viridis', - edge_contrast_limits=None, + border_width=0.05, + border_width_is_relative=True, + border_color='dimgray', + border_color_cycle=None, + border_colormap='viridis', + border_contrast_limits=None, face_color='white', face_color_cycle=None, face_colormap='viridis', @@ -2095,12 +2099,12 @@ def __init__( text=text, symbol=symbol, size=size, - edge_width=edge_width, - edge_width_is_relative=edge_width_is_relative, - edge_color=edge_color, - edge_color_cycle=edge_color_cycle, - edge_colormap=edge_colormap, - edge_contrast_limits=edge_contrast_limits, + border_width=border_width, + border_width_is_relative=border_width_is_relative, + border_color=border_color, + border_color_cycle=border_color_cycle, + border_colormap=border_colormap, + border_contrast_limits=border_contrast_limits, face_color=face_color, face_color_cycle=face_color_cycle, face_colormap=face_colormap, @@ -2143,15 +2147,15 @@ def data(self, data: Optional[np.ndarray]): self._data = data # Add/remove property and style values based on the number of new points. - with self.events.blocker_all(), self._edge.events.blocker_all(), self._face.events.blocker_all(): + with self.events.blocker_all(), self._border.events.blocker_all(), self._face.events.blocker_all(): self._feature_table.resize(len(data)) self.text.apply(self.features) if len(data) < cur_npoints: # If there are now fewer points, remove the size and colors of the # extra ones - if len(self._edge.colors) > len(data): - self._edge._remove( - np.arange(len(data), len(self._edge.colors)) + if len(self._border.colors) > len(data): + self._border._remove( + np.arange(len(data), len(self._border.colors)) ) if len(self._face.colors) > len(data): self._face._remove( @@ -2159,7 +2163,7 @@ def data(self, data: Optional[np.ndarray]): ) self._shown = self._shown[: len(data)] self._size = self._size[: len(data)] - self._edge_width = self._edge_width[: len(data)] + self._border_width = self._border_width[: len(data)] self._symbol = self._symbol[: len(data)] elif len(data) > cur_npoints: @@ -2177,11 +2181,11 @@ def data(self, data: Optional[np.ndarray]): ) size = np.repeat([new_size], adding, axis=0) - if len(self._edge_width) > 0: - new_edge_width = copy(self._edge_width[-1]) + if len(self._border_width) > 0: + new_border_width = copy(self._border_width[-1]) else: - new_edge_width = self.current_edge_width - edge_width = np.repeat([new_edge_width], adding, axis=0) + new_border_width = self.current_border_width + border_width = np.repeat([new_border_width], adding, axis=0) if len(self._symbol) > 0: new_symbol = copy(self._symbol[-1]) @@ -2193,8 +2197,8 @@ def data(self, data: Optional[np.ndarray]): # to handle any in-place modification of feature_defaults. # Also see: https://github.com/napari/napari/issues/5634 current_properties = self._feature_table.currents() - self._edge._update_current_properties(current_properties) - self._edge._add(n_colors=adding) + self._border._update_current_properties(current_properties) + self._border._add(n_colors=adding) self._face._update_current_properties(current_properties) self._face._add(n_colors=adding) @@ -2202,8 +2206,8 @@ def data(self, data: Optional[np.ndarray]): self._shown = np.concatenate((self._shown, shown), axis=0) self.size = np.concatenate((self._size, size), axis=0) - self.edge_width = np.concatenate( - (self._edge_width, edge_width), axis=0 + self.border_width = np.concatenate( + (self._border_width, border_width), axis=0 ) self.symbol = np.concatenate((self._symbol, symbol), axis=0) self.selected_data = set(np.arange(cur_npoints, len(data))) @@ -2245,9 +2249,9 @@ def remove_selected(self): self._shown = np.delete(self._shown, index, axis=0) self._size = np.delete(self._size, index, axis=0) self._symbol = np.delete(self._symbol, index, axis=0) - self._edge_width = np.delete(self._edge_width, index, axis=0) - with self._edge.events.blocker_all(): - self._edge._remove(indices_to_remove=index) + self._border_width = np.delete(self._border_width, index, axis=0) + with self._border.events.blocker_all(): + self._border._remove(indices_to_remove=index) with self._face.events.blocker_all(): self._face._remove(indices_to_remove=index) self._feature_table.remove(index) @@ -2308,13 +2312,13 @@ def _paste_data(self): self.text._paste(**self._clipboard['text']) - self._edge_width = np.append( - self.edge_width, - deepcopy(self._clipboard['edge_width']), + self._border_width = np.append( + self.border_width, + deepcopy(self._clipboard['border_width']), axis=0, ) - self._edge._paste( - colors=self._clipboard['edge_color'], + self._border._paste( + colors=self._clipboard['border_color'], properties=_features_to_properties( self._clipboard['features'] ), @@ -2340,12 +2344,12 @@ def _copy_data(self): index = list(self.selected_data) self._clipboard = { 'data': deepcopy(self.data[index]), - 'edge_color': deepcopy(self.edge_color[index]), + 'border_color': deepcopy(self.border_color[index]), 'face_color': deepcopy(self.face_color[index]), 'shown': deepcopy(self.shown[index]), 'size': deepcopy(self.size[index]), 'symbol': deepcopy(self.symbol[index]), - 'edge_width': deepcopy(self.edge_width[index]), + 'border_width': deepcopy(self.border_width[index]), 'features': deepcopy(self.features.iloc[index]), 'indices': self._slice_indices, 'text': self.text._copy(index), diff --git a/tools/string_list.json b/tools/string_list.json index 168191af529..7279cf86eb6 100644 --- a/tools/string_list.json +++ b/tools/string_list.json @@ -1873,14 +1873,15 @@ "napari/layers/points/_points_utils.py": [], "napari/layers/points/points.py": [ "_{attribute}", + "border", + "border_color", + "border_color_cycle", + "border_colormap", + "border_contrast_limits", + "border_width", + "border_width_is_relative", "current_value", "data", - "edge", - "edge_color", - "edge_color_cycle", - "edge_colormap", - "edge_contrast_limits", - "edge_width", "face", "face_color", "face_color_cycle", @@ -1912,7 +1913,6 @@ "shown", "{k}: {v[value]}", "dimgray", - "edge_width_is_relative", "antialiasing", "canvas_size_limits", "coordinates" From 7b12d4ba5cb302336b9b498e6348c9a604178cdc Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 25 May 2023 16:26:46 -0700 Subject: [PATCH 016/105] fixed basegraph typing when napari-graph is not installed --- napari/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/types.py b/napari/types.py index 999ea22b139..697e6b9d1ef 100644 --- a/napari/types.py +++ b/napari/types.py @@ -33,7 +33,7 @@ from napari_graph import BaseGraph except ModuleNotFoundError: - BaseGraph = None + BaseGraph = Any # This is a WOEFULLY inadequate stub for a duck-array type. # Mostly, just a placeholder for the concept of needing an ArrayLike type. From 7ba748e31ec9d5e6b6e876252bff11607b8e6767 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 26 May 2023 09:29:08 -0700 Subject: [PATCH 017/105] fixed mypy new type error --- napari/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/types.py b/napari/types.py index 697e6b9d1ef..22c7fc6aa53 100644 --- a/napari/types.py +++ b/napari/types.py @@ -88,7 +88,7 @@ class SampleDict(TypedDict): ArrayBase: Type[np.ndarray] = np.ndarray -GraphData = NewType("GraphData", BaseGraph) +GraphData = NewType("GraphData", BaseGraph) # type: ignore [valid-newtype] ImageData = NewType("ImageData", np.ndarray) LabelsData = NewType("LabelsData", np.ndarray) PointsData = NewType("PointsData", np.ndarray) From 4cf0931bb1149c792475e95ab6f8a89464779bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jord=C3=A3o=20Bragantini?= Date: Thu, 1 Jun 2023 13:21:19 -0700 Subject: [PATCH 018/105] Update napari/layers/graph/graph.py Co-authored-by: Andy Sweet --- napari/layers/graph/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index ca7954ce0db..1be9fcdeae7 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -165,7 +165,7 @@ def data(self) -> BaseGraph: return self._data @data.setter - def data(self, data: Optional[BaseGraph]) -> None: + def data(self, data: Union[BaseGraph, ArrayLike, None]) -> None: prev_size = self.data.n_allocated_nodes self._data = self._fix_data(data) self._data_changed(prev_size) From bbe6d46f3003e6449389932953e3c554f4cba29c Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 1 Jun 2023 13:37:04 -0700 Subject: [PATCH 019/105] minor fixes --- napari/components/_tests/test_prune_kwargs.py | 2 ++ napari/layers/graph/graph.py | 7 ++----- napari/types.py | 9 ++++++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/napari/components/_tests/test_prune_kwargs.py b/napari/components/_tests/test_prune_kwargs.py index 22c7784dea2..8d336ccac5a 100644 --- a/napari/components/_tests/test_prune_kwargs.py +++ b/napari/components/_tests/test_prune_kwargs.py @@ -7,6 +7,7 @@ 'blending': 'translucent', 'num_colors': 10, 'edge_color': 'red', + 'border_color': 'blue', 'z_index': 20, 'edge_width': 2, 'face_color': 'white', @@ -41,6 +42,7 @@ 'scale': (0.75, 1), 'blending': 'translucent', 'face_color': 'white', + 'border_color': 'blue', 'name': 'name', }, ), diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 1be9fcdeae7..b59ae9b5625 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -24,7 +24,6 @@ def __init__( ndim=None, features=None, feature_defaults=None, - properties=None, text=None, symbol='o', size=10, @@ -51,7 +50,6 @@ def __init__( blending='translucent', visible=True, cache=True, - property_choices=None, experimental_clipping_planes=None, shading='none', canvas_size_limits=(2, 10000), @@ -73,7 +71,6 @@ def __init__( ndim=self._data.ndim, features=features, feature_defaults=feature_defaults, - properties=properties, text=text, symbol=symbol, size=size, @@ -100,7 +97,6 @@ def __init__( blending=blending, visible=visible, cache=cache, - property_choices=property_choices, experimental_clipping_planes=experimental_clipping_planes, shading=shading, canvas_size_limits=canvas_size_limits, @@ -115,9 +111,10 @@ def _fix_data( ) -> BaseGraph: """Checks input data and return a empty graph if is None.""" if ndim is None: - ndim = 3 + ndim = 2 if data is None: + # empty but pre-allocated graph return UndirectedGraph(n_nodes=100, ndim=ndim, n_edges=200) if isinstance(data, BaseGraph): diff --git a/napari/types.py b/napari/types.py index 22c7fc6aa53..44fcfe5d211 100644 --- a/napari/types.py +++ b/napari/types.py @@ -44,7 +44,14 @@ ArrayLike = Union[np.ndarray, 'dask.array.Array', 'zarr.Array'] LayerTypeName = Literal[ - "image", "labels", "points", "shapes", "surface", "tracks", "vectors" + "graph", + "image", + "labels", + "points", + "shapes", + "surface", + "tracks", + "vectors", ] # layer data may be: (data,) (data, meta), or (data, meta, layer_type) From fef8077bbf5a985adb86067b1d8661f621ddd2e4 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 5 Jun 2023 08:53:47 -0700 Subject: [PATCH 020/105] removed predefined buffers size and fixed properties arguments --- napari/layers/graph/graph.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index b59ae9b5625..c9c74123e72 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union import numpy as np from numpy.typing import ArrayLike @@ -115,7 +115,7 @@ def _fix_data( if data is None: # empty but pre-allocated graph - return UndirectedGraph(n_nodes=100, ndim=ndim, n_edges=200) + return UndirectedGraph(ndim=ndim) if isinstance(data, BaseGraph): if data._coords is None: @@ -317,3 +317,10 @@ def _data_changed(self, prev_size: int) -> None: self._update_props_and_style(self.data.n_allocated_nodes, prev_size) self._update_dims() self.events.data(value=self.data) + + def _get_state(self) -> Dict[str, Any]: + # FIXME: this method can be removed once 'properties' argument is deprecreated. + state = super()._get_state() + state.pop("properties", None) + state.pop("property_choices", None) + return state From e1a0d32b6cc556593538ac656841e98dfb8a796d Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 5 Jun 2023 11:48:40 -0700 Subject: [PATCH 021/105] fixing typing --- napari/layers/graph/_slice.py | 11 +++++------ napari/layers/graph/graph.py | 7 ++++--- napari/layers/points/_slice.py | 7 ++++--- napari/layers/points/points.py | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 8c5c2a16f08..d76bcaa9aec 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -2,7 +2,9 @@ from typing import Any, Sequence, Tuple import numpy as np +from numpy.typing import ArrayLike +from napari.layers.points._slice import _PointSliceResponse from napari.layers.utils._slice_input import _SliceInput try: @@ -15,7 +17,7 @@ @dataclass(frozen=True) -class _GraphSliceResponse: +class _GraphSliceResponse(_PointSliceResponse): """Contains all the output data of slicing an graph layer. Attributes @@ -31,10 +33,7 @@ class _GraphSliceResponse: Describes the slicing plane or bounding box in the layer's dimensions. """ - indices: np.ndarray = field(repr=False) - edges_indices: np.ndarray = field(repr=False) - scale: Any = field(repr=False) - dims: _SliceInput + edges_indices: ArrayLike = field(repr=False) @dataclass(frozen=True) @@ -123,7 +122,7 @@ def _get_out_of_display_slice_data( self, not_disp: Sequence[int], not_disp_indices: np.ndarray, - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + ) -> Tuple[np.ndarray, np.ndarray, ArrayLike]: """ Slices data according to non displayed indices and compute scaling factor for out-slice display diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index c9c74123e72..e6fa14a3623 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -64,7 +64,7 @@ def __init__( ) self._data = self._fix_data(data, ndim) - self._edges_indices_view = [] + self._edges_indices_view: ArrayLike = [] super().__init__( self._data, @@ -182,7 +182,7 @@ def _make_slice_request_internal( size=self.size, ) - def _update_slice_response(self, response: _GraphSliceResponse) -> None: + def _update_slice_response(self, response: _GraphSliceResponse) -> None: # type: ignore[override] super()._update_slice_response(response) self._edges_indices_view = response.edges_indices @@ -248,7 +248,8 @@ def remove(self, indices: ArrayLike) -> None: # descending order indices = np.flip(np.sort(indices)) - for idx in indices: + # it got error missing __iter__ attribute, but we guarantee by np.atleast_1d call + for idx in indices: # type: ignore[union-attr] self.data.remove_node(idx) self._data_changed(prev_size) diff --git a/napari/layers/points/_slice.py b/napari/layers/points/_slice.py index 48eefbc0dc8..c87292e48fd 100644 --- a/napari/layers/points/_slice.py +++ b/napari/layers/points/_slice.py @@ -1,7 +1,8 @@ from dataclasses import dataclass, field -from typing import Any +from typing import Any, Union import numpy as np +from numpy.typing import ArrayLike from napari.layers.utils._slice_input import _SliceInput @@ -21,8 +22,8 @@ class _PointSliceResponse: Describes the slicing plane or bounding box in the layer's dimensions. """ - indices: np.ndarray = field(repr=False) - scale: Any = field(repr=False) + indices: ArrayLike = field(repr=False) + scale: Union[ArrayLike, float, int] = field(repr=False) dims: _SliceInput diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 67487f26f45..4e32b4eac53 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -895,7 +895,7 @@ def border_width( ) ) - self._border_width = border_width + self._border_width: np.ndarray = border_width self.refresh() @property From 05b9aa2af89538911509dfbb509a59fd22505780 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 6 Jun 2023 14:50:00 -0700 Subject: [PATCH 022/105] add deprecation warning to edge -> border points arguments --- napari/layers/points/points.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 4e32b4eac53..ea2f71ba6cc 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -44,6 +44,7 @@ from napari.utils.events import Event from napari.utils.events.custom_types import Array from napari.utils.geometry import project_points_onto_plane, rotate_points +from napari.utils.migrations import rename_argument from napari.utils.status_messages import generate_layer_coords_status from napari.utils.transforms import Affine from napari.utils.translations import trans @@ -2041,6 +2042,14 @@ def _get_properties( class Points(_BasePoints): + @rename_argument("edge_width", "border_width", "5.1.0") + @rename_argument( + "edge_width_is_relative", "border_width_is_relative", "5.1.0" + ) + @rename_argument("edge_color", "border_color", "5.1.0") + @rename_argument("edge_color_cycle", "border_color_cycle", "5.1.0") + @rename_argument("edge_colormap", "border_colormap", "5.1.0") + @rename_argument("edge_contrast_limits", "border_contrast_limits", "5.1.0") def __init__( self, data=None, From eb8a90e8fb33e4099b144681683070098501effa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jord=C3=A3o=20Bragantini?= Date: Wed, 7 Jun 2023 14:46:15 -0700 Subject: [PATCH 023/105] Update examples/add_graph.py Co-authored-by: Grzegorz Bokota --- examples/add_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/add_graph.py b/examples/add_graph.py index 472ffa3f934..b62b2657b55 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -23,7 +23,7 @@ def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: 400 * np.random.uniform(size=(n_nodes, 4)), columns=["t", "z", "y", "x"], ) - graph = UndirectedGraph(edges=edges, coords=nodes_df[["t", "z", "y", "x"]]) + graph = UndirectedGraph(edges=edges, coords=nodes_df) return graph From d07aa405f5bfb555fa0edb2de31173e505500cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jord=C3=A3o=20Bragantini?= Date: Wed, 7 Jun 2023 14:46:37 -0700 Subject: [PATCH 024/105] Update napari/layers/graph/graph.py Co-authored-by: Grzegorz Bokota --- napari/layers/graph/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index e6fa14a3623..9eafda0f344 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -57,7 +57,7 @@ def __init__( shown=True, ) -> None: if BaseGraph is None: - raise ImportError( + raise RuntimeError( trans._( "`napari-graph` module is required by the graph layer." ) From f36c8bf3058f85aa89633e9be6d54e4872c6d82d Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 8 Jun 2023 08:41:42 -0700 Subject: [PATCH 025/105] updated edge -> border deprecation version --- napari/layers/points/points.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index ea2f71ba6cc..065cf697b31 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2042,14 +2042,14 @@ def _get_properties( class Points(_BasePoints): - @rename_argument("edge_width", "border_width", "5.1.0") + @rename_argument("edge_width", "border_width", "6.0.0") @rename_argument( - "edge_width_is_relative", "border_width_is_relative", "5.1.0" + "edge_width_is_relative", "border_width_is_relative", "6.0.0" ) - @rename_argument("edge_color", "border_color", "5.1.0") - @rename_argument("edge_color_cycle", "border_color_cycle", "5.1.0") - @rename_argument("edge_colormap", "border_colormap", "5.1.0") - @rename_argument("edge_contrast_limits", "border_contrast_limits", "5.1.0") + @rename_argument("edge_color", "border_color", "6.0.0") + @rename_argument("edge_color_cycle", "border_color_cycle", "6.0.0") + @rename_argument("edge_colormap", "border_colormap", "6.0.0") + @rename_argument("edge_contrast_limits", "border_contrast_limits", "6.0.0") def __init__( self, data=None, From 05fe4b48c3a7b66dcf3394cfcdd6d936a25ae84c Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 8 Jun 2023 10:27:22 -0700 Subject: [PATCH 026/105] add deprecated points edge_ events warning --- .../_qt/layer_controls/qt_points_controls.py | 5 +++ napari/_vispy/layers/points.py | 5 +++ napari/layers/graph/graph.py | 12 ++++++ napari/layers/points/points.py | 28 +++++++++++++ .../events/_tests/test_event_migrations.py | 11 +++++ napari/utils/events/migrations.py | 40 +++++++++++++++++++ 6 files changed, 101 insertions(+) create mode 100644 napari/utils/events/_tests/test_event_migrations.py create mode 100644 napari/utils/events/migrations.py diff --git a/napari/_qt/layer_controls/qt_points_controls.py b/napari/_qt/layer_controls/qt_points_controls.py index 5839f09c0bb..2a4735cd618 100644 --- a/napari/_qt/layer_controls/qt_points_controls.py +++ b/napari/_qt/layer_controls/qt_points_controls.py @@ -99,6 +99,11 @@ def __init__(self, layer) -> None: self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.text.events.visible.connect(self._on_text_visibility_change) + # TODO: deprecated, should be removed in 6.0.0 + self.layer.events.current_edge_color.connect( + self._on_current_border_color_change + ) + sld = QSlider(Qt.Orientation.Horizontal) sld.setToolTip( trans._( diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index 4b081c8cf6b..109d6da018c 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -42,6 +42,11 @@ def __init__(self, layer) -> None: self._on_canvas_size_limits_change ) + # TODO: deprecated, should be removed in 6.0.0 + self.layer.events.edge_width.connect(self._on_data_change) + self.layer.events.edge_width_is_relative.connect(self._on_data_change) + self.layer.events.edge_color.connect(self._on_data_change) + self._on_data_change() def _on_data_change(self): diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 9eafda0f344..0e0960b2cfd 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -6,6 +6,7 @@ from napari.layers.graph._slice import _GraphSliceRequest, _GraphSliceResponse from napari.layers.points.points import _BasePoints from napari.layers.utils._slice_input import _SliceInput +from napari.utils.events import Event from napari.utils.translations import trans try: @@ -104,6 +105,17 @@ def __init__( shown=shown, ) + # TODO: + # dummy events because of VispyGraphLayer's VispyPointsLayerinheritance + # should be removed in 6.0.0 + self.events.add( + edge_width=Event, + current_edge_width=Event, + edge_width_is_relative=Event, + edge_color=Event, + current_edge_color=Event, + ) + @staticmethod def _fix_data( data: Optional[Union[BaseGraph, ArrayLike]] = None, diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 065cf697b31..f4c77d19776 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -43,6 +43,7 @@ from napari.utils.colormaps.standardize_color import hex_to_name, rgb_to_hex from napari.utils.events import Event from napari.utils.events.custom_types import Array +from napari.utils.events.migrations import deprecation_warning_event from napari.utils.geometry import project_points_onto_plane, rotate_points from napari.utils.migrations import rename_argument from napari.utils.status_messages import generate_layer_coords_status @@ -2139,6 +2140,33 @@ def __init__( shown=shown, ) + self.events.add( + edge_width=deprecation_warning_event( + "layer.events", "edge_width", "border_width", "0.6.0" + ), + current_edge_width=deprecation_warning_event( + "layer.events", + "current_edge_width", + "current_border_width", + "0.6.0", + ), + edge_width_is_relative=deprecation_warning_event( + "layer.events", + "edge_width_is_relative", + "border_width_is_relative", + "0.6.0", + ), + edge_color=deprecation_warning_event( + "layer.events", "edge_color", "border_color", "0.6.0" + ), + current_edge_color=deprecation_warning_event( + "layer.events", + "current_edge_color", + "current_border_color", + "0.6.0", + ), + ) + @property def _points_data(self) -> np.ndarray: """Spatialy distributed coordinates.""" diff --git a/napari/utils/events/_tests/test_event_migrations.py b/napari/utils/events/_tests/test_event_migrations.py new file mode 100644 index 00000000000..2d8da0bc373 --- /dev/null +++ b/napari/utils/events/_tests/test_event_migrations.py @@ -0,0 +1,11 @@ +import pytest + +from napari.utils.events.migrations import deprecation_warning_event + + +def test_deprecation_warning_event() -> None: + event = deprecation_warning_event("obj.events", "old", "new", "0.0.1") + event.connect(lambda x: print(x)) + + with pytest.deprecated_call(): + event("test") diff --git a/napari/utils/events/migrations.py b/napari/utils/events/migrations.py new file mode 100644 index 00000000000..33383b63a8e --- /dev/null +++ b/napari/utils/events/migrations.py @@ -0,0 +1,40 @@ +from napari.utils.events.event import WarningEmitter +from napari.utils.translations import trans + + +def deprecation_warning_event( + prefix: str, + previous_name: str, + new_name: str, + version: str, +) -> WarningEmitter: + """ + Helper function for event emitter deprecation warning. + + This event still needs to be added to the events group. + + Parameters + ---------- + prefix: + Prefix indicating class and event (e.g. layer.event) + previous_name : str + Name of deprecated event (e.g. edge_width) + new_name : str + Name of new event (e.g. border_width) + version : str + Version where deprecated event will be removed. + + Returns + ------- + WarningEmitter + Event emitter that prints a deprecation warning. + """ + previous_path = f"{prefix}.{previous_name}" + new_path = f"{prefix}.{new_name}" + return WarningEmitter( + trans._( + f"{previous_path} is deprecated and will be removed in {version}. Please use {new_path}", + deferred=True, + ), + type_name=previous_name, + ) From a910ca973ba051ad2e9491e3ba1d21458bb31e9e Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 8 Jun 2023 14:06:41 -0700 Subject: [PATCH 027/105] warning on warningemitter connecti --- napari/utils/events/event.py | 11 +++++++---- napari/utils/events/migrations.py | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/napari/utils/events/event.py b/napari/utils/events/event.py index 82a61ed0f7c..8f795345c14 100644 --- a/napari/utils/events/event.py +++ b/napari/utils/events/event.py @@ -885,9 +885,10 @@ class WarningEmitter(EventEmitter): def __init__( self, - message, - category=FutureWarning, - stacklevel=3, + message: str, + category: Type[Warning] = FutureWarning, + stacklevel: int = 3, + warn_on_connect: bool = True, *args, **kwargs, ) -> None: @@ -895,10 +896,12 @@ def __init__( self._warned = False self._category = category self._stacklevel = stacklevel + self._warn_on_connect = warn_on_connect EventEmitter.__init__(self, *args, **kwargs) def connect(self, cb, *args, **kwargs): - self._warn(cb) + if self._warn_on_connect: + self._warn(cb) return EventEmitter.connect(self, cb, *args, **kwargs) def _invoke_callback(self, cb, event): diff --git a/napari/utils/events/migrations.py b/napari/utils/events/migrations.py index 33383b63a8e..b776704657a 100644 --- a/napari/utils/events/migrations.py +++ b/napari/utils/events/migrations.py @@ -36,5 +36,6 @@ def deprecation_warning_event( f"{previous_path} is deprecated and will be removed in {version}. Please use {new_path}", deferred=True, ), + warn_on_connect=False, type_name=previous_name, ) From 42bdb183777f09ecfc577acfd1de9076978dd73a Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 08:57:31 -0700 Subject: [PATCH 028/105] jni minor comments --- .../_qt/layer_controls/qt_points_controls.py | 2 +- napari/layers/graph/graph.py | 2 +- napari/layers/points/points.py | 57 +++++++++---------- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/napari/_qt/layer_controls/qt_points_controls.py b/napari/_qt/layer_controls/qt_points_controls.py index 2a4735cd618..c7a406e9a90 100644 --- a/napari/_qt/layer_controls/qt_points_controls.py +++ b/napari/_qt/layer_controls/qt_points_controls.py @@ -99,7 +99,7 @@ def __init__(self, layer) -> None: self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.text.events.visible.connect(self._on_text_visibility_change) - # TODO: deprecated, should be removed in 6.0.0 + # TODO: deprecated, should be removed in 0.6.0 self.layer.events.current_edge_color.connect( self._on_current_border_color_change ) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 0e0960b2cfd..77f9ee31045 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -107,7 +107,7 @@ def __init__( # TODO: # dummy events because of VispyGraphLayer's VispyPointsLayerinheritance - # should be removed in 6.0.0 + # should be removed in 0.6.0 self.events.add( edge_width=Event, current_edge_width=Event, diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index f4c77d19776..9d43554204b 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -520,7 +520,7 @@ def __init__( @property def _points_data(self) -> np.ndarray: - """Spatialy distributed coordinates.""" + """Spatially distributed coordinates.""" raise NotImplementedError @property @@ -1926,25 +1926,25 @@ def get_status( ) -> dict: """Status message information of the data at a coordinate position. - # Parameters - # ---------- - # position : tuple - # Position in either data or world coordinates. - # view_direction : Optional[np.ndarray] - # A unit vector giving the direction of the ray in nD world coordinates. - # The default value is None. - # dims_displayed : Optional[List[int]] - # A list of the dimensions currently being displayed in the viewer. - # The default value is None. - # world : bool - # If True the position is taken to be in world coordinates - # and converted into data coordinates. False by default. - - # Returns - # ------- - # source_info : dict - # Dict containing information that can be used in a status update. - #""" + Parameters + ---------- + position : tuple + Position in either data or world coordinates. + view_direction : Optional[np.ndarray] + A unit vector giving the direction of the ray in nD world coordinates. + The default value is None. + dims_displayed : Optional[List[int]] + A list of the dimensions currently being displayed in the viewer. + The default value is None. + world : bool + If True the position is taken to be in world coordinates + and converted into data coordinates. False by default. + + Returns + ------- + source_info : dict + Dict containing information that can be used in a status update. + """ if position is not None: value = self.get_value( position, @@ -1980,8 +1980,7 @@ def _get_tooltip_text( dims_displayed: Optional[List[int]] = None, world: bool = False, ): - """ - tooltip message of the data at a coordinate position. + """Tooltip message of the data at a coordinate position. Parameters ---------- @@ -2043,14 +2042,14 @@ def _get_properties( class Points(_BasePoints): - @rename_argument("edge_width", "border_width", "6.0.0") + @rename_argument("edge_width", "border_width", "0.6.0") @rename_argument( - "edge_width_is_relative", "border_width_is_relative", "6.0.0" + "edge_width_is_relative", "border_width_is_relative", "0.6.0" ) - @rename_argument("edge_color", "border_color", "6.0.0") - @rename_argument("edge_color_cycle", "border_color_cycle", "6.0.0") - @rename_argument("edge_colormap", "border_colormap", "6.0.0") - @rename_argument("edge_contrast_limits", "border_contrast_limits", "6.0.0") + @rename_argument("edge_color", "border_color", "0.6.0") + @rename_argument("edge_color_cycle", "border_color_cycle", "0.6.0") + @rename_argument("edge_colormap", "border_colormap", "0.6.0") + @rename_argument("edge_contrast_limits", "border_contrast_limits", "0.6.0") def __init__( self, data=None, @@ -2169,7 +2168,7 @@ def __init__( @property def _points_data(self) -> np.ndarray: - """Spatialy distributed coordinates.""" + """Spatially distributed coordinates.""" return self.data @property From f04520f64ee1e872d439565eda707a228988adb3 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 09:32:43 -0700 Subject: [PATCH 029/105] testing deprecated property with property func --- napari/layers/points/points.py | 20 +++++++++++++++- napari/utils/migrations.py | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 9d43554204b..3281b8396be 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -45,7 +45,7 @@ from napari.utils.events.custom_types import Array from napari.utils.events.migrations import deprecation_warning_event from napari.utils.geometry import project_points_onto_plane, rotate_points -from napari.utils.migrations import rename_argument +from napari.utils.migrations import add_deprecated_property, rename_argument from napari.utils.status_messages import generate_layer_coords_status from napari.utils.transforms import Affine from napari.utils.translations import trans @@ -2166,6 +2166,24 @@ def __init__( ), ) + deprecated_properties = [ + "edge_width", + "edge_width_is_relative", + "current_edge_width", + "edge_color", + "edge_color_cycle", + "edge_colormap", + "edge_contrast_limits", + "current_edge_color", + "edge_color_mode", + ] + for old_property in deprecated_properties: + new_property = old_property.replace("edge", "border") + add_deprecated_property(self, old_property, new_property, "0.6.0") + print(new_property) + print(getattr(self, old_property)) + print(getattr(self, new_property)) + @property def _points_data(self) -> np.ndarray: """Spatially distributed coordinates.""" diff --git a/napari/utils/migrations.py b/napari/utils/migrations.py index aeee5daf929..a5f82685ae9 100644 --- a/napari/utils/migrations.py +++ b/napari/utils/migrations.py @@ -1,5 +1,6 @@ import warnings from functools import wraps +from typing import Any from napari.utils.translations import trans @@ -48,3 +49,46 @@ def _update_from_dict(*args, **kwargs): return _update_from_dict return _wrapper + + +def add_deprecated_property( + obj: Any, + previous_name: str, + new_name: str, + version: str, +) -> None: + """ + Adds deprecated property and links to new property name setter and getter. + + Parameters + ---------- + obj: + Class instances to add property + previous_name : str + Name of previous property, it methods must be removed. + new_name : str + Name of new property, must have its setter and getter implemented. + version : str + Version where deprecated property will be removed. + """ + + if hasattr(obj, previous_name): + raise RuntimeError(f"{previous_name} attribute already exists.") + + if not hasattr(obj, new_name): + raise RuntimeError(f"{new_name} property must exists.") + + msg = trans._( + f"{previous_name} is deprecated and will be removed in {version}. Please use {new_name}", + deferred=True, + ) + + def _getter(instance) -> Any: + warnings.warn(msg, category=FutureWarning, stacklevel=3) + return getattr(instance, new_name) + + def _setter(instance, value: Any) -> None: + warnings.warn(msg, category=FutureWarning, stacklevel=3) + setattr(instance, new_name, value) + + setattr(obj, previous_name, property(_getter, _setter)) From 81e988480e1443b8e49c329d01e2d6c63b11b316 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 09:45:38 -0700 Subject: [PATCH 030/105] fixed bug by replacing instance method with class method --- napari/layers/points/points.py | 11 +++++---- napari/utils/_tests/test_migrations.py | 32 +++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 3281b8396be..21cf9a52a61 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2166,6 +2166,9 @@ def __init__( ), ) + @classmethod + def _add_deprecated_properties(cls) -> None: + """Adds deprecated properties to class.""" deprecated_properties = [ "edge_width", "edge_width_is_relative", @@ -2179,10 +2182,7 @@ def __init__( ] for old_property in deprecated_properties: new_property = old_property.replace("edge", "border") - add_deprecated_property(self, old_property, new_property, "0.6.0") - print(new_property) - print(getattr(self, old_property)) - print(getattr(self, new_property)) + add_deprecated_property(cls, old_property, new_property, "0.6.0") @property def _points_data(self) -> np.ndarray: @@ -2499,3 +2499,6 @@ def to_mask( ) mask[np.ix_(*submask_coords)] |= normalized_square_distances <= 1 return mask + + +Points._add_deprecated_properties() diff --git a/napari/utils/_tests/test_migrations.py b/napari/utils/_tests/test_migrations.py index 30706ee9f6e..da3d39b386b 100644 --- a/napari/utils/_tests/test_migrations.py +++ b/napari/utils/_tests/test_migrations.py @@ -1,6 +1,6 @@ import pytest -from napari.utils.migrations import rename_argument +from napari.utils.migrations import add_deprecated_property, rename_argument def test_simple(): @@ -26,3 +26,33 @@ def __init__(self, b) -> None: assert Sample(b=1).b == 1 with pytest.deprecated_call(): assert Sample(a=1).b == 1 + + +def test_deprecated_property() -> None: + class Dummy: + def __init__(self) -> None: + self._value = 0 + + @property + def new_property(self) -> int: + return self._value + + @new_property.setter + def new_property(self, value: int) -> int: + self._value = value + + instance = Dummy() + + add_deprecated_property(Dummy, "old_property", "new_property", "0.0.0") + + assert instance.new_property == 0 + + instance.new_property = 1 + + with pytest.warns(FutureWarning): + assert instance.old_property == 1 + + with pytest.warns(FutureWarning): + instance.old_property = 2 + + assert instance.new_property == 2 From 6e97eb5890f71e107fd94963845c561e39d003f7 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 09:55:50 -0700 Subject: [PATCH 031/105] fixed deprecation warning event test --- napari/utils/events/_tests/test_event_migrations.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/napari/utils/events/_tests/test_event_migrations.py b/napari/utils/events/_tests/test_event_migrations.py index 2d8da0bc373..7170c77b984 100644 --- a/napari/utils/events/_tests/test_event_migrations.py +++ b/napari/utils/events/_tests/test_event_migrations.py @@ -4,8 +4,12 @@ def test_deprecation_warning_event() -> None: - event = deprecation_warning_event("obj.events", "old", "new", "0.0.1") - event.connect(lambda x: print(x)) + event = deprecation_warning_event("obj.events", "old", "new", "0.0.0") - with pytest.deprecated_call(): - event("test") + def _print(msg: str) -> None: + print(msg) + + event.connect(_print) + + with pytest.warns(FutureWarning): + event(msg="test") From 3775280049e1eebe152ea6b9f08d4ea52478c35b Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 10:04:03 -0700 Subject: [PATCH 032/105] improving nuclei segmentation graph example --- examples/nuclei_segmentation_graph.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/nuclei_segmentation_graph.py b/examples/nuclei_segmentation_graph.py index c9ef08fc092..a24ff7abed2 100644 --- a/examples/nuclei_segmentation_graph.py +++ b/examples/nuclei_segmentation_graph.py @@ -18,11 +18,13 @@ def delaunay_edges(points: np.ndarray) -> np.ndarray: delaunay = Delaunay(points) - edges = [] + edges = set() for simplex in delaunay.simplices: - edges += list(combinations(simplex, 2)) + # each simplex is represented as a list of four points. + # we add all edges between the points to the edge list + edges |= set(combinations(simplex, 2)) - return edges + return np.asarray(list(edges)) cells = data.cells3d() From d2036b00b969f3ca6fc1f60e3609e73ffd75640f Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 10:51:21 -0700 Subject: [PATCH 033/105] replaced a few o private napari-graph class --- napari/layers/graph/_slice.py | 14 +++++--------- napari/layers/graph/graph.py | 9 +++++---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index d76bcaa9aec..4a01009f6e0 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -9,11 +9,9 @@ try: from napari_graph import BaseGraph - from napari_graph.base_graph import _NODE_EMPTY_PTR except ModuleNotFoundError: BaseGraph = None - _NODE_EMPTY_PTR = None @dataclass(frozen=True) @@ -82,9 +80,7 @@ def __call__(self) -> _GraphSliceResponse: # If we want to display everything, then use all indices. # scale is only impacted by not displayed data, therefore 1 node_indices = np.arange(self.data.n_allocated_nodes) - node_indices = node_indices[ - self.data._buffer2world != _NODE_EMPTY_PTR - ] + node_indices = node_indices[self.data.initialized_buffer_mask()] _, edges = self.data.get_edges_buffers(is_buffer_domain=True) return _GraphSliceResponse( indices=node_indices, @@ -128,9 +124,9 @@ def _get_out_of_display_slice_data( and compute scaling factor for out-slice display while ignoring not initialized nodes from graph. """ - valid_nodes = self.data._buffer2world != _NODE_EMPTY_PTR + valid_nodes = self.data.initialized_buffer_mask() ixgrid = np.ix_(valid_nodes, not_disp) - data = self.data._coords[ixgrid] + data = self.data.coords_buffer[ixgrid] sizes = self.size[ixgrid] / 2 distances = abs(data - not_disp_indices) matches = np.all(distances <= sizes, axis=1) @@ -153,8 +149,8 @@ def _get_slice_data( Slices data according to non displayed indices while ignoring not initialized nodes from graph. """ - valid_nodes = self.data._buffer2world != _NODE_EMPTY_PTR - data = self.data._coords[np.ix_(valid_nodes, not_disp)] + valid_nodes = self.data.initialized_buffer_mask() + data = self.data.coords_buffer[np.ix_(valid_nodes, not_disp)] distances = np.abs(data - not_disp_indices) matches = np.all(distances <= 0.5, axis=1) valid_nodes[valid_nodes] = matches diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 77f9ee31045..47a3eaa47c7 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -200,7 +200,7 @@ def _update_slice_response(self, response: _GraphSliceResponse) -> None: # type @property def _view_edges_coordinates(self) -> np.ndarray: - return self.data._coords[self._edges_indices_view][ + return self.data.coords_buffer[self._edges_indices_view][ ..., self._slice_input.displayed ] @@ -233,8 +233,7 @@ def add( prev_size = self.data.n_allocated_nodes - for idx, coord in zip(indices, coords): - self.data.add_nodes(idx, coord) + self.data.add_nodes(indices, coords) self._data_changed(prev_size) @@ -278,7 +277,9 @@ def _move_points( shift : np.ndarray Selected coordinates shift """ - self.data._coords[ixgrid] = self.data._coords[ixgrid] + shift + self.data.coords_buffer[ixgrid] = ( + self.data.coords_buffer[ixgrid] + shift + ) def _update_props_and_style(self, data_size: int, prev_size: int) -> None: # Add/remove property and style values based on the number of new points. From 3c9c33d54a066b8b5bed829b5f6fccf7d9a6e2a0 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 14:06:33 -0700 Subject: [PATCH 034/105] update docs --- napari/layers/graph/graph.py | 214 +++++++++++++++ napari/layers/points/points.py | 485 +++++++++++++++++---------------- setup.cfg | 4 +- 3 files changed, 460 insertions(+), 243 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 47a3eaa47c7..9a9b8173bb8 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -18,6 +18,220 @@ class Graph(_BasePoints): + """ + Graph layer used to display spatial graphs. + + Parameters + ---------- + data : GraphLike + A napari-graph compatible data, for example, networkx graph, 2D array of + coordinates or a napari-graph object. + ndim : int + Number of dimensions for shapes. When data is not None, ndim must be D. + An empty points layer can be instantiated with arbitrary ndim. + features : dict[str, array-like] or DataFrame + Features table where each row corresponds to a point and each column + is a feature. + feature_defaults : dict[str, Any] or DataFrame + The default value of each feature in a table with one row. + text : str, dict + Text to be displayed with the points. If text is set to a key in properties, + the value of that property will be displayed. Multiple properties can be + composed using f-string-like syntax (e.g., '{property_1}, {float_property:.2f}). + A dictionary can be provided with keyword arguments to set the text values + and display properties. See TextManager.__init__() for the valid keyword arguments. + For example usage, see /napari/examples/add_points_with_text.py. + symbol : str, array + Symbols to be used for the point markers. Must be one of the + following: arrow, clobber, cross, diamond, disc, hbar, ring, + square, star, tailed_arrow, triangle_down, triangle_up, vbar, x. + size : float, array + Size of the point marker in data pixels. If given as a scalar, all points are made + the same size. If given as an array, size must be the same or broadcastable + to the same shape as the data. + border_width : float, array + Width of the symbol border in pixels. + border_width_is_relative : bool + If enabled, border_width is interpreted as a fraction of the point size. + border_color : str, array-like, dict + Color of the point marker border. Numeric color values should be RGB(A). + border_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a + categorical attribute is used color the vectors. + border_colormap : str, napari.utils.Colormap + Colormap to set border_color if a continuous attribute is used to set face_color. + border_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + face_color : str, array-like, dict + Color of the point marker body. Numeric color values should be RGB(A). + face_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a + categorical attribute is used color the vectors. + face_colormap : str, napari.utils.Colormap + Colormap to set face_color if a continuous attribute is used to set face_color. + face_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + out_of_slice_display : bool + If True, renders points not just in central plane but also slightly out of slice + according to specified point marker size. + n_dimensional : bool + This property will soon be deprecated in favor of 'out_of_slice_display'. + Use that instead. + name : str + Name of the layer. + metadata : dict + Layer metadata. + scale : tuple of float + Scale factors for the layer. + translate : tuple of float + Translation values for the layer. + rotate : float, 3-tuple of float, or n-D array. + If a float convert into a 2D rotation matrix using that value as an + angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, + pitch, roll convention. Otherwise assume an nD rotation. Angles are + assumed to be in degrees. They can be converted from radians with + np.degrees if needed. + shear : 1-D array or n-D array + Either a vector of upper triangular values, or an nD shear matrix with + ones along the main diagonal. + affine : n-D array or napari.utils.transforms.Affine + (N+1, N+1) affine transformation matrix in homogeneous coordinates. + The first (N, N) entries correspond to a linear transform and + the final column is a length N translation vector and a 1 or a napari + `Affine` transform object. Applied as an extra transform on top of the + provided scale, rotate, and shear values. + opacity : float + Opacity of the layer visual, between 0.0 and 1.0. + blending : str + One of a list of preset blending modes that determines how RGB and + alpha values of the layer visual get mixed. Allowed values are + {'opaque', 'translucent', and 'additive'}. + visible : bool + Whether the layer visual is currently being displayed. + cache : bool + Whether slices of out-of-core datasets should be cached upon retrieval. + Currently, this only applies to dask arrays. + shading : str, Shading + Render lighting and shading on points. Options are: + + * 'none' + No shading is added to the points. + * 'spherical' + Shading and depth buffer are changed to give a 3D spherical look to the points + antialiasing: float + Amount of antialiasing in canvas pixels. + canvas_size_limits : tuple of float + Lower and upper limits for the size of points in canvas pixels. + shown : 1-D array of bool + Whether to show each point. + + Attributes + ---------- + data : array (N, D) + Coordinates for N points in D dimensions. + features : DataFrame-like + Features table where each row corresponds to a point and each column + is a feature. + feature_defaults : DataFrame-like + Stores the default value of each feature in a table with one row. + text : str + Text to be displayed with the points. If text is set to a key in properties, the value of + that property will be displayed. Multiple properties can be composed using f-string-like + syntax (e.g., '{property_1}, {float_property:.2f}). + For example usage, see /napari/examples/add_points_with_text.py. + symbol : array of str + Array of symbols for each point. + size : array (N, D) + Array of sizes for each point in each dimension. Must have the same + shape as the layer `data`. + border_width : array (N,) + Width of the marker borders in pixels for all points + border_width : array (N,) + Width of the marker borders for all points as a fraction of their size. + border_color : Nx4 numpy array + Array of border color RGBA values, one for each point. + border_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a + categorical attribute is used color the vectors. + border_colormap : str, napari.utils.Colormap + Colormap to set border_color if a continuous attribute is used to set face_color. + border_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + face_color : Nx4 numpy array + Array of face color RGBA values, one for each point. + face_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a + categorical attribute is used color the vectors. + face_colormap : str, napari.utils.Colormap + Colormap to set face_color if a continuous attribute is used to set face_color. + face_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + current_symbol : Symbol + Symbol for the next point to be added or the currently selected points. + current_size : float + Size of the marker for the next point to be added or the currently + selected point. + current_border_width : float + border width of the marker for the next point to be added or the currently + selected point. + current_border_color : str + border color of the marker border for the next point to be added or the currently + selected point. + current_face_color : str + Face color of the marker border for the next point to be added or the currently + selected point. + out_of_slice_display : bool + If True, renders points not just in central plane but also slightly out of slice + according to specified point marker size. + selected_data : Selection + Integer indices of any selected points. + mode : str + Interactive mode. The normal, default mode is PAN_ZOOM, which + allows for normal interactivity with the canvas. + + In ADD mode clicks of the cursor add points at the clicked location. + + In SELECT mode the cursor can select points by clicking on them or + by dragging a box around them. Once selected points can be moved, + have their properties edited, or be deleted. + face_color_mode : str + Face color setting mode. + + DIRECT (default mode) allows each point to be set arbitrarily + + CYCLE allows the color to be set via a color cycle over an attribute + + COLORMAP allows color to be set via a color map over an attribute + border_color_mode : str + border color setting mode. + + DIRECT (default mode) allows each point to be set arbitrarily + + CYCLE allows the color to be set via a color cycle over an attribute + + COLORMAP allows color to be set via a color map over an attribute + shading : Shading + Shading mode. + antialiasing: float + Amount of antialiasing in canvas pixels. + canvas_size_limits : tuple of float + Lower and upper limits for the size of points in canvas pixels. + shown : 1-D array of bool + Whether each node is shown. + """ + def __init__( self, data=None, diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 21cf9a52a61..f00eeaa5342 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -58,247 +58,7 @@ class _BasePoints(Layer): Implements the basic functionality of spatially distributed coordinates. Used by to display points and graph nodes. - TODO: update documentation and typing - - Parameters - ---------- - data : array (N, D) - Coordinates for N points in D dimensions. - ndim : int - Number of dimensions for shapes. When data is not None, ndim must be D. - An empty points layer can be instantiated with arbitrary ndim. - features : dict[str, array-like] or DataFrame - Features table where each row corresponds to a point and each column - is a feature. - feature_defaults : dict[str, Any] or DataFrame - The default value of each feature in a table with one row. - properties : dict {str: array (N,)}, DataFrame - Properties for each point. Each property should be an array of length N, - where N is the number of points. - property_choices : dict {str: array (N,)} - possible values for each property. - text : str, dict - Text to be displayed with the points. If text is set to a key in properties, - the value of that property will be displayed. Multiple properties can be - composed using f-string-like syntax (e.g., '{property_1}, {float_property:.2f}). - A dictionary can be provided with keyword arguments to set the text values - and display properties. See TextManager.__init__() for the valid keyword arguments. - For example usage, see /napari/examples/add_points_with_text.py. - symbol : str, array - Symbols to be used for the point markers. Must be one of the - following: arrow, clobber, cross, diamond, disc, hbar, ring, - square, star, tailed_arrow, triangle_down, triangle_up, vbar, x. - size : float, array - Size of the point marker in data pixels. If given as a scalar, all points are made - the same size. If given as an array, size must be the same or broadcastable - to the same shape as the data. - border_width : float, array - Width of the symbol border in pixels. - border_width_is_relative : bool - If enabled, border_width is interpreted as a fraction of the point size. - border_color : str, array-like, dict - Color of the point marker border. Numeric color values should be RGB(A). - border_color_cycle : np.ndarray, list - Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a - categorical attribute is used color the vectors. - border_colormap : str, napari.utils.Colormap - Colormap to set border_color if a continuous attribute is used to set face_color. - border_contrast_limits : None, (float, float) - clims for mapping the property to a color map. These are the min and max value - of the specified property that are mapped to 0 and 1, respectively. - The default value is None. If set the none, the clims will be set to - (property.min(), property.max()) - face_color : str, array-like, dict - Color of the point marker body. Numeric color values should be RGB(A). - face_color_cycle : np.ndarray, list - Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a - categorical attribute is used color the vectors. - face_colormap : str, napari.utils.Colormap - Colormap to set face_color if a continuous attribute is used to set face_color. - face_contrast_limits : None, (float, float) - clims for mapping the property to a color map. These are the min and max value - of the specified property that are mapped to 0 and 1, respectively. - The default value is None. If set the none, the clims will be set to - (property.min(), property.max()) - out_of_slice_display : bool - If True, renders points not just in central plane but also slightly out of slice - according to specified point marker size. - n_dimensional : bool - This property will soon be deprecated in favor of 'out_of_slice_display'. - Use that instead. - name : str - Name of the layer. - metadata : dict - Layer metadata. - scale : tuple of float - Scale factors for the layer. - translate : tuple of float - Translation values for the layer. - rotate : float, 3-tuple of float, or n-D array. - If a float convert into a 2D rotation matrix using that value as an - angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, - pitch, roll convention. Otherwise assume an nD rotation. Angles are - assumed to be in degrees. They can be converted from radians with - np.degrees if needed. - shear : 1-D array or n-D array - Either a vector of upper triangular values, or an nD shear matrix with - ones along the main diagonal. - affine : n-D array or napari.utils.transforms.Affine - (N+1, N+1) affine transformation matrix in homogeneous coordinates. - The first (N, N) entries correspond to a linear transform and - the final column is a length N translation vector and a 1 or a napari - `Affine` transform object. Applied as an extra transform on top of the - provided scale, rotate, and shear values. - opacity : float - Opacity of the layer visual, between 0.0 and 1.0. - blending : str - One of a list of preset blending modes that determines how RGB and - alpha values of the layer visual get mixed. Allowed values are - {'opaque', 'translucent', and 'additive'}. - visible : bool - Whether the layer visual is currently being displayed. - cache : bool - Whether slices of out-of-core datasets should be cached upon retrieval. - Currently, this only applies to dask arrays. - shading : str, Shading - Render lighting and shading on points. Options are: - - * 'none' - No shading is added to the points. - * 'spherical' - Shading and depth buffer are changed to give a 3D spherical look to the points - antialiasing: float - Amount of antialiasing in canvas pixels. - canvas_size_limits : tuple of float - Lower and upper limits for the size of points in canvas pixels. - shown : 1-D array of bool - Whether to show each point. - - Attributes - ---------- - data : array (N, D) - Coordinates for N points in D dimensions. - features : DataFrame-like - Features table where each row corresponds to a point and each column - is a feature. - feature_defaults : DataFrame-like - Stores the default value of each feature in a table with one row. - properties : dict {str: array (N,)} or DataFrame - Annotations for each point. Each property should be an array of length N, - where N is the number of points. - text : str - Text to be displayed with the points. If text is set to a key in properties, the value of - that property will be displayed. Multiple properties can be composed using f-string-like - syntax (e.g., '{property_1}, {float_property:.2f}). - For example usage, see /napari/examples/add_points_with_text.py. - symbol : array of str - Array of symbols for each point. - size : array (N, D) - Array of sizes for each point in each dimension. Must have the same - shape as the layer `data`. - border_width : array (N,) - Width of the marker borders in pixels for all points - border_width : array (N,) - Width of the marker borders for all points as a fraction of their size. - border_color : Nx4 numpy array - Array of border color RGBA values, one for each point. - border_color_cycle : np.ndarray, list - Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a - categorical attribute is used color the vectors. - border_colormap : str, napari.utils.Colormap - Colormap to set border_color if a continuous attribute is used to set face_color. - border_contrast_limits : None, (float, float) - clims for mapping the property to a color map. These are the min and max value - of the specified property that are mapped to 0 and 1, respectively. - The default value is None. If set the none, the clims will be set to - (property.min(), property.max()) - face_color : Nx4 numpy array - Array of face color RGBA values, one for each point. - face_color_cycle : np.ndarray, list - Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a - categorical attribute is used color the vectors. - face_colormap : str, napari.utils.Colormap - Colormap to set face_color if a continuous attribute is used to set face_color. - face_contrast_limits : None, (float, float) - clims for mapping the property to a color map. These are the min and max value - of the specified property that are mapped to 0 and 1, respectively. - The default value is None. If set the none, the clims will be set to - (property.min(), property.max()) - current_symbol : Symbol - Symbol for the next point to be added or the currently selected points. - current_size : float - Size of the marker for the next point to be added or the currently - selected point. - current_border_width : float - border width of the marker for the next point to be added or the currently - selected point. - current_border_color : str - border color of the marker border for the next point to be added or the currently - selected point. - current_face_color : str - Face color of the marker border for the next point to be added or the currently - selected point. - out_of_slice_display : bool - If True, renders points not just in central plane but also slightly out of slice - according to specified point marker size. - selected_data : Selection - Integer indices of any selected points. - mode : str - Interactive mode. The normal, default mode is PAN_ZOOM, which - allows for normal interactivity with the canvas. - - In ADD mode clicks of the cursor add points at the clicked location. - - In SELECT mode the cursor can select points by clicking on them or - by dragging a box around them. Once selected points can be moved, - have their properties edited, or be deleted. - face_color_mode : str - Face color setting mode. - - DIRECT (default mode) allows each point to be set arbitrarily - - CYCLE allows the color to be set via a color cycle over an attribute - - COLORMAP allows color to be set via a color map over an attribute - border_color_mode : str - border color setting mode. - - DIRECT (default mode) allows each point to be set arbitrarily - - CYCLE allows the color to be set via a color cycle over an attribute - - COLORMAP allows color to be set via a color map over an attribute - shading : Shading - Shading mode. - antialiasing: float - Amount of antialiasing in canvas pixels. - canvas_size_limits : tuple of float - Lower and upper limits for the size of points in canvas pixels. - shown : 1-D array of bool - Whether each point is shown. - - Notes - ----- - _view_data : array (M, D) - coordinates of points in the currently viewed slice. - _view_size : array (M, ) - Size of the point markers in the currently viewed slice. - _view_symbol : array (M, ) - Symbols of the point markers in the currently viewed slice. - _view_border_width : array (M, ) - border width of the point markers in the currently viewed slice. - _indices_view : array (M, ) - Integer indices of the points in the currently viewed slice and are shown. - _selected_view : - Integer indices of selected points in the currently viewed slice within - the `_view_data` array. - _selected_box : array (4, 2) or None - Four corners of any box either around currently selected points or - being created during a drag action. Starting in the top left and - going clockwise. - _drag_start : list or None - Coordinates of first cursor click during a drag action. Gets reset to - None after dragging is done. + Refer to Points documentation. """ _modeclass = Mode @@ -2042,6 +1802,249 @@ def _get_properties( class Points(_BasePoints): + """Points layer. + + Parameters + ---------- + data : array (N, D) + Coordinates for N points in D dimensions. + ndim : int + Number of dimensions for shapes. When data is not None, ndim must be D. + An empty points layer can be instantiated with arbitrary ndim. + features : dict[str, array-like] or DataFrame + Features table where each row corresponds to a point and each column + is a feature. + feature_defaults : dict[str, Any] or DataFrame + The default value of each feature in a table with one row. + properties : dict {str: array (N,)}, DataFrame + Properties for each point. Each property should be an array of length N, + where N is the number of points. + property_choices : dict {str: array (N,)} + possible values for each property. + text : str, dict + Text to be displayed with the points. If text is set to a key in properties, + the value of that property will be displayed. Multiple properties can be + composed using f-string-like syntax (e.g., '{property_1}, {float_property:.2f}). + A dictionary can be provided with keyword arguments to set the text values + and display properties. See TextManager.__init__() for the valid keyword arguments. + For example usage, see /napari/examples/add_points_with_text.py. + symbol : str, array + Symbols to be used for the point markers. Must be one of the + following: arrow, clobber, cross, diamond, disc, hbar, ring, + square, star, tailed_arrow, triangle_down, triangle_up, vbar, x. + size : float, array + Size of the point marker in data pixels. If given as a scalar, all points are made + the same size. If given as an array, size must be the same or broadcastable + to the same shape as the data. + border_width : float, array + Width of the symbol border in pixels. + border_width_is_relative : bool + If enabled, border_width is interpreted as a fraction of the point size. + border_color : str, array-like, dict + Color of the point marker border. Numeric color values should be RGB(A). + border_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a + categorical attribute is used color the vectors. + border_colormap : str, napari.utils.Colormap + Colormap to set border_color if a continuous attribute is used to set face_color. + border_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + face_color : str, array-like, dict + Color of the point marker body. Numeric color values should be RGB(A). + face_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a + categorical attribute is used color the vectors. + face_colormap : str, napari.utils.Colormap + Colormap to set face_color if a continuous attribute is used to set face_color. + face_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + out_of_slice_display : bool + If True, renders points not just in central plane but also slightly out of slice + according to specified point marker size. + n_dimensional : bool + This property will soon be deprecated in favor of 'out_of_slice_display'. + Use that instead. + name : str + Name of the layer. + metadata : dict + Layer metadata. + scale : tuple of float + Scale factors for the layer. + translate : tuple of float + Translation values for the layer. + rotate : float, 3-tuple of float, or n-D array. + If a float convert into a 2D rotation matrix using that value as an + angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, + pitch, roll convention. Otherwise assume an nD rotation. Angles are + assumed to be in degrees. They can be converted from radians with + np.degrees if needed. + shear : 1-D array or n-D array + Either a vector of upper triangular values, or an nD shear matrix with + ones along the main diagonal. + affine : n-D array or napari.utils.transforms.Affine + (N+1, N+1) affine transformation matrix in homogeneous coordinates. + The first (N, N) entries correspond to a linear transform and + the final column is a length N translation vector and a 1 or a napari + `Affine` transform object. Applied as an extra transform on top of the + provided scale, rotate, and shear values. + opacity : float + Opacity of the layer visual, between 0.0 and 1.0. + blending : str + One of a list of preset blending modes that determines how RGB and + alpha values of the layer visual get mixed. Allowed values are + {'opaque', 'translucent', and 'additive'}. + visible : bool + Whether the layer visual is currently being displayed. + cache : bool + Whether slices of out-of-core datasets should be cached upon retrieval. + Currently, this only applies to dask arrays. + shading : str, Shading + Render lighting and shading on points. Options are: + + * 'none' + No shading is added to the points. + * 'spherical' + Shading and depth buffer are changed to give a 3D spherical look to the points + antialiasing: float + Amount of antialiasing in canvas pixels. + canvas_size_limits : tuple of float + Lower and upper limits for the size of points in canvas pixels. + shown : 1-D array of bool + Whether to show each point. + + Attributes + ---------- + data : array (N, D) + Coordinates for N points in D dimensions. + features : DataFrame-like + Features table where each row corresponds to a point and each column + is a feature. + feature_defaults : DataFrame-like + Stores the default value of each feature in a table with one row. + properties : dict {str: array (N,)} or DataFrame + Annotations for each point. Each property should be an array of length N, + where N is the number of points. + text : str + Text to be displayed with the points. If text is set to a key in properties, the value of + that property will be displayed. Multiple properties can be composed using f-string-like + syntax (e.g., '{property_1}, {float_property:.2f}). + For example usage, see /napari/examples/add_points_with_text.py. + symbol : array of str + Array of symbols for each point. + size : array (N, D) + Array of sizes for each point in each dimension. Must have the same + shape as the layer `data`. + border_width : array (N,) + Width of the marker borders in pixels for all points + border_width : array (N,) + Width of the marker borders for all points as a fraction of their size. + border_color : Nx4 numpy array + Array of border color RGBA values, one for each point. + border_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to border_color if a + categorical attribute is used color the vectors. + border_colormap : str, napari.utils.Colormap + Colormap to set border_color if a continuous attribute is used to set face_color. + border_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + face_color : Nx4 numpy array + Array of face color RGBA values, one for each point. + face_color_cycle : np.ndarray, list + Cycle of colors (provided as string name, RGB, or RGBA) to map to face_color if a + categorical attribute is used color the vectors. + face_colormap : str, napari.utils.Colormap + Colormap to set face_color if a continuous attribute is used to set face_color. + face_contrast_limits : None, (float, float) + clims for mapping the property to a color map. These are the min and max value + of the specified property that are mapped to 0 and 1, respectively. + The default value is None. If set the none, the clims will be set to + (property.min(), property.max()) + current_symbol : Symbol + Symbol for the next point to be added or the currently selected points. + current_size : float + Size of the marker for the next point to be added or the currently + selected point. + current_border_width : float + border width of the marker for the next point to be added or the currently + selected point. + current_border_color : str + border color of the marker border for the next point to be added or the currently + selected point. + current_face_color : str + Face color of the marker border for the next point to be added or the currently + selected point. + out_of_slice_display : bool + If True, renders points not just in central plane but also slightly out of slice + according to specified point marker size. + selected_data : Selection + Integer indices of any selected points. + mode : str + Interactive mode. The normal, default mode is PAN_ZOOM, which + allows for normal interactivity with the canvas. + + In ADD mode clicks of the cursor add points at the clicked location. + + In SELECT mode the cursor can select points by clicking on them or + by dragging a box around them. Once selected points can be moved, + have their properties edited, or be deleted. + face_color_mode : str + Face color setting mode. + + DIRECT (default mode) allows each point to be set arbitrarily + + CYCLE allows the color to be set via a color cycle over an attribute + + COLORMAP allows color to be set via a color map over an attribute + border_color_mode : str + border color setting mode. + + DIRECT (default mode) allows each point to be set arbitrarily + + CYCLE allows the color to be set via a color cycle over an attribute + + COLORMAP allows color to be set via a color map over an attribute + shading : Shading + Shading mode. + antialiasing: float + Amount of antialiasing in canvas pixels. + canvas_size_limits : tuple of float + Lower and upper limits for the size of points in canvas pixels. + shown : 1-D array of bool + Whether each point is shown. + + Notes + ----- + _view_data : array (M, D) + coordinates of points in the currently viewed slice. + _view_size : array (M, ) + Size of the point markers in the currently viewed slice. + _view_symbol : array (M, ) + Symbols of the point markers in the currently viewed slice. + _view_border_width : array (M, ) + border width of the point markers in the currently viewed slice. + _indices_view : array (M, ) + Integer indices of the points in the currently viewed slice and are shown. + _selected_view : + Integer indices of selected points in the currently viewed slice within + the `_view_data` array. + _selected_box : array (4, 2) or None + Four corners of any box either around currently selected points or + being created during a drag action. Starting in the top left and + going clockwise. + _drag_start : list or None + Coordinates of first cursor click during a drag action. Gets reset to + None after dragging is done. + """ + @rename_argument("edge_width", "border_width", "0.6.0") @rename_argument( "edge_width_is_relative", "border_width_is_relative", "0.6.0" diff --git a/setup.cfg b/setup.cfg index 4cb9a37e030..14b3745aa95 100644 --- a/setup.cfg +++ b/setup.cfg @@ -132,7 +132,7 @@ testing = qtconsole>=4.5.1 rich>=12.0.0 napari-plugin-manager >=0.1.0a1, <0.2.0 - napari-graph + napari-graph>0.1.0 release = PyGithub>=1.44.1 twine>=3.1.1 @@ -150,7 +150,7 @@ build = ruff pyqt5 graph = - napari-graph + napari-graph>0.1.0 [options.entry_points] console_scripts = From f68f4b312c50ccdefa415351da4ec2638b21f363 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 13 Jun 2023 16:12:01 -0700 Subject: [PATCH 035/105] add networkx support --- napari/layers/graph/_tests/test_graph.py | 31 ++++++++++++++++++++++++ napari/layers/graph/graph.py | 14 +++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 3a908da5e22..62605c93164 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -1,5 +1,6 @@ from typing import Type +import networkx as nx import numpy as np import pandas as pd import pytest @@ -58,6 +59,36 @@ def test_non_spatial_graph() -> None: Graph(non_spatial_graph) +def test_networkx_graph() -> None: + m = 5 + n = 5 + graph = nx.grid_2d_graph(m=m, n=n) + + mapping = {} + for i, j in graph.nodes: + graph.nodes[i, j]["pos"] = (i, j) + mapping[i, j] = i * m + j + + nx.relabel_nodes(graph, mapping, copy=False) + + layer = Graph(graph) + + assert len(layer.data) == m * n + assert layer.ndim == 2 + + +def test_networkx_nonspatial_graph() -> None: + m = 5 + n = 5 + graph = nx.grid_2d_graph(m=m, n=n) + + mapping = {(i, j): i * m + j for i, j in graph.nodes} + nx.relabel_nodes(graph, mapping, copy=False) + + with pytest.raises(ValueError): + Graph(graph) + + @pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) def test_changing_graph(graph_class: Type[BaseGraph]) -> None: graph_a = graph_class(edges=[[0, 1]], coords=[[0, 0], [1, 1]]) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 9a9b8173bb8..35786965f07 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,5 +1,6 @@ from typing import Any, Dict, Optional, Tuple, Union +import networkx as nx import numpy as np from numpy.typing import ArrayLike @@ -10,11 +11,12 @@ from napari.utils.translations import trans try: - from napari_graph import BaseGraph, UndirectedGraph + from napari_graph import BaseGraph, UndirectedGraph, from_networkx except ModuleNotFoundError: BaseGraph = None UndirectedGraph = None + from_networkx = None class Graph(_BasePoints): @@ -336,18 +338,20 @@ def _fix_data( ndim: Optional[int] = None, ) -> BaseGraph: """Checks input data and return a empty graph if is None.""" - if ndim is None: - ndim = 2 - if data is None: + if ndim is None: + ndim = 2 # empty but pre-allocated graph return UndirectedGraph(ndim=ndim) + if isinstance(data, nx.Graph): + data = from_networkx(data) + if isinstance(data, BaseGraph): if data._coords is None: raise ValueError( trans._( - "Graph layer must be a spatial graph, have the `coords` attribute." + "Graph layer must be a spatial graph, have the `coords` attribute (`pos` in NetworkX)." ) ) return data From 6d8eb92994e09cbf24c6b68944a324b7b7c71211 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 16 Jun 2023 11:35:38 -0700 Subject: [PATCH 036/105] add since_version --- napari/layers/points/points.py | 63 +++++++++++++++++++++----- napari/utils/_tests/test_migrations.py | 4 +- napari/utils/events/migrations.py | 5 +- napari/utils/migrations.py | 5 +- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index f00eeaa5342..bc84069a03f 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2045,14 +2045,36 @@ class Points(_BasePoints): None after dragging is done. """ - @rename_argument("edge_width", "border_width", "0.6.0") @rename_argument( - "edge_width_is_relative", "border_width_is_relative", "0.6.0" + "edge_width", "border_width", since_version="0.5.0", version="0.6.0" + ) + @rename_argument( + "edge_width_is_relative", + "border_width_is_relative", + since_version="0.5.0", + version="0.6.0", + ) + @rename_argument( + "edge_color", "border_color", since_version="0.5.0", version="0.6.0" + ) + @rename_argument( + "edge_color_cycle", + "border_color_cycle", + since_version="0.5.0", + version="0.6.0", + ) + @rename_argument( + "edge_colormap", + "border_colormap", + since_version="0.5.0", + version="0.6.0", + ) + @rename_argument( + "edge_contrast_limits", + "border_contrast_limits", + since_version="0.5.0", + version="0.6.0", ) - @rename_argument("edge_color", "border_color", "0.6.0") - @rename_argument("edge_color_cycle", "border_color_cycle", "0.6.0") - @rename_argument("edge_colormap", "border_colormap", "0.6.0") - @rename_argument("edge_contrast_limits", "border_contrast_limits", "0.6.0") def __init__( self, data=None, @@ -2144,28 +2166,39 @@ def __init__( self.events.add( edge_width=deprecation_warning_event( - "layer.events", "edge_width", "border_width", "0.6.0" + "layer.events", + "edge_width", + "border_width", + since_version="0.5.0", + version="0.6.0", ), current_edge_width=deprecation_warning_event( "layer.events", "current_edge_width", "current_border_width", - "0.6.0", + since_version="0.5.0", + version="0.6.0", ), edge_width_is_relative=deprecation_warning_event( "layer.events", "edge_width_is_relative", "border_width_is_relative", - "0.6.0", + since_version="0.5.0", + version="0.6.0", ), edge_color=deprecation_warning_event( - "layer.events", "edge_color", "border_color", "0.6.0" + "layer.events", + "edge_color", + "border_color", + since_version="0.5.0", + version="0.6.0", ), current_edge_color=deprecation_warning_event( "layer.events", "current_edge_color", "current_border_color", - "0.6.0", + since_version="0.5.0", + version="0.6.0", ), ) @@ -2185,7 +2218,13 @@ def _add_deprecated_properties(cls) -> None: ] for old_property in deprecated_properties: new_property = old_property.replace("edge", "border") - add_deprecated_property(cls, old_property, new_property, "0.6.0") + add_deprecated_property( + cls, + old_property, + new_property, + since_version="0.5.0", + version="0.6.0", + ) @property def _points_data(self) -> np.ndarray: diff --git a/napari/utils/_tests/test_migrations.py b/napari/utils/_tests/test_migrations.py index 0617b4fa546..67acfec1ac3 100644 --- a/napari/utils/_tests/test_migrations.py +++ b/napari/utils/_tests/test_migrations.py @@ -43,7 +43,9 @@ def new_property(self, value: int) -> int: instance = Dummy() - add_deprecated_property(Dummy, "old_property", "new_property", "0.0.0") + add_deprecated_property( + Dummy, "old_property", "new_property", "0.1.0", "0.0.0" + ) assert instance.new_property == 0 diff --git a/napari/utils/events/migrations.py b/napari/utils/events/migrations.py index b776704657a..c42987992b2 100644 --- a/napari/utils/events/migrations.py +++ b/napari/utils/events/migrations.py @@ -7,6 +7,7 @@ def deprecation_warning_event( previous_name: str, new_name: str, version: str, + since_version: str, ) -> WarningEmitter: """ Helper function for event emitter deprecation warning. @@ -23,6 +24,8 @@ def deprecation_warning_event( Name of new event (e.g. border_width) version : str Version where deprecated event will be removed. + since_version : str + Version when new event name was added. Returns ------- @@ -33,7 +36,7 @@ def deprecation_warning_event( new_path = f"{prefix}.{new_name}" return WarningEmitter( trans._( - f"{previous_path} is deprecated and will be removed in {version}. Please use {new_path}", + f"{previous_path} is deprecated since {since_version} and will be removed in {version}. Please use {new_path}", deferred=True, ), warn_on_connect=False, diff --git a/napari/utils/migrations.py b/napari/utils/migrations.py index 1dc74e54e2c..f09ff2d589a 100644 --- a/napari/utils/migrations.py +++ b/napari/utils/migrations.py @@ -69,6 +69,7 @@ def add_deprecated_property( previous_name: str, new_name: str, version: str, + since_version: str, ) -> None: """ Adds deprecated property and links to new property name setter and getter. @@ -83,6 +84,8 @@ def add_deprecated_property( Name of new property, must have its setter and getter implemented. version : str Version where deprecated property will be removed. + since_version : str + version when new property was added """ if hasattr(obj, previous_name): @@ -92,7 +95,7 @@ def add_deprecated_property( raise RuntimeError(f"{new_name} property must exists.") msg = trans._( - f"{previous_name} is deprecated and will be removed in {version}. Please use {new_name}", + f"{previous_name} is deprecated since {since_version} and will be removed in {version}. Please use {new_name}", deferred=True, ) From bf509a815f62f42597350b73da116d9ea98d5c99 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 16 Jun 2023 11:36:21 -0700 Subject: [PATCH 037/105] using to_napari_graph API --- napari/layers/graph/graph.py | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 35786965f07..420695d8ef1 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,6 +1,5 @@ from typing import Any, Dict, Optional, Tuple, Union -import networkx as nx import numpy as np from numpy.typing import ArrayLike @@ -11,12 +10,12 @@ from napari.utils.translations import trans try: - from napari_graph import BaseGraph, UndirectedGraph, from_networkx + from napari_graph import BaseGraph, UndirectedGraph, to_napari_graph except ModuleNotFoundError: BaseGraph = None UndirectedGraph = None - from_networkx = None + to_napari_graph = None class Graph(_BasePoints): @@ -344,8 +343,7 @@ def _fix_data( # empty but pre-allocated graph return UndirectedGraph(ndim=ndim) - if isinstance(data, nx.Graph): - data = from_networkx(data) + data = to_napari_graph(data) if isinstance(data, BaseGraph): if data._coords is None: @@ -356,32 +354,7 @@ def _fix_data( ) return data - try: - arr_data = np.atleast_2d(data) - except ValueError as err: - raise NotImplementedError( - trans._( - "Could not convert to {data} to a napari graph.", - data=data, - ) - ) from err - - if not issubclass(arr_data.dtype.type, np.number): - raise TypeError( - trans._( - "Expected numeric type. Found{dtype}.", - dtype=arr_data.dtype, - ) - ) - - if arr_data.ndim > 2: - raise ValueError( - trans._( - "Graph layer only supports 2-dim arrays. Found {ndim}.", - ndim=arr_data.ndim, - ) - ) - return UndirectedGraph(coords=arr_data) + return data @property def _points_data(self) -> np.ndarray: From 2e16e742e56b3a86cb86c51b394889379f1e3cba Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 16 Jun 2023 13:28:40 -0700 Subject: [PATCH 038/105] using to_napari_graph API --- napari/layers/graph/_tests/test_graph.py | 9 --------- napari/layers/graph/graph.py | 5 ++++- napari/utils/events/_tests/test_event_migrations.py | 4 +++- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 62605c93164..dea56a54613 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -21,15 +21,6 @@ def test_empty_graph() -> None: assert len(graph.data) == 0 -def test_1_dim_array_graph() -> None: - shape = (2,) - - graph = Graph(np.random.random(shape)) - - assert len(graph.data) == 1 - assert graph.ndim == shape[0] - - def test_2_dim_array_graph() -> None: shape = (5, 2) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 420695d8ef1..444534ee9bd 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -343,7 +343,10 @@ def _fix_data( # empty but pre-allocated graph return UndirectedGraph(ndim=ndim) - data = to_napari_graph(data) + try: + data = to_napari_graph(data) + except NotImplementedError as e: + raise TypeError from e if isinstance(data, BaseGraph): if data._coords is None: diff --git a/napari/utils/events/_tests/test_event_migrations.py b/napari/utils/events/_tests/test_event_migrations.py index 7170c77b984..9014a45f273 100644 --- a/napari/utils/events/_tests/test_event_migrations.py +++ b/napari/utils/events/_tests/test_event_migrations.py @@ -4,7 +4,9 @@ def test_deprecation_warning_event() -> None: - event = deprecation_warning_event("obj.events", "old", "new", "0.0.0") + event = deprecation_warning_event( + "obj.events", "old", "new", "0.1.0", "0.0.0" + ) def _print(msg: str) -> None: print(msg) From 98adf824bbeaadf9f9e3b75832ba0710e28aa322 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 20 Jun 2023 11:55:19 -0700 Subject: [PATCH 039/105] improving graph nodes removal --- napari/layers/graph/graph.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 444534ee9bd..d5908ca4654 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -349,7 +349,7 @@ def _fix_data( raise TypeError from e if isinstance(data, BaseGraph): - if data._coords is None: + if not data.is_spatial(): raise ValueError( trans._( "Graph layer must be a spatial graph, have the `coords` attribute (`pos` in NetworkX)." @@ -361,7 +361,7 @@ def _fix_data( @property def _points_data(self) -> np.ndarray: - return self._data._coords + return self._data.coords_buffer @property def data(self) -> BaseGraph: @@ -431,15 +431,29 @@ def add( self._data_changed(prev_size) - def remove_selected(self): + def remove_selected(self) -> None: """Removes selected points if any.""" if len(self.selected_data): - indices = self.data._buffer2world[list(self.selected_data)] - self.remove(indices) + self._remove_nodes(list(self.selected_data), is_buffer_domain=True) self.selected_data = set() def remove(self, indices: ArrayLike) -> None: - """Removes nodes given their indices.""" + """Remove nodes given indices.""" + self._remove_nodes(indices, is_buffer_domain=False) + + def _remove_nodes( + self, + indices: ArrayLike, + is_buffer_domain: bool, + ) -> None: + """ + Parameters + ---------- + indices : ArrayLike + List of node indices to remove. + is_buffer_domain : bool + Indicates if node indices are on world or buffer domain. + """ indices = np.atleast_1d(indices) if indices.ndim > 1: raise ValueError( @@ -450,12 +464,10 @@ def remove(self, indices: ArrayLike) -> None: ) prev_size = self.data.n_allocated_nodes - # descending order - indices = np.flip(np.sort(indices)) # it got error missing __iter__ attribute, but we guarantee by np.atleast_1d call for idx in indices: # type: ignore[union-attr] - self.data.remove_node(idx) + self.data.remove_node(idx, is_buffer_domain) self._data_changed(prev_size) From 761c05e765e447d8a9fadeb05d0413a6c97723bb Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 22 Jun 2023 15:22:55 -0700 Subject: [PATCH 040/105] fixed bug from merge with main --- napari/layers/graph/graph.py | 4 ++-- napari/layers/points/points.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 6b72eb6d1d6..4e850006923 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -517,7 +517,8 @@ def _update_props_and_style(self, data_size: int, prev_size: int) -> None: self._face._update_current_properties(current_properties) self._face._add(n_colors=adding) - for attribute in ("border_width", "shown", "size", "symbol"): + # `shown` must be first due to "refresh" calls inside `attribute`.setters + for attribute in ("shown", "size", "symbol", "border_width"): if attribute == "shown": default_value = True else: @@ -531,7 +532,6 @@ def _update_props_and_style(self, data_size: int, prev_size: int) -> None: def _data_changed(self, prev_size: int) -> None: self._update_props_and_style(self.data.n_allocated_nodes, prev_size) self._update_dims() - self.events.data(value=self.data) def _get_state(self) -> Dict[str, Any]: # FIXME: this method can be removed once 'properties' argument is deprecreated. diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 1130c0eb177..dc331b4b907 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -1652,7 +1652,12 @@ def _move( shift = np.array(position)[disp] - center - self._drag_start self._move_points(ixgrid, shift) self.refresh() - self.events.data(value=self.data) + self.events.data( + value=self.data, + action=ActionType.CHANGE.value, + data_indices=tuple(selection_indices), + vertex_indices=((),), + ) @abstractmethod def _move_points( @@ -2318,7 +2323,6 @@ def data(self, data: Optional[np.ndarray]): self.selected_data = set(np.arange(cur_npoints, len(data))) self._update_dims() - self.events.data(value=self.data) self._reset_editable() def _get_ndim(self) -> int: From fd531c4fb420d617a4f2cb162471b900cd474524 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 23 Jun 2023 09:31:43 -0700 Subject: [PATCH 041/105] updating to new graph API --- napari/layers/graph/_slice.py | 30 ++++++------------------------ napari/layers/graph/graph.py | 22 +--------------------- 2 files changed, 7 insertions(+), 45 deletions(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index bc17749f350..268f997741e 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -144,7 +144,9 @@ def _get_out_of_display_slice_data( scale = np.prod(scale_per_dim, axis=1) valid_nodes[valid_nodes] = matches slice_indices = np.where(valid_nodes)[0].astype(int) - edge_indices = self._valid_edges(valid_nodes) + edge_indices = self.data.subgraph_edges( + slice_indices, is_buffer_domain=True + ) return slice_indices, edge_indices, scale def _get_slice_data( @@ -162,27 +164,7 @@ def _get_slice_data( matches = np.all(distances <= 0.5, axis=1) valid_nodes[valid_nodes] = matches slice_indices = np.where(valid_nodes)[0].astype(int) - edge_indices = self._valid_edges(valid_nodes) + edge_indices = self.data.subgraph_edges( + slice_indices, is_buffer_domain=True + ) return slice_indices, edge_indices, 1 - - def _valid_edges( - self, - nodes_mask: np.ndarray, - ) -> np.ndarray: - """Compute edges (node pair) where both nodes are presents. - - Parameters - ---------- - nodes_mask : np.ndarray - Binary mask of available nodes. - - Returns - ------- - np.ndarray - (N x 2) array of nodes indices, where N is the number of valid edges. - """ - _, edges = self.data.get_edges_buffers(is_buffer_domain=True) - valid_edges = edges[ - np.logical_and(nodes_mask[edges[:, 0]], nodes_mask[edges[:, 1]]) - ] - return valid_edges diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 4e850006923..d133be0816d 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -407,28 +407,8 @@ def add( coords : sequence of indices to add point at indices : optional indices of the newly inserted nodes. """ - coords = np.atleast_2d(coords) - if indices is None: - new_starting_idx = self.data._buffer2world.max() + 1 - indices = np.arange( - new_starting_idx, new_starting_idx + len(coords) - ) - - indices = np.atleast_1d(indices) - - if len(coords) != len(indices): - raise ValueError( - trans._( - 'coordinates and indices must have the same length. Found {coords_size} and {idx_size}', - coords_size=len(coords), - idx_size=len(indices), - ) - ) - prev_size = self.data.n_allocated_nodes - - self.data.add_nodes(indices, coords) - + self.data.add_nodes(indices=indices, coords=coords) self._data_changed(prev_size) def remove_selected(self) -> None: From 41eb555b72da345a9288777ea9e2c9bd27b0328f Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 27 Jun 2023 09:14:13 -0700 Subject: [PATCH 042/105] fixing magicgui tests --- napari/_tests/utils.py | 3 +++ napari/types.py | 1 + 2 files changed, 4 insertions(+) diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index e08f8e20e77..59ccdf36fc0 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -46,6 +46,8 @@ Used as pytest params for testing layer add and view functionality (Layer class, data, ndim) """ layer_test_data = [ + (Graph, 20 * np.random.random((10, 2)), 2), # only nodes, no edges + (Graph, 20 * np.random.random((10, 3)), 3), # only nodes, no edges (Image, np.random.random((10, 15)), 2), (Image, np.random.random((10, 15, 20)), 3), (Image, np.random.random((5, 10, 15, 20)), 4), @@ -108,6 +110,7 @@ (np.random.random((10, 10, 3)), {'rgb': True}), (np.random.randint(20, size=(10, 15)), {'seed': 0.3}, 'labels'), (np.random.random((10, 2)) * 20, {'face_color': 'blue'}, 'points'), + (np.random.random((10, 2)) * 20, {'border_color': 'blue'}, 'graph'), (np.random.random((10, 2, 2)) * 20, {}, 'vectors'), (np.random.random((10, 4, 2)) * 20, {'opacity': 1}, 'shapes'), ( diff --git a/napari/types.py b/napari/types.py index 7fd6636e36e..5346fbdf53a 100644 --- a/napari/types.py +++ b/napari/types.py @@ -130,6 +130,7 @@ class SampleDict(TypedDict): TracksData = NewType("TracksData", np.ndarray) VectorsData = NewType("VectorsData", np.ndarray) _LayerData = Union[ + GraphData, ImageData, LabelsData, PointsData, From b1a34827246e7ded6fcd4d13ceda2d0ffb877cdf Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 27 Jun 2023 10:05:57 -0700 Subject: [PATCH 043/105] fixed viewer and types tests --- napari/__init__.py | 1 + napari/__init__.pyi | 2 ++ napari/_tests/utils.py | 27 +++++++++++++++++++--- napari/layers/_tests/test_data_protocol.py | 6 +++-- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/napari/__init__.py b/napari/__init__.py index ba1d5572c7f..75254c516c9 100644 --- a/napari/__init__.py +++ b/napari/__init__.py @@ -29,6 +29,7 @@ 'utils': ['sys_info'], 'utils.notifications': ['notification_manager'], 'view_layers': [ + 'view_graph', 'view_image', 'view_labels', 'view_path', diff --git a/napari/__init__.pyi b/napari/__init__.pyi index 1354203c169..6709ed1e49d 100644 --- a/napari/__init__.pyi +++ b/napari/__init__.pyi @@ -2,6 +2,7 @@ import napari.utils.notifications from napari._qt.qt_event_loop import gui_qt, run from napari.plugins.io import save_layers from napari.view_layers import ( + view_graph, view_image, view_labels, view_path, @@ -20,6 +21,7 @@ notification_manager: napari.utils.notifications.NotificationManager __all__ = ( 'Viewer', 'current_viewer', + 'view_graph', 'view_image', 'view_labels', 'view_path', diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index 59ccdf36fc0..9505896cd5c 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -24,6 +24,15 @@ from napari.layers._data_protocols import Index, LayerDataProtocol from napari.utils.color import ColorArray +try: + from napari_graph import to_napari_graph + +except ModuleNotFoundError: + + def to_napari_graph(x): + return x + + skip_on_win_ci = pytest.mark.skipif( sys.platform.startswith('win') and os.getenv('CI', '0') != '0', reason='Screenshot tests are not supported on windows CI.', @@ -46,8 +55,16 @@ Used as pytest params for testing layer add and view functionality (Layer class, data, ndim) """ layer_test_data = [ - (Graph, 20 * np.random.random((10, 2)), 2), # only nodes, no edges - (Graph, 20 * np.random.random((10, 3)), 3), # only nodes, no edges + ( + Graph, + to_napari_graph(20 * np.random.random((10, 2))), + 2, + ), # only nodes, no edges + ( + Graph, + to_napari_graph(20 * np.random.random((10, 3))), + 3, + ), # only nodes, no edges (Image, np.random.random((10, 15)), 2), (Image, np.random.random((10, 15, 20)), 3), (Image, np.random.random((5, 10, 15, 20)), 4), @@ -110,7 +127,11 @@ (np.random.random((10, 10, 3)), {'rgb': True}), (np.random.randint(20, size=(10, 15)), {'seed': 0.3}, 'labels'), (np.random.random((10, 2)) * 20, {'face_color': 'blue'}, 'points'), - (np.random.random((10, 2)) * 20, {'border_color': 'blue'}, 'graph'), + ( + to_napari_graph(np.random.random((10, 2))), + {'border_color': 'blue'}, + 'graph', + ), (np.random.random((10, 2, 2)) * 20, {}, 'vectors'), (np.random.random((10, 4, 2)) * 20, {'opacity': 1}, 'shapes'), ( diff --git a/napari/layers/_tests/test_data_protocol.py b/napari/layers/_tests/test_data_protocol.py index 40dd8047568..e26836b80e3 100644 --- a/napari/layers/_tests/test_data_protocol.py +++ b/napari/layers/_tests/test_data_protocol.py @@ -1,10 +1,12 @@ import pytest from napari._tests.utils import layer_test_data -from napari.layers import Shapes, Surface +from napari.layers import Graph, Shapes, Surface from napari.layers._data_protocols import assert_protocol -EASY_TYPES = [i for i in layer_test_data if i[0] not in (Shapes, Surface)] +EASY_TYPES = [ + i for i in layer_test_data if i[0] not in (Graph, Shapes, Surface) +] def _layer_test_data_id(test_data): From 78cc0bcbd4b458223ce3f0eba8e35969cd9a8af5 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 27 Jun 2023 10:18:57 -0700 Subject: [PATCH 044/105] fixing typing --- napari/layers/graph/_slice.py | 2 +- napari/layers/graph/graph.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 268f997741e..afdc47be27f 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -67,7 +67,7 @@ class _GraphSliceRequest: dims_indices: Any = field(repr=False) size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) - id: int = field(default=_next_request_id) + id: int = field(default_factory=_next_request_id) def __call__(self) -> _GraphSliceResponse: # Return early if no data diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index d133be0816d..d4ca777dffd 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,7 +1,8 @@ -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union, cast import numpy as np from numpy.typing import ArrayLike +from psygnal.containers import Selection from napari.layers.graph._slice import _GraphSliceRequest, _GraphSliceResponse from napari.layers.points.points import _BasePoints @@ -415,7 +416,7 @@ def remove_selected(self) -> None: """Removes selected points if any.""" if len(self.selected_data): self._remove_nodes(list(self.selected_data), is_buffer_domain=True) - self.selected_data = set() + self.selected_data = cast(Selection[int], set()) def remove(self, indices: ArrayLike) -> None: """Remove nodes given indices.""" From 3f4cfd89979b558d644652395e0d1cb4f6c5cea7 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 7 Jul 2023 10:32:42 -0700 Subject: [PATCH 045/105] testing CI with numba>=0.57.0 --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 8812b312754..fd3c5b2a122 100644 --- a/setup.cfg +++ b/setup.cfg @@ -133,6 +133,7 @@ testing = qtconsole>=4.5.1 rich>=12.0.0 napari-plugin-manager >=0.1.0a2, <0.2.0 + numba>=0.57.0 napari-graph>0.1.0 release = PyGithub>=1.44.1 From 9cd83950edc427b0e62bd3611a09a63986bb1f87 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 7 Jul 2023 11:27:04 -0700 Subject: [PATCH 046/105] added removed napari-graph install from python=3.11 and made napari-graph tests optional --- napari/_tests/utils.py | 43 +++++++++++++++++++++++------------- napari/layers/graph/graph.py | 5 +++++ setup.cfg | 3 +-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index 9505896cd5c..bb6ac7171c3 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -55,16 +55,6 @@ def to_napari_graph(x): Used as pytest params for testing layer add and view functionality (Layer class, data, ndim) """ layer_test_data = [ - ( - Graph, - to_napari_graph(20 * np.random.random((10, 2))), - 2, - ), # only nodes, no edges - ( - Graph, - to_napari_graph(20 * np.random.random((10, 3))), - 3, - ), # only nodes, no edges (Image, np.random.random((10, 15)), 2), (Image, np.random.random((10, 15, 20)), 3), (Image, np.random.random((5, 10, 15, 20)), 4), @@ -107,6 +97,24 @@ def to_napari_graph(x): ), ] + +if Graph.napari_graph_installed(): + layer_test_data.extend( + [ + ( + Graph, + to_napari_graph(20 * np.random.random((10, 2))), + 2, + ), # only nodes, no edges + ( + Graph, + to_napari_graph(20 * np.random.random((10, 3))), + 3, + ), # only nodes, no edges + ] + ) + + with suppress(ModuleNotFoundError): import tensorstore as ts @@ -127,11 +135,6 @@ def to_napari_graph(x): (np.random.random((10, 10, 3)), {'rgb': True}), (np.random.randint(20, size=(10, 15)), {'seed': 0.3}, 'labels'), (np.random.random((10, 2)) * 20, {'face_color': 'blue'}, 'points'), - ( - to_napari_graph(np.random.random((10, 2))), - {'border_color': 'blue'}, - 'graph', - ), (np.random.random((10, 2, 2)) * 20, {}, 'vectors'), (np.random.random((10, 4, 2)) * 20, {'opacity': 1}, 'shapes'), ( @@ -146,6 +149,16 @@ def to_napari_graph(x): ] +if Graph.napari_graph_installed(): + good_layer_data.append( + ( + to_napari_graph(np.random.random((10, 2))), + {'border_color': 'blue'}, + 'graph', + ) + ) + + class LockableData: """A wrapper for napari layer data that blocks read-access with a lock. diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index d4ca777dffd..425c7b07fa5 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -520,3 +520,8 @@ def _get_state(self) -> Dict[str, Any]: state.pop("properties", None) state.pop("property_choices", None) return state + + @staticmethod + def napari_graph_installed() -> bool: + """Check if napari_graph is installed.""" + return BaseGraph is not None diff --git a/setup.cfg b/setup.cfg index fd3c5b2a122..44d96040acb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -133,8 +133,7 @@ testing = qtconsole>=4.5.1 rich>=12.0.0 napari-plugin-manager >=0.1.0a2, <0.2.0 - numba>=0.57.0 - napari-graph>0.1.0 + napari-graph>0.1.0 ; python_version < '3.11' release = PyGithub>=1.44.1 twine>=3.1.1 From 0b289eefd060b94110f4240d91aa100e2c5defc7 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Sun, 9 Jul 2023 17:12:35 -0700 Subject: [PATCH 047/105] downgranding numpy constraints --- resources/constraints/constraints_py3.10.txt | 2 +- resources/constraints/constraints_py3.11.txt | 2 +- resources/constraints/constraints_py3.9.txt | 2 +- resources/requirements_mypy.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/constraints/constraints_py3.10.txt b/resources/constraints/constraints_py3.10.txt index 2e0b30cc139..6471e2ee9e3 100644 --- a/resources/constraints/constraints_py3.10.txt +++ b/resources/constraints/constraints_py3.10.txt @@ -192,7 +192,7 @@ npe2==0.7.0 # napari-plugin-manager numcodecs==0.11.0 # via zarr -numpy==1.25.0 +numpy==1.24.4 # via # contourpy # dask diff --git a/resources/constraints/constraints_py3.11.txt b/resources/constraints/constraints_py3.11.txt index d3a85530747..6f9c510dd3d 100644 --- a/resources/constraints/constraints_py3.11.txt +++ b/resources/constraints/constraints_py3.11.txt @@ -188,7 +188,7 @@ npe2==0.7.0 # napari-plugin-manager numcodecs==0.11.0 # via zarr -numpy==1.25.0 +numpy==1.24.4 # via # contourpy # dask diff --git a/resources/constraints/constraints_py3.9.txt b/resources/constraints/constraints_py3.9.txt index a2521a3aa06..a16e58697dc 100644 --- a/resources/constraints/constraints_py3.9.txt +++ b/resources/constraints/constraints_py3.9.txt @@ -197,7 +197,7 @@ npe2==0.7.0 # napari-plugin-manager numcodecs==0.11.0 # via zarr -numpy==1.25.0 +numpy==1.24.4 # via # contourpy # dask diff --git a/resources/requirements_mypy.txt b/resources/requirements_mypy.txt index 88dcaefedfd..c840896d9f0 100644 --- a/resources/requirements_mypy.txt +++ b/resources/requirements_mypy.txt @@ -26,7 +26,7 @@ mypy-extensions==1.0.0 # psygnal npe2==0.7.0 # via -r resources/requirements_mypy.in -numpy==1.25.0 +numpy==1.24.4 # via -r resources/requirements_mypy.in packaging==23.1 # via From b7c18888625db54bd9793b4a297033800d345bfb Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Mon, 17 Jul 2023 08:21:58 -0700 Subject: [PATCH 048/105] updated npe2 requirement --- resources/constraints/constraints_py3.10.txt | 2 +- resources/constraints/constraints_py3.11.txt | 2 +- resources/constraints/constraints_py3.8.txt | 2 +- resources/constraints/constraints_py3.9.txt | 2 +- resources/constraints/constraints_py3.9_examples.txt | 2 +- resources/requirements_mypy.txt | 2 +- setup.cfg | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/resources/constraints/constraints_py3.10.txt b/resources/constraints/constraints_py3.10.txt index 2e0b30cc139..86225a0ebe4 100644 --- a/resources/constraints/constraints_py3.10.txt +++ b/resources/constraints/constraints_py3.10.txt @@ -186,7 +186,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.1 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.11.txt b/resources/constraints/constraints_py3.11.txt index d3a85530747..2e6ae4bd722 100644 --- a/resources/constraints/constraints_py3.11.txt +++ b/resources/constraints/constraints_py3.11.txt @@ -182,7 +182,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.1 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.8.txt b/resources/constraints/constraints_py3.8.txt index 32c7ea12f29..0d7c6ba536d 100644 --- a/resources/constraints/constraints_py3.8.txt +++ b/resources/constraints/constraints_py3.8.txt @@ -193,7 +193,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.1 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.9.txt b/resources/constraints/constraints_py3.9.txt index a2521a3aa06..e3ff97ef5f2 100644 --- a/resources/constraints/constraints_py3.9.txt +++ b/resources/constraints/constraints_py3.9.txt @@ -191,7 +191,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.1 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.9_examples.txt b/resources/constraints/constraints_py3.9_examples.txt index 0d2312a941f..d7b496d8d1d 100644 --- a/resources/constraints/constraints_py3.9_examples.txt +++ b/resources/constraints/constraints_py3.9_examples.txt @@ -201,7 +201,7 @@ nibabel==5.1.0 # via nilearn nilearn==0.10.1 # via -r resources/constraints/version_denylist_examples.txt -npe2==0.7.0 +npe2==0.7.1 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/requirements_mypy.txt b/resources/requirements_mypy.txt index 88dcaefedfd..16390667145 100644 --- a/resources/requirements_mypy.txt +++ b/resources/requirements_mypy.txt @@ -24,7 +24,7 @@ mypy-extensions==1.0.0 # via # mypy # psygnal -npe2==0.7.0 +npe2==0.7.1 # via -r resources/requirements_mypy.in numpy==1.25.0 # via -r resources/requirements_mypy.in diff --git a/setup.cfg b/setup.cfg index 44d96040acb..9f1103ba2ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -55,7 +55,7 @@ install_requires = napari-console>=0.0.6 napari-plugin-engine>=0.1.9 napari-svg>=0.1.7 - npe2>=0.5.2 + npe2>=0.7.1 numpy>=1.21 numpydoc>=0.9.2 pandas>=1.1.0 ; python_version < '3.9' From ba3becab405ecb21f5cd78f01db1e0196215c265 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 18 Jul 2023 11:34:54 -0700 Subject: [PATCH 049/105] removed napari graph not optional --- napari/_tests/utils.py | 52 ++++++------------- .../_vispy/_tests/test_vispy_graph_layer.py | 11 ++-- napari/layers/graph/_slice.py | 7 +-- napari/layers/graph/_tests/test_graph.py | 7 +-- napari/layers/graph/graph.py | 14 +---- napari/types.py | 8 +-- setup.cfg | 7 +-- 7 files changed, 30 insertions(+), 76 deletions(-) diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index bb6ac7171c3..55aef1c815a 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd import pytest +from napari_graph import to_napari_graph from numpy.typing import DTypeLike from napari import Viewer @@ -24,15 +25,6 @@ from napari.layers._data_protocols import Index, LayerDataProtocol from napari.utils.color import ColorArray -try: - from napari_graph import to_napari_graph - -except ModuleNotFoundError: - - def to_napari_graph(x): - return x - - skip_on_win_ci = pytest.mark.skipif( sys.platform.startswith('win') and os.getenv('CI', '0') != '0', reason='Screenshot tests are not supported on windows CI.', @@ -95,26 +87,19 @@ def to_napari_graph(x): ), 4, ), + ( + Graph, + to_napari_graph(20 * np.random.random((10, 2))), + 2, + ), # only nodes, no edges + ( + Graph, + to_napari_graph(20 * np.random.random((10, 3))), + 3, + ), # only nodes, no edges ] -if Graph.napari_graph_installed(): - layer_test_data.extend( - [ - ( - Graph, - to_napari_graph(20 * np.random.random((10, 2))), - 2, - ), # only nodes, no edges - ( - Graph, - to_napari_graph(20 * np.random.random((10, 3))), - 3, - ), # only nodes, no edges - ] - ) - - with suppress(ModuleNotFoundError): import tensorstore as ts @@ -146,19 +131,14 @@ def to_napari_graph(x): {'name': 'some surface'}, 'surface', ), + ( + to_napari_graph(np.random.random((10, 2))), + {'border_color': 'blue'}, + 'graph', + ), ] -if Graph.napari_graph_installed(): - good_layer_data.append( - ( - to_napari_graph(np.random.random((10, 2))), - {'border_color': 'blue'}, - 'graph', - ) - ) - - class LockableData: """A wrapper for napari layer data that blocks read-access with a lock. diff --git a/napari/_vispy/_tests/test_vispy_graph_layer.py b/napari/_vispy/_tests/test_vispy_graph_layer.py index adb93692c5a..30017353d39 100644 --- a/napari/_vispy/_tests/test_vispy_graph_layer.py +++ b/napari/_vispy/_tests/test_vispy_graph_layer.py @@ -2,18 +2,15 @@ import numpy as np import pytest - -from napari._vispy.layers.graph import VispyGraphLayer -from napari.layers import Graph - -pytest.importorskip("napari_graph") - -from napari_graph import ( # noqa: E402 +from napari_graph import ( BaseGraph, DirectedGraph, UndirectedGraph, ) +from napari._vispy.layers.graph import VispyGraphLayer +from napari.layers import Graph + @pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) def test_vispy_graph_layer(graph_class: Type[BaseGraph]) -> None: diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index afdc47be27f..342264aa00c 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -2,18 +2,13 @@ from typing import Any, Sequence, Tuple import numpy as np +from napari_graph import BaseGraph from numpy.typing import ArrayLike from napari.layers.base._slice import _next_request_id from napari.layers.points._slice import _PointSliceResponse from napari.layers.utils._slice_input import _SliceInput -try: - from napari_graph import BaseGraph - -except ModuleNotFoundError: - BaseGraph = None - @dataclass(frozen=True) class _GraphSliceResponse(_PointSliceResponse): diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index dea56a54613..aaca90c9b6f 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -4,16 +4,13 @@ import numpy as np import pandas as pd import pytest - -pytest.importorskip("napari_graph") - -from napari_graph import ( # noqa: E402 +from napari_graph import ( BaseGraph, DirectedGraph, UndirectedGraph, ) -from napari.layers import Graph # noqa: E402 +from napari.layers import Graph def test_empty_graph() -> None: diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 425c7b07fa5..62bf7c19e45 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -1,6 +1,7 @@ from typing import Any, Dict, Optional, Tuple, Union, cast import numpy as np +from napari_graph import BaseGraph, UndirectedGraph, to_napari_graph from numpy.typing import ArrayLike from psygnal.containers import Selection @@ -10,14 +11,6 @@ from napari.utils.events import Event from napari.utils.translations import trans -try: - from napari_graph import BaseGraph, UndirectedGraph, to_napari_graph - -except ModuleNotFoundError: - BaseGraph = None - UndirectedGraph = None - to_napari_graph = None - class Graph(_BasePoints): """ @@ -520,8 +513,3 @@ def _get_state(self) -> Dict[str, Any]: state.pop("properties", None) state.pop("property_choices", None) return state - - @staticmethod - def napari_graph_installed() -> bool: - """Check if napari_graph is installed.""" - return BaseGraph is not None diff --git a/napari/types.py b/napari/types.py index 5346fbdf53a..bb7b340f6e3 100644 --- a/napari/types.py +++ b/napari/types.py @@ -18,6 +18,7 @@ ) import numpy as np +from napari_graph import BaseGraph from typing_extensions import TypedDict, get_args if TYPE_CHECKING: @@ -29,11 +30,6 @@ from magicgui.widgets import FunctionGui from qtpy.QtWidgets import QWidget # type: ignore [attr-defined] -try: - from napari_graph import BaseGraph - -except ModuleNotFoundError: - BaseGraph = Any __all__ = [ 'ArrayLike', @@ -121,7 +117,7 @@ class SampleDict(TypedDict): ArrayBase: Type[np.ndarray] = np.ndarray -GraphData = NewType("GraphData", BaseGraph) # type: ignore [valid-newtype] +GraphData = NewType("GraphData", BaseGraph) ImageData = NewType("ImageData", np.ndarray) LabelsData = NewType("LabelsData", np.ndarray) PointsData = NewType("PointsData", np.ndarray) diff --git a/setup.cfg b/setup.cfg index 9f1103ba2ff..e91556fccd5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -80,6 +80,7 @@ install_requires = typing_extensions>=4.2.0 vispy>=0.12.1,<0.13 wrapt>=1.11.1 + napari-graph>=0.2.0 [options.package_data] * = *.pyi @@ -113,6 +114,9 @@ all = # optional (i.e. opt-in) packages, see https://github.com/napari/napari/pull/3867#discussion_r864354854 optional = triangle +performance = + triangle + numba testing = babel>=2.9.0 fsspec @@ -133,7 +137,6 @@ testing = qtconsole>=4.5.1 rich>=12.0.0 napari-plugin-manager >=0.1.0a2, <0.2.0 - napari-graph>0.1.0 ; python_version < '3.11' release = PyGithub>=1.44.1 twine>=3.1.1 @@ -150,8 +153,6 @@ build = black ruff pyqt5 -graph = - napari-graph>0.1.0 [options.entry_points] console_scripts = From 6fa5c54790094744dfda5cc8b79f639a28722425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jord=C3=A3o=20Bragantini?= Date: Tue, 18 Jul 2023 11:39:46 -0700 Subject: [PATCH 050/105] Apply suggestions from Juan's code review Co-authored-by: Juan Nunez-Iglesias --- examples/add_graph.py | 7 +++---- examples/nuclei_segmentation_graph.py | 4 ++-- napari/_vispy/visuals/graph.py | 3 +++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/add_graph.py b/examples/add_graph.py index b62b2657b55..7ac120f18b7 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -28,11 +28,10 @@ def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: return graph +graph = build_graph(n_nodes=1_000_000, n_neighbors=5) + viewer = napari.Viewer() -n_nodes = 1000000 -graph = build_graph(n_nodes, 5) -layer = Graph(graph, out_of_slice_display=True) -viewer.add_layer(layer) +layer = viewer.add_graph(graph, out_of_slice_display=True) if __name__ == "__main__": diff --git a/examples/nuclei_segmentation_graph.py b/examples/nuclei_segmentation_graph.py index a24ff7abed2..26b6918caf2 100644 --- a/examples/nuclei_segmentation_graph.py +++ b/examples/nuclei_segmentation_graph.py @@ -34,10 +34,10 @@ def delaunay_edges(points: np.ndarray) -> np.ndarray: nodes_coords = feature.peak_local_max(smooth) edges = delaunay_edges(nodes_coords) graph = UndirectedGraph(edges, nodes_coords) -viewer = napari.view_image( +viewer, image_layer = napari.imshow( cells, channel_axis=1, name=['membranes', 'nuclei'], ndisplay=3 ) -viewer.add_graph(graph) +graph_layer = viewer.add_graph(graph) viewer.camera.angles = (10, -20, 130) if __name__ == '__main__': diff --git a/napari/_vispy/visuals/graph.py b/napari/_vispy/visuals/graph.py index d97b7a06a94..9c49c1665c5 100644 --- a/napari/_vispy/visuals/graph.py +++ b/napari/_vispy/visuals/graph.py @@ -6,4 +6,7 @@ class GraphVisual(PointsVisual): def __init__(self): super().__init__() + # connect='segments' indicates you need start point and end point for + # each segment, rather than just a list of points. This mode means you + # don't need segments to be sorted to display a line. self.add_subvisual(LineVisual(connect='segments')) From 6adaa7d2381abccc2437675c31921bc6c2a3ac76 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Jul 2023 18:40:12 +0000 Subject: [PATCH 051/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/add_graph.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/add_graph.py b/examples/add_graph.py index 7ac120f18b7..c1860de2a8e 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -12,7 +12,6 @@ from napari_graph import UndirectedGraph import napari -from napari.layers import Graph def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: From 08f6f3aa626dce31d59c1364e590eed294219a99 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 18 Jul 2023 11:45:10 -0700 Subject: [PATCH 052/105] rolling back numpy constaints downgrade --- resources/constraints/constraints_py3.10.txt | 2 +- resources/constraints/constraints_py3.11.txt | 2 +- resources/constraints/constraints_py3.9.txt | 2 +- resources/requirements_mypy.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/constraints/constraints_py3.10.txt b/resources/constraints/constraints_py3.10.txt index d5b47fae2bc..86225a0ebe4 100644 --- a/resources/constraints/constraints_py3.10.txt +++ b/resources/constraints/constraints_py3.10.txt @@ -192,7 +192,7 @@ npe2==0.7.1 # napari-plugin-manager numcodecs==0.11.0 # via zarr -numpy==1.24.4 +numpy==1.25.0 # via # contourpy # dask diff --git a/resources/constraints/constraints_py3.11.txt b/resources/constraints/constraints_py3.11.txt index a8e3f743b79..2e6ae4bd722 100644 --- a/resources/constraints/constraints_py3.11.txt +++ b/resources/constraints/constraints_py3.11.txt @@ -188,7 +188,7 @@ npe2==0.7.1 # napari-plugin-manager numcodecs==0.11.0 # via zarr -numpy==1.24.4 +numpy==1.25.0 # via # contourpy # dask diff --git a/resources/constraints/constraints_py3.9.txt b/resources/constraints/constraints_py3.9.txt index 1d1aa1300ec..e3ff97ef5f2 100644 --- a/resources/constraints/constraints_py3.9.txt +++ b/resources/constraints/constraints_py3.9.txt @@ -197,7 +197,7 @@ npe2==0.7.1 # napari-plugin-manager numcodecs==0.11.0 # via zarr -numpy==1.24.4 +numpy==1.25.0 # via # contourpy # dask diff --git a/resources/requirements_mypy.txt b/resources/requirements_mypy.txt index e03c8d24e5c..16390667145 100644 --- a/resources/requirements_mypy.txt +++ b/resources/requirements_mypy.txt @@ -26,7 +26,7 @@ mypy-extensions==1.0.0 # psygnal npe2==0.7.1 # via -r resources/requirements_mypy.in -numpy==1.24.4 +numpy==1.25.0 # via -r resources/requirements_mypy.in packaging==23.1 # via From f4ea0678bdde33ce03ba942180c43e853ba5b4a5 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Wed, 19 Jul 2023 09:00:25 -0700 Subject: [PATCH 053/105] fixing typing --- napari/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/types.py b/napari/types.py index bb7b340f6e3..39f9d5384d0 100644 --- a/napari/types.py +++ b/napari/types.py @@ -117,7 +117,7 @@ class SampleDict(TypedDict): ArrayBase: Type[np.ndarray] = np.ndarray -GraphData = NewType("GraphData", BaseGraph) +GraphData = NewType("GraphData", BaseGraph) # type: ignore [valid-newtype] ImageData = NewType("ImageData", np.ndarray) LabelsData = NewType("LabelsData", np.ndarray) PointsData = NewType("PointsData", np.ndarray) From 1a7123f0827738df4f452f506791737f4c776024 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Wed, 19 Jul 2023 10:30:00 -0700 Subject: [PATCH 054/105] fixed napari-graph numba warning --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 44d4f640320..8ea47c4ddb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,6 +170,7 @@ filterwarnings = [ "ignore:Alternative shading modes are only available in 3D, defaulting to none", "ignore:distutils Version classes are deprecated::", "ignore:There is no current event loop:DeprecationWarning:", + "ignore:numba not installed, falling back to stubs. Install numba for better napari-graph performance.:UserWarning", ] markers = [ "examples: Test of examples", From ad701738fd0d93d21674106403cdb47cdc1e2087 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Wed, 19 Jul 2023 16:01:14 -0700 Subject: [PATCH 055/105] Fixing 6016 errors --- napari/plugins/_tests/test_npe2.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/napari/plugins/_tests/test_npe2.py b/napari/plugins/_tests/test_npe2.py index 5d1d9bb6606..63e33026774 100644 --- a/napari/plugins/_tests/test_npe2.py +++ b/napari/plugins/_tests/test_npe2.py @@ -40,7 +40,8 @@ def test_read(mock_pm: 'TestPluginManager'): mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.some_reader') mock_pm.commands.get.reset_mock() - assert _npe2.read(["some.randomext"], stack=True) is None + with pytest.raises(ValueError): + _npe2.read(["some.randomext"], stack=True) mock_pm.commands.get.assert_not_called() mock_pm.commands.get.reset_mock() @@ -63,8 +64,7 @@ def test_read(mock_pm: 'TestPluginManager'): reason='Older versions of npe2 do not throw specific error.', ) def test_read_with_plugin_failure(mock_pm: 'TestPluginManager'): - match = f"Plugin '{PLUGIN_NAME}' was selected" - with pytest.raises(ValueError, match=match): + with pytest.raises(ValueError): _npe2.read(["some.randomext"], stack=True, plugin=PLUGIN_NAME) From 6b576a4cf2de79fa9b26cefe00b271841bb34622 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Wed, 19 Jul 2023 17:03:35 -0700 Subject: [PATCH 056/105] updated requirements --- resources/constraints/constraints_py3.10.txt | 2 +- resources/constraints/constraints_py3.11.txt | 2 +- resources/constraints/constraints_py3.8.txt | 2 +- resources/constraints/constraints_py3.9.txt | 2 +- resources/constraints/constraints_py3.9_examples.txt | 2 +- resources/requirements_mypy.txt | 2 +- setup.cfg | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/resources/constraints/constraints_py3.10.txt b/resources/constraints/constraints_py3.10.txt index 2e0b30cc139..4a3eca57dc8 100644 --- a/resources/constraints/constraints_py3.10.txt +++ b/resources/constraints/constraints_py3.10.txt @@ -186,7 +186,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.11.txt b/resources/constraints/constraints_py3.11.txt index d3a85530747..62a08b7d5bd 100644 --- a/resources/constraints/constraints_py3.11.txt +++ b/resources/constraints/constraints_py3.11.txt @@ -182,7 +182,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.8.txt b/resources/constraints/constraints_py3.8.txt index 32c7ea12f29..bd5cc33447d 100644 --- a/resources/constraints/constraints_py3.8.txt +++ b/resources/constraints/constraints_py3.8.txt @@ -193,7 +193,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.9.txt b/resources/constraints/constraints_py3.9.txt index a2521a3aa06..0c565938bcc 100644 --- a/resources/constraints/constraints_py3.9.txt +++ b/resources/constraints/constraints_py3.9.txt @@ -191,7 +191,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.0 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.9_examples.txt b/resources/constraints/constraints_py3.9_examples.txt index 0d2312a941f..a54d40095b6 100644 --- a/resources/constraints/constraints_py3.9_examples.txt +++ b/resources/constraints/constraints_py3.9_examples.txt @@ -201,7 +201,7 @@ nibabel==5.1.0 # via nilearn nilearn==0.10.1 # via -r resources/constraints/version_denylist_examples.txt -npe2==0.7.0 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/requirements_mypy.txt b/resources/requirements_mypy.txt index 88dcaefedfd..5eccf538946 100644 --- a/resources/requirements_mypy.txt +++ b/resources/requirements_mypy.txt @@ -24,7 +24,7 @@ mypy-extensions==1.0.0 # via # mypy # psygnal -npe2==0.7.0 +npe2==0.7.2 # via -r resources/requirements_mypy.in numpy==1.25.0 # via -r resources/requirements_mypy.in diff --git a/setup.cfg b/setup.cfg index 2d009a8cb41..2ec1d4c4263 100644 --- a/setup.cfg +++ b/setup.cfg @@ -55,7 +55,7 @@ install_requires = napari-console>=0.0.6 napari-plugin-engine>=0.1.9 napari-svg>=0.1.7 - npe2>=0.5.2 + npe2>=0.7.2 numpy>=1.21 numpydoc>=0.9.2 pandas>=1.1.0 ; python_version < '3.9' From 3731322082b1937eaf6ed2c7b1ca48d21d88f466 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 21 Jul 2023 09:52:41 -0700 Subject: [PATCH 057/105] fix requirements --- resources/constraints/constraints_py3.10.txt | 2 +- resources/constraints/constraints_py3.11.txt | 2 +- resources/constraints/constraints_py3.8.txt | 2 +- resources/constraints/constraints_py3.9.txt | 2 +- resources/constraints/constraints_py3.9_examples.txt | 2 +- resources/requirements_mypy.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/resources/constraints/constraints_py3.10.txt b/resources/constraints/constraints_py3.10.txt index 6f443922a72..4001dc389ad 100644 --- a/resources/constraints/constraints_py3.10.txt +++ b/resources/constraints/constraints_py3.10.txt @@ -189,7 +189,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.1 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.11.txt b/resources/constraints/constraints_py3.11.txt index ff34405898b..e730cef76b2 100644 --- a/resources/constraints/constraints_py3.11.txt +++ b/resources/constraints/constraints_py3.11.txt @@ -185,7 +185,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.1 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.8.txt b/resources/constraints/constraints_py3.8.txt index a1732fc4206..74edd07300b 100644 --- a/resources/constraints/constraints_py3.8.txt +++ b/resources/constraints/constraints_py3.8.txt @@ -197,7 +197,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.1 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.9.txt b/resources/constraints/constraints_py3.9.txt index 202e5b33389..6da6727a95b 100644 --- a/resources/constraints/constraints_py3.9.txt +++ b/resources/constraints/constraints_py3.9.txt @@ -194,7 +194,7 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.1 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/constraints/constraints_py3.9_examples.txt b/resources/constraints/constraints_py3.9_examples.txt index 4157e86662c..a82fa899b01 100644 --- a/resources/constraints/constraints_py3.9_examples.txt +++ b/resources/constraints/constraints_py3.9_examples.txt @@ -204,7 +204,7 @@ nibabel==5.1.0 # via nilearn nilearn==0.10.1 # via -r resources/constraints/version_denylist_examples.txt -npe2==0.7.1 +npe2==0.7.2 # via # napari (setup.cfg) # napari-plugin-manager diff --git a/resources/requirements_mypy.txt b/resources/requirements_mypy.txt index 74b31698cba..8ba71a2c8dd 100644 --- a/resources/requirements_mypy.txt +++ b/resources/requirements_mypy.txt @@ -24,7 +24,7 @@ mypy-extensions==1.0.0 # via # mypy # psygnal -npe2==0.7.1 +npe2==0.7.2 # via -r resources/requirements_mypy.in numpy==1.25.1 # via -r resources/requirements_mypy.in From 0b39ce587a621b7efd244773c154e8cd1df6808b Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 21 Jul 2023 16:10:24 -0700 Subject: [PATCH 058/105] minor comments fix --- napari/layers/graph/_tests/test_graph.py | 2 ++ napari/layers/points/_slice.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index aaca90c9b6f..e8ed7f7c9d7 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -21,6 +21,7 @@ def test_empty_graph() -> None: def test_2_dim_array_graph() -> None: shape = (5, 2) + np.random.seed(1) graph = Graph(np.random.random(shape)) assert len(graph.data) == shape[0] @@ -30,6 +31,7 @@ def test_2_dim_array_graph() -> None: def test_3_dim_array_graph() -> None: shape = (5, 2, 2) + np.random.seed(1) with pytest.raises(ValueError): Graph(np.random.random(shape)) diff --git a/napari/layers/points/_slice.py b/napari/layers/points/_slice.py index 9ada0fa1d33..5e4b46f6c4a 100644 --- a/napari/layers/points/_slice.py +++ b/napari/layers/points/_slice.py @@ -26,7 +26,7 @@ class _PointSliceResponse: """ indices: ArrayLike = field(repr=False) - scale: Union[ArrayLike, float, int] = field(repr=False) + scale: Union[ArrayLike, float] = field(repr=False) dims: _SliceInput request_id: int @@ -79,7 +79,7 @@ def __call__(self) -> _PointSliceResponse: # scale is only impacted by not displayed data, therefore 1 return _PointSliceResponse( indices=np.arange(len(self.data), dtype=int), - scale=1, + scale=1.0, dims=self.dims, request_id=self.id, ) From cf1f40ec4978ece17376472b1e116d3da9c3c6fe Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 21 Jul 2023 17:20:10 -0700 Subject: [PATCH 059/105] fixing czaki and jni comments --- .../_qt/layer_controls/qt_points_controls.py | 5 -- napari/_tests/utils.py | 8 +++ napari/_vispy/layers/points.py | 5 -- napari/components/_tests/test_viewer_model.py | 19 ++++--- napari/layers/_tests/test_serialize.py | 8 ++- napari/layers/graph/graph.py | 7 --- napari/layers/points/points.py | 52 +++++++------------ .../events/_tests/test_event_migrations.py | 3 +- napari/utils/events/event.py | 5 +- napari/utils/events/migrations.py | 1 - 10 files changed, 47 insertions(+), 66 deletions(-) diff --git a/napari/_qt/layer_controls/qt_points_controls.py b/napari/_qt/layer_controls/qt_points_controls.py index c7a406e9a90..5839f09c0bb 100644 --- a/napari/_qt/layer_controls/qt_points_controls.py +++ b/napari/_qt/layer_controls/qt_points_controls.py @@ -99,11 +99,6 @@ def __init__(self, layer) -> None: self.layer.events.visible.connect(self._on_editable_or_visible_change) self.layer.text.events.visible.connect(self._on_text_visibility_change) - # TODO: deprecated, should be removed in 0.6.0 - self.layer.events.current_edge_color.connect( - self._on_current_border_color_change - ) - sld = QSlider(Qt.Orientation.Horizontal) sld.setToolTip( trans._( diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index 55aef1c815a..4b6f7805e78 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -24,6 +24,7 @@ ) from napari.layers._data_protocols import Index, LayerDataProtocol from napari.utils.color import ColorArray +from napari.utils.events.event import WarningEmitter skip_on_win_ci = pytest.mark.skipif( sys.platform.startswith('win') and os.getenv('CI', '0') != '0', @@ -328,3 +329,10 @@ def assert_colors_equal(actual, expected): actual_array = ColorArray.validate(actual) expected_array = ColorArray.validate(expected) np.testing.assert_array_equal(actual_array, expected_array) + + +def count_warning_events(callbacks) -> int: + """Counts the number of WarningEmitter in the callback list.""" + return len( + list(filter(lambda x: isinstance(x, WarningEmitter), callbacks)) + ) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index c72c1eb94df..3ee83ac56fc 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -39,11 +39,6 @@ def __init__(self, layer) -> None: self._on_canvas_size_limits_change ) - # TODO: deprecated, should be removed in 6.0.0 - self.layer.events.edge_width.connect(self._on_data_change) - self.layer.events.edge_width_is_relative.connect(self._on_data_change) - self.layer.events.edge_color.connect(self._on_data_change) - self._on_data_change() def _on_data_change(self): diff --git a/napari/components/_tests/test_viewer_model.py b/napari/components/_tests/test_viewer_model.py index 64eb3745e9e..dfbba117325 100644 --- a/napari/components/_tests/test_viewer_model.py +++ b/napari/components/_tests/test_viewer_model.py @@ -4,7 +4,11 @@ import pytest from npe2 import DynamicPlugin -from napari._tests.utils import good_layer_data, layer_test_data +from napari._tests.utils import ( + count_warning_events, + good_layer_data, + layer_test_data, +) from napari.components import ViewerModel from napari.errors import MultipleReaderError, ReaderPluginError from napari.errors.reader_errors import NoAvailableReaderError @@ -741,7 +745,7 @@ def test_add_remove_layer_no_callbacks(Layer, data, ndim): # Check that no internal callbacks have been registered assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): - assert len(em.callbacks) == 0 + assert len(em.callbacks) == count_warning_events(em.callbacks) viewer.layers.append(layer) # Check layer added correctly @@ -757,7 +761,7 @@ def test_add_remove_layer_no_callbacks(Layer, data, ndim): # Check that all callbacks have been removed assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): - assert len(em.callbacks) == 0 + assert len(em.callbacks) == count_warning_events(em.callbacks) @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) @@ -779,14 +783,17 @@ def my_custom_callback(): assert len(layer.events.callbacks) == 1 for em in layer.events.emitters.values(): if not isinstance(em, WarningEmitter): - assert len(em.callbacks) == 1 + assert len(em.callbacks) == count_warning_events(em.callbacks) + 1 viewer.layers.append(layer) # Check layer added correctly assert len(viewer.layers) == 1 # check that adding a layer created new callbacks - assert any(len(em.callbacks) > 0 for em in layer.events.emitters.values()) + assert any( + len(em.callbacks) > count_warning_events(em.callbacks) + for em in layer.events.emitters.values() + ) viewer.layers.remove(layer) # Check layer added correctly @@ -796,7 +803,7 @@ def my_custom_callback(): assert len(layer.events.callbacks) == 1 for em in layer.events.emitters.values(): if not isinstance(em, WarningEmitter): - assert len(em.callbacks) == 1 + assert len(em.callbacks) == count_warning_events(em.callbacks) + 1 @pytest.mark.parametrize( diff --git a/napari/layers/_tests/test_serialize.py b/napari/layers/_tests/test_serialize.py index d25ed3b2773..7e3189bdd9b 100644 --- a/napari/layers/_tests/test_serialize.py +++ b/napari/layers/_tests/test_serialize.py @@ -3,7 +3,11 @@ import numpy as np import pytest -from napari._tests.utils import are_objects_equal, layer_test_data +from napari._tests.utils import ( + are_objects_equal, + count_warning_events, + layer_test_data, +) @pytest.mark.parametrize('Layer, data, ndim', layer_test_data) @@ -47,4 +51,4 @@ def test_no_callbacks(Layer, data, ndim): # Check that no internal callbacks have been registered assert len(layer.events.callbacks) == 0 for em in layer.events.emitters.values(): - assert len(em.callbacks) == 0 + assert len(em.callbacks) == count_warning_events(em.callbacks) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 62bf7c19e45..8e9dcd2083c 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -266,13 +266,6 @@ def __init__( antialiasing=1, shown=True, ) -> None: - if BaseGraph is None: - raise RuntimeError( - trans._( - "`napari-graph` module is required by the graph layer." - ) - ) - self._data = self._fix_data(data, ndim) self._edges_indices_view: ArrayLike = [] diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 93ab224a3c2..17118440b31 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2187,43 +2187,27 @@ def __init__( shown=shown, ) - self.events.add( - edge_width=deprecation_warning_event( - "layer.events", - "edge_width", - "border_width", - since_version="0.5.0", - version="0.6.0", - ), - current_edge_width=deprecation_warning_event( - "layer.events", - "current_edge_width", - "current_border_width", - since_version="0.5.0", - version="0.6.0", - ), - edge_width_is_relative=deprecation_warning_event( - "layer.events", - "edge_width_is_relative", - "border_width_is_relative", - since_version="0.5.0", - version="0.6.0", - ), - edge_color=deprecation_warning_event( - "layer.events", - "edge_color", - "border_color", - since_version="0.5.0", - version="0.6.0", - ), - current_edge_color=deprecation_warning_event( + deprecated_events = {} + for attr in [ + "{}_width", + "current_{}_width", + "{}_width_is_relative", + "{}_color", + "current_{}_color", + ]: + old_attr = attr.format("edge") + new_attr = attr.format("border") + old_emitter = deprecation_warning_event( "layer.events", - "current_edge_color", - "current_border_color", + old_attr, + new_attr, since_version="0.5.0", version="0.6.0", - ), - ) + ) + getattr(self.events, new_attr).connect(old_emitter) + deprecated_events[old_attr] = old_emitter + + self.events.add(**deprecated_events) @classmethod def _add_deprecated_properties(cls) -> None: diff --git a/napari/utils/events/_tests/test_event_migrations.py b/napari/utils/events/_tests/test_event_migrations.py index 9014a45f273..ba95f900cea 100644 --- a/napari/utils/events/_tests/test_event_migrations.py +++ b/napari/utils/events/_tests/test_event_migrations.py @@ -11,7 +11,6 @@ def test_deprecation_warning_event() -> None: def _print(msg: str) -> None: print(msg) - event.connect(_print) - with pytest.warns(FutureWarning): + event.connect(_print) event(msg="test") diff --git a/napari/utils/events/event.py b/napari/utils/events/event.py index 9b0f8a67c85..1f254ab8ed3 100644 --- a/napari/utils/events/event.py +++ b/napari/utils/events/event.py @@ -893,7 +893,6 @@ def __init__( message: str, category: Type[Warning] = FutureWarning, stacklevel: int = 3, - warn_on_connect: bool = True, *args, **kwargs, ) -> None: @@ -901,12 +900,10 @@ def __init__( self._warned = False self._category = category self._stacklevel = stacklevel - self._warn_on_connect = warn_on_connect EventEmitter.__init__(self, *args, **kwargs) def connect(self, cb, *args, **kwargs): - if self._warn_on_connect: - self._warn(cb) + self._warn(cb) return EventEmitter.connect(self, cb, *args, **kwargs) def _invoke_callback(self, cb, event): diff --git a/napari/utils/events/migrations.py b/napari/utils/events/migrations.py index c42987992b2..7281a8e7fbb 100644 --- a/napari/utils/events/migrations.py +++ b/napari/utils/events/migrations.py @@ -39,6 +39,5 @@ def deprecation_warning_event( f"{previous_path} is deprecated since {since_version} and will be removed in {version}. Please use {new_path}", deferred=True, ), - warn_on_connect=False, type_name=previous_name, ) From b34884f6bc76bf78fea07ac82ea23968c6bd7d3c Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Fri, 21 Jul 2023 21:56:30 -0700 Subject: [PATCH 060/105] fixed tests --- napari/_tests/test_adding_removing.py | 5 +++-- napari/_tests/utils.py | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/napari/_tests/test_adding_removing.py b/napari/_tests/test_adding_removing.py index 94533b4f83c..80b21b808e4 100644 --- a/napari/_tests/test_adding_removing.py +++ b/napari/_tests/test_adding_removing.py @@ -2,6 +2,7 @@ import pytest from napari._tests.utils import ( + count_warning_events, layer_test_data, skip_local_popups, skip_on_win_ci, @@ -120,7 +121,7 @@ def my_custom_callback(): for em in layer.events.emitters.values(): # warningEmitters are not connected when connecting to the emitterGroup if not isinstance(em, WarningEmitter): - assert len(em.callbacks) == 1 + assert len(em.callbacks) == count_warning_events(em.callbacks) + 1 viewer.layers.append(layer) # Check layer added correctly @@ -137,4 +138,4 @@ def my_custom_callback(): for em in layer.events.emitters.values(): # warningEmitters are not connected when connecting to the emitterGroup if not isinstance(em, WarningEmitter): - assert len(em.callbacks) == 1 + assert len(em.callbacks) == count_warning_events(em.callbacks) + 1 diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index 4b6f7805e78..61c35f486b6 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -332,7 +332,10 @@ def assert_colors_equal(actual, expected): def count_warning_events(callbacks) -> int: - """Counts the number of WarningEmitter in the callback list.""" + """ + Counts the number of WarningEmitter in the callback list. + Useful to filter out deprecated events' callbacks. + """ return len( list(filter(lambda x: isinstance(x, WarningEmitter), callbacks)) ) From 5cc814c834175e1245c2b53285c04bee0c30c82f Mon Sep 17 00:00:00 2001 From: Juan Nunez-Iglesias Date: Thu, 10 Aug 2023 12:43:46 +1000 Subject: [PATCH 061/105] Only compute Delaunay graph in 2D --- examples/nuclei_segmentation_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nuclei_segmentation_graph.py b/examples/nuclei_segmentation_graph.py index 26b6918caf2..cc3f9538be5 100644 --- a/examples/nuclei_segmentation_graph.py +++ b/examples/nuclei_segmentation_graph.py @@ -32,7 +32,7 @@ def delaunay_edges(points: np.ndarray) -> np.ndarray: nuclei = cells[:, 1] smooth = filters.gaussian(nuclei, sigma=10) nodes_coords = feature.peak_local_max(smooth) -edges = delaunay_edges(nodes_coords) +edges = delaunay_edges(nodes_coords[:, 1:]) graph = UndirectedGraph(edges, nodes_coords) viewer, image_layer = napari.imshow( cells, channel_axis=1, name=['membranes', 'nuclei'], ndisplay=3 From ff6ac5c736372714bb6cee78344a7ca348a1d0d1 Mon Sep 17 00:00:00 2001 From: Juan Nunez-Iglesias Date: Thu, 10 Aug 2023 12:49:33 +1000 Subject: [PATCH 062/105] Add example of adding a networkx spatial graph directly --- examples/add-graph-networkx.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 examples/add-graph-networkx.py diff --git a/examples/add-graph-networkx.py b/examples/add-graph-networkx.py new file mode 100644 index 00000000000..2c334c1ea8b --- /dev/null +++ b/examples/add-graph-networkx.py @@ -0,0 +1,23 @@ +""" +Add networkx graph +================== + +Add a networkx graph directly to napari. This works as long as nodes +have a "pos" attribute with the node coordinate. + +.. tags:: visualization-basic +""" + +import networkx as nx + +import napari + +hex_grid = nx.hexagonal_lattice_graph(5, 5, with_positions=True) +# below conversion not needed after napari/napari-graph#11 is released +hex_grid_ints = nx.convert_node_labels_to_integers(hex_grid) + +viewer = napari.Viewer() +layer = viewer.add_graph(hex_grid_ints, size=1) + +if __name__ == "__main__": + napari.run() From 69ebbc44b15565c69c844055d962cf8caacbc615 Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Thu, 10 Aug 2023 09:42:32 -0700 Subject: [PATCH 063/105] fixing rolledback changes from 'border' -> 'edge' refactor --- napari/layers/points/points.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 1a415851ef1..752189a90c9 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2279,15 +2279,15 @@ def _set_data(self, data: Optional[np.ndarray]): self._data = data # Add/remove property and style values based on the number of new points. - with self.events.blocker_all(), self._edge.events.blocker_all(), self._face.events.blocker_all(): + with self.events.blocker_all(), self._border.events.blocker_all(), self._face.events.blocker_all(): self._feature_table.resize(len(data)) self.text.apply(self.features) if len(data) < cur_npoints: # If there are now fewer points, remove the size and colors of the # extra ones - if len(self._edge.colors) > len(data): - self._edge._remove( - np.arange(len(data), len(self._edge.colors)) + if len(self._border.colors) > len(data): + self._border._remove( + np.arange(len(data), len(self._border.colors)) ) if len(self._face.colors) > len(data): self._face._remove( @@ -2295,7 +2295,7 @@ def _set_data(self, data: Optional[np.ndarray]): ) self._shown = self._shown[: len(data)] self._size = self._size[: len(data)] - self._edge_width = self._edge_width[: len(data)] + self._border_width = self._border_width[: len(data)] self._symbol = self._symbol[: len(data)] elif len(data) > cur_npoints: @@ -2304,11 +2304,11 @@ def _set_data(self, data: Optional[np.ndarray]): adding = len(data) - cur_npoints size = np.repeat(self.current_size, adding, axis=0) - if len(self._edge_width) > 0: - new_edge_width = copy(self._edge_width[-1]) + if len(self._border_width) > 0: + new_border_width = copy(self._border_width[-1]) else: - new_edge_width = self.current_edge_width - edge_width = np.repeat([new_edge_width], adding, axis=0) + new_border_width = self.current_border_width + border_width = np.repeat([new_border_width], adding, axis=0) if len(self._symbol) > 0: new_symbol = copy(self._symbol[-1]) @@ -2320,8 +2320,8 @@ def _set_data(self, data: Optional[np.ndarray]): # to handle any in-place modification of feature_defaults. # Also see: https://github.com/napari/napari/issues/5634 current_properties = self._feature_table.currents() - self._edge._update_current_properties(current_properties) - self._edge._add(n_colors=adding) + self._border._update_current_properties(current_properties) + self._border._add(n_colors=adding) self._face._update_current_properties(current_properties) self._face._add(n_colors=adding) @@ -2329,8 +2329,8 @@ def _set_data(self, data: Optional[np.ndarray]): self._shown = np.concatenate((self._shown, shown), axis=0) self.size = np.concatenate((self._size, size), axis=0) - self.edge_width = np.concatenate( - (self._edge_width, edge_width), axis=0 + self.border_width = np.concatenate( + (self._border_width, border_width), axis=0 ) self.symbol = np.concatenate((self._symbol, symbol), axis=0) From bef7d9e41f81987f9228cb45f5d9ccfa91c23c8a Mon Sep 17 00:00:00 2001 From: Juan Nunez-Iglesias Date: Fri, 11 Aug 2023 12:15:04 +1000 Subject: [PATCH 064/105] Add networkx graph to graph layer test matrix --- napari/_tests/utils.py | 8 ++++++++ setup.cfg | 1 + 2 files changed, 9 insertions(+) diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index 61c35f486b6..b57616ebdf3 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -5,6 +5,7 @@ from threading import RLock from typing import Any, Dict, Tuple, Union +import networkx as nx import numpy as np import pandas as pd import pytest @@ -98,6 +99,13 @@ to_napari_graph(20 * np.random.random((10, 3))), 3, ), # only nodes, no edges + ( + Graph, + nx.convert_node_labels_to_integers( + nx.hexagonal_lattice_graph(5, 5, with_positions=True) + ), + 2, + ), ] diff --git a/setup.cfg b/setup.cfg index 3d56c9290cc..4ff1b4a1890 100644 --- a/setup.cfg +++ b/setup.cfg @@ -125,6 +125,7 @@ testing = hypothesis>=6.8.0 lxml matplotlib + networkx>=2.7.0 pooch>=1.6.0 pytest-cov pytest-qt From ea2ac8c9364855cfff62a5692aab7f214ddd95bc Mon Sep 17 00:00:00 2001 From: Jordao Bragantini Date: Tue, 22 Aug 2023 16:20:13 -0700 Subject: [PATCH 065/105] converting networkx graph to napari-graph before testing --- napari/_tests/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index b57616ebdf3..cf51d914261 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -101,8 +101,10 @@ ), # only nodes, no edges ( Graph, - nx.convert_node_labels_to_integers( - nx.hexagonal_lattice_graph(5, 5, with_positions=True) + to_napari_graph( + nx.convert_node_labels_to_integers( + nx.hexagonal_lattice_graph(5, 5, with_positions=True) + ), ), 2, ), From f969d6c345ebf1d577a34d7ced23ac7cd1569a86 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 19:51:09 +0000 Subject: [PATCH 066/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- napari/layers/points/points.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index b93f7406896..cc9a85d4582 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2296,7 +2296,7 @@ def _points_data(self) -> np.ndarray: def data(self) -> np.ndarray: """(N, D) array: coordinates for N points in D dimensions.""" return self._data - + @data.setter def data(self, data: np.ndarray) -> None: """Set the data array and emit a corresponding event.""" From 6f9e718ef576850e315fb4920b59c4bd178d8843 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Fri, 13 Oct 2023 12:05:37 +1100 Subject: [PATCH 067/105] fix add point bug --- napari/layers/base/base.py | 13 ++++++++++++ napari/layers/graph/_tests/test_graph.py | 14 +++++++++++++ napari/layers/graph/graph.py | 26 +++++++++++++----------- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py index 1fbfefb240f..9fdceb95707 100644 --- a/napari/layers/base/base.py +++ b/napari/layers/base/base.py @@ -429,6 +429,8 @@ def __init__( # until we figure out nested evented objects self._overlays.events.connect(self.events._overlays) + self._refresh_blocked = False + def __str__(self): """Return self.name.""" return self.name @@ -1357,8 +1359,19 @@ def _set_highlight(self, force=False): Bool that forces a redraw to occur when `True`. """ + @contextmanager + def _block_refresh(self): + previous = self._refresh_blocked + self._refresh_blocked = True + try: + yield + finally: + self._refresh_blocked = previous + def refresh(self, event=None): """Refresh all layer data based on current view slice.""" + if self._refresh_blocked: + return logger.debug('Layer.refresh: %s', self) # If async is enabled then emit an event that the viewer should handle. if get_settings().experimental.async_: diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index e8ed7f7c9d7..7d10e558389 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -245,3 +245,17 @@ def test_graph_from_data_tuple_non_empty(graph_class: Type[BaseGraph]) -> None: assert layer.name == new_layer.name assert len(layer.data) == len(new_layer.data) assert layer.ndim == new_layer.ndim + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_add_nodes_buffer_resize(graph_class): + coords = np.asarray([(0, 0, 0)]) + + graph = graph_class(coords=coords) + layer = Graph(graph, out_of_slice_display=True) + + # adding will cause buffer to resize + layer.add([(1, 1, 1)]) + assert len(layer.data) == coords.shape[0] + 1 + assert graph.n_nodes == coords.shape[0] + 1 + diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index c3013dc070f..a5e5c84f2da 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -488,18 +488,19 @@ def _update_props_and_style(self, data_size: int, prev_size: int) -> None: self._border._add(n_colors=adding) self._face._update_current_properties(current_properties) self._face._add(n_colors=adding) - - # `shown` must be first due to "refresh" calls inside `attribute`.setters - for attribute in ("shown", "size", "symbol", "border_width"): - if attribute == "shown": - default_value = True - else: - default_value = getattr(self, f"current_{attribute}") - new_values = np.repeat([default_value], adding, axis=0) - values = np.concatenate( - (getattr(self, f"_{attribute}"), new_values), axis=0 - ) - setattr(self, attribute, values) + + # ensure each attribute is updated before refreshing + with self._block_refresh(): + for attribute in ("shown", "size", "symbol", "border_width"): + if attribute == "shown": + default_value = True + else: + default_value = getattr(self, f"current_{attribute}") + new_values = np.repeat([default_value], adding, axis=0) + values = np.concatenate( + (getattr(self, f"_{attribute}"), new_values), axis=0 + ) + setattr(self, attribute, values) def _data_changed(self, prev_size: int) -> None: self._update_props_and_style(self.data.n_allocated_nodes, prev_size) @@ -511,3 +512,4 @@ def _get_state(self) -> Dict[str, Any]: state.pop("properties", None) state.pop("property_choices", None) return state + \ No newline at end of file From b5480304acae00dfdbe8675d6feb1ccbda62cdfc Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Mon, 6 Nov 2023 14:46:41 +1100 Subject: [PATCH 068/105] add events to add/remove graph nodes --- napari/layers/graph/graph.py | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index c3013dc070f..d0d239068c3 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -5,6 +5,8 @@ from numpy.typing import ArrayLike from psygnal.containers import Selection +from napari.layers.base._base_constants import ActionType + from napari.layers.graph._slice import _GraphSliceRequest, _GraphSliceResponse from napari.layers.points.points import _BasePoints from napari.layers.utils._slice_input import _SliceInput @@ -399,10 +401,29 @@ def add( coords : sequence of indices to add point at indices : optional indices of the newly inserted nodes. """ + # Adding/Added? + self.events.data( + value=self.data, + action=ActionType.ADDING, + data_indices=tuple( + self.selected_data, + ), + vertex_indices=((),), + ) + prev_size = self.data.n_allocated_nodes self.data.add_nodes(indices=indices, coords=coords) self._data_changed(prev_size) + self.events.data( + value=self.data, + action=ActionType.ADDED, + data_indices=tuple( + self.selected_data, + ), + ) + + def remove_selected(self) -> None: """Removes selected points if any.""" if len(self.selected_data): @@ -426,6 +447,16 @@ def _remove_nodes( is_buffer_domain : bool Indicates if node indices are on world or buffer domain. """ + # Removing/removed events + self.events.data( + value=self.data, + action=ActionType.REMOVING, + data_indices=tuple( + self.selected_data, + ), + vertex_indices=((),), + ) + indices = np.atleast_1d(indices) if indices.ndim > 1: raise ValueError( @@ -443,6 +474,15 @@ def _remove_nodes( self._data_changed(prev_size) + self.events.data( + value=self.data, + action=ActionType.REMOVED, + data_indices=tuple( + self.selected_data, + ), + vertex_indices=((),), + ) + def _move_points( self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray ) -> None: From b5c03feb99c374fbf0ae0c92754686e0cd682903 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Tue, 7 Nov 2023 12:12:41 +1100 Subject: [PATCH 069/105] tidy up --- napari/layers/graph/graph.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index d0d239068c3..6e4a4899e18 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -401,7 +401,6 @@ def add( coords : sequence of indices to add point at indices : optional indices of the newly inserted nodes. """ - # Adding/Added? self.events.data( value=self.data, action=ActionType.ADDING, @@ -423,7 +422,6 @@ def add( ), ) - def remove_selected(self) -> None: """Removes selected points if any.""" if len(self.selected_data): @@ -447,7 +445,6 @@ def _remove_nodes( is_buffer_domain : bool Indicates if node indices are on world or buffer domain. """ - # Removing/removed events self.events.data( value=self.data, action=ActionType.REMOVING, From 7f2e91794c49398ba1eb1066b289c9a578b9505b Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Tue, 14 Nov 2023 10:40:31 +1100 Subject: [PATCH 070/105] consistent use of vertex indices --- napari/layers/graph/graph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 6e4a4899e18..2020a647b7e 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -420,6 +420,7 @@ def add( data_indices=tuple( self.selected_data, ), + vertex_indices=((),), ) def remove_selected(self) -> None: From 00dc63cf8c36ed565e0faadd850e40b8bc97b19f Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Wed, 13 Dec 2023 13:40:44 +1100 Subject: [PATCH 071/105] fix formatting --- napari/layers/graph/graph.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 2020a647b7e..c6dc2ef5d74 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -6,7 +6,6 @@ from psygnal.containers import Selection from napari.layers.base._base_constants import ActionType - from napari.layers.graph._slice import _GraphSliceRequest, _GraphSliceResponse from napari.layers.points.points import _BasePoints from napari.layers.utils._slice_input import _SliceInput @@ -402,9 +401,9 @@ def add( indices : optional indices of the newly inserted nodes. """ self.events.data( - value=self.data, - action=ActionType.ADDING, - data_indices=tuple( + value=self.data, + action=ActionType.ADDING, + data_indices=tuple( self.selected_data, ), vertex_indices=((),), @@ -418,11 +417,11 @@ def add( value=self.data, action=ActionType.ADDED, data_indices=tuple( - self.selected_data, - ), + self.selected_data, + ), vertex_indices=((),), ) - + def remove_selected(self) -> None: """Removes selected points if any.""" if len(self.selected_data): @@ -450,9 +449,9 @@ def _remove_nodes( value=self.data, action=ActionType.REMOVING, data_indices=tuple( - self.selected_data, - ), - vertex_indices=((),), + self.selected_data, + ), + vertex_indices=((),), ) indices = np.atleast_1d(indices) @@ -476,9 +475,9 @@ def _remove_nodes( value=self.data, action=ActionType.REMOVED, data_indices=tuple( - self.selected_data, - ), - vertex_indices=((),), + self.selected_data, + ), + vertex_indices=((),), ) def _move_points( From 2848ebbc7c50223101dbb564b64e7c2186c7c5f3 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Wed, 13 Dec 2023 13:44:55 +1100 Subject: [PATCH 072/105] change vertex indices from slected to -1 --- napari/layers/graph/graph.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index c6dc2ef5d74..8b62327b600 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -403,9 +403,7 @@ def add( self.events.data( value=self.data, action=ActionType.ADDING, - data_indices=tuple( - self.selected_data, - ), + data_indices=(-1,), vertex_indices=((),), ) From 8ac802db5e599233cea1f0b9e543a9be31e358a8 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Wed, 20 Dec 2023 13:59:01 +1100 Subject: [PATCH 073/105] Merge main --- .circleci/config.yml | 65 +- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 2 +- .env_sample | 2 +- .gitattributes | 1 - .github/ISSUE_TEMPLATE/bug_report.md | 36 - .github/ISSUE_TEMPLATE/bug_report.yml | 90 ++ .github/dependabot.yml | 9 +- .github/labeler.yml | 3 + .github/missing_translations.md | 1 - .github/workflows/auto_author_assign.yml | 15 - .github/workflows/benchmarks.yml | 3 + .github/workflows/build_docs.yml | 25 +- .github/workflows/circleci.yml | 6 +- .../workflows/docker-singularity-publish.yml | 12 +- .../workflows/label_and_milesone_checker.yml | 5 +- .github/workflows/labeler.yml | 7 +- .github/workflows/make_bundle_conda.yml | 2 + .github/workflows/make_release.yml | 30 +- .github/workflows/reusable_build_wheel.yml | 37 + .../workflows/reusable_coverage_upload.yml | 41 + .github/workflows/reusable_pip_test.yml | 42 + .github/workflows/reusable_run_tox_test.yml | 194 +++++ .github/workflows/test_comprehensive.yml | 160 +--- .github/workflows/test_prereleases.yml | 3 + .github/workflows/test_pull_requests.yml | 245 +++--- .github/workflows/test_translations.yml | 3 + .github/workflows/test_typing.yml | 4 + .github/workflows/test_vendored.yml | 3 +- .../workflows/upgrade_test_constraints.yml | 15 +- .pre-commit-config.yaml | 55 +- binder/environment.yml | 2 +- binder/xsettings.xml | 2 +- codecov.yml | 4 +- dockerfile | 10 +- examples/README.rst | 1 - examples/add_labels_with_features.py | 4 +- examples/clipboard_.py | 4 +- examples/dev/direct-colormap-aliasing-test.py | 2 +- examples/dev/grin.svg | 2 +- examples/dev/issue-6456.py | 22 + examples/dev/slicing/README.md | 2 +- .../dev/slicing/janelia_s3_n5_multiscale.py | 1 - examples/mgui_with_threadpoolexec_.py | 1 + examples/mgui_with_threadworker_.py | 1 + examples/notebook.ipynb | 2 +- .../viewer_loop_reproducible_screenshots.md | 2 +- examples/vortex.py | 70 ++ napari/__main__.py | 31 +- napari/_app_model/actions/_layer_actions.py | 45 +- napari/_app_model/constants/_commands.py | 8 + napari/_app_model/context/_context.py | 2 +- napari/_app_model/injection/_processors.py | 35 +- napari/_pydantic_compat.py | 97 +++ napari/_qt/_qapp_model/_menus.py | 2 +- .../_qt/_qapp_model/_tests/test_file_menu.py | 16 +- napari/_qt/_qapp_model/qactions/_file.py | 1 + napari/_qt/_tests/test_async_slicing.py | 36 +- napari/_qt/_tests/test_plugin_widgets.py | 6 +- napari/_qt/_tests/test_qt_viewer.py | 358 +++++++- napari/_qt/_tests/test_qt_viewer_2.py | 20 + napari/_qt/containers/qt_layer_list.py | 12 +- .../dialogs/_tests/test_preferences_dialog.py | 36 +- napari/_qt/dialogs/preferences_dialog.py | 49 +- .../_tests/test_qt_image_base_layer_.py | 3 + .../_tests/test_qt_image_layer.py | 15 +- .../_tests/test_qt_labels_layer.py | 15 +- .../_tests/test_qt_layer_controls.py | 49 +- .../layer_controls/qt_image_controls_base.py | 5 + .../_qt/layer_controls/qt_labels_controls.py | 8 +- .../_qt/layer_controls/qt_vectors_controls.py | 2 +- napari/_qt/perf/_tests/test_perf.py | 40 +- napari/_qt/perf/qt_performance.py | 2 +- napari/_qt/qt_main_window.py | 133 ++- napari/_qt/qt_resources/__init__.py | 17 +- napari/_qt/qt_resources/styles/00_base.qss | 15 +- napari/_qt/qt_resources/styles/01_buttons.qss | 2 +- napari/_qt/qt_resources/styles/02_custom.qss | 4 +- napari/_qt/qt_viewer.py | 123 ++- napari/_qt/utils.py | 2 +- .../_tests/test_qt_extension2reader.py | 12 + napari/_qt/widgets/_tests/test_qt_tooltip.py | 12 +- .../_tests/test_shortcut_editor_widget.py | 127 +++ napari/_qt/widgets/qt_dims_slider.py | 9 +- napari/_qt/widgets/qt_extension2reader.py | 18 +- napari/_qt/widgets/qt_font_size.py | 76 ++ napari/_qt/widgets/qt_keyboard_settings.py | 19 +- napari/_qt/widgets/qt_viewer_buttons.py | 2 +- napari/_tests/test_conftest_fixtures.py | 8 - napari/_tests/test_dtypes.py | 2 +- napari/_tests/test_windowsettings.py | 22 +- napari/_tests/utils.py | 2 +- .../qt_jsonschema_form/form.py | 1 + .../qt_jsonschema_form/widgets.py | 35 + napari/_vispy/_tests/test_image_rendering.py | 3 +- napari/_vispy/_tests/test_utils.py | 2 +- .../_vispy/_tests/test_vispy_image_layer.py | 9 +- napari/_vispy/_tests/test_vispy_labels.py | 117 +-- .../test_vispy_labels_polygon_overlay.py | 16 +- .../_vispy/_tests/test_vispy_surface_layer.py | 5 +- napari/_vispy/camera.py | 2 +- napari/_vispy/canvas.py | 32 +- napari/_vispy/layers/base.py | 42 +- napari/_vispy/layers/image.py | 132 ++- napari/_vispy/layers/labels.py | 591 +++++-------- napari/_vispy/overlays/base.py | 7 +- napari/_vispy/overlays/bounding_box.py | 2 +- napari/_vispy/overlays/interaction_box.py | 2 +- napari/_vispy/overlays/labels_polygon.py | 2 + napari/_vispy/utils/gl.py | 26 +- napari/_vispy/utils/visual.py | 4 +- napari/_vispy/visuals/image.py | 4 +- napari/_vispy/visuals/labels.py | 40 + napari/_vispy/visuals/util.py | 30 + napari/_vispy/visuals/volume.py | 4 +- napari/benchmarks/benchmark_image_layer.py | 6 +- napari/benchmarks/benchmark_labels_layer.py | 89 +- napari/benchmarks/benchmark_points_layer.py | 6 +- .../benchmarks/benchmark_qt_viewer_labels.py | 132 ++- napari/benchmarks/benchmark_shapes_layer.py | 6 +- napari/benchmarks/benchmark_surface_layer.py | 6 +- napari/benchmarks/benchmark_vectors_layer.py | 6 +- napari/components/_layer_slicer.py | 14 +- napari/components/_tests/test_add_layers.py | 3 + napari/components/_tests/test_layers_base.py | 20 + napari/components/_tests/test_multichannel.py | 4 +- napari/components/_viewer_key_bindings.py | 2 +- napari/components/camera.py | 10 +- napari/components/cursor.py | 4 +- napari/components/dims.py | 2 +- napari/components/layerlist.py | 33 +- napari/components/overlays/bounding_box.py | 3 +- napari/components/overlays/labels_polygon.py | 3 +- napari/components/overlays/scale_bar.py | 3 +- napari/components/overlays/text.py | 3 +- napari/components/viewer_model.py | 113 ++- napari/conftest.py | 104 ++- napari/errors/reader_errors.py | 14 +- napari/layers/__init__.py | 3 +- napari/layers/_layer_actions.py | 29 +- napari/layers/_source.py | 2 +- napari/layers/_tests/test_layer_actions.py | 106 +++ napari/layers/_tests/test_layer_attributes.py | 41 +- napari/layers/_tests/test_source.py | 4 +- napari/layers/base/_base_constants.py | 10 + napari/layers/base/base.py | 269 ++++-- napari/layers/image/_image_constants.py | 62 +- napari/layers/image/_image_key_bindings.py | 34 +- napari/layers/image/_image_mouse_bindings.py | 17 +- napari/layers/image/_image_utils.py | 28 +- napari/layers/image/_slice.py | 183 +++- napari/layers/image/_tests/test_image.py | 132 ++- .../layers/image/_tests/test_image_utils.py | 28 + napari/layers/image/_tests/test_volume.py | 29 +- napari/layers/image/image.py | 108 ++- napari/layers/intensity_mixin.py | 2 +- napari/layers/labels/_labels_key_bindings.py | 3 + napari/layers/labels/_labels_utils.py | 20 +- napari/layers/labels/_tests/test_labels.py | 178 +++- .../_tests/test_labels_mouse_bindings.py | 7 +- .../labels/_tests/test_labels_multiscale.py | 117 +++ .../labels/_tests/test_labels_pyramid.py | 54 -- .../layers/labels/_tests/test_labels_utils.py | 3 +- napari/layers/labels/labels.py | 380 ++++++--- napari/layers/points/_points_constants.py | 12 + .../layers/points/_points_mouse_bindings.py | 2 +- napari/layers/points/_slice.py | 110 +-- napari/layers/points/_tests/test_points.py | 73 +- .../_tests/test_points_mouse_bindings.py | 3 +- napari/layers/points/points.py | 176 ++-- napari/layers/shapes/_shape_list.py | 98 ++- .../shapes/_shapes_models/_polgyon_base.py | 2 +- .../_tests/test_shapes_models.py | 62 +- .../layers/shapes/_shapes_models/ellipse.py | 2 +- .../layers/shapes/_shapes_models/rectangle.py | 2 - .../layers/shapes/_shapes_mouse_bindings.py | 96 ++- napari/layers/shapes/_shapes_utils.py | 14 +- napari/layers/shapes/_tests/test_shapes.py | 57 +- .../layers/shapes/_tests/test_shapes_utils.py | 1 - napari/layers/shapes/shapes.py | 114 ++- napari/layers/surface/_tests/test_surface.py | 41 +- napari/layers/surface/normals.py | 3 +- napari/layers/surface/surface.py | 47 +- napari/layers/surface/wireframe.py | 3 +- napari/layers/tracks/tracks.py | 6 +- napari/layers/utils/_link_layers.py | 4 +- napari/layers/utils/_slice_input.py | 158 +++- .../layers/utils/_tests/test_color_manager.py | 2 +- .../layers/utils/_tests/test_link_layers.py | 18 +- napari/layers/utils/_tests/test_plane.py | 2 +- .../utils/_tests/test_style_encoding.py | 2 +- .../layers/utils/_tests/test_text_manager.py | 8 +- napari/layers/utils/_text_utils.py | 21 + napari/layers/utils/color_encoding.py | 2 +- napari/layers/utils/color_manager.py | 2 +- napari/layers/utils/color_manager_utils.py | 6 +- napari/layers/utils/layer_utils.py | 17 +- napari/layers/utils/plane.py | 54 +- napari/layers/utils/string_encoding.py | 2 +- napari/layers/utils/text_manager.py | 2 +- napari/layers/vectors/_slice.py | 91 +- napari/layers/vectors/_tests/test_vectors.py | 3 +- napari/layers/vectors/_vectors_constants.py | 12 + napari/layers/vectors/vectors.py | 30 +- napari/plugins/_npe2.py | 46 +- napari/plugins/_plugin_manager.py | 2 +- napari/plugins/_tests/_sample_manifest.yaml | 2 +- napari/plugins/io.py | 4 +- napari/plugins/utils.py | 19 +- napari/resources/_icons.py | 2 +- napari/resources/icons/add.svg | 2 +- napari/resources/icons/check.svg | 4 +- napari/resources/icons/circle.svg | 4 +- napari/resources/icons/copy_to_clipboard.svg | 2 +- napari/resources/icons/debug.svg | 2 +- napari/resources/icons/error.svg | 2 +- .../resources/icons/horizontal_separator.svg | 2 +- napari/resources/icons/info.svg | 2 +- napari/resources/icons/none.svg | 2 +- napari/resources/icons/pan_arrows.svg | 2 +- napari/resources/icons/plus.svg | 2 +- napari/resources/icons/vertex_insert.svg | 2 +- napari/resources/icons/vertical_separator.svg | 2 +- napari/resources/icons/warning.svg | 2 +- napari/settings/_appearance.py | 55 +- napari/settings/_application.py | 2 +- napari/settings/_base.py | 23 +- napari/settings/_experimental.py | 3 +- napari/settings/_napari_settings.py | 3 +- napari/settings/_plugins.py | 2 +- napari/settings/_shortcuts.py | 3 +- napari/settings/_tests/test_settings.py | 18 +- napari/settings/_yaml.py | 4 +- napari/types.py | 4 +- napari/utils/__init__.py | 3 +- napari/utils/_dask_utils.py | 14 +- napari/utils/_magicgui.py | 4 +- napari/utils/_proxies.py | 34 +- napari/utils/_tests/test_migrations.py | 6 +- .../utils/_tests/test_notification_manager.py | 31 +- napari/utils/_tests/test_theme.py | 2 +- napari/utils/_testsupport.py | 19 +- napari/utils/color.py | 16 +- .../utils/colormaps/_tests/test_colormap.py | 322 ++++++- .../colormaps/_tests/test_colormap_utils.py | 40 + .../utils/colormaps/_tests/test_colormaps.py | 4 +- .../utils/colormaps/categorical_colormap.py | 2 +- napari/utils/colormaps/colorbars.py | 10 +- napari/utils/colormaps/colormap.py | 794 +++++++++++++++++- napari/utils/colormaps/colormap_utils.py | 131 ++- napari/utils/config.py | 3 +- .../events/_tests/test_event_migrations.py | 19 +- .../utils/events/_tests/test_evented_model.py | 2 +- napari/utils/events/_tests/test_selection.py | 2 +- napari/utils/events/containers/_selection.py | 6 +- napari/utils/events/containers/_set.py | 8 +- napari/utils/events/containers/_typed.py | 6 +- napari/utils/events/custom_types.py | 5 +- napari/utils/events/debugging.py | 11 +- napari/utils/events/event.py | 2 +- napari/utils/events/evented_model.py | 10 +- napari/utils/events/migrations.py | 6 +- napari/utils/history.py | 9 +- napari/utils/indexing.py | 7 +- napari/utils/info.py | 8 +- napari/utils/interactions.py | 15 +- napari/utils/migrations.py | 73 +- napari/utils/misc.py | 19 +- napari/utils/naming.py | 35 +- napari/utils/notifications.py | 7 +- napari/utils/stubgen.py | 2 +- napari/utils/theme.py | 49 +- napari/utils/transforms/transforms.py | 24 +- napari/view_layers.py | 6 +- pyproject.toml | 274 +++++- resources/bundle_license.rtf | 2 +- resources/constraints/constraints_py3.10.txt | 246 +++--- .../constraints/constraints_py3.10_docs.txt | 255 +++--- .../constraints_py3.10_pydantic_1.txt | 561 +++++++++++++ resources/constraints/constraints_py3.11.txt | 244 +++--- .../constraints_py3.11_pydantic_1.txt | 548 ++++++++++++ resources/constraints/constraints_py3.8.txt | 224 ++--- .../constraints_py3.8_pydantic_1.txt | 575 +++++++++++++ resources/constraints/constraints_py3.9.txt | 248 +++--- .../constraints_py3.9_examples.txt | 250 +++--- .../constraints_py3.9_pydantic_1.txt | 570 +++++++++++++ resources/constraints/pydantic_le_2.txt | 1 + resources/constraints/version_denylist.txt | 2 +- resources/osx_pkg_welcome.rtf.tmpl | 2 +- resources/requirements_mypy.in | 1 + resources/requirements_mypy.txt | 16 +- setup.cfg | 50 +- tools/create_pr_or_update_existing_one.py | 14 +- tools/perfmon/README.md | 1 - tools/split_qt_backend.py | 10 + .../{test_strings.py => validate_strings.py} | 2 +- tox.ini | 50 +- 297 files changed, 10390 insertions(+), 3397 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/workflows/auto_author_assign.yml create mode 100644 .github/workflows/reusable_build_wheel.yml create mode 100644 .github/workflows/reusable_coverage_upload.yml create mode 100644 .github/workflows/reusable_pip_test.yml create mode 100644 .github/workflows/reusable_run_tox_test.yml create mode 100644 examples/dev/issue-6456.py create mode 100644 examples/vortex.py create mode 100644 napari/_pydantic_compat.py create mode 100644 napari/_qt/widgets/qt_font_size.py create mode 100644 napari/_vispy/visuals/labels.py create mode 100644 napari/_vispy/visuals/util.py create mode 100644 napari/components/_tests/test_layers_base.py create mode 100644 napari/layers/labels/_tests/test_labels_multiscale.py delete mode 100644 napari/layers/labels/_tests/test_labels_pyramid.py create mode 100644 napari/utils/colormaps/_tests/test_colormap_utils.py create mode 100644 resources/constraints/constraints_py3.10_pydantic_1.txt create mode 100644 resources/constraints/constraints_py3.11_pydantic_1.txt create mode 100644 resources/constraints/constraints_py3.8_pydantic_1.txt create mode 100644 resources/constraints/constraints_py3.9_pydantic_1.txt create mode 100644 resources/constraints/pydantic_le_2.txt create mode 100644 tools/split_qt_backend.py rename tools/{test_strings.py => validate_strings.py} (99%) diff --git a/.circleci/config.yml b/.circleci/config.yml index f48bd7bb626..ce175ce83f9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,72 +1,55 @@ +# As much as possible, this file should be kept in sync with: +# https://github.com/napari/docs/blob/main/.circleci/config.yaml # Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/2.0/configuration-reference +# See: https://circleci.com/docs/2.1/configuration-reference version: 2.1 - -# Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects. -# See: https://circleci.com/docs/2.0/orb-intro/ +# Orbs are reusable packages of CircleCI configuration that you may share across projects. +# See: https://circleci.com/docs/2.1/orb-intro/ orbs: - # The python orb contains a set of prepackaged CircleCI configuration you can use repeatedly in your configuration files - # Orb commands and jobs help you with common scripting around a language/tool - # so you dont have to copy and paste it everywhere. - # See the orb documentation here: https://circleci.com/developer/orbs/orb/circleci/python python: circleci/python@1.5.0 - -# Define a job to be invoked later in a workflow. -# See: https://circleci.com/docs/2.0/configuration-reference/#jobs jobs: - build-docs: # This is the name of the job, feel free to change it to better match what you're trying to do! - # These next lines defines a Docker executors: https://circleci.com/docs/2.0/executor-types/ - # You can specify an image from Dockerhub or use one of the convenience images from CircleCI's Developer Hub - # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python - # The executor is the environment in which the steps below will be executed - below will use a python 3.10.2 container - # Change the version below to your required version of python + build-docs: docker: - - image: cimg/python:3.10.2 - # Checkout the code as the first step. This is a dedicated CircleCI step. - # The python orb's install-packages step will install the dependencies from a Pipfile via Pipenv by default. - # Here we're making sure we use just use the system-wide pip. By default it uses the project root's requirements.txt. - # Then run your tests! - # CircleCI will report the results back to your VCS provider. + # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python + - image: cimg/python:3.10.13 steps: - - checkout + - checkout: + path: napari + - run: + name: Clone docs repo into a subdirectory + command: git clone git@github.com:napari/docs.git docs - run: name: Install qt libs + xvfb command: sudo apt-get update && sudo apt-get install -y xvfb libegl1 libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 x11-utils - - run: - name: Install python dependencies - # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory. + name: Setup virtual environment command: | python -m venv venv . venv/bin/activate python -m pip install --upgrade pip - - run: - name: Clone docs repo - command: git clone git@github.com:napari/docs.git napari-docs + - run: name: Install napari-dev command: | . venv/bin/activate - python -m pip install -e ".[pyside,dev]" -c resources/constraints/constraints_py3.10_docs.txt + python -m pip install -e "napari/[pyside,dev]" + environment: + PIP_CONSTRAINT: napari/resources/constraints/constraints_py3.10_docs.txt - run: name: Build docs command: | . venv/bin/activate - cd napari-docs - xvfb-run --auto-servernum make docs GALLERY_PATH=../../examples/ + cd docs + xvfb-run --auto-servernum make docs environment: - PIP_CONSTRAINTS: ../resources/constraints/constraints_py3.10_docs.txt + PIP_CONSTRAINT: ../napari/resources/constraints/constraints_py3.10_docs.txt - store_artifacts: - path: napari-docs/docs/_build/ + path: docs/docs/_build/ - persist_to_workspace: root: . paths: - - napari-docs/docs/_build/ - -# Invoke jobs via workflows -# See: https://circleci.com/docs/2.0/configuration-reference/#workflows + - docs/docs/_build/ workflows: - build-docs: # This is the name of the workflow, feel free to change it to better match your workflow. - # Inside the workflow, you define the jobs you want to run. + build-docs: jobs: - build-docs diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ddec6ecdf21..38ca7e85837 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,4 +6,4 @@ FROM mcr.microsoft.com/vscode/devcontainers/miniconda:0-3 RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ && sudo apt-get -y install --no-install-recommends \ libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ - libxcb-render-util0 libxcb-xinerama0 libxkbcommon-x11-0 \ No newline at end of file + libxcb-render-util0 libxcb-xinerama0 libxkbcommon-x11-0 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d5dfad30203..939886e1b0e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ // https://github.com/microsoft/vscode-dev-containers/tree/v0.233.0/containers/python-3-miniconda { "name": "Miniconda (Python 3)", - "build": { + "build": { "context": "..", "dockerfile": "Dockerfile", "args": { diff --git a/.env_sample b/.env_sample index 352ada230af..23e75344072 100644 --- a/.env_sample +++ b/.env_sample @@ -7,7 +7,7 @@ NAPARI_DEBUG_EVENTS=0 # these are strict json, use double quotes -# if INCLUDE_X is used, EXCLUDE_X is ignored. +# if INCLUDE_X is used, EXCLUDE_X is ignored. EVENT_DEBUG_INCLUDE_EMITTERS = [] # e.g. ["Points", "Selection"] EVENT_DEBUG_EXCLUDE_EMITTERS = ["TransformChain", "Context"] EVENT_DEBUG_INCLUDE_EVENTS = [] # e.g. ["set_data", "changed"] diff --git a/.gitattributes b/.gitattributes index 2724ec60314..fa9fa451fe9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1 @@ napari_gui/_version.py export-subst - diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 731a460e1a8..00000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: "\U0001F41B Bug Report" -about: Submit a bug report to help us improve napari -title: '' -labels: bug -assignees: '' - ---- - -## 🐛 Bug - - - -## To Reproduce - -Steps to reproduce the behavior: - -1. -2. -3. - - - -## Expected behavior - - - -## Environment - - - Please copy and paste the information at napari info option in help menubar here: - - - Any other relevant information: - -## Additional context - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..4ab512fdcab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,90 @@ +name: "\U0001F41B Bug Report" +description: Report a bug encountered while using napari +labels: + - "bug" + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this issue! 🙏🏼 + + Please fill out the sections below to help us reproduce the problem. + + If you've found a problem with content on the napari documentation site (napari.org) or with the rendering of the content, + please let us know [here](https://github.com/napari/docs/issues) + + - type: textarea + id: bug-report + attributes: + label: "\U0001F41B Bug Report" + description: "Please provide a clear and concise description of the bug." + placeholder: "What went wrong? What did you expect to happen?" + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: "\U0001F4A1 Steps to Reproduce" + description: "Please provide a minimal code snippet or list of steps to reproduce the bug." + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: "\U0001F4A1 Expected Behavior" + description: "Please provide a clear and concise description of what you expected to happen." + placeholder: "What did you expect to happen?" + + - type: textarea + id: environment + attributes: + label: "\U0001F30E Environment" + description: | + Please provide detailed information regarding your environment. Please paste the output of `napari --info` here or copy the information from the "napari info" dialog in the napari Help menu. + + Otherwise, please provide information regarding your operating system (OS), Python version, napari version, Qt backend and version, Qt platform, method of installation, and any other relevant information related to your environment. + + + placeholder: | + napari: 0.5.0 + Platform: macOS-13.2.1-arm64-arm-64bit + System: MacOS 13.2.1 + Python: 3.11.4 (main, Aug 7 2023, 20:34:01) [Clang 14.0.3 (clang-1403.0.22.14.1)] + Qt: 5.15.10 + PyQt5: 5.15.10 + NumPy: 1.25.1 + SciPy: 1.11.1 + Dask: 2023.7.1 + VisPy: 0.13.0 + magicgui: 0.7.2 + superqt: 0.5.4 + in-n-out: 0.1.8 + app-model: 0.2.0 + npe2: 0.7.2 + + OpenGL: + - GL version: 2.1 Metal - 83 + - MAX_TEXTURE_SIZE: 16384 + + Screens: + - screen 1: resolution 1512x982, scale 2.0 + + Settings path: + - /Users/.../napari/napari_5c6993c40c104085444cfc0c77fa392cb5cb8f56/settings.yaml + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: "\U0001F4A1 Additional Context" + description: "Please provide any additional information or context regarding the problem here." + placeholder: "Add any other context about the problem here." diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c4bf2884896..dedfd1296de 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,10 +8,5 @@ updates: interval: "monthly" commit-message: prefix: "ci(dependabot):" - - - package-ecosystem: "pip" - directory: "/resources" - schedule: - interval: "weekly" - - target-branch: "develop" + labels: + - "maintenance" diff --git a/.github/labeler.yml b/.github/labeler.yml index 0af04414401..48e846c5735 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -20,3 +20,6 @@ tests: vispy: - 'napari/_vispy' + +maintenance: + - '.pre-commit-config.yaml' diff --git a/.github/missing_translations.md b/.github/missing_translations.md index 773a9dfc99d..4f4ea5f296a 100644 --- a/.github/missing_translations.md +++ b/.github/missing_translations.md @@ -11,4 +11,3 @@ You can also Update the cron script to update this issue with better information Note that this issue will be automatically updated if kept open, or a new one will be created when necessary, if no open issue is found and new `_.trans` call are missing. - diff --git a/.github/workflows/auto_author_assign.yml b/.github/workflows/auto_author_assign.yml deleted file mode 100644 index 046a6a71bfe..00000000000 --- a/.github/workflows/auto_author_assign.yml +++ /dev/null @@ -1,15 +0,0 @@ -# https://github.com/marketplace/actions/auto-author-assign -name: 'Auto Author Assign' - -on: - pull_request_target: - types: [opened, reopened] - -permissions: - pull-requests: write - -jobs: - assign-author: - runs-on: ubuntu-latest - steps: - - uses: toshimaru/auto-author-assign@v1.6.2 diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 194d5d70c8d..cd2352195cd 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -37,6 +37,9 @@ jobs: if: ${{ github.event.label.name == 'run-benchmarks' && github.event_name == 'pull_request' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} name: ${{ matrix.benchmark-name }} runs-on: ${{ matrix.runs-on }} + permissions: + contents: read + issues: write strategy: fail-fast: false matrix: diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index 7190dcbfc9c..8981dd6b163 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -1,15 +1,15 @@ +# As much as possible, this file should be kept in sync with +# https://github.com/napari/docs/blob/main/.github/workflows/build_docs.yml name: Build PR Docs on: - pull_request: - branches: - - main push: branches: - docs tags: - 'v*' workflow_dispatch: + workflow_call: jobs: build-and-upload: @@ -25,19 +25,15 @@ jobs: - name: Clone main repo uses: actions/checkout@v4 with: - path: napari-repo + path: napari # place in a named directory # ensure version metadata is proper fetch-depth: 0 - - name: Copy examples to docs folder - run: | - cp -R napari-repo/examples docs - - uses: actions/setup-python@v4 with: python-version: "3.10" cache-dependency-path: | - setup.cfg + napari/setup.cfg docs/requirements.txt - uses: tlambert03/setup-qt-libs@v1 @@ -45,7 +41,10 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - python -m pip install "napari-repo/[all]" -c "napari-repo/resources/constraints/constraints_py3.10_docs.txt" + python -m pip install "napari/[all]" + python -m pip install -r docs/requirements.txt + env: + PIP_CONSTRAINT: ${{ github.workspace }}/napari/resources/constraints/constraints_py3.10_docs.txt - name: Testing run: | @@ -57,11 +56,9 @@ jobs: env: GOOGLE_CALENDAR_ID: ${{ secrets.GOOGLE_CALENDAR_ID }} GOOGLE_CALENDAR_API_KEY: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} - PIP_CONSTRAINTS: ${{ github.workspace }}/napari-repo/resources/constraints/constraints_py3.10_docs.txt + PIP_CONSTRAINT: ${{ github.workspace }}/napari/resources/constraints/constraints_py3.10_docs.txt with: - # the napari-docs repo is cloned into a docs/ folder, hence the - # invocation below. Locally, you should simply run make docs - run: make -C docs docs GALLERY_PATH=../examples/ + run: make -C docs docs - name: Upload artifact diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml index 39ac83d4304..93df84304e3 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -4,6 +4,10 @@ name: CircleCI artifact redirector +concurrency: + group: docs-preview-${{ github.ref }} + cancel-in-progress: true + on: [status] jobs: circleci_artifacts_redirector_job: @@ -19,6 +23,6 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_TOKEN }} - artifact-path: 0/napari-docs/docs/_build/index.html + artifact-path: 0/docs/docs/_build/index.html circleci-jobs: build-docs job-title: Check the rendered docs here! diff --git a/.github/workflows/docker-singularity-publish.yml b/.github/workflows/docker-singularity-publish.yml index 7bc9c22c6d6..8940df9b56a 100644 --- a/.github/workflows/docker-singularity-publish.yml +++ b/.github/workflows/docker-singularity-publish.yml @@ -44,7 +44,7 @@ jobs: # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@3.0.0 + uses: docker/login-action@v3.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -55,7 +55,7 @@ jobs: # https://github.com/docker/build-push-action/blob/master/docs/advanced/tags-labels.md - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: # list of Docker images to use as base name for tags images: ${{ env.REGISTRY }}/${{ matrix.image-name }} @@ -77,7 +77,7 @@ jobs: # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 id: docker_build with: context: . @@ -89,18 +89,18 @@ jobs: # We build from the the tag name if triggered by tag, otherwise from the commit hash build-args: | NAPARI_COMMIT=${{ github.ref_type == 'tag' && github.ref_name || github.sha }} - + - name: Test Docker image run: | docker run --rm --entrypoint=/bin/bash ${{ steps.docker_build.outputs.imageid }} -ec "python3 -m napari --version" - + # ---- build2: needs: build1 runs-on: ubuntu-latest container: - image: quay.io/singularity/docker2singularity:v3.10.0 + image: quay.io/singularity/docker2singularity:v3.11.5 options: --privileged permissions: contents: read diff --git a/.github/workflows/label_and_milesone_checker.yml b/.github/workflows/label_and_milesone_checker.yml index 5e76ac99006..35f41a9e21a 100644 --- a/.github/workflows/label_and_milesone_checker.yml +++ b/.github/workflows/label_and_milesone_checker.yml @@ -7,8 +7,6 @@ on: - reopened - labeled - unlabeled - issues: - types: - milestoned - demilestoned merge_group: # to be prepared on merge queue @@ -16,7 +14,7 @@ on: jobs: check_labels_and_milestone: - if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ready to merge') + if: (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ready to merge')) name: Check labels and milestone runs-on: ubuntu-latest steps: @@ -31,4 +29,3 @@ jobs: run: | echo "Please add a milestone to this PR" exit 1 - diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f04408a9935..2a60efbd3c8 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -5,8 +5,13 @@ on: jobs: triage: + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@main + - uses: actions/labeler@v4 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" + # sync-labels need to be removed when upgrade to v5 of labeler + sync-labels: '' diff --git a/.github/workflows/make_bundle_conda.yml b/.github/workflows/make_bundle_conda.yml index 5f213c682d2..62799e22d65 100644 --- a/.github/workflows/make_bundle_conda.yml +++ b/.github/workflows/make_bundle_conda.yml @@ -11,6 +11,8 @@ on: jobs: packaging: + permissions: + contents: write uses: napari/packaging/.github/workflows/make_bundle_conda.yml@main secrets: inherit with: diff --git a/.github/workflows/make_release.yml b/.github/workflows/make_release.yml index 5352d399095..fc0267655dc 100644 --- a/.github/workflows/make_release.yml +++ b/.github/workflows/make_release.yml @@ -8,6 +8,9 @@ name: Create Release jobs: build: + permissions: + contents: write + id-token: write name: Create Release runs-on: ubuntu-latest if: github.repository == 'napari/napari' @@ -17,7 +20,7 @@ jobs: - name: Install Python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.11 cache-dependency-path: setup.cfg - name: Install Dependencies run: | @@ -41,29 +44,16 @@ jobs: fi echo "tag=${TAG}" >> $GITHUB_ENV # https://help.github.com/en/actions/reference/workflow-commands-for-github-actions - echo "::set-output name=contents::$RELEASE_NOTES" + echo "name=contents=${RELEASE_NOTES}" >> $GITHUB_ENV - name: Create Release - id: create_release - uses: actions/create-release@latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token + uses: "softprops/action-gh-release@v1" with: tag_name: ${{ github.ref }} - release_name: ${{ env.tag }} + name: ${{ env.tag }} body: ${{ steps.release_notes.outputs.contents }} draft: false prerelease: ${{ contains(github.ref, 'rc') }} - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/napari-${{ env.tag }}.tar.gz - asset_name: napari-${{ env.tag }}.tar.gz - asset_content_type: application/gzip + files: | + dist/* - name: Publish PyPI Package - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/reusable_build_wheel.yml b/.github/workflows/reusable_build_wheel.yml new file mode 100644 index 00000000000..c7aceb95535 --- /dev/null +++ b/.github/workflows/reusable_build_wheel.yml @@ -0,0 +1,37 @@ +on: + workflow_call: + +jobs: + build_wheel: + name: Build wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + cache: "pip" + cache-dependency-path: setup.cfg + + - name: Install Dependencies + run: | + pip install --upgrade pip + pip install build wheel + + - name: Build wheel + run: | + python -m build --wheel --outdir dist/ + + - name: Rename wheel + run: | + mv dist/*.whl dist/napari-0.0.1-py3-none-any.whl + + - name: Upload wheel + uses: actions/upload-artifact@v3 + with: + name: wheel + path: dist/*.whl diff --git a/.github/workflows/reusable_coverage_upload.yml b/.github/workflows/reusable_coverage_upload.yml new file mode 100644 index 00000000000..9e0ed4e173a --- /dev/null +++ b/.github/workflows/reusable_coverage_upload.yml @@ -0,0 +1,41 @@ +on: + workflow_call: + + +jobs: + upload_coverage: + name: Upload coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v4 + with: + python-version: "3.x" + cache-dependency-path: setup.cfg + cache: 'pip' + + - name: Install Dependencies + run: | + pip install --upgrade pip + pip install codecov + + - name: Download coverage data + uses: actions/download-artifact@v3 + with: + name: coverage reports + path: coverage + + - name: combine coverage data + run: | + python -Im coverage combine coverage + python -Im coverage xml -o coverage.xml + + # Report and write to summary. + python -Im coverage report --format=markdown --skip-empty --skip-covered >> $GITHUB_STEP_SUMMARY + + - name: Upload coverage data + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/reusable_pip_test.yml b/.github/workflows/reusable_pip_test.yml new file mode 100644 index 00000000000..846150f9bc2 --- /dev/null +++ b/.github/workflows/reusable_pip_test.yml @@ -0,0 +1,42 @@ +on: + workflow_call: + +jobs: + test_pip_install: + name: ubuntu-latest 3.9 pip install + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + path: napari-from-github + + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + cache: "pip" + cache-dependency-path: napari-from-github/setup.cfg + + - uses: tlambert03/setup-qt-libs@v1 + + - name: Install this commit + run: | + pip install --upgrade pip + pip install ./napari-from-github[pyqt,testing] + env: + PIP_CONSTRAINT: napari-from-github/resources/constraints/constraints_py3.9.txt + + - name: Test + uses: aganders3/headless-gui@v1 + with: + run: | + python -m pytest --pyargs napari --color=yes --basetemp=.pytest_tmp + python -m pytest --pyargs napari_builtins --color=yes --basetemp=.pytest_tmp + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v3 + with: + name: test artifacts pip install + path: .pytest_tmp diff --git a/.github/workflows/reusable_run_tox_test.yml b/.github/workflows/reusable_run_tox_test.yml new file mode 100644 index 00000000000..0ab787cbe90 --- /dev/null +++ b/.github/workflows/reusable_run_tox_test.yml @@ -0,0 +1,194 @@ +on: + workflow_call: + inputs: + python_version: + required: true + type: string + platform: + required: false + type: string + default: "ubuntu-latest" + toxenv: + required: false + type: string + default: "" + qt_backend: + required: false + type: string + default: "headless" + min_req: + required: false + type: string + default: "" + coverage: + required: false + type: string + default: no_cov + timeout: + required: false + type: number + default: 40 + constraints_suffix: + required: false + type: string + default: "" + tox_extras: + required: false + type: string + default: "" + +jobs: + test: + name: ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} ${{ inputs.MIN_REQ && 'min_req' }} ${{ inputs.coverage }} + runs-on: ${{ inputs.platform }} + env: + TOXENV: ${{ inputs.toxenv }} + NUMPY_EXPERIMENTAL_ARRAY_FUNCTION: ${{ inputs.MIN_REQ || 1 }} + PYVISTA_OFF_SCREEN: True + MIN_REQ: ${{ inputs.min_req }} + FORCE_COLOR: 1 + PIP_CONSTRAINT: resources/constraints/constraints_py${{ inputs.python_version }}${{ inputs.min_req && '_min_req' }}${{ inputs.constraints_suffix }}.txt + COVERAGE: ${{ inputs.coverage }} + TOX_WORK_DIR: .tox + TOX_EXTRAS: ${{ inputs.tox_extras }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + with: + name: wheel + path: dist + + - name: Set up Python ${{ inputs.python_version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python_version }} + cache: "pip" + cache-dependency-path: setup.cfg + + - uses: tlambert03/setup-qt-libs@v1 + + # strategy borrowed from vispy for installing opengl libs on windows + - name: Install Windows OpenGL + if: runner.os == 'Windows' + run: | + git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git + powershell gl-ci-helpers/appveyor/install_opengl.ps1 + if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} + + - name: Disable ptrace security restrictions + if: runner.os == 'Linux' + run: | + echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope + + # tox and tox-gh-actions will take care of the "actual" installation + # of python dependendencies into a virtualenv. see tox.ini for more + - name: Install dependencies + run: | + pip install --upgrade pip + pip install setuptools tox tox-gh-actions tox-min-req + + - name: create _version.py file + # workaround for not using src layout + run: | + echo "__version__ = version = '0.5.0a2.dev364'" > napari/_version.py + echo "__version_tuple__ = version_tuple = (0, 5, 0, 'dev364', '')" >> napari/_version.py + + # here we pass off control of environment creation and running of tests to tox + # tox-gh-actions, installed above, helps to convert environment variables into + # tox "factors" ... limiting the scope of what gets tested on each platform + # for instance, on ubuntu-latest with python 3.8, it would be equivalent to this command: + # `tox -e py38-linux-pyqt,py38-linux-pyside` + # see tox.ini for more + + - name: Split qt backend + # This is a hack to split the qt_backend variable into four parts + # This is required as github actions allow setting only one environment variable in + # a single line (redirection to $GITHUB_ENV). + # + # For example, if qt_backend is set to "pyqt5,pyside2", then the following four + # environment variables will be set: + # MAIN=pyqt5 + # SECOND=pyside2 + # THIRD=none + # FOURTH=none + shell: bash + run: | + python tools/split_qt_backend.py 0 ${{ inputs.qt_backend }} >> $GITHUB_ENV + python tools/split_qt_backend.py 1 ${{ inputs.qt_backend }} >> $GITHUB_ENV + python tools/split_qt_backend.py 2 ${{ inputs.qt_backend }} >> $GITHUB_ENV + python tools/split_qt_backend.py 3 ${{ inputs.qt_backend }} >> $GITHUB_ENV + + - name: Test with tox main + timeout-minutes: ${{ inputs.timeout }} + uses: aganders3/headless-gui@v1 + with: + shell: bash + run: | + echo ${{ env.MAIN }} + python -m tox run --installpkg dist/napari-0.0.1-py3-none-any.whl -- --basetemp=.pytest_tmp + rm -r .tox + env: + BACKEND: ${{ env.MAIN }} + TOX_WORK_DIR: .tox + + - name: Test with tox second + timeout-minutes: ${{ inputs.timeout }} + uses: aganders3/headless-gui@v1 + if : ${{ env.SECOND != 'none' }} + with: + shell: bash + run: | + python -m tox run --installpkg dist/napari-0.0.1-py3-none-any.whl -- --basetemp=.pytest_tmp + rm -r .tox + env: + BACKEND: ${{ env.SECOND }} + NAPARI_TEST_SUBSET: qt + + - name: Test with tox third + timeout-minutes: ${{ inputs.timeout }} + uses: aganders3/headless-gui@v1 + if : ${{ env.THIRD != 'none' }} + with: + shell: bash + run: | + python -m tox run --installpkg dist/napari-0.0.1-py3-none-any.whl -- --basetemp=.pytest_tmp + rm -r .tox + env: + BACKEND: ${{ env.THIRD }} + NAPARI_TEST_SUBSET: qt + + - name: Test with tox fourth + timeout-minutes: ${{ inputs.timeout }} + uses: aganders3/headless-gui@v1 + if: ${{ env.FOURTH != 'none' }} + with: + shell: bash + run: | + python -m tox run --installpkg dist/napari-0.0.1-py3-none-any.whl -- --basetemp=.pytest_tmp + rm -r .tox + env: + BACKEND: ${{ env.FOURTH }} + NAPARI_TEST_SUBSET: qt + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v3 + with: + name: test artifacts ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} + path: .pytest_tmp + + - name: Upload pytest timing reports as json + uses: actions/upload-artifact@v3 + with: + name: upload pytest timing reports as json + path: | + ./report-*.json + + - name: Upload coverage data + uses: actions/upload-artifact@v3 + if: ${{ inputs.coverage == 'cov' }} + with: + name: coverage reports + path: | + ./.coverage.* diff --git a/.github/workflows/test_comprehensive.yml b/.github/workflows/test_comprehensive.yml index fbe16bf6524..e26a45bfc9c 100644 --- a/.github/workflows/test_comprehensive.yml +++ b/.github/workflows/test_comprehensive.yml @@ -35,10 +35,14 @@ jobs: - name: Check Manifest run: check-manifest + build_wheel: + name: Build wheel + uses: ./.github/workflows/reusable_build_wheel.yml + test: - name: ${{ matrix.platform }} py${{ matrix.python }} ${{ matrix.toxenv }} ${{ matrix.backend }} ${{ matrix.MIN_REQ && 'min_req' }} - timeout-minutes: 60 - runs-on: ${{ matrix.platform }} + name: ${{ matrix.platform }} + uses: ./.github/workflows/reusable_run_tox_test.yml + needs: build_wheel strategy: fail-fast: false matrix: @@ -61,138 +65,52 @@ jobs: - python: "3.11" platform: ubuntu-latest backend: pyqt6 + tox_extras: "testing_extra" - python: "3.11" platform: ubuntu-latest backend: pyside6 + tox_extras: "testing_extra" exclude: - python: "3.11" backend: pyside2 - - - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python }} - cache: "pip" - cache-dependency-path: setup.cfg - - - uses: tlambert03/setup-qt-libs@v1 - - # strategy borrowed from vispy for installing opengl libs on windows - - name: Install Windows OpenGL - if: runner.os == 'Windows' - run: | - git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git - powershell gl-ci-helpers/appveyor/install_opengl.ps1 - if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} - - - name: Install dependencies - run: | - pip install --upgrade pip - pip install setuptools tox tox-gh-actions tox-min-req - env: - MIN_REQ: ${{ matrix.MIN_REQ }} - - # here we pass off control of environment creation and running of tests to tox - # tox-gh-actions, installed above, helps to convert environment variables into - # tox "factors" ... limiting the scope of what gets tested on each platform - # The one exception is if the "toxenv" environment variable has been set, - # in which case we are declaring one specific tox environment to run. - # see tox.ini for more - - name: Test with tox - uses: aganders3/headless-gui@v1 - with: - run: python -m tox - env: - PLATFORM: ${{ matrix.platform }} - BACKEND: ${{ matrix.backend }} - TOXENV: ${{ matrix.toxenv }} - NUMPY_EXPERIMENTAL_ARRAY_FUNCTION: ${{ matrix.MIN_REQ || 1 }} - PYVISTA_OFF_SCREEN: True - MIN_REQ: ${{ matrix.MIN_REQ }} - PIP_CONSTRAINT: resources/constraints/constraints_py${{ matrix.python }}${{ matrix.MIN_REQ && '_min_req' }}.txt - - - name: Coverage - uses: codecov/codecov-action@v3 - - - name: Report Failures - if: ${{ failure() }} - uses: JasonEtco/create-an-issue@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLATFORM: ${{ matrix.platform }} - PYTHON: ${{ matrix.python }} - BACKEND: ${{ matrix.toxenv }} - RUN_ID: ${{ github.run_id }} - TITLE: "[test-bot] Comprehensive tests failing" - with: - filename: .github/TEST_FAIL_TEMPLATE.md - update_existing: true + with: + python_version: ${{ matrix.python }} + platform: ${{ matrix.platform }} + qt_backend: ${{ matrix.backend }} + min_req: ${{ matrix.MIN_REQ }} + coverage: cov + toxenv: ${{ matrix.toxenv }} + tox_extras: ${{ matrix.tox_extras }} test_pip_install: - name: ubuntu-latest 3.9 pip install - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - path: napari-from-github - - - name: Set up Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: "3.9" - cache: "pip" - cache-dependency-path: napari-from-github/setup.cfg - - - uses: tlambert03/setup-qt-libs@v1 - - - name: Install this commit - run: | - pip install --upgrade pip - pip install ./napari-from-github[all,testing] - env: - PIP_CONSTRAINT: napari-from-github/resources/constraints/constraints_py3.9.txt - - - name: Test - uses: aganders3/headless-gui@v1 - with: - run: python -m pytest --pyargs napari --color=yes + name: pip install + uses: ./.github/workflows/reusable_pip_test.yml test_examples: name: test examples - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: "3.9" - - uses: tlambert03/setup-qt-libs@v1 - - name: Install this commit - run: | - pip install --upgrade pip - pip install setuptools tox tox-gh-actions - - - name: Test - uses: aganders3/headless-gui@v1 - with: - run: tox -e py39-linux-pyside2-examples - env: - PIP_CONSTRAINT: resources/constraints/constraints_py3.9_examples.txt + uses: ./.github/workflows/reusable_run_tox_test.yml + needs: build_wheel + with: + toxenv: py39-linux-pyside2-examples-cov + timeout: 60 + python_version: 3.9 + constraints_suffix: _examples + coverage: cov + + coverage_report: + if: ${{ always() }} + needs: + - test + - test_examples + uses: ./.github/workflows/reusable_coverage_upload.yml synchronize_bot_repository: name: Synchronize bot repository runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.repository == 'napari/napari' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v4 with: @@ -202,8 +120,8 @@ jobs: git remote add napari-bot https://github.com/napari-bot/napari.git git fetch napari-bot git push --force --set-upstream napari-bot main - - name: Report Failures + if: ${{ failure() }} uses: JasonEtco/create-an-issue@v2 env: @@ -212,4 +130,4 @@ jobs: TITLE: '[bot-repo] bot repo update is failing' with: filename: .github/BOT_REPO_UPDATE_FAIL_TEMPLATE.md - update_existing: true \ No newline at end of file + update_existing: true diff --git a/.github/workflows/test_prereleases.yml b/.github/workflows/test_prereleases.yml index 64d2259e1a9..6dbc99622f6 100644 --- a/.github/workflows/test_prereleases.yml +++ b/.github/workflows/test_prereleases.yml @@ -17,6 +17,9 @@ jobs: name: ${{ matrix.platform }} py${{ matrix.python }} ${{ matrix.backend }} --pre timeout-minutes: 40 runs-on: ${{ matrix.platform }} + permissions: + contents: read + issues: write if: github.repository == 'napari/napari' strategy: fail-fast: false diff --git a/.github/workflows/test_pull_requests.yml b/.github/workflows/test_pull_requests.yml index a225934ab93..28b22c1dc27 100644 --- a/.github/workflows/test_pull_requests.yml +++ b/.github/workflows/test_pull_requests.yml @@ -15,6 +15,23 @@ env: COLUMNS: 120 jobs: + import_lint: + name: Import lint + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Install dependencies + run: | + pip install --upgrade pip + pip install tox + - name: Run import lint + run: tox -e import-lint + manifest: # make sure all necessary files will be bundled in the release name: Check Manifest @@ -24,16 +41,18 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: - python-version: "3.x" + python-version: "3.11" cache-dependency-path: setup.cfg cache: 'pip' - name: Install Dependencies run: pip install --upgrade pip - name: Install Napari dev run: pip install -e .[build] - - name: Check Manifest + - name: Make Typestubs run: | make typestubs + - name: Check Manifest + run: | make check-manifest localization_syntax: @@ -53,169 +72,115 @@ jobs: semgrep --error --lang python --pattern 'trans._(f"...")' napari semgrep --error --lang python --pattern 'trans._($X.format(...))' napari + build_wheel: + name: Build wheel + uses: ./.github/workflows/reusable_build_wheel.yml + + test_initial: + name: Initial test + uses: ./.github/workflows/reusable_run_tox_test.yml + needs: build_wheel + strategy: + fail-fast: false + matrix: + include: + - python: 3.8 + platform: ubuntu-latest + backend: pyqt5 + pydantic: "_pydantic_1" + - python: 3.11 + platform: ubuntu-latest + backend: pyqt6 + pydantic: "" + with: + python_version: ${{ matrix.python }} + platform: ${{ matrix.platform }} + qt_backend: ${{ matrix.backend }} + coverage: no_cov + constraints_suffix: ${{ matrix.pydantic }} + test: - name: ${{ matrix.platform }} ${{ matrix.python }} ${{ matrix.toxenv || matrix.backend }} ${{ matrix.MIN_REQ && 'min_req' }} - runs-on: ${{ matrix.platform }} - timeout-minutes: 40 + name: ${{ matrix.platform }} + uses: ./.github/workflows/reusable_run_tox_test.yml + needs: test_initial strategy: fail-fast: false matrix: - platform: [ubuntu-latest] - python: ["3.8", "3.9", "3.10", "3.11"] - backend: [pyqt5, pyside2] - exclude: - - python: '3.11' - backend: pyside2 + platform: [ ubuntu-latest ] + python: [ "3.9", "3.10" ] + backend: [ "pyqt5,pyside2" ] + coverage: [ cov ] + pydantic: ["_pydantic_1"] include: # Windows py38 - python: 3.8 platform: windows-latest - backend: pyqt5 - - python: 3.8 + backend: pyqt5,pyside2 + coverage: no_cov + - python: 3.11 platform: windows-latest - backend: pyside2 - - python: 3.9 + backend: pyqt6 + coverage: no_cov + - python: 3.11 platform: macos-latest backend: pyqt5 + coverage: no_cov # minimum specified requirements - python: 3.8 platform: ubuntu-20.04 backend: pyqt5 MIN_REQ: 1 + coverage: cov + - python: "3.10" + platform: ubuntu-22.04 + backend: pyqt5 + coverage: cov + pydantic: "" + tox_extras: "optional" # test without any Qt backends - - python: 3.8 + - python: 3.9 platform: ubuntu-20.04 backend: headless - - python: 3.9 + coverage: no_cov + - python: 3.11 platform: ubuntu-latest - backend: pyqt6 - - python: 3.9 - platform: ubuntu-latest - backend: pyside6 - # pyside 6 - - python: '3.10' - platform: ubuntu-latest - backend: pyside6 - - python: '3.11' - platform: ubuntu-latest - backend: pyside6 - - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python }} - cache: "pip" - cache-dependency-path: setup.cfg + backend: pyqt6,pyside6 + coverage: cov + tox_extras: "testing_extra" - - uses: tlambert03/setup-qt-libs@v1 - - # strategy borrowed from vispy for installing opengl libs on windows - - name: Install Windows OpenGL - if: runner.os == 'Windows' - run: | - git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git - powershell gl-ci-helpers/appveyor/install_opengl.ps1 - if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} - - # tox and tox-gh-actions will take care of the "actual" installation - # of python dependendencies into a virtualenv. see tox.ini for more - - name: Install dependencies - run: | - pip install --upgrade pip - pip install setuptools tox tox-gh-actions tox-min-req - - # here we pass off control of environment creation and running of tests to tox - # tox-gh-actions, installed above, helps to convert environment variables into - # tox "factors" ... limiting the scope of what gets tested on each platform - # for instance, on ubuntu-latest with python 3.8, it would be equivalent to this command: - # `tox -e py38-linux-pyqt,py38-linux-pyside` - # see tox.ini for more - - name: Test with tox - # the longest is macos-latest 3.9 pyqt5 at ~30 minutes. - timeout-minutes: 40 - uses: aganders3/headless-gui@v1 - with: - run: python -m tox - env: - PLATFORM: ${{ matrix.platform }} - BACKEND: ${{ matrix.backend }} - TOXENV: ${{ matrix.toxenv }} - NUMPY_EXPERIMENTAL_ARRAY_FUNCTION: ${{ matrix.MIN_REQ || 1 }} - PYVISTA_OFF_SCREEN: True - MIN_REQ: ${{ matrix.MIN_REQ }} - FORCE_COLOR: 1 - PIP_CONSTRAINT: resources/constraints/constraints_py${{ matrix.python }}${{ matrix.MIN_REQ && '_min_req' }}.txt - - uses: actions/upload-artifact@v3 - with: - name: upload pytest timing reports as json - path: | - ./report-*.json + with: + python_version: ${{ matrix.python }} + platform: ${{ matrix.platform }} + qt_backend: ${{ matrix.backend }} + min_req: ${{ matrix.MIN_REQ }} + coverage: ${{ matrix.coverage }} + toxenv: ${{ matrix.toxenv }} + tox_extras: ${{ matrix.tox_extras }} + constraints_suffix: ${{ matrix.pydantic }} - - name: Coverage - uses: codecov/codecov-action@v3 test_pip_install: - name: ubuntu-latest 3.9 pip install - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - with: - path: napari-from-github - - - name: Set up Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: 3.9 - cache: "pip" - cache-dependency-path: napari-from-github/setup.cfg - - - uses: tlambert03/setup-qt-libs@v1 - - - name: Install this commit - run: | - pip install --upgrade pip - pip install ./napari-from-github[all,testing] - env: - PIP_CONSTRAINT: napari-from-github/resources/constraints/constraints_py3.9.txt - - - name: Test - uses: aganders3/headless-gui@v1 - with: - run: | - python -m pytest --pyargs napari --color=yes - python -m pytest --pyargs napari_builtins --color=yes + needs: test_initial + name: pip install + uses: ./.github/workflows/reusable_pip_test.yml test_examples: name: test examples - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: 3.9 - cache-dependency-path: napari-from-github/setup.cfg - - uses: tlambert03/setup-qt-libs@v1 - - name: Install this commit - run: | - pip install --upgrade pip - pip install setuptools tox tox-gh-actions - - - name: Test - uses: aganders3/headless-gui@v1 - with: - run: tox -e py39-linux-pyside2-examples - env: - PIP_CONSTRAINT: resources/constraints/constraints_py3.9_examples.txt + uses: ./.github/workflows/reusable_run_tox_test.yml + needs: test_initial + with: + toxenv: py39-linux-pyside2-examples-cov + timeout: 60 + python_version: 3.9 + constraints_suffix: _examples + coverage: cov + + coverage_report: + if: ${{ always() }} + needs: + - test + - test_examples + uses: ./.github/workflows/reusable_coverage_upload.yml test_benchmarks: @@ -227,14 +192,14 @@ jobs: - uses: actions/setup-python@v4 with: python-version: 3.11 - cache-dependency-path: napari-from-github/setup.cfg + cache-dependency-path: setup.cfg - uses: tlambert03/setup-qt-libs@v1 - name: install dependencies run: | pip install --upgrade pip - pip install asv[virtualenv] + pip install asv[virtualenv] - name: Run benchmarks uses: aganders3/headless-gui@v1 diff --git a/.github/workflows/test_translations.yml b/.github/workflows/test_translations.yml index 0b2d7e23286..a235d7c2dad 100644 --- a/.github/workflows/test_translations.yml +++ b/.github/workflows/test_translations.yml @@ -10,6 +10,9 @@ jobs: translations: name: Check missing translations runs-on: ubuntu-latest + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 diff --git a/.github/workflows/test_typing.yml b/.github/workflows/test_typing.yml index 053639d902f..bae978d5e70 100644 --- a/.github/workflows/test_typing.yml +++ b/.github/workflows/test_typing.yml @@ -5,6 +5,10 @@ on: branches: - main +concurrency: + group: typing-${{ github.ref }} + cancel-in-progress: true + jobs: typing: runs-on: ubuntu-latest diff --git a/.github/workflows/test_vendored.yml b/.github/workflows/test_vendored.yml index db2a6caf7a5..88eebe8f32e 100644 --- a/.github/workflows/test_vendored.yml +++ b/.github/workflows/test_vendored.yml @@ -32,7 +32,8 @@ jobs: It look like ${{ steps.check_v.outputs.vendored }} has a new version. token: ${{ secrets.GHA_TOKEN }} + author: napari-bot # Token permissions required by the action: # * pull requests: write and read # * repository contents: read and write - # for screenshots please see https://github.com/napari/napari/pull/5777 \ No newline at end of file + # for screenshots please see https://github.com/napari/napari/pull/5777 diff --git a/.github/workflows/upgrade_test_constraints.yml b/.github/workflows/upgrade_test_constraints.yml index b1b9fe21c35..9494d8bc6d5 100644 --- a/.github/workflows/upgrade_test_constraints.yml +++ b/.github/workflows/upgrade_test_constraints.yml @@ -57,6 +57,9 @@ on: jobs: upgrade: + permissions: + pull-requests: write + issues: write name: Upgrade & Open Pull Request if: (github.event.issue.pull_request != '' && contains(github.event.comment.body, '@napari-bot update constraints')) || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -82,10 +85,10 @@ jobs: -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_number" \ ) - + FULL_NAME=$(echo $PR_data | jq -r .head.repo.full_name) echo "FULL_NAME=$FULL_NAME" >> $GITHUB_ENV - + BRANCH=$(echo $PR_data | jq -r .head.ref) echo "BRANCH=$BRANCH" >> $GITHUB_ENV @@ -167,7 +170,7 @@ jobs: # ADD YOUR CUSTOM DEPENDENCY UPGRADE COMMANDS BELOW run: | flags="" - # Explanation of below commands + # Explanation of below commands # python3.8 -m piptools compile - call pip-compile but ensure proper interpreter # --upgrade upgrade to the latest possible version. Without this pip-compile will take a look to output files and reuse versions (so will ad something on when adding dependency. # -o resources/constraints/constraints_py3.8.txt - output file @@ -179,6 +182,8 @@ jobs: flags+=" --extra pyside2" flags+=" --extra pyside6_experimental" flags+=" --extra testing" + flags+=" --extra testing_extra" + flags+=" --extra optional" prefix="napari_repo" setup_cfg="${prefix}/setup.cfg" constraints="${prefix}/resources/constraints" @@ -196,10 +201,11 @@ jobs: for pyv in 3.8 3.9 3.10 3.11; do python${pyv} -m pip install -U pip pip-tools python${pyv} -m piptools compile --upgrade -o $constraints/constraints_py${pyv}.txt $setup_cfg $constraints/version_denylist.txt ${flags} + python${pyv} -m piptools compile --upgrade -o $constraints/constraints_py${pyv}_pydantic_1.txt $setup_cfg $constraints/version_denylist.txt $constraints/pydantic_le_2.txt ${flags} done python3.9 -m piptools compile --upgrade -o $constraints/constraints_py3.9_examples.txt $setup_cfg $constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt ${flags} - python3.10 -m piptools compile --upgrade -o $constraints/constraints_py3.10_docs.txt $setup_cfg $constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt docs/requirements.txt ${flags} + python3.10 -m piptools compile --upgrade -o $constraints/constraints_py3.10_docs.txt $setup_cfg $constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt docs/requirements.txt $constraints/pydantic_le_2.txt ${flags} python3.11 -m piptools compile --upgrade -o resources/requirements_mypy.txt resources/requirements_mypy.in --resolver=backtracking # END PYTHON DEPENDENCIES @@ -231,4 +237,3 @@ jobs: GHA_TOKEN_MAIN_REPO: ${{ secrets.GHA_TOKEN_NAPARI_BOT_MAIN_REPO }} PR_NUMBER: ${{ github.event.issue.number }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fcd115c0e99..0d8773fdf25 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,36 @@ +exclude: _vendor|vendored repos: -- repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.9.1 - hooks: - - id: black - pass_filenames: true - exclude: _vendor|vendored|examples -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.291 - hooks: - - id: ruff - exclude: _vendor|vendored -- repo: https://github.com/seddonym/import-linter - rev: v1.12.0 - hooks: - - id: import-linter - stages: [manual] -- repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.26.3 - hooks: - - id: check-github-workflows +- repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.11.0 + hooks: + - id: black + pass_filenames: true + exclude: examples +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.6 + hooks: + - id: ruff +- repo: https://github.com/seddonym/import-linter + rev: v1.12.1 + hooks: + - id: import-linter + stages: [manual] +- repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.27.2 + hooks: + - id: check-github-workflows +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + # .py files are skipped cause already checked by other hooks + hooks: + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + exclude: .*\.py + - id: end-of-file-fixer + exclude: .*\.py + - id: trailing-whitespace + # trailing whitespace has meaning in markdown https://www.markdownguide.org/hacks/#indent-tab + exclude: .*\.py|.*\.md + - id: mixed-line-ending + exclude: .*\.py diff --git a/binder/environment.yml b/binder/environment.yml index b92a7be2530..487f0f0b454 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -11,7 +11,7 @@ dependencies: - imageio >=2.20 - importlib-metadata >=1.5.0 # not needed for py>37 but keeping for noarch - jsonschema >=3.2.0 -- magicgui >=0.2.6 +- magicgui >=0.7.0 - napari-console >=0.0.4 - napari-plugin-engine >=0.1.9 - napari-svg >=0.1.4 diff --git a/binder/xsettings.xml b/binder/xsettings.xml index d117187876f..53fbbb522bd 100644 --- a/binder/xsettings.xml +++ b/binder/xsettings.xml @@ -1,5 +1,5 @@ - + diff --git a/codecov.yml b/codecov.yml index 143a2e1aeed..6e3732525f9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -32,7 +32,7 @@ coverage: target: 0% codecov: notify: - after_n_builds: 11 + after_n_builds: 1 comment: require_changes: true # if true: only post the PR comment if coverage changes - after_n_builds: 11 \ No newline at end of file + after_n_builds: 1 diff --git a/dockerfile b/dockerfile index 07873c9d75c..d6ac3a071dd 100644 --- a/dockerfile +++ b/dockerfile @@ -50,9 +50,11 @@ ENTRYPOINT ["python3", "-m", "napari"] FROM napari AS napari-xpra # Install Xpra and dependencies -RUN apt-get install -y wget gnupg2 apt-transport-https && \ - wget -O - https://xpra.org/gpg.asc | apt-key add - && \ - echo "deb https://xpra.org/ jammy main" > /etc/apt/sources.list.d/xpra.list +RUN apt-get update && apt-get install -y wget gnupg2 apt-transport-https \ + software-properties-common ca-certificates && \ + wget -O "/usr/share/keyrings/xpra.asc" https://xpra.org/xpra.asc && \ + wget -O "/etc/apt/sources.list.d/xpra.sources" https://xpra.org/repos/jammy/xpra.sources + RUN apt-get update && \ apt-get install -yqq \ @@ -70,7 +72,7 @@ ENV XPRA_EXIT_WITH_CLIENT="yes" ENV XPRA_XVFB_SCREEN="1920x1080x24+32" EXPOSE 9876 -CMD echo "Launching napari on Xpra. Connect via http://localhost:$XPRA_PORT"; \ +CMD echo "Launching napari on Xpra. Connect via http://localhost:$XPRA_PORT or $(hostname -i):$XPRA_PORT"; \ xpra start \ --bind-tcp=0.0.0.0:$XPRA_PORT \ --html=on \ diff --git a/examples/README.rst b/examples/README.rst index 8b137891791..e69de29bb2d 100644 --- a/examples/README.rst +++ b/examples/README.rst @@ -1 +0,0 @@ - diff --git a/examples/add_labels_with_features.py b/examples/add_labels_with_features.py index 1f45dbe989d..f0670ff1d88 100644 --- a/examples/add_labels_with_features.py +++ b/examples/add_labels_with_features.py @@ -47,7 +47,9 @@ 'size': ["none", *coin_sizes], # background is size: none } -color = {1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow'} +color = {1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow', None: 'magenta'} +# Here we provide a dict with color mappings for a subset of labels; +# we also provide a default color (`None` key) which will be used by all other labels # add the labels label_layer = viewer.add_labels( diff --git a/examples/clipboard_.py b/examples/clipboard_.py index 9ca21ad6c9d..4b933ad89c8 100644 --- a/examples/clipboard_.py +++ b/examples/clipboard_.py @@ -34,8 +34,8 @@ def create_grabber_widget(): widget = Grabber() # connect buttons - widget.copy_canvas_btn.clicked.connect(lambda: viewer.window.qt_viewer.clipboard()) - widget.copy_viewer_btn.clicked.connect(lambda: viewer.window.clipboard()) + widget.copy_canvas_btn.clicked.connect(lambda: viewer.window.clipboard(canvas_only=True)) + widget.copy_viewer_btn.clicked.connect(lambda: viewer.window.clipboard(canvas_only=False)) return widget diff --git a/examples/dev/direct-colormap-aliasing-test.py b/examples/dev/direct-colormap-aliasing-test.py index 24197b37cf7..97ecf76c00a 100644 --- a/examples/dev/direct-colormap-aliasing-test.py +++ b/examples/dev/direct-colormap-aliasing-test.py @@ -52,7 +52,7 @@ from napari._vispy.layers.labels import build_textures_from_dict, hash2d_get # noqa tex_shape = (1000, 1000) # NOTE: this has to be equal to the actual texture shape in build_textures_from_dict! -keys, values = build_textures_from_dict(colormap_ordered) +keys, values, _ = build_textures_from_dict(colormap_ordered) texel_pos_img = np.zeros((1, nb_steps, 4)) texel_pos_img[..., -1] = 1 # alpha for k in range(nb_steps): diff --git a/examples/dev/grin.svg b/examples/dev/grin.svg index 33112989580..56c3a9e5d22 100644 --- a/examples/dev/grin.svg +++ b/examples/dev/grin.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/examples/dev/issue-6456.py b/examples/dev/issue-6456.py new file mode 100644 index 00000000000..3f0167014d1 --- /dev/null +++ b/examples/dev/issue-6456.py @@ -0,0 +1,22 @@ +import numpy as np + +import napari + +# Set the number of steps +num_steps = 2**17 + +base = np.linspace(start=1, stop=num_steps, num=num_steps).astype('uint32') +label_img = np.repeat( + base.reshape([1, base.shape[0]]), int(num_steps/1000), axis=0 + ) + +viewer = napari.Viewer() +viewer.add_image( + label_img, + scale=(100, 1), + colormap='viridis', + contrast_limits=(0, num_steps), + ) + +if __name__ == '__main__': + napari.run() diff --git a/examples/dev/slicing/README.md b/examples/dev/slicing/README.md index 909cb2eb23b..3521f7a31d7 100644 --- a/examples/dev/slicing/README.md +++ b/examples/dev/slicing/README.md @@ -38,4 +38,4 @@ resulting in non-responsive user interface due to synchronous slicing on large o ## Performance monitoring The [perfmon](../../../tools/perfmon/README.md) tooling can be used to monitor the data -access performance on these examples. \ No newline at end of file +access performance on these examples. diff --git a/examples/dev/slicing/janelia_s3_n5_multiscale.py b/examples/dev/slicing/janelia_s3_n5_multiscale.py index 501e3ccb9cd..fc17a4c952f 100644 --- a/examples/dev/slicing/janelia_s3_n5_multiscale.py +++ b/examples/dev/slicing/janelia_s3_n5_multiscale.py @@ -28,4 +28,3 @@ if __name__ == '__main__': napari.run() - diff --git a/examples/mgui_with_threadpoolexec_.py b/examples/mgui_with_threadpoolexec_.py index 0f4c4f333df..d3c2be40afc 100644 --- a/examples/mgui_with_threadpoolexec_.py +++ b/examples/mgui_with_threadpoolexec_.py @@ -55,6 +55,7 @@ def _make_blob(): "size": blobs[:, -1], "border_color": "red", "border_width": 2, + "border_width_is_relative": False, "face_color": "transparent", } return (data, kwargs, 'points') diff --git a/examples/mgui_with_threadworker_.py b/examples/mgui_with_threadworker_.py index bd3257ca5ca..0694a8a6c32 100644 --- a/examples/mgui_with_threadworker_.py +++ b/examples/mgui_with_threadworker_.py @@ -38,6 +38,7 @@ def detect_blobs() -> LayerDataTuple: "size": blobs[:, -1], "border_color": "red", "border_width": 2, + "border_width_is_relative": False, "face_color": "transparent", } # return a "LayerDataTuple" diff --git a/examples/notebook.ipynb b/examples/notebook.ipynb index 853793eff3c..478c770d356 100644 --- a/examples/notebook.ipynb +++ b/examples/notebook.ipynb @@ -69,4 +69,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} \ No newline at end of file +} diff --git a/examples/viewer_loop_reproducible_screenshots.md b/examples/viewer_loop_reproducible_screenshots.md index 30bfbdf9cca..55a9f48b393 100644 --- a/examples/viewer_loop_reproducible_screenshots.md +++ b/examples/viewer_loop_reproducible_screenshots.md @@ -73,7 +73,7 @@ viewer.text_overlay.text = "Hello World!" # Not yet implemented, but can be added as soon as this feature exisits (syntax might change): # viewer.controls.visible = False -viewer.add_labels(myball, name="result" , opacity=1) +viewer.add_labels(myball, name="result" , opacity=1.0) viewer.camera.angles = (19, -33, -121) viewer.camera.zoom = 1.3 ``` diff --git a/examples/vortex.py b/examples/vortex.py new file mode 100644 index 00000000000..a12b52f60d9 --- /dev/null +++ b/examples/vortex.py @@ -0,0 +1,70 @@ +""" +Visualizing optical flow in napari +================================== + +Adapted from the scikit-image gallery [1]_. + +In napari, we can show the flowing vortex as an additional dimension in the +image, visible by moving the slider. + +.. tags:: visualization-advanced, layers + +.. [1] https://scikit-image.org/docs/stable/auto_examples/registration/plot_opticalflow.html +""" +import numpy as np +from skimage.data import vortex +from skimage.registration import optical_flow_ilk + +import napari + +####################################################################### +# First, we load the vortex image as a 3D array. (time, row, column) + +vortex_im = np.asarray(vortex()) + +####################################################################### +# We compute the optical flow using scikit-image. (Note: as of +# scikit-image 0.21, there seems to be a transposition of the image in +# the output, which we account for later.) + +u, v = optical_flow_ilk(vortex_im[0], vortex_im[1], radius=15) + +####################################################################### +# Compute the flow magnitude, for visualization. + +magnitude = np.sqrt(u ** 2 + v ** 2) + +####################################################################### +# We subsample the vector field to display it — it's too +# messy otherwise! And we transpose the rows/columns axes to match the +# current scikit-image output. + +nvec = 21 +nr, nc = magnitude.shape +step = max(nr//nvec, nc//nvec) +offset = step // 2 +usub = u[offset::step, offset::step] +vsub = v[offset::step, offset::step] + +vectors_field = np.transpose( # transpose required — skimage bug? + np.stack([usub, vsub], axis=-1), + (1, 0, 2), + ) + +####################################################################### +# Finally, we create a viewer, and add the vortex frames, the flow +# magnitude, and the vector field. + +viewer, vortex_layer = napari.imshow(vortex_im) +mag_layer = viewer.add_image(magnitude, colormap='magma', opacity=0.3) +flow_layer = viewer.add_vectors( + vectors_field, + name='optical flow', + scale=[step, step], + translate=[offset, offset], + edge_width=0.3, + length=0.3, + ) + +if __name__ == '__main__': + napari.run() diff --git a/napari/__main__.py b/napari/__main__.py index f656c25d67a..82d27c81988 100644 --- a/napari/__main__.py +++ b/napari/__main__.py @@ -209,7 +209,7 @@ def parse_sys_argv(): return args, kwargs -def _run(): +def _run() -> None: from napari import Viewer, run from napari.settings import get_settings @@ -280,19 +280,22 @@ def _run(): npe2_plugins = [] for plugin in args.with_: pname, *wnames = plugin - for _name, (_pname, _wnames) in _npe2.widget_iterator(): - if _name == 'dock' and pname == _pname: + for name, (w_pname, wnames) in _npe2.widget_iterator(): + if name == 'dock' and pname == w_pname: npe2_plugins.append(plugin) if '__all__' in wnames: - wnames = _wnames + wnames = wnames break - for _name, (_pname, _wnames) in plugin_manager.iter_widgets(): - if _name == 'dock' and pname == _pname: + for name2, ( + w_pname, + wnames_dict, + ) in plugin_manager.iter_widgets(): + if name2 == 'dock' and pname == w_pname: plugin_manager_plugins.append(plugin) if '__all__' in wnames: # Plugin_manager iter_widgets return wnames as dict keys - wnames = list(_wnames.keys()) + wnames = list(wnames_dict) print( trans._( 'Non-npe2 plugin {pname} detected. Disable tabify for this plugin.', @@ -354,15 +357,15 @@ def _run(): ): pname, *wnames = plugin if '__all__' in wnames: - for name, (_pname, _wnames) in chain( + for name, (_pname, wnames_collection) in chain( _npe2.widget_iterator(), plugin_manager.iter_widgets() ): if name == 'dock' and pname == _pname: - if isinstance(_wnames, dict): + if isinstance(wnames_collection, dict): # Plugin_manager iter_widgets return wnames as dict keys - wnames = list(_wnames.keys()) + wnames = list(wnames_collection.keys()) else: - wnames = _wnames + wnames = wnames_collection break if wnames: @@ -439,7 +442,11 @@ def _maybe_rerun_with_macos_fixes(): # This import mus be here to raise exception about PySide6 problem - if sys.platform != "darwin": + if ( + sys.platform != "darwin" + or "pdb" in sys.modules + or "pydevd" in sys.modules + ): return if "_NAPARI_RERUN_WITH_FIXES" in os.environ: diff --git a/napari/_app_model/actions/_layer_actions.py b/napari/_app_model/actions/_layer_actions.py index 100fecb7ced..43f3edb74dd 100644 --- a/napari/_app_model/actions/_layer_actions.py +++ b/napari/_app_model/actions/_layer_actions.py @@ -129,9 +129,52 @@ enablement=LLSCK.num_unselected_linked_layers, menus=[LAYERCTX_LINK], ), + Action( + id=CommandId.SHOW_SELECTED_LAYERS, + title=CommandId.SHOW_SELECTED_LAYERS.command_title, + callback=_layer_actions._show_selected, + menus=[ + { + 'id': MenuId.LAYERLIST_CONTEXT, + 'group': MenuGroup.NAVIGATION, + } + ], + ), + Action( + id=CommandId.HIDE_SELECTED_LAYERS, + title=CommandId.HIDE_SELECTED_LAYERS.command_title, + callback=_layer_actions._hide_selected, + menus=[ + { + 'id': MenuId.LAYERLIST_CONTEXT, + 'group': MenuGroup.NAVIGATION, + } + ], + ), + Action( + id=CommandId.SHOW_UNSELECTED_LAYERS, + title=CommandId.SHOW_UNSELECTED_LAYERS.command_title, + callback=_layer_actions._show_unselected, + menus=[ + { + 'id': MenuId.LAYERLIST_CONTEXT, + 'group': MenuGroup.NAVIGATION, + } + ], + ), + Action( + id=CommandId.HIDE_UNSELECTED_LAYERS, + title=CommandId.HIDE_UNSELECTED_LAYERS.command_title, + callback=_layer_actions._hide_unselected, + menus=[ + { + 'id': MenuId.LAYERLIST_CONTEXT, + 'group': MenuGroup.NAVIGATION, + } + ], + ), ] - for _dtype in ( 'int8', 'int16', diff --git a/napari/_app_model/constants/_commands.py b/napari/_app_model/constants/_commands.py index 8ad7594cd9e..9a1d1a4079a 100644 --- a/napari/_app_model/constants/_commands.py +++ b/napari/_app_model/constants/_commands.py @@ -70,6 +70,10 @@ class CommandId(StrEnum): LAYER_SPLIT_RGB = 'napari:layer:split_rgb' LAYER_MERGE_STACK = 'napari:layer:merge_stack' LAYER_TOGGLE_VISIBILITY = 'napari:layer:toggle_visibility' + SHOW_SELECTED_LAYERS = 'napari:layer:show_selected' + HIDE_SELECTED_LAYERS = 'napari:layer:hide_selected' + SHOW_UNSELECTED_LAYERS = 'napari:layer:show_unselected' + HIDE_UNSELECTED_LAYERS = 'napari:layer:hide_unselected' LAYER_LINK_SELECTED = 'napari:layer:link_selected_layers' LAYER_UNLINK_SELECTED = 'napari:layer:unlink_selected_layers' @@ -161,6 +165,10 @@ class _i(NamedTuple): CommandId.LAYER_SPLIT_RGB: _i(trans._('Split RGB')), CommandId.LAYER_MERGE_STACK: _i(trans._('Merge to Stack')), CommandId.LAYER_TOGGLE_VISIBILITY: _i(trans._('Toggle visibility')), + CommandId.SHOW_SELECTED_LAYERS: _i(trans._('Show All Selected Layers')), + CommandId.HIDE_SELECTED_LAYERS: _i(trans._('Hide All Selected Layers')), + CommandId.SHOW_UNSELECTED_LAYERS: _i(trans._('Show All Unselected Layers')), + CommandId.HIDE_UNSELECTED_LAYERS: _i(trans._('Hide All Unselected Layers')), CommandId.LAYER_LINK_SELECTED: _i(trans._('Link Layers')), CommandId.LAYER_UNLINK_SELECTED: _i(trans._('Unlink Layers')), CommandId.LAYER_SELECT_LINKED: _i(trans._('Select Linked Layers')), diff --git a/napari/_app_model/context/_context.py b/napari/_app_model/context/_context.py index 67969625cac..02c0522b445 100644 --- a/napari/_app_model/context/_context.py +++ b/napari/_app_model/context/_context.py @@ -79,7 +79,7 @@ def create_context( max_depth: int = 20, start: int = 2, root: Optional[Context] = None, -) -> Optional[Context]: +) -> Context: return _create_context( obj=obj, max_depth=max_depth, diff --git a/napari/_app_model/injection/_processors.py b/napari/_app_model/injection/_processors.py index a4463b77d68..dcb7b7a6bed 100644 --- a/napari/_app_model/injection/_processors.py +++ b/napari/_app_model/injection/_processors.py @@ -2,7 +2,16 @@ from concurrent.futures import Future from contextlib import nullcontext, suppress from functools import partial -from typing import Any, Callable, Dict, List, Optional, Set, Union, get_origin +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Set, + Union, + get_origin, +) from napari import layers, types, viewer from napari._app_model.injection._providers import _provide_viewer @@ -70,6 +79,7 @@ def _add_layer_data_to_viewer( if data is not None and (viewer := viewer or _provide_viewer()): if layer_name: with suppress(KeyError): + # layerlist also allow lookup by name viewer.layers[layer_name].data = data return if get_origin(return_type) is Union: @@ -130,19 +140,18 @@ def _add_future_data( # when the future is done, add layer data to viewer, dispatching # to the appropriate method based on the Future data type. - adder = ( - _add_layer_data_tuples_to_viewer - if _from_tuple - else _add_layer_data_to_viewer - ) + + add_kwargs = { + 'return_type': return_type, + 'viewer': viewer, + 'source': source, + } def _on_future_ready(f: Future): - adder( - f.result(), - return_type=return_type, - viewer=viewer, - source=source, - ) + if _from_tuple: + _add_layer_data_tuples_to_viewer(f.result(), **add_kwargs) + else: + _add_layer_data_to_viewer(f.result(), **add_kwargs) _FUTURES.discard(future) # We need the callback to happen in the main thread... @@ -168,6 +177,6 @@ def _on_future_ready(f: Future): PROCESSORS[t] = partial(_add_layer_data_to_viewer, return_type=t) if sys.version_info >= (3, 9): - PROCESSORS[Future[t]] = partial( + PROCESSORS[Future[t]] = partial( # type: ignore [valid-type] _add_future_data, return_type=t, _from_tuple=False ) diff --git a/napari/_pydantic_compat.py b/napari/_pydantic_compat.py new file mode 100644 index 00000000000..d9142b24e5c --- /dev/null +++ b/napari/_pydantic_compat.py @@ -0,0 +1,97 @@ +try: + # pydantic v2 + from pydantic.v1 import ( + BaseModel, + BaseSettings, + Extra, + Field, + PositiveInt, + PrivateAttr, + ValidationError, + color, + conlist, + constr, + errors, + main, + parse_obj_as, + root_validator, + types, + utils, + validator, + ) + from pydantic.v1.env_settings import ( + EnvSettingsSource, + SettingsError, + SettingsSourceCallable, + ) + from pydantic.v1.error_wrappers import ErrorWrapper, display_errors + from pydantic.v1.fields import SHAPE_LIST, ModelField + from pydantic.v1.generics import GenericModel + from pydantic.v1.main import ClassAttribute, ModelMetaclass + from pydantic.v1.utils import ROOT_KEY, sequence_like +except ImportError: + # pydantic v1 + from pydantic import ( + BaseModel, + BaseSettings, + Extra, + Field, + PositiveInt, + PrivateAttr, + ValidationError, + color, + conlist, + constr, + errors, + main, + parse_obj_as, + root_validator, + types, + utils, + validator, + ) + from pydantic.env_settings import ( + EnvSettingsSource, + SettingsError, + SettingsSourceCallable, + ) + from pydantic.error_wrappers import ErrorWrapper, display_errors + from pydantic.fields import SHAPE_LIST, ModelField + from pydantic.generics import GenericModel + from pydantic.main import ClassAttribute, ModelMetaclass + from pydantic.utils import ROOT_KEY, sequence_like + +Color = color.Color + +__all__ = ( + 'BaseModel', + 'BaseSettings', + 'ClassAttribute', + 'Color', + 'EnvSettingsSource', + 'ErrorWrapper', + 'Extra', + 'Field', + 'ModelField', + 'GenericModel', + 'ModelMetaclass', + 'PositiveInt', + 'PrivateAttr', + 'ROOT_KEY', + 'SettingsError', + 'SettingsSourceCallable', + 'SHAPE_LIST', + 'ValidationError', + 'color', + 'conlist', + 'constr', + 'display_errors', + 'errors', + 'main', + 'parse_obj_as', + 'root_validator', + 'sequence_like', + 'types', + 'utils', + 'validator', +) diff --git a/napari/_qt/_qapp_model/_menus.py b/napari/_qt/_qapp_model/_menus.py index 8bf474e14de..21e79c19af0 100644 --- a/napari/_qt/_qapp_model/_menus.py +++ b/napari/_qt/_qapp_model/_menus.py @@ -3,7 +3,7 @@ from app_model.backends.qt import QModelMenu if TYPE_CHECKING: - from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] + from qtpy.QtWidgets import QWidget def build_qmodel_menu( diff --git a/napari/_qt/_qapp_model/_tests/test_file_menu.py b/napari/_qt/_qapp_model/_tests/test_file_menu.py index 29c659892c1..bc8fe1bb206 100644 --- a/napari/_qt/_qapp_model/_tests/test_file_menu.py +++ b/napari/_qt/_qapp_model/_tests/test_file_menu.py @@ -9,7 +9,7 @@ from qtpy.QtWidgets import QMenu from napari._app_model import get_app -from napari._app_model.constants import CommandId +from napari._app_model.constants import CommandId, MenuId from napari.layers import Image from napari.utils.action_manager import action_manager @@ -58,7 +58,7 @@ def test_plugin_display_name_use_for_multiple_samples( # builtins provides more than one sample, # so the submenu should use the `display_name` from manifest - samples_menu = app.menus.get_menu('napari/file/samples') + samples_menu = app.menus.get_menu(MenuId.FILE_SAMPLES) assert samples_menu[0].title == 'napari builtins' # Now ensure that the actions are still correct # trigger the action, opening the first sample: `Astronaut` @@ -79,7 +79,7 @@ def test_sample_menu_plugin_state_change( pm = tmp_plugin.plugin_manager # Check no samples menu before plugin registration with pytest.raises(KeyError): - app.menus.get_menu('napari/file/samples') + app.menus.get_menu(MenuId.FILE_SAMPLES) sample1 = SampleDataURI( key='tmp-sample-1', @@ -96,11 +96,11 @@ def test_sample_menu_plugin_state_change( # Configures `app`, registers actions and initializes plugins make_napari_viewer() - samples_menu = app.menus.get_menu('napari/file/samples') + samples_menu = app.menus.get_menu(MenuId.FILE_SAMPLES) assert len(samples_menu) == 1 assert isinstance(samples_menu[0], SubmenuItem) assert samples_menu[0].title == tmp_plugin.display_name - samples_sub_menu = app.menus.get_menu('napari/file/samples/tmp_plugin') + samples_sub_menu = app.menus.get_menu(MenuId.FILE_SAMPLES + '/tmp_plugin') assert len(samples_sub_menu) == 2 assert isinstance(samples_sub_menu[0], MenuItem) assert samples_sub_menu[0].command.title == 'Temp Sample One' @@ -109,12 +109,12 @@ def test_sample_menu_plugin_state_change( # Disable plugin pm.disable(tmp_plugin.name) with pytest.raises(KeyError): - app.menus.get_menu('napari/file/samples') + app.menus.get_menu(MenuId.FILE_SAMPLES) assert 'tmp_plugin:tmp-sample-1' not in app.commands # Enable plugin pm.enable(tmp_plugin.name) - samples_sub_menu = app.menus.get_menu('napari/file/samples/tmp_plugin') + samples_sub_menu = app.menus.get_menu(MenuId.FILE_SAMPLES + '/tmp_plugin') assert len(samples_sub_menu) == 2 assert 'tmp_plugin:tmp-sample-1' in app.commands @@ -134,7 +134,7 @@ def test_sample_menu_single_data( # Configures `app`, registers actions and initializes plugins make_napari_viewer() - samples_menu = app.menus.get_menu('napari/file/samples') + samples_menu = app.menus.get_menu(MenuId.FILE_SAMPLES) assert isinstance(samples_menu[0], MenuItem) assert len(samples_menu) == 1 assert samples_menu[0].command.title == 'Temp Sample One (Temp Plugin)' diff --git a/napari/_qt/_qapp_model/qactions/_file.py b/napari/_qt/_qapp_model/qactions/_file.py index bcf06a76fde..f6997e66f41 100644 --- a/napari/_qt/_qapp_model/qactions/_file.py +++ b/napari/_qt/_qapp_model/qactions/_file.py @@ -181,5 +181,6 @@ def _close_app(window: Window): title=CommandId.DLG_QUIT.command_title, callback=_close_app, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.CLOSE}], + keybindings=[StandardKeyBinding.Quit], ), ] diff --git a/napari/_qt/_tests/test_async_slicing.py b/napari/_qt/_tests/test_async_slicing.py index b7d8904fc9d..ee5c76ae672 100644 --- a/napari/_qt/_tests/test_async_slicing.py +++ b/napari/_qt/_tests/test_async_slicing.py @@ -23,6 +23,18 @@ def rng() -> np.random.Generator: return np.random.default_rng(0) +@pytest.fixture() +def enable_async(fresh_settings, make_napari_viewer): + """ + This fixture depends on fresh_settings and make_napari_viewer + to enforce proper order of fixture execution. + """ + from napari import settings + + settings.get_settings().experimental.async_ = True + + +@pytest.mark.usefixtures("enable_async") def test_async_slice_image_on_current_step_change( make_napari_viewer, qtbot, rng ): @@ -37,6 +49,7 @@ def test_async_slice_image_on_current_step_change( wait_until_vispy_image_data_equal(qtbot, vispy_image, data[2, :, :]) +@pytest.mark.usefixtures("enable_async") def test_async_slice_image_on_order_change(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 5, 7)) @@ -49,6 +62,7 @@ def test_async_slice_image_on_order_change(make_napari_viewer, qtbot, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data[:, 2, :]) +@pytest.mark.usefixtures("enable_async") def test_async_slice_image_on_ndisplay_change(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 4, 5)) @@ -61,6 +75,7 @@ def test_async_slice_image_on_ndisplay_change(make_napari_viewer, qtbot, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data) +@pytest.mark.usefixtures("enable_async") def test_async_slice_multiscale_image_on_pan(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = [rng.random((4, 8, 10)), rng.random((2, 4, 5))] @@ -82,6 +97,7 @@ def test_async_slice_multiscale_image_on_pan(make_napari_viewer, qtbot, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data[1][0, 0:4, 0:3]) +@pytest.mark.usefixtures("enable_async") def test_async_slice_multiscale_image_on_zoom(qtbot, make_napari_viewer, rng): viewer = make_napari_viewer() data = [rng.random((4, 8, 10)), rng.random((2, 4, 5))] @@ -103,6 +119,7 @@ def test_async_slice_multiscale_image_on_zoom(qtbot, make_napari_viewer, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data[0][1, 2:6, 3:7]) +@pytest.mark.usefixtures("enable_async") def test_async_slice_points_on_current_step_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() data = np.array( @@ -123,6 +140,7 @@ def test_async_slice_points_on_current_step_change(make_napari_viewer, qtbot): wait_until_vispy_points_data_equal(qtbot, vispy_points, np.array([[5, 6]])) +@pytest.mark.usefixtures("enable_async") def test_async_slice_points_on_point_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() # Define data so that slicing at 1.6 in the first dimension should match the @@ -146,6 +164,7 @@ def test_async_slice_points_on_point_change(make_napari_viewer, qtbot): wait_until_vispy_points_data_equal(qtbot, vispy_points, np.array([[3, 4]])) +@pytest.mark.usefixtures("enable_async") def test_async_slice_image_loaded(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 4, 5)) @@ -160,10 +179,12 @@ def test_async_slice_image_loaded(make_napari_viewer, qtbot, rng): viewer.dims.current_step = (2, 0, 0) assert not layer.loaded - wait_until_vispy_image_data_equal(qtbot, vispy_layer, data[2, :, :]) - assert layer.loaded + qtbot.waitUntil(lambda: layer.loaded) + + np.testing.assert_allclose(vispy_layer.node._data, data[2, :, :]) +@pytest.mark.usefixtures("enable_async") def test_async_slice_vectors_on_current_step_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() data = np.array( @@ -190,12 +211,11 @@ def setup_viewer_for_async_slicing( ) -> VispyBaseLayer: # Initially force synchronous slicing so any slicing caused # by adding the layer finishes before any other slicing starts. - viewer._layer_slicer._force_sync = True - # Add the layer and get the corresponding vispy layer. - layer = viewer.add_layer(layer) - vispy_layer = viewer.window._qt_viewer.layer_to_visual[layer] - # Then allow asynchronous slicing for testing. - viewer._layer_slicer._force_sync = False + with viewer._layer_slicer.force_sync(): + # Add the layer and get the corresponding vispy layer. + layer = viewer.add_layer(layer) + vispy_layer = viewer.window._qt_viewer.layer_to_visual[layer] + return vispy_layer diff --git a/napari/_qt/_tests/test_plugin_widgets.py b/napari/_qt/_tests/test_plugin_widgets.py index 2bb2dd9040c..5f68ec1c858 100644 --- a/napari/_qt/_tests/test_plugin_widgets.py +++ b/napari/_qt/_tests/test_plugin_widgets.py @@ -172,11 +172,7 @@ def test_making_function_dock_widgets(test_plugin_widgets, make_napari_viewer): dw = viewer.window._dock_widgets['magic (TestP3)'] # make sure that it contains a magicgui widget magic_widget = dw.widget()._magic_widget - FGui = getattr(magicgui.widgets, 'FunctionGui', None) - if FGui is None: - # pre magicgui 0.2.6 - FGui = magicgui.FunctionGui - assert isinstance(magic_widget, FGui) + assert isinstance(magic_widget, magicgui.widgets.FunctionGui) # This magicgui widget uses the parameter annotation to receive a viewer assert isinstance(magic_widget.viewer.value, napari.Viewer) # The function just returns the viewer... make sure we can call it diff --git a/napari/_qt/_tests/test_qt_viewer.py b/napari/_qt/_tests/test_qt_viewer.py index 48ff5b924c9..4f202125acc 100644 --- a/napari/_qt/_tests/test_qt_viewer.py +++ b/napari/_qt/_tests/test_qt_viewer.py @@ -2,14 +2,19 @@ import os import weakref from dataclasses import dataclass -from typing import List +from itertools import product, takewhile +from typing import List, Tuple from unittest import mock import numpy as np +import numpy.testing as npt import pytest from imageio import imread -from qtpy.QtGui import QGuiApplication -from qtpy.QtWidgets import QMessageBox +from pytestqt.qtbot import QtBot +from qtpy.QtCore import QEvent, Qt +from qtpy.QtGui import QGuiApplication, QKeyEvent +from qtpy.QtWidgets import QApplication, QMessageBox +from scipy import ndimage as ndi from napari._qt.qt_viewer import QtViewer from napari._tests.utils import ( @@ -21,7 +26,7 @@ ) from napari._vispy._tests.utils import vispy_image_scene_size from napari.components.viewer_model import ViewerModel -from napari.layers import Points +from napari.layers import Labels, Points from napari.settings import get_settings from napari.utils.interactions import mouse_press_callbacks from napari.utils.theme import available_themes @@ -664,42 +669,129 @@ def test_create_non_empty_viewer_model(qtbot): gc.collect() +def _update_data( + layer: Labels, + label: int, + qtbot: QtBot, + qt_viewer: QtViewer, + dtype: np.dtype = np.uint64, +) -> Tuple[np.ndarray, np.ndarray]: + """Change layer data and return color of label and middle pixel of screenshot.""" + layer.data = np.full((2, 2), label, dtype=dtype) + layer.selected_label = label + + qtbot.wait(50) # wait for .update() to be called on QtColorBox from Qt + + color_box_color = qt_viewer.controls.widgets[layer].colorBox.color + screenshot = qt_viewer.screenshot(flash=False) + shape = np.array(screenshot.shape[:2]) + middle_pixel = screenshot[tuple(shape // 2)] + + return color_box_color, middle_pixel + + +@pytest.fixture() +def qt_viewer_with_controls(qtbot): + qt_viewer = QtViewer(viewer=ViewerModel()) + qt_viewer.show() + qt_viewer.controls.show() + yield qt_viewer + qt_viewer.controls.hide() + qt_viewer.controls.close() + qt_viewer.hide() + qt_viewer.close() + qt_viewer._instances.clear() + qtbot.wait(50) + + @skip_local_popups @skip_on_win_ci -def test_label_colors_matching_widget(qtbot, make_napari_viewer): +@pytest.mark.parametrize( + "use_selection", [True, False], ids=["selected", "all"] +) +@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int64]) +def test_label_colors_matching_widget_auto( + qtbot, qt_viewer_with_controls, use_selection, dtype +): """Make sure the rendered label colors match the QtColorBox widget.""" - viewer = make_napari_viewer(show=True) + # XXX TODO: this unstable! Seed = 0 fails, for example. This is due to numerical # imprecision in random colormap on gpu vs cpu np.random.seed(1) - data = np.ones((2, 2), dtype=np.uint64) - layer = viewer.add_labels(data) + data = np.ones((2, 2), dtype=dtype) + layer = qt_viewer_with_controls.viewer.add_labels(data) + layer.show_selected_label = use_selection layer.opacity = 1.0 # QtColorBox & single layer are blending differently + n_c = layer.num_colors test_colors = np.concatenate( ( - np.arange(1, 10, dtype=np.uint64), - np.random.randint(2**20, size=(20), dtype=np.uint64), + np.arange(1, 10, dtype=dtype), + [n_c - 1, n_c, n_c + 1], + np.random.randint( + 1, min(2**20, np.iinfo(dtype).max), size=20, dtype=dtype + ), + [-1, -2, -10], ) ) for label in test_colors: # Change color & selected color to the same label - layer.data = np.full((2, 2), label, dtype=np.uint64) - layer.selected_label = label + color_box_color, middle_pixel = _update_data( + layer, label, qtbot, qt_viewer_with_controls, dtype + ) - qtbot.wait( - 100 - ) # wait for .update() to be called on QtColorBox from Qt + npt.assert_allclose( + color_box_color, middle_pixel, atol=1, err_msg=f"label {label}" + ) + # there is a difference of rounding between the QtColorBox and the screenshot - color_box_color = viewer.window._qt_viewer.controls.widgets[ - layer - ].colorBox.color - screenshot = viewer.window.screenshot(flash=False, canvas_only=True) - shape = np.array(screenshot.shape[:2]) - middle_pixel = screenshot[tuple(shape // 2)] - np.testing.assert_equal(color_box_color, middle_pixel) +@skip_local_popups +@skip_on_win_ci +@pytest.mark.parametrize("use_selection", [True, False]) +@pytest.mark.parametrize("dtype", [np.uint64, np.uint16, np.uint8, np.int16]) +def test_label_colors_matching_widget_direct( + qtbot, qt_viewer_with_controls, use_selection, dtype +): + """Make sure the rendered label colors match the QtColorBox widget.""" + data = np.ones((2, 2), dtype=dtype) + layer = qt_viewer_with_controls.viewer.add_labels(data) + layer.show_selected_label = use_selection + layer.opacity = 1.0 # QtColorBox & single layer are blending differently + color = { + 0: "transparent", + 1: "yellow", + 3: "blue", + 8: "red", + 150: "green", + None: "white", + } + test_colors = (1, 2, 3, 8, 150, 50) + + if np.iinfo(dtype).min < 0: + color[-1] = "pink" + color[-2] = "orange" + test_colors = test_colors + (-1, -2, -10) + + layer.color = color + + color_box_color, middle_pixel = _update_data( + layer, 0, qtbot, qt_viewer_with_controls, dtype + ) + assert np.allclose([0, 0, 0, 255], middle_pixel) + + for label in test_colors: + # Change color & selected color to the same label + color_box_color, middle_pixel = _update_data( + layer, label, qtbot, qt_viewer_with_controls, dtype + ) + assert np.allclose(color_box_color, middle_pixel, atol=1), label + assert np.allclose( + color_box_color, + layer.color.get(label, layer.color[None]) * 255, + atol=1, + ), label def test_axes_labels(make_napari_viewer): @@ -714,3 +806,225 @@ def test_axes_labels(make_napari_viewer): layer_visual_size = vispy_image_scene_size(layer_visual) assert tuple(layer_visual_size) == (8, 4, 2) assert tuple(axes_visual.node.text.text) == ('2', '1', '0') + + +@pytest.fixture() +def qt_viewer(qtbot): + qt_viewer = QtViewer(ViewerModel()) + qt_viewer.show() + qt_viewer.resize(460, 460) + QApplication.processEvents() + yield qt_viewer + qt_viewer.close() + qt_viewer._instances.clear() + del qt_viewer + + +def _find_margin(data: np.ndarray, additional_margin: int) -> Tuple[int, int]: + """ + helper function to determine margins in test_thumbnail_labels + """ + + mid_x, mid_y = data.shape[0] // 2, data.shape[1] // 2 + x_margin = len( + list(takewhile(lambda x: np.all(x == 0), data[:, mid_y, :3][::-1])) + ) + y_margin = len( + list(takewhile(lambda x: np.all(x == 0), data[mid_x, :, :3][::-1])) + ) + return x_margin + additional_margin, y_margin + additional_margin + + +# @pytest.mark.xfail(reason="Fails on CI, but not locally") +@skip_local_popups +@pytest.mark.parametrize('direct', [True, False], ids=["direct", "auto"]) +def test_thumbnail_labels(qtbot, direct, qt_viewer: QtViewer, tmp_path): + # Add labels to empty viewer + layer = qt_viewer.viewer.add_labels( + np.array([[0, 1], [2, 3]]), opacity=1.0 + ) + if direct: + layer.color = {0: 'red', 1: 'green', 2: 'blue', 3: 'yellow'} + else: + layer.num_colors = 49 + qt_viewer.viewer.reset_view() + qt_viewer.canvas.native.paintGL() + QApplication.processEvents() + qtbot.wait(50) + + canvas_screenshot_ = qt_viewer.screenshot(flash=False) + + import imageio + + imageio.imwrite(tmp_path / "canvas_screenshot_.png", canvas_screenshot_) + np.savez(tmp_path / "canvas_screenshot_.npz", canvas_screenshot_) + + # cut off black border + margin1, margin2 = _find_margin(canvas_screenshot_, 10) + canvas_screenshot = canvas_screenshot_[margin1:-margin1, margin2:-margin2] + assert ( + canvas_screenshot.size > 0 + ), f"{canvas_screenshot_.shape}, {margin1=}, {margin2=}" + + thumbnail = layer.thumbnail + scaled_thumbnail = ndi.zoom( + thumbnail, + np.array(canvas_screenshot.shape) / np.array(thumbnail.shape), + order=0, + mode="nearest", + ) + close = np.isclose(canvas_screenshot, scaled_thumbnail) + problematic_pixels_count = np.sum(~close) + assert problematic_pixels_count < 0.01 * canvas_screenshot.size + + +@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32]) +def test_background_color(qtbot, qt_viewer: QtViewer, dtype): + data = np.zeros((10, 10), dtype=dtype) + data[5:] = 10 + layer = qt_viewer.viewer.add_labels(data, opacity=1) + color = layer.colormap.map(10)[0] * 255 + + backgrounds = (0, 2, -2) + + for background in backgrounds: + layer._background_label = background + data[:5] = background + layer.data = data + layer.num_colors = 49 + qtbot.wait(50) + canvas_screenshot = qt_viewer.screenshot(flash=False) + shape = np.array(canvas_screenshot.shape[:2]) + background_pixel = canvas_screenshot[tuple((shape * 0.25).astype(int))] + color_pixel = canvas_screenshot[tuple((shape * 0.75).astype(int))] + npt.assert_array_equal( + background_pixel, + [0, 0, 0, 255], + err_msg=f"background {background}", + ) + npt.assert_array_equal( + color_pixel, color, err_msg=f"background {background}" + ) + + +def test_shortcut_passing(make_napari_viewer): + viewer = make_napari_viewer(ndisplay=3) + layer = viewer.add_labels( + np.zeros((2, 2, 2), dtype=np.uint8), scale=(1, 2, 4) + ) + layer.mode = "fill" + + qt_window = viewer.window._qt_window + + qt_window.keyPressEvent( + QKeyEvent( + QEvent.Type.KeyPress, Qt.Key.Key_1, Qt.KeyboardModifier.NoModifier + ) + ) + qt_window.keyReleaseEvent( + QKeyEvent( + QEvent.Type.KeyPress, Qt.Key.Key_1, Qt.KeyboardModifier.NoModifier + ) + ) + assert layer.mode == "erase" + + +@pytest.mark.parametrize("mode", ["direct", "random"]) +def test_selection_collision(qt_viewer: QtViewer, mode): + data = np.zeros((10, 10), dtype=np.uint8) + data[:5] = 10 + data[5:] = 10 + 49 + layer = qt_viewer.viewer.add_labels(data, opacity=1) + layer.selected_label = 10 + if mode == "direct": + layer.color = {10: "red", 10 + 49: "red"} + + for dtype in np.sctypes['int'] + np.sctypes['uint']: + layer.data = data.astype(dtype) + layer.show_selected_label = False + QApplication.processEvents() + canvas_screenshot = qt_viewer.screenshot(flash=False) + shape = np.array(canvas_screenshot.shape[:2]) + pixel_10 = canvas_screenshot[tuple((shape * 0.25).astype(int))] + pixel_59 = canvas_screenshot[tuple((shape * 0.75).astype(int))] + npt.assert_array_equal(pixel_10, pixel_59, err_msg=f"{dtype}") + assert not np.all(pixel_10 == [0, 0, 0, 255]), dtype + + layer.show_selected_label = True + + canvas_screenshot = qt_viewer.screenshot(flash=False) + shape = np.array(canvas_screenshot.shape[:2]) + pixel_10_2 = canvas_screenshot[tuple((shape * 0.25).astype(int))] + pixel_59_2 = canvas_screenshot[tuple((shape * 0.75).astype(int))] + + npt.assert_array_equal(pixel_59_2, [0, 0, 0, 255], err_msg=f"{dtype}") + npt.assert_array_equal(pixel_10_2, pixel_10, err_msg=f"{dtype}") + + +def test_all_supported_dtypes(qt_viewer): + data = np.zeros((10, 10), dtype=np.uint8) + layer = qt_viewer.viewer.add_labels(data, opacity=1) + + for i, dtype in enumerate(np.sctypes['int'] + np.sctypes['uint'], start=1): + data = np.full((10, 10), i, dtype=dtype) + layer.data = data + QApplication.processEvents() + canvas_screenshot = qt_viewer.screenshot(flash=False) + midd_pixel = canvas_screenshot[ + tuple(np.array(canvas_screenshot.shape[:2]) // 2) + ] + npt.assert_equal( + midd_pixel, layer.colormap.map(i)[0] * 255, err_msg=f"{dtype} {i}" + ) + + layer.color = { + 0: 'red', + 1: 'green', + 2: 'blue', + 3: 'yellow', + 4: 'magenta', + 5: 'cyan', + 6: 'white', + 7: 'pink', + 8: 'orange', + 9: 'purple', + 10: 'brown', + 11: 'gray', + } + + for i, dtype in enumerate(np.sctypes['int'] + np.sctypes['uint'], start=1): + data = np.full((10, 10), i, dtype=dtype) + layer.data = data + QApplication.processEvents() + canvas_screenshot = qt_viewer.screenshot(flash=False) + midd_pixel = canvas_screenshot[ + tuple(np.array(canvas_screenshot.shape[:2]) // 2) + ] + npt.assert_equal( + midd_pixel, layer.colormap.map(i)[0] * 255, err_msg=f"{dtype} {i}" + ) + + +def test_more_than_uint16_colors(qt_viewer): + # this test is slow (10s locally) + data = np.zeros((10, 10), dtype=np.uint32) + colors = { + i: (x, y, z, 1) + for i, (x, y, z) in zip( + range(256**2 + 20), + product(np.linspace(0, 1, 256, endpoint=True), repeat=3), + ) + } + layer = qt_viewer.viewer.add_labels(data, opacity=1, color=colors) + assert layer._slice.image.view.dtype == np.float32 + + for i in [1, 1000, 100000]: + data = np.full((10, 10), i, dtype=np.uint32) + layer.data = data + canvas_screenshot = qt_viewer.screenshot(flash=False) + midd_pixel = canvas_screenshot[ + tuple(np.array(canvas_screenshot.shape[:2]) // 2) + ] + npt.assert_equal( + midd_pixel, layer.colormap.map(i)[0] * 255, err_msg=f"{i}" + ) diff --git a/napari/_qt/_tests/test_qt_viewer_2.py b/napari/_qt/_tests/test_qt_viewer_2.py index eef02e427af..faa166e37b3 100644 --- a/napari/_qt/_tests/test_qt_viewer_2.py +++ b/napari/_qt/_tests/test_qt_viewer_2.py @@ -47,3 +47,23 @@ def test_qt_viewer_data_integrity(make_napari_viewer, dtype): # also check that vispy gets (almost) the same data datamean = np.mean(fix_data_dtype(data)) assert np.allclose(datamean, imean, rtol=5e-04) + + +@pytest.mark.parametrize( + "dtype,expected", + [ + (np.bool_, np.uint8), + (np.int8, np.float32), + (np.uint8, np.uint8), + (np.int16, np.float32), + (np.uint16, np.uint16), + (np.uint32, np.float32), + (np.float32, np.float32), + (np.float64, np.float32), + ], +) +def test_fix_data_dtype_big_values(dtype, expected): + data = np.array([0, 2, 2**17], dtype=np.int32).astype(dtype) + casted = fix_data_dtype(data) + assert np.allclose(casted, data) + assert casted.dtype == expected diff --git a/napari/_qt/containers/qt_layer_list.py b/napari/_qt/containers/qt_layer_list.py index e76460a989d..4ea354ac33d 100644 --- a/napari/_qt/containers/qt_layer_list.py +++ b/napari/_qt/containers/qt_layer_list.py @@ -53,19 +53,21 @@ def __init__( # To be able to update the loading indicator frame in the item delegate # smoothly and also be able to leave the item painted in a coherent # state (showing the loading indicator or the thumbnail) - layer_delegate.loading_frame_changed.connect(self.viewport().update) + viewport = self.viewport() + assert viewport is not None + + layer_delegate.loading_frame_changed.connect(viewport.update) self.setToolTip(trans._('Layer list')) - font = self.font() - font.setPointSize(12) - self.setFont(font) # This reverses the order of the items in the view, # so items at the end of the list are at the top. self.setModel(ReverseProxyModel(self.model())) - def keyPressEvent(self, e: QKeyEvent) -> None: + def keyPressEvent(self, e: Optional[QKeyEvent]) -> None: """Override Qt event to pass events to the viewer.""" + if e is None: + return if e.key() != Qt.Key.Key_Space: super().keyPressEvent(e) if e.key() not in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): diff --git a/napari/_qt/dialogs/_tests/test_preferences_dialog.py b/napari/_qt/dialogs/_tests/test_preferences_dialog.py index 814d5f52332..67bb7d98ff6 100644 --- a/napari/_qt/dialogs/_tests/test_preferences_dialog.py +++ b/napari/_qt/dialogs/_tests/test_preferences_dialog.py @@ -1,12 +1,15 @@ +import sys + import pytest -from pydantic import BaseModel from qtpy.QtCore import Qt +from napari._pydantic_compat import BaseModel from napari._qt.dialogs.preferences_dialog import ( PreferencesDialog, QMessageBox, ) from napari._vendor.qt_json_builder.qt_jsonschema_form.widgets import ( + FontSizeSchemaWidget, HorizontalObjectSchemaWidget, ) from napari.settings import NapariSettings, get_settings @@ -33,11 +36,40 @@ def test_prefdialog_populated(pref): def test_dask_widget(qtbot, pref): assert isinstance( - pref._stack.currentWidget().widget.widgets['dask'], + pref._stack.currentWidget().widget().widget.widgets['dask'], HorizontalObjectSchemaWidget, ) +def test_font_size_widget(qtbot, pref): + font_size_widget = ( + pref._stack.widget(1).widget().widget.widgets['font_size'] + ) + def_font_size = 12 if sys.platform == 'darwin' else 9 + + # check custom widget definition usage for the font size setting + # and default values + assert isinstance(font_size_widget, FontSizeSchemaWidget) + assert get_settings().appearance.font_size == def_font_size + assert font_size_widget.state == def_font_size + + # check setting a new font size value via widget + new_font_size = 14 + font_size_widget.state = new_font_size + assert get_settings().appearance.font_size == new_font_size + + # check a theme change keeps setted font size value + assert get_settings().appearance.theme == 'light' + get_settings().appearance.theme = 'dark' + assert get_settings().appearance.font_size == new_font_size + assert font_size_widget.state == new_font_size + + # check reset button works + font_size_widget._reset_button.click() + assert get_settings().appearance.font_size == def_font_size + assert font_size_widget.state == def_font_size + + def test_preferences_dialog_accept(qtbot, pref): with qtbot.waitSignal(pref.finished): pref.accept() diff --git a/napari/_qt/dialogs/preferences_dialog.py b/napari/_qt/dialogs/preferences_dialog.py index 15cc91a45ce..d038782230d 100644 --- a/napari/_qt/dialogs/preferences_dialog.py +++ b/napari/_qt/dialogs/preferences_dialog.py @@ -2,22 +2,23 @@ from enum import EnumMeta from typing import TYPE_CHECKING, ClassVar, Dict, Tuple, cast -from pydantic.main import BaseModel, ModelMetaclass from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtWidgets import ( + QApplication, QDialog, QHBoxLayout, QListWidget, QMessageBox, QPushButton, + QScrollArea, QStackedWidget, QVBoxLayout, ) +from napari._pydantic_compat import BaseModel, ModelField, ModelMetaclass from napari.utils.translations import trans if TYPE_CHECKING: - from pydantic.fields import ModelField from qtpy.QtGui import QCloseEvent, QKeyEvent @@ -30,6 +31,7 @@ class PreferencesDialog(QDialog): "shortcuts": {"ui:widget": "shortcuts"}, "extension2reader": {"ui:widget": "extension2reader"}, "dask": {"ui:widget": "horizontal_object"}, + "font_size": {"ui:widget": "font_size"}, } resized = Signal(QSize) @@ -39,6 +41,7 @@ def __init__(self, parent=None) -> None: super().__init__(parent) self.setWindowTitle(trans._("Preferences")) + self.setMinimumSize(QSize(1065, 470)) self._settings = get_settings() self._stack = QStackedWidget(self) @@ -65,7 +68,7 @@ def __init__(self, parent=None) -> None: self.setLayout(QHBoxLayout()) self.layout().addLayout(left_layout, 1) - self.layout().addWidget(self._stack, 3) + self.layout().addWidget(self._stack, 4) # Build dialog from settings self._rebuild_dialog() @@ -126,9 +129,25 @@ def _add_page(self, field: 'ModelField'): form.widget.on_changed.connect( lambda d: getattr(self._settings, name.lower()).update(d) ) + # make widgets follow values of the settings + settings_category = getattr(self._settings, name.lower()) + excluded = set( + getattr( + getattr(settings_category, 'NapariConfig', None), + "preferences_exclude", + {}, + ) + ) + for name_, emitter in settings_category.events.emitters.items(): + if name_ not in excluded: + emitter.connect(update_widget_state(name_, form.widget)) + + page_scrollarea = QScrollArea() + page_scrollarea.setWidgetResizable(True) + page_scrollarea.setWidget(form) self._list.addItem(field.field_info.title or field.name) - self._stack.addWidget(form) + self._stack.addWidget(page_scrollarea) def _get_page_dict(self, field: 'ModelField') -> Tuple[dict, dict]: """Provides the schema, set of values for each setting, and the @@ -191,12 +210,23 @@ def _get_page_dict(self, field: 'ModelField') -> Tuple[dict, dict]: def _restore_default_dialog(self): """Launches dialog to confirm restore settings choice.""" + prev = QApplication.instance().testAttribute( + Qt.ApplicationAttribute.AA_DontUseNativeDialogs + ) + QApplication.instance().setAttribute( + Qt.ApplicationAttribute.AA_DontUseNativeDialogs, True + ) + response = QMessageBox.question( self, trans._("Restore Settings"), trans._("Are you sure you want to restore default settings?"), - QMessageBox.RestoreDefaults | QMessageBox.Cancel, - QMessageBox.RestoreDefaults, + QMessageBox.StandardButton.RestoreDefaults + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.RestoreDefaults, + ) + QApplication.instance().setAttribute( + Qt.ApplicationAttribute.AA_DontUseNativeDialogs, prev ) if response == QMessageBox.RestoreDefaults: self._settings.reset() @@ -226,3 +256,10 @@ def reject(self): plugin_manager.set_call_order(self._starting_pm_order) super().reject() + + +def update_widget_state(name, widget): + def _update_widget_state(event): + widget.state = {name: event.value} + + return _update_widget_state diff --git a/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py b/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py index 6eca3cf105b..92c866e544a 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py +++ b/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py @@ -13,6 +13,7 @@ QtLayerControls, range_to_decimals, ) +from napari.components.dims import Dims from napari.layers import Image, Surface _IMAGE = np.arange(100).astype(np.uint16).reshape((10, 10)) @@ -65,6 +66,8 @@ def test_changing_model_updates_view(qtbot, layer): ) def test_range_popup_clim_buttons(mock_show, qtbot, qapp, layer): """The buttons in the clim_popup should adjust the contrast limits value""" + # this test relies implicitly on ndisplay=3 which is now a broken assumption? + layer._slice_dims(Dims(ndim=3, ndisplay=3)) qtctrl = QtBaseImageControls(layer) qtbot.addWidget(qtctrl) original_clims = tuple(layer.contrast_limits) diff --git a/napari/_qt/layer_controls/_tests/test_qt_image_layer.py b/napari/_qt/layer_controls/_tests/test_qt_image_layer.py index b64e6b36e5e..22ebe3ed0b5 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_image_layer.py +++ b/napari/_qt/layer_controls/_tests/test_qt_image_layer.py @@ -1,6 +1,7 @@ import numpy as np from napari._qt.layer_controls.qt_image_controls import QtImageControls +from napari.components.dims import Dims from napari.layers import Image @@ -116,19 +117,25 @@ def test_auto_contrast_buttons(qtbot): assert layer.contrast_limits == [0, 63] # change slice - layer._slice_dims((1, 8, 8)) + dims = Dims( + ndim=3, range=((0, 4, 1), (0, 8, 1), (0, 8, 1)), point=(1, 8, 8) + ) + layer._slice_dims(dims) # hasn't changed yet assert layer.contrast_limits == [0, 63] # with auto_btn, it should always change qtctrl.autoScaleBar._auto_btn.click() assert layer.contrast_limits == [64, 127] - layer._slice_dims((2, 8, 8)) + dims.point = (2, 8, 8) + layer._slice_dims(dims) assert layer.contrast_limits == [128, 191] - layer._slice_dims((3, 8, 8)) + dims.point = (3, 8, 8) + layer._slice_dims(dims) assert layer.contrast_limits == [192, 255] # once button turns off continuous qtctrl.autoScaleBar._once_btn.click() - layer._slice_dims((4, 8, 8)) + dims.point = (4, 8, 8) + layer._slice_dims(dims) assert layer.contrast_limits == [192, 255] diff --git a/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py b/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py index 8bc3c38d8fa..711b4953d1a 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py +++ b/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py @@ -6,7 +6,7 @@ from napari.utils.colormaps import colormap_utils np.random.seed(0) -_LABELS = np.random.randint(5, size=(10, 15)) +_LABELS = np.random.randint(5, size=(10, 15), dtype=np.uint8) _COLOR = {1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow'} @@ -115,3 +115,16 @@ def test_preserve_labels_checkbox(make_labels_controls): assert not layer.preserve_labels qtctrl.preserveLabelsCheckBox.setChecked(True) assert layer.preserve_labels + + +def test_change_label_selector_range(make_labels_controls): + """Changing the label layer dtype should update label selector range.""" + layer, qtctrl = make_labels_controls() + assert layer.data.dtype == np.uint8 + assert qtctrl.selectionSpinBox.minimum() == 0 + assert qtctrl.selectionSpinBox.maximum() == 255 + + layer.data = layer.data.astype(np.int8) + + assert qtctrl.selectionSpinBox.minimum() == -128 + assert qtctrl.selectionSpinBox.maximum() == 127 diff --git a/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py b/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py index eda37ab9830..53ab57795bf 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py +++ b/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py @@ -186,30 +186,38 @@ def test_create_layer_controls( qcombobox.setCurrentIndex(qcombobox_initial_idx) -if sys.version_info[:2] == (3, 11) and ( +skip_predicate = sys.version_info >= (3, 11) and ( qtpy.API == 'pyqt5' or qtpy.API == 'pyqt6' -): - test_data = [] -else: - # those 2 fail on 3.11 + pyqt5 and pyqt6 with a segfault that can't be caught by - # pytest in qspinbox.setValue(value) - # See: https://github.com/napari/napari/pull/5439 - test_data = [_LABELS_WITH_COLOR, _LABELS] - - -test_data += [ - _IMAGE, - _POINTS, - _SHAPES, - _SURFACE, - _TRACKS, - _VECTORS, -] +) @pytest.mark.parametrize( 'layer_type_with_data', - test_data, + [ + # those 2 fail on 3.11 + pyqt5 and pyqt6 with a segfault that can't be caught by + # pytest in qspinbox.setValue(value) + # See: https://github.com/napari/napari/pull/5439 + pytest.param( + _LABELS_WITH_COLOR, + marks=pytest.mark.skipif( + skip_predicate, + reason='segfault on Python 3.11+ and pyqt5 or Pyqt6', + ), + ), + pytest.param( + _LABELS, + marks=pytest.mark.skipif( + skip_predicate, + reason='segfault on Python 3.11+ and pyqt5 or Pyqt6', + ), + ), + _IMAGE, + _POINTS, + _SHAPES, + _SURFACE, + _TRACKS, + _VECTORS, + ], ) @pytest.mark.qt_no_exception_capture @pytest.mark.skipif(os.environ.get("MIN_REQ", "0") == "1", reason="min req") @@ -218,6 +226,7 @@ def test_create_layer_controls_spin( ): # create layer controls widget ctrl = create_layer_controls(layer_type_with_data) + qtbot.addWidget(ctrl) # check create widget corresponds to the expected class for each type of layer assert isinstance(ctrl, layer_type_with_data.expected_isinstance) @@ -267,7 +276,7 @@ def test_create_layer_controls_spin( assert any( expected_error in captured.err for expected_error in expected_errors - ), captured.err + ), f"value: {value}, range {value_range}\nerr: {captured.err}" assert qspinbox.value() in [qspinbox_max, qspinbox_max - 1] qspinbox.setValue(qspinbox_initial_value) diff --git a/napari/_qt/layer_controls/qt_image_controls_base.py b/napari/_qt/layer_controls/qt_image_controls_base.py index a188f86a6cb..c41f9fa7a7b 100644 --- a/napari/_qt/layer_controls/qt_image_controls_base.py +++ b/napari/_qt/layer_controls/qt_image_controls_base.py @@ -254,6 +254,11 @@ def __init__(self, layer: Image, parent=None) -> None: def reset(): layer.reset_contrast_limits() layer.contrast_limits_range = layer.contrast_limits + decimals_ = range_to_decimals( + layer.contrast_limits_range, layer.dtype + ) + self.slider.setDecimals(decimals_) + self.slider.setSingleStep(10**-decimals_) reset_btn = QPushButton("reset") reset_btn.setObjectName("reset_clims_button") diff --git a/napari/_qt/layer_controls/qt_labels_controls.py b/napari/_qt/layer_controls/qt_labels_controls.py index 458c12b25fd..7a449eb1838 100644 --- a/napari/_qt/layer_controls/qt_labels_controls.py +++ b/napari/_qt/layer_controls/qt_labels_controls.py @@ -110,6 +110,7 @@ def __init__(self, layer) -> None: self._on_show_selected_label_change ) self.layer.events.color_mode.connect(self._on_color_mode_change) + self.layer.events.data.connect(self._on_data_change) # selection spinbox self.selectionSpinBox = QLargeIntSpinBox() @@ -305,6 +306,11 @@ def __init__(self, layer) -> None: trans._('show\nselected:'), self.selectedColorCheckbox ) + def _on_data_change(self): + """Update label selection spinbox min/max when data changes.""" + dtype_lims = get_dtype_limits(get_dtype(self.layer)) + self.selectionSpinBox.setRange(*dtype_lims) + def _on_mode_change(self, event): """Receive layer model mode change event and update checkbox ticks. @@ -535,7 +541,7 @@ class QtColorBox(QWidget): Parameters ---------- - layer : napari.layers.Layer + layer : napari.layers.Labels An instance of a napari layer. """ diff --git a/napari/_qt/layer_controls/qt_vectors_controls.py b/napari/_qt/layer_controls/qt_vectors_controls.py index 46bcf4ff2ad..d49811252e1 100644 --- a/napari/_qt/layer_controls/qt_vectors_controls.py +++ b/napari/_qt/layer_controls/qt_vectors_controls.py @@ -239,7 +239,7 @@ def change_out_of_slice(self, state): Parameters ---------- state : int - Integer value of Qt.CheckState that indicates the check state of outOfSliceCheckBox + Integer value of Qt.CheckState that indicates the check state of outOfSliceCheckBox """ self.layer.out_of_slice_display = ( Qt.CheckState(state) == Qt.CheckState.Checked diff --git a/napari/_qt/perf/_tests/test_perf.py b/napari/_qt/perf/_tests/test_perf.py index 28f557b5a14..94d12fbdd0a 100644 --- a/napari/_qt/perf/_tests/test_perf.py +++ b/napari/_qt/perf/_tests/test_perf.py @@ -6,6 +6,9 @@ from pathlib import Path from unittest.mock import MagicMock +import pytest +from pretend import stub + from napari._qt.perf import qt_performance from napari._tests.utils import skip_local_popups, skip_on_win_ci @@ -34,25 +37,44 @@ } -@skip_on_win_ci -@skip_local_popups -def test_trace_on_start(tmp_path: Path): - """Make sure napari can write a perfmon trace file.""" +@pytest.fixture() +def perf_config(tmp_path: Path): trace_path = tmp_path / "trace.json" config_path = tmp_path / "perfmon.json" CONFIG['trace_file_on_start'] = str(trace_path) config_path.write_text(json.dumps(CONFIG)) + return stub(path=config_path, trace_path=trace_path) + + +@pytest.fixture() +def perfmon_script(tmp_path): + script = PERFMON_SCRIPT + if "coverage" in sys.modules: + script_path = tmp_path / "script.py" + with script_path.open("w") as f: + f.write(script) + return "-m", "coverage", "run", str(script_path) + return "-c", script + + +@skip_on_win_ci +@skip_local_popups +@pytest.mark.usefixtures("qapp") +def test_trace_on_start(tmp_path: Path, perf_config, perfmon_script): + """Make sure napari can write a perfmon trace file.""" + env = os.environ.copy() - env.update({'NAPARI_PERFMON': str(config_path), 'NAPARI_CONFIG': ''}) - subprocess.run([sys.executable, '-c', PERFMON_SCRIPT], env=env, check=True) + env.update({'NAPARI_PERFMON': str(perf_config.path), 'NAPARI_CONFIG': ''}) + + subprocess.run([sys.executable, *perfmon_script], env=env, check=True) # Make sure file exists and is not empty. - assert trace_path.exists(), "Trace file not written" - assert trace_path.stat().st_size > 0, "Trace file is empty" + assert perf_config.trace_path.exists(), "Trace file not written" + assert perf_config.trace_path.stat().st_size > 0, "Trace file is empty" # Assert every event contains every important field. - with open(trace_path) as infile: + with perf_config.trace_path.open() as infile: data = json.load(infile) assert len(data) > 0 for event in data: diff --git a/napari/_qt/perf/qt_performance.py b/napari/_qt/perf/qt_performance.py index 286e44b1d76..d708dd4377d 100644 --- a/napari/_qt/perf/qt_performance.py +++ b/napari/_qt/perf/qt_performance.py @@ -162,7 +162,7 @@ def _get_timer_info(self): for name, timer in perf.timers.timers.items(): # The Qt Event "UpdateRequest" is the main "draw" event, so # that's what we use for our progress bar. - if name == "UpdateRequest": + if name.startswith("UpdateRequest"): average = timer.average # Log any "long" events to the text window. diff --git a/napari/_qt/qt_main_window.py b/napari/_qt/qt_main_window.py index 4f0f7f0d0a5..ca293a49cd1 100644 --- a/napari/_qt/qt_main_window.py +++ b/napari/_qt/qt_main_window.py @@ -13,8 +13,8 @@ TYPE_CHECKING, Any, ClassVar, - Dict, List, + MutableMapping, Optional, Sequence, Tuple, @@ -56,7 +56,11 @@ from napari._qt.dialogs.preferences_dialog import PreferencesDialog from napari._qt.dialogs.qt_activity_dialog import QtActivityDialog from napari._qt.dialogs.qt_notification import NapariQtNotification -from napari._qt.qt_event_loop import NAPARI_ICON_PATH, get_app, quit_app +from napari._qt.qt_event_loop import ( + NAPARI_ICON_PATH, + get_app, + quit_app as quit_app_, +) from napari._qt.qt_resources import get_stylesheet from napari._qt.qt_viewer import QtViewer from napari._qt.utils import QImg2array, qbytearray_to_str, str_to_qbytearray @@ -105,7 +109,7 @@ class _QtMainWindow(QMainWindow): # *no* active windows, so we want to track the most recently active windows _instances: ClassVar[List['_QtMainWindow']] = [] - # `window` is passed through on construction so it's available to a window + # `window` is passed through on construction, so it's available to a window # provider for dependency injection # See https://github.com/napari/napari/pull/4826 def __init__( @@ -164,7 +168,7 @@ def __init__( _QtMainWindow._instances.append(self) - # since we initialize canvas before window, + # since we initialize canvas before the window, # we need to manually connect them again. handle = self.windowHandle() if handle is not None: @@ -215,17 +219,20 @@ def event(self, e: QEvent) -> bool: else e.globalPos() ) QToolTip.showText(pnt, self._qt_viewer.viewer.tooltip.text, self) - if e.type() == QEvent.Type.Close: - # when we close the MainWindow, remove it from the instances list - with contextlib.suppress(ValueError): - _QtMainWindow._instances.remove(self) if e.type() in {QEvent.Type.WindowActivate, QEvent.Type.ZOrderChange}: # upon activation or raise_, put window at the end of _instances with contextlib.suppress(ValueError): inst = _QtMainWindow._instances inst.append(inst.pop(inst.index(self))) - return super().event(e) + res = super().event(e) + + if e.type() == QEvent.Type.Close and e.isAccepted(): + # when we close the MainWindow, remove it from the instance list + with contextlib.suppress(ValueError): + _QtMainWindow._instances.remove(self) + + return res def isFullScreen(self): # Needed to prevent errors when going to fullscreen mode on Windows @@ -246,7 +253,10 @@ def showNormal(self): if os.name == 'nt': self.setWindowFlags( self.windowFlags() - ^ (Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + ^ ( + Qt.WindowType.FramelessWindowHint + | Qt.WindowType.WindowStaysOnTopHint + ) ) self.setGeometry(self._normal_geometry) super().showNormal() @@ -266,8 +276,8 @@ def showFullScreen(self): if os.name == 'nt': self.setWindowFlags( self.windowFlags() - | Qt.FramelessWindowHint - | Qt.WindowStaysOnTopHint + | Qt.WindowType.FramelessWindowHint + | Qt.WindowType.WindowStaysOnTopHint ) super().showNormal() self._normal_geometry = self.normalGeometry() @@ -289,7 +299,7 @@ def eventFilter(self, source, event): QApplication.activePopupWidget() is None and self._toggle_menubar_visibility ): - if event.type() == QEvent.MouseMove: + if event.type() == QEvent.Type.MouseMove: if self.menuBar().isHidden(): rect = self.geometry() # set mouse-sensitive zone to trigger showing the menubar @@ -303,7 +313,7 @@ def eventFilter(self, source, event): ) if not rect.contains(event.globalPos()): self.menuBar().hide() - elif event.type() == QEvent.Leave and source is self: + elif event.type() == QEvent.Type.Leave and source is self: self.menuBar().hide() return QMainWindow.eventFilter(self, source, event) @@ -491,9 +501,35 @@ def changeEvent(self, event): super().changeEvent(event) + def keyPressEvent(self, event): + """Called whenever a key is pressed. + + Parameters + ---------- + event : qtpy.QtCore.QEvent + Event from the Qt context. + """ + self._qt_viewer.canvas._scene_canvas._backend._keyEvent( + self._qt_viewer.canvas._scene_canvas.events.key_press, event + ) + event.accept() + + def keyReleaseEvent(self, event): + """Called whenever a key is released. + + Parameters + ---------- + event : qtpy.QtCore.QEvent + Event from the Qt context. + """ + self._qt_viewer.canvas._scene_canvas._backend._keyEvent( + self._qt_viewer.canvas._scene_canvas.events.key_release, event + ) + event.accept() + def resizeEvent(self, event): """Override to handle original size before maximizing.""" - # the first resize event will have nonsense positions that we dont + # the first resize event will have nonsense positions that we don't # want to store (and potentially restore) if event.oldSize().isValid(): self._old_size = event.oldSize() @@ -541,7 +577,7 @@ def closeEvent(self, event): self._qt_viewer.dims.stop() if self._quit_app: - quit_app() + quit_app_() event.accept() @@ -600,7 +636,7 @@ def __init__(self, viewer: 'Viewer', *, show: bool = True) -> None: qapp = get_app() # Dictionary holding dock widgets - self._dock_widgets: Dict[ + self._dock_widgets: MutableMapping[ str, QtViewerDockWidget ] = WeakValueDictionary() self._unnamed_dockwidget_count = 1 @@ -622,7 +658,11 @@ def __init__(self, viewer: 'Viewer', *, show: bool = True) -> None: self._add_menus() self._update_theme() + self._update_theme_font_size() get_settings().appearance.events.theme.connect(self._update_theme) + get_settings().appearance.events.font_size.connect( + self._update_theme_font_size + ) self._add_viewer_dock_widget( self._qt_viewer.dockConsole, tabify=False, menu=self.window_menu @@ -739,7 +779,10 @@ def _remove_theme(self, event): def qt_viewer(self): warnings.warn( trans._( - 'Public access to Window.qt_viewer is deprecated and will be removed in\nv0.5.0. It is considered an "implementation detail" of the napari\napplication, not part of the napari viewer model. If your use case\nrequires access to qt_viewer, please open an issue to discuss.', + 'Public access to Window.qt_viewer is deprecated and will be removed in\n' + 'v0.6.0. It is considered an "implementation detail" of the napari\napplication, ' + 'not part of the napari viewer model. If your use case\n' + 'requires access to qt_viewer, please open an issue to discuss.', deferred=True, ), category=FutureWarning, @@ -866,6 +909,8 @@ def add_plugin_dock_widget( Name of a widget provided by `plugin_name`. If `None`, and the specified plugin provides only a single widget, that widget will be returned, otherwise a ValueError will be raised, by default None + tabify : bool + Flag to tabify dock widget or not. Returns ------- @@ -875,14 +920,14 @@ def add_plugin_dock_widget( """ from napari.plugins import _npe2 - Widget = None + widget_class = None dock_kwargs = {} if result := _npe2.get_widget_contribution(plugin_name, widget_name): - Widget, widget_name = result + widget_class, widget_name = result - if Widget is None: - Widget, dock_kwargs = plugin_manager.get_widget( + if widget_class is None: + widget_class, dock_kwargs = plugin_manager.get_widget( plugin_name, widget_name ) @@ -900,7 +945,7 @@ def add_plugin_dock_widget( return dock_widget, wdg wdg = _instantiate_dock_widget( - Widget, cast('Viewer', self._qt_viewer.viewer) + widget_class, cast('Viewer', self._qt_viewer.viewer) ) # Add dock widget @@ -960,7 +1005,7 @@ def add_dock_widget( Side of the main window to which the new dock widget will be added. Must be in {'left', 'right', 'top', 'bottom'} allowed_areas : list[str], optional - Areas, relative to main window, that the widget is allowed dock. + Areas, relative to the main window, that the widget is allowed dock. Each item in list must be in {'left', 'right', 'top', 'bottom'} By default, all areas are allowed. shortcut : str, optional @@ -974,9 +1019,9 @@ def add_dock_widget( The shortcut parameter is deprecated since version 0.4.8, please use the action and shortcut manager APIs. The new action manager and - shortcut API allow user configuration and localisation. + shortcut API allow user configuration and localization. tabify : bool - Flag to tabify dockwidget or not. + Flag to tabify dock widget or not. menu : QMenu, optional Menu bar to add toggle action to. If `None` nothing added to menu. @@ -1097,7 +1142,7 @@ def _add_viewer_dock_widget( # see #3663, to fix #3624 more generally dock_widget.setFloating(False) - def _remove_dock_widget(self, event=None): + def _remove_dock_widget(self, event): names = list(self._dock_widgets.keys()) for widget_name in names: if event.value in widget_name: @@ -1116,6 +1161,9 @@ def remove_dock_widget(self, widget: QWidget, menu=None): ---------- widget : QWidget | str If widget == 'all', all docked widgets will be removed. + menu : QMenu, optional + Menu bar to remove toggle action from. If `None` nothing removed + from menu. """ if widget == 'all': for dw in list(self._dock_widgets.values()): @@ -1342,8 +1390,16 @@ def activate(self): def _update_theme_no_event(self): self._update_theme() - def _update_theme(self, event=None): + def _update_theme_font_size(self, event=None): + settings = get_settings() + font_size = event.value if event else settings.appearance.font_size + extra_variables = {"font_size": f"{font_size}pt"} + self._update_theme(extra_variables=extra_variables) + + def _update_theme(self, event=None, extra_variables=None): """Update widget color theme.""" + if extra_variables is None: + extra_variables = {} settings = get_settings() with contextlib.suppress(AttributeError, RuntimeError): value = event.value if event else settings.appearance.theme @@ -1352,8 +1408,17 @@ def _update_theme(self, event=None): if value == "system": # system isn't a theme, so get the name actual_theme_name = get_system_theme() - # set the style sheet with the theme name - self._qt_window.setStyleSheet(get_stylesheet(actual_theme_name)) + # check `font_size` value is always passed when updating style + if "font_size" not in extra_variables: + extra_variables.update( + {"font_size": f"{settings.appearance.font_size}pt"} + ) + # set the style sheet with the theme name and extra_variables + self._qt_window.setStyleSheet( + get_stylesheet( + actual_theme_name, extra_variables=extra_variables + ) + ) def _status_changed(self, event): """Update status bar. @@ -1412,7 +1477,8 @@ def _screenshot( Size (resolution height x width) of the screenshot. By default, the currently displayed size. Only used if `canvas_only` is True. scale : float - Scale factor used to increase resolution of canvas for the screenshot. By default, the currently displayed resolution. + Scale factor used to increase resolution of canvas for the screenshot. + By default, the currently displayed resolution. Only used if `canvas_only` is True. canvas_only : bool If True, screenshot shows only the image display canvas, and @@ -1472,14 +1538,15 @@ def screenshot( Size (resolution) of the screenshot. By default, the currently displayed size. Only used if `canvas_only` is True. scale : float - Scale factor used to increase resolution of canvas for the screenshot. By default, the currently displayed resolution. + Scale factor used to increase resolution of canvas for the screenshot. + By default, the currently displayed resolution. Only used if `canvas_only` is True. flash : bool Flag to indicate whether flash animation should be shown after the screenshot was captured. canvas_only : bool If True, screenshot shows only the image display canvas, and - if False include the napari viewer frame in the screenshot, + if False includes the napari viewer frame in the screenshot, By default, True. Returns diff --git a/napari/_qt/qt_resources/__init__.py b/napari/_qt/qt_resources/__init__.py index 21260b7385c..ae4998e5ab2 100644 --- a/napari/_qt/qt_resources/__init__.py +++ b/napari/_qt/qt_resources/__init__.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional from napari._qt.qt_resources._svg import QColoredSVGIcon from napari.settings import get_settings @@ -12,19 +12,24 @@ def get_stylesheet( - theme_id: Optional[str] = None, extra: Optional[List[str]] = None + theme_id: Optional[str] = None, + extra: Optional[List[str]] = None, + extra_variables: Optional[Dict[str, str]] = None, ) -> str: """Combine all qss files into single, possibly pre-themed, style string. Parameters ---------- - theme : str, optional + theme_id : str, optional Theme to apply to the stylesheet. If no theme is provided, the returned stylesheet will still have ``{{ template_variables }}`` that need to be replaced using the :func:`napari.utils.theme.template` function prior to using the stylesheet. extra : list of str, optional Additional paths to QSS files to include in stylesheet, by default None + extra_variables : dict, optional + Dictionary of variables values that replace default theme values. + For example: `{ 'font_size': '14pt'}` Returns ------- @@ -44,7 +49,11 @@ def get_stylesheet( if theme_id: from napari.utils.theme import get_theme, template - return template(stylesheet, **get_theme(theme_id).to_rgb_dict()) + theme_dict = get_theme(theme_id).to_rgb_dict() + if extra_variables: + theme_dict.update(extra_variables) + + return template(stylesheet, **theme_dict) return stylesheet diff --git a/napari/_qt/qt_resources/styles/00_base.qss b/napari/_qt/qt_resources/styles/00_base.qss index 3b7708d314b..dd17019535a 100644 --- a/napari/_qt/qt_resources/styles/00_base.qss +++ b/napari/_qt/qt_resources/styles/00_base.qss @@ -1,4 +1,4 @@ -/* +/* Styles in this file should only refer to built-in QtWidgets It will be imported first, and styles declared in other files may override these styles, but should only do so on @@ -10,7 +10,7 @@ might be possible to convert px to em by 1px = 0.0625em /* ----------------- QWidget ------------------ */ -/* +/* mappings between property and QPalette.ColorRole: these colors can be looked up dynamically in widgets using, e.g @@ -49,7 +49,7 @@ QWidget[emphasized="true"] > QFrame { /* ------------ QAbstractScrollArea ------------- */ -/* QAbstractScrollArea is the superclass */ +/* QAbstractScrollArea is the superclass */ QTextEdit { background-color: {{ console }}; @@ -157,10 +157,11 @@ QComboBox:on { } QListView { - /* controls the color of the open dropdown menu */ + /* controls the color of the open dropdown menu */ background-color: {{ foreground }}; color: {{ text }}; border-radius: 2px; + font-size: {{ font_size }}; } QListView:item:selected { @@ -481,7 +482,7 @@ QScrollBar::add-line, QScrollBar::sub-line { subcontrol-origin: margin; } -QWidget[emphasized="true"] QScrollBar::add-line, +QWidget[emphasized="true"] QScrollBar::add-line, QWidget[emphasized="true"] QScrollBar::sub-line { background: {{ primary }}; } @@ -506,12 +507,12 @@ QScrollBar::sub-line:vertical { subcontrol-position: top; } -QScrollBar::add-line:horizontal:pressed, +QScrollBar::add-line:horizontal:pressed, QScrollBar::sub-line:horizontal:pressed { background: {{ highlight }}; } -QWidget[emphasized="true"] QScrollBar::add-line:horizontal:pressed, +QWidget[emphasized="true"] QScrollBar::add-line:horizontal:pressed, QWidget[emphasized="true"] QScrollBar::sub-line:horizontal:pressed { background: {{ secondary }}; } diff --git a/napari/_qt/qt_resources/styles/01_buttons.qss b/napari/_qt/qt_resources/styles/01_buttons.qss index 104d536360b..db51fdbd3ab 100644 --- a/napari/_qt/qt_resources/styles/01_buttons.qss +++ b/napari/_qt/qt_resources/styles/01_buttons.qss @@ -7,7 +7,7 @@ QtViewerPushButton{ min-height : 28px; max-height : 28px; padding: 0px; - + } QtViewerPushButton[mode="delete_button"] { diff --git a/napari/_qt/qt_resources/styles/02_custom.qss b/napari/_qt/qt_resources/styles/02_custom.qss index 84a7e000853..a17e51efe1f 100644 --- a/napari/_qt/qt_resources/styles/02_custom.qss +++ b/napari/_qt/qt_resources/styles/02_custom.qss @@ -25,7 +25,7 @@ QtLayerButtons, QtViewerButtons, QtLayerList { as long as they are docked (though they use the style of QDockWidget when undocked) */ -QStatusBar { +QStatusBar { background: {{ background }}; color: {{ text }}; } @@ -855,7 +855,7 @@ QtLayerList::indicator { QtLayerList::indicator:unchecked { image: url("theme_{{ id }}:/visibility_off_50.svg"); - + } QtLayerList::indicator:checked { diff --git a/napari/_qt/qt_viewer.py b/napari/_qt/qt_viewer.py index de35740db37..5f92f3940f0 100644 --- a/napari/_qt/qt_viewer.py +++ b/napari/_qt/qt_viewer.py @@ -7,6 +7,7 @@ import warnings import weakref from pathlib import Path +from types import FrameType from typing import ( TYPE_CHECKING, Any, @@ -55,12 +56,14 @@ from napari.utils.io import imsave from napari.utils.key_bindings import KeymapHandler from napari.utils.misc import in_ipython, in_jupyter +from napari.utils.naming import CallerFrame from napari.utils.translations import trans from napari_builtins.io import imsave_extensions from napari._vispy import VispyCanvas, create_vispy_layer # isort:skip if TYPE_CHECKING: + from napari_console import QtConsole from npe2.manifest.contributions import WriterContribution from napari._qt.layer_controls import QtLayerControlsContainer @@ -534,49 +537,75 @@ def console_backlog(self): """List: items to push to console when instantiated.""" return self._console_backlog + def _get_console(self) -> Optional[QtConsole]: + """ + Function for setup console. + + Returns + ------- + + Notes + _____ + extracted to separated function for simplify testing + + """ + try: + import numpy as np + + # QtConsole imports debugpy that overwrites default breakpoint. + # It makes problems with debugging if you do not know this. + # So we do not want to overwrite it if it is already set. + breakpoint_handler = sys.breakpointhook + from napari_console import QtConsole + + sys.breakpointhook = breakpoint_handler + + import napari + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + console = QtConsole(self.viewer) + console.push( + {'napari': napari, 'action_manager': action_manager} + ) + with CallerFrame(_in_napari) as c: + if c.frame.f_globals.get("__name__", "") == "__main__": + console.push({"np": np}) + for i in self.console_backlog: + # recover weak refs + console.push( + { + k: self._unwrap_if_weakref(v) + for k, v in i.items() + if self._unwrap_if_weakref(v) is not None + } + ) + return console + except ModuleNotFoundError: + warnings.warn( + trans._( + 'napari-console not found. It can be installed with' + ' "pip install napari_console"' + ), + stacklevel=1, + ) + return None + except ImportError: + traceback.print_exc() + warnings.warn( + trans._( + 'error importing napari-console. See console for full error.' + ), + stacklevel=1, + ) + return None + @property def console(self): """QtConsole: iPython console terminal integrated into the napari GUI.""" if self._console is None: - try: - from napari_console import QtConsole - - import napari - - with warnings.catch_warnings(): - warnings.filterwarnings("ignore") - self.console = QtConsole(self.viewer) - self.console.push( - {'napari': napari, 'action_manager': action_manager} - ) - for i in self.console_backlog: - # recover weak refs - self.console.push( - { - k: self._unwrap_if_weakref(v) - for k, v in i.items() - if self._unwrap_if_weakref(v) is not None - } - ) - self._console_backlog = [] - except ModuleNotFoundError: - warnings.warn( - trans._( - 'napari-console not found. It can be installed with' - ' "pip install napari_console"' - ), - stacklevel=1, - ) - self._console = None - except ImportError: - traceback.print_exc() - warnings.warn( - trans._( - 'error importing napari-console. See console for full error.' - ), - stacklevel=1, - ) - self._console = None + self.console = self._get_console() + self._console_backlog = [] return self._console @console.setter @@ -743,7 +772,6 @@ def _screenshot(self, flash=True): Flag to indicate whether flash animation should be shown after the screenshot was captured. """ - # CAN REMOVE THIS AFTER DEPRECATION IS DONE, see self.screenshot. img = self.canvas.screenshot() if flash: from napari._qt.utils import add_flash_animation @@ -1131,3 +1159,18 @@ def _create_remote_manager( qt_poll.events.poll.connect(monitor.on_poll) return manager + + +def _in_napari(n: int, frame: FrameType): + """ + Determines whether we are in napari by looking at: + 1) the frames modules names: + 2) the min_depth + """ + if n < 2: + return True + # in-n-out is used in napari for dependency injection. + for pref in {"napari.", "in_n_out."}: + if frame.f_globals.get("__name__", "").startswith(pref): + return True + return False diff --git a/napari/_qt/utils.py b/napari/_qt/utils.py index bf85c96411c..ebecff490b1 100644 --- a/napari/_qt/utils.py +++ b/napari/_qt/utils.py @@ -375,7 +375,7 @@ def qt_might_be_rich_text(text) -> bool: return bool(RICH_TEXT_PATTERN.search(text)) -def in_qt_main_thread(): +def in_qt_main_thread() -> bool: """ Check if we are in the thread in which QApplication object was created. diff --git a/napari/_qt/widgets/_tests/test_qt_extension2reader.py b/napari/_qt/widgets/_tests/test_qt_extension2reader.py index 695687c72fe..85c737d5389 100644 --- a/napari/_qt/widgets/_tests/test_qt_extension2reader.py +++ b/napari/_qt/widgets/_tests/test_qt_extension2reader.py @@ -151,6 +151,18 @@ def test_filtering_readers( ) +@pytest.mark.parametrize("pattern", [".", "", "/"]) +def test_filtering_readers_problematic_patterns( + extension2reader_widget, builtins, tif_reader, npy_reader, pattern +): + widget = extension2reader_widget( + npe1_readers={builtins.display_name: builtins.display_name} + ) + widget._filter_compatible_readers(pattern) + assert widget._new_reader_dropdown.count() == 1 + assert widget._new_reader_dropdown.itemText(0) == "None available" + + def test_filtering_readers_complex_pattern( extension2reader_widget, npy_reader, tif_reader ): diff --git a/napari/_qt/widgets/_tests/test_qt_tooltip.py b/napari/_qt/widgets/_tests/test_qt_tooltip.py index 821a5b4c233..c833089ea7d 100644 --- a/napari/_qt/widgets/_tests/test_qt_tooltip.py +++ b/napari/_qt/widgets/_tests/test_qt_tooltip.py @@ -1,14 +1,17 @@ +import os import sys import pytest +from qtpy.QtCore import QPointF +from qtpy.QtGui import QEnterEvent from qtpy.QtWidgets import QToolTip from napari._qt.widgets.qt_tooltip import QtToolTipLabel @pytest.mark.skipif( - sys.platform.startswith('linux') or sys.platform == 'darwin', - reason='Timeouts when running on CI with Linux or macOS', + os.environ.get("CI", False) and sys.platform == "darwin", + reason="Timeouts when running on macOS CI", ) def test_qt_tooltip_label(qtbot): tooltip_text = "Test QtToolTipLabel showing a tooltip" @@ -18,6 +21,9 @@ def test_qt_tooltip_label(qtbot): widget.show() assert QToolTip.text() == "" - qtbot.mouseMove(widget) + # simulate movement mouse from outside the widget to the center + pos = QPointF(widget.rect().center()) + event = QEnterEvent(pos, pos, QPointF(widget.pos()) + pos) + widget.enterEvent(event) qtbot.waitUntil(lambda: QToolTip.isVisible()) qtbot.waitUntil(lambda: QToolTip.text() == tooltip_text) diff --git a/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py b/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py index 97eae8ab163..1199124939d 100644 --- a/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py +++ b/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py @@ -1,15 +1,31 @@ +import sys from unittest.mock import patch import pytest +from qtpy.QtCore import QPoint, Qt +from qtpy.QtWidgets import QApplication, QMessageBox from napari._qt.widgets.qt_keyboard_settings import ShortcutEditor, WarnPopup +from napari.settings import get_settings from napari.utils.action_manager import action_manager +from napari.utils.interactions import KEY_SYMBOLS @pytest.fixture def shortcut_editor_widget(qtbot): + # Always reset shortcuts (settings and action manager) + get_settings().shortcuts.reset() + for ( + action, + shortcuts, + ) in get_settings().shortcuts.shortcuts.items(): + action_manager.unbind_shortcut(action) + for shortcut in shortcuts: + action_manager.bind_shortcut(action, shortcut) + def _shortcut_editor_widget(**kwargs): widget = ShortcutEditor(**kwargs) + widget._reset_shortcuts() widget.show() qtbot.addWidget(widget) @@ -45,3 +61,114 @@ def test_mark_conflicts(shortcut_editor_widget, qtbot): assert widget._mark_conflicts("Y", 1) # "Y" is arbitrary chosen and on conflict with existing shortcut should be changed qtbot.add_widget(widget._warn_dialog) + + +def test_restore_defaults(shortcut_editor_widget): + widget = shortcut_editor_widget() + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == KEY_SYMBOLS["Ctrl"] + widget._table.item(0, widget._shortcut_col).setText("R") + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == "R" + with patch( + "napari._qt.widgets.qt_keyboard_settings.QMessageBox.question" + ) as mock: + mock.return_value = QMessageBox.RestoreDefaults + widget._restore_button.click() + assert mock.called + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == KEY_SYMBOLS["Ctrl"] + + +@pytest.mark.parametrize( + "key, modifier, key_symbols", + [ + ( + "U", + Qt.KeyboardModifier.MetaModifier + if sys.platform == "darwin" + else Qt.KeyboardModifier.ControlModifier, + [KEY_SYMBOLS["Ctrl"], "U"], + ), + ( + "Y", + Qt.KeyboardModifier.MetaModifier + | Qt.KeyboardModifier.ShiftModifier + if sys.platform == "darwin" + else Qt.KeyboardModifier.ControlModifier + | Qt.KeyboardModifier.ShiftModifier, + [KEY_SYMBOLS["Ctrl"], KEY_SYMBOLS["Shift"], "Y"], + ), + ], +) +def test_keybinding_with_modifiers( + shortcut_editor_widget, qtbot, recwarn, key, modifier, key_symbols +): + widget = shortcut_editor_widget() + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == KEY_SYMBOLS["Ctrl"] + + x = widget._table.columnViewportPosition(widget._shortcut_col) + y = widget._table.rowViewportPosition(0) + item_pos = QPoint(x, y) + qtbot.mouseClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.mouseDClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) + qtbot.keyClicks(QApplication.focusWidget(), key, modifier=modifier) + assert len([warn for warn in recwarn if warn.category is UserWarning]) == 0 + + shortcut = widget._table.item(0, widget._shortcut_col).text() + for key_symbol in key_symbols: + assert key_symbol in shortcut + + +@pytest.mark.parametrize( + "modifiers, key_symbols, valid", + [ + ( + Qt.KeyboardModifier.ShiftModifier, + [KEY_SYMBOLS["Shift"]], + True, + ), + ( + Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.ShiftModifier, + [KEY_SYMBOLS["Ctrl"]], + False, + ), + ], +) +def test_keybinding_with_only_modifiers( + shortcut_editor_widget, qtbot, recwarn, modifiers, key_symbols, valid +): + widget = shortcut_editor_widget() + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == KEY_SYMBOLS["Ctrl"] + + x = widget._table.columnViewportPosition(widget._shortcut_col) + y = widget._table.rowViewportPosition(0) + item_pos = QPoint(x, y) + qtbot.mouseClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.mouseDClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) + with patch.object(WarnPopup, "exec_") as mock: + qtbot.keyClick( + QApplication.focusWidget(), Qt.Key_Enter, modifier=modifiers + ) + if valid: + assert not mock.called + else: + assert mock.called + assert len([warn for warn in recwarn if warn.category is UserWarning]) == 0 + + shortcut = widget._table.item(0, widget._shortcut_col).text() + for key_symbol in key_symbols: + assert key_symbol in shortcut diff --git a/napari/_qt/widgets/qt_dims_slider.py b/napari/_qt/widgets/qt_dims_slider.py index 6c702842b87..2b667a6f28a 100644 --- a/napari/_qt/widgets/qt_dims_slider.py +++ b/napari/_qt/widgets/qt_dims_slider.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import TYPE_CHECKING, Optional, Tuple from weakref import ref import numpy as np @@ -27,6 +27,9 @@ from napari.utils.events.event_utils import connect_setattr_value from napari.utils.translations import trans +if TYPE_CHECKING: + from napari._qt.widgets.qt_dims import QtDims + class QtDimSliderWidget(QWidget): """Compound widget to hold the label, slider and play button for an axis. @@ -45,7 +48,7 @@ class QtDimSliderWidget(QWidget): def __init__(self, parent: QWidget, axis: int) -> None: super().__init__(parent=parent) self.axis = axis - self.qt_dims = parent + self.qt_dims: QtDims = parent self.dims = parent.dims self.axis_label = None self.slider = None @@ -255,6 +258,8 @@ def fps(self, value): value : float Frames per second for animation. """ + if self._fps == value: + return self._fps = value self.play_button.fpsspin.setValue(abs(value)) self.play_button.reverse_check.setChecked(value < 0) diff --git a/napari/_qt/widgets/qt_extension2reader.py b/napari/_qt/widgets/qt_extension2reader.py index d8a7bcea698..047b401e39f 100644 --- a/napari/_qt/widgets/qt_extension2reader.py +++ b/napari/_qt/widgets/qt_extension2reader.py @@ -170,7 +170,12 @@ def _filter_compatible_readers(self, new_pattern): readers = self._npe2_readers.copy() to_delete = [] - compatible_readers = get_potential_readers(new_pattern) + try: + compatible_readers = get_potential_readers(new_pattern) + except ValueError as e: + if "empty name" not in str(e): + raise + compatible_readers = {} for plugin_name in readers: if plugin_name not in compatible_readers: to_delete.append(plugin_name) @@ -180,13 +185,12 @@ def _filter_compatible_readers(self, new_pattern): readers.update(self._npe1_readers) - if not readers: + for i, (plugin_name, display_name) in enumerate( + sorted(readers.items()) + ): + self._add_reader_choice(i, plugin_name, display_name) + if self._new_reader_dropdown.count() == 0: self._new_reader_dropdown.addItem(trans._("None available")) - else: - for i, (plugin_name, display_name) in enumerate( - sorted(readers.items()) - ): - self._add_reader_choice(i, plugin_name, display_name) def _save_new_preference(self, event): """Save current preference to settings and show in table""" diff --git a/napari/_qt/widgets/qt_font_size.py b/napari/_qt/widgets/qt_font_size.py new file mode 100644 index 00000000000..d34fffe3dfe --- /dev/null +++ b/napari/_qt/widgets/qt_font_size.py @@ -0,0 +1,76 @@ +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget + +from napari._qt.widgets.qt_spinbox import QtSpinBox +from napari.settings import get_settings +from napari.utils.theme import get_system_theme, get_theme +from napari.utils.translations import trans + + +class QtFontSizeWidget(QWidget): + """ + Widget to change `font_size` and enable to reset is value to the current + selected theme default `font_size` value. + """ + + valueChanged = Signal(int) + + def __init__(self, parent: QWidget = None) -> None: + super().__init__(parent=parent) + self._spinbox = QtSpinBox() + self._reset_button = QPushButton(trans._("Reset font size")) + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._spinbox) + layout.addWidget(self._reset_button) + self.setLayout(layout) + + self._spinbox.valueChanged.connect(self.valueChanged) + self._reset_button.clicked.connect(self._reset) + + def _reset(self) -> None: + """ + Reset the widget value to the current selected theme font size value. + """ + current_theme_name = get_settings().appearance.theme + if current_theme_name == "system": + # system isn't a theme, so get the name + current_theme_name = get_system_theme() + current_theme = get_theme(current_theme_name) + self.setValue(int(current_theme.font_size[:-2])) + + def value(self) -> int: + """ + Return the current widget value. + + Returns + ------- + int + The current value. + """ + return self._spinbox.value() + + def setValue(self, value: int) -> None: + """ + Set the current widget value. + + Parameters + ---------- + value : int + The current value. + """ + self._spinbox.setValue(value) + + def setRange(self, min_value: int, max_value: int) -> None: + """ + Value range that the spinbox widget will use. + + Parameters + ---------- + min_value : int + Minimum value the font_size could be set. + max_value : int + Maximum value the font_size could be set. + """ + self._spinbox.setRange(min_value, max_value) diff --git a/napari/_qt/widgets/qt_keyboard_settings.py b/napari/_qt/widgets/qt_keyboard_settings.py index 66d3a1a2136..4cbe77e2c15 100644 --- a/napari/_qt/widgets/qt_keyboard_settings.py +++ b/napari/_qt/widgets/qt_keyboard_settings.py @@ -11,6 +11,7 @@ from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QAbstractItemView, + QApplication, QComboBox, QHBoxLayout, QItemDelegate, @@ -132,13 +133,22 @@ def __init__( def restore_defaults(self): """Launches dialog to confirm restore choice.""" - + prev = QApplication.instance().testAttribute( + Qt.ApplicationAttribute.AA_DontUseNativeDialogs + ) + QApplication.instance().setAttribute( + Qt.ApplicationAttribute.AA_DontUseNativeDialogs, True + ) response = QMessageBox.question( self, trans._("Restore Shortcuts"), trans._("Are you sure you want to restore default shortcuts?"), - QMessageBox.RestoreDefaults | QMessageBox.Cancel, - QMessageBox.RestoreDefaults, + QMessageBox.StandardButton.RestoreDefaults + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.RestoreDefaults, + ) + QApplication.instance().setAttribute( + Qt.ApplicationAttribute.AA_DontUseNativeDialogs, prev ) if response == QMessageBox.RestoreDefaults: @@ -405,7 +415,6 @@ def _set_keybinding(self, row, col): action_manager._shortcuts.get(current_action, []) ) for mod in {"Shift", "Ctrl", "Alt", "Cmd", "Super", 'Meta'}: - # we want to prevent multiple modifiers but still allow single modifiers. if new_shortcut.endswith('-' + mod): self._show_bind_shortcut_error( current_action, @@ -656,6 +665,8 @@ def keyPressEvent(self, event) -> None: Qt.Key.Key_Enter, }: # Do not allow user to set these keys as shortcut. + # Use them as a save trigger for modifier only shortcuts. + self.clearFocus() return # Translate key value to key string. diff --git a/napari/_qt/widgets/qt_viewer_buttons.py b/napari/_qt/widgets/qt_viewer_buttons.py index 7c5d4f4900d..5e571876550 100644 --- a/napari/_qt/widgets/qt_viewer_buttons.py +++ b/napari/_qt/widgets/qt_viewer_buttons.py @@ -76,7 +76,7 @@ def __init__(self, viewer: 'ViewerModel') -> None: self.newLabelsButton = QtViewerPushButton( 'new_labels', trans._('New labels layer'), - lambda: self.viewer._new_labels(), + self.viewer._new_labels, ) layout = QHBoxLayout() diff --git a/napari/_tests/test_conftest_fixtures.py b/napari/_tests/test_conftest_fixtures.py index c58f449d7cd..bf959fec8c2 100644 --- a/napari/_tests/test_conftest_fixtures.py +++ b/napari/_tests/test_conftest_fixtures.py @@ -4,9 +4,6 @@ from qtpy.QtCore import QMutex, QThread, QTimer from superqt.utils import qdebounced -from napari._qt.qt_viewer import QtViewer -from napari.viewer import ViewerModel - class _TestThread(QThread): def __init__(self) -> None: @@ -52,11 +49,6 @@ def test_disable_qtimer(qtbot): assert not th.isRunning() -def test_console_mock(qapp): - qt_viewer = QtViewer(ViewerModel()) - assert qt_viewer.console.__class__.__name__ == "FakeQtConsole" - - @pytest.mark.usefixtures("disable_throttling") @patch("qtpy.QtCore.QTimer.start") def test_disable_throttle(start_mock): diff --git a/napari/_tests/test_dtypes.py b/napari/_tests/test_dtypes.py index f4d3fbe33c5..7d4094a3759 100644 --- a/napari/_tests/test_dtypes.py +++ b/napari/_tests/test_dtypes.py @@ -28,7 +28,7 @@ def test_image_dytpes(dtype): # add dtype image data data = np.random.randint(20, size=(30, 40)).astype(dtype) viewer.add_image(data) - assert np.all(viewer.layers[0].data == data) + np.testing.assert_array_equal(viewer.layers[0].data, data) # add dtype multiscale data data = [ diff --git a/napari/_tests/test_windowsettings.py b/napari/_tests/test_windowsettings.py index f0fae43fc7c..84f7bf27266 100644 --- a/napari/_tests/test_windowsettings.py +++ b/napari/_tests/test_windowsettings.py @@ -1,8 +1,28 @@ +from qtpy.QtCore import QRect + from napari.settings import get_settings -def test_singlescreen_window_settings(make_napari_viewer): +class ScreenMock: + def __init__(self): + self._geometry = QRect(0, 0, 1000, 1000) + + def geometry(self): + return self._geometry + + +def screen_at(point): + if point.x() < 0 or point.y() < 0 or point.x() > 1000 or point.y() > 1000: + return None + return ScreenMock() + + +def test_singlescreen_window_settings(make_napari_viewer, monkeypatch): """Test whether valid screen position is returned even after disconnected secondary screen.""" + + monkeypatch.setattr( + "napari._qt.qt_main_window.QApplication.screenAt", screen_at + ) settings = get_settings() viewer = make_napari_viewer() default_window_position = ( diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index d2e0d47ea50..954700ec859 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -129,7 +129,7 @@ good_layer_data = [ (np.random.random((10, 10)),), (np.random.random((10, 10, 3)), {'rgb': True}), - (np.random.randint(20, size=(10, 15)), {'seed': 0.3}, 'labels'), + (np.random.randint(20, size=(10, 15)), {'seed_rng': 5}, 'labels'), (np.random.random((10, 2)) * 20, {'face_color': 'blue'}, 'points'), (np.random.random((10, 2, 2)) * 20, {}, 'vectors'), (np.random.random((10, 4, 2)) * 20, {'opacity': 1}, 'shapes'), diff --git a/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py b/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py index 5949802c48f..a1f6004893b 100644 --- a/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py +++ b/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py @@ -49,6 +49,7 @@ class WidgetBuilder: "range": widgets.IntegerRangeSchemaWidget, "enum": widgets.EnumSchemaWidget, "highlight": widgets.HighlightSizePreviewWidget, + "font_size": widgets.FontSizeSchemaWidget, }, "array": { "array": widgets.ArraySchemaWidget, diff --git a/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py b/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py index ccf0ace679a..7168ee8c32f 100644 --- a/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py +++ b/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py @@ -6,6 +6,7 @@ from ...._qt.widgets.qt_extension2reader import Extension2ReaderTable from ...._qt.widgets.qt_highlight_preview import QtHighlightSizePreviewWidget from ...._qt.widgets.qt_keyboard_settings import ShortcutEditor +from ...._qt.widgets.qt_font_size import QtFontSizeWidget from .signal import Signal from .utils import is_concrete_schema, iter_layout_widgets, state_property @@ -644,6 +645,40 @@ def configure(self): self.setGraphicsEffect(self.opacity) self.opacity.setOpacity(1) + +class FontSizeSchemaWidget(SchemaWidgetMixin, QtFontSizeWidget): + @state_property + def state(self) -> int: + return self.value() + + @state.setter + def state(self, state: int): + self.setValue(state) + + def configure(self): + self.valueChanged.connect(self.on_changed.emit) + self.opacity = QtWidgets.QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self.opacity) + self.opacity.setOpacity(1) + + minimum = 1 + if "minimum" in self.schema: + minimum = self.schema["minimum"] + if self.schema.get("exclusiveMinimum"): + minimum += 1 + + maximum = 100 + if "maximum" in self.schema: + maximum = self.schema["maximum"] + if self.schema.get("exclusiveMaximum"): + maximum -= 1 + + self.setRange(minimum, maximum) + + def setDescription(self, description: str): + self.description = description + + class ObjectSchemaWidgetMinix(SchemaWidgetMixin): def __init__( self, diff --git a/napari/_vispy/_tests/test_image_rendering.py b/napari/_vispy/_tests/test_image_rendering.py index 12e0b871650..faca918fc6b 100644 --- a/napari/_vispy/_tests/test_image_rendering.py +++ b/napari/_vispy/_tests/test_image_rendering.py @@ -3,6 +3,7 @@ from napari._tests.utils import skip_on_win_ci from napari._vispy.layers.image import VispyImageLayer +from napari.components.dims import Dims from napari.layers.image import Image @@ -88,6 +89,6 @@ def test_clipping_planes_dims(): vispy_layer = VispyImageLayer(image_layer) napari_clip = image_layer.experimental_clipping_planes.as_array() # needed to get volume node - image_layer._slice_dims(ndisplay=3) + image_layer._slice_dims(Dims(ndim=3, ndisplay=3)) vispy_clip = vispy_layer.node.clipping_planes np.testing.assert_array_equal(napari_clip, vispy_clip[..., ::-1]) diff --git a/napari/_vispy/_tests/test_utils.py b/napari/_vispy/_tests/test_utils.py index 25921a17ca1..f298e00fb14 100644 --- a/napari/_vispy/_tests/test_utils.py +++ b/napari/_vispy/_tests/test_utils.py @@ -1,9 +1,9 @@ import numpy as np import pytest -from pydantic import ValidationError from qtpy.QtCore import Qt from vispy.util.quaternion import Quaternion +from napari._pydantic_compat import ValidationError from napari._vispy.utils.cursor import QtCursorVisual from napari._vispy.utils.quaternion import quaternion2euler from napari._vispy.utils.visual import get_view_direction_in_scene_coordinates diff --git a/napari/_vispy/_tests/test_vispy_image_layer.py b/napari/_vispy/_tests/test_vispy_image_layer.py index 0ba4061fccb..b64c32dbdb3 100644 --- a/napari/_vispy/_tests/test_vispy_image_layer.py +++ b/napari/_vispy/_tests/test_vispy_image_layer.py @@ -5,6 +5,7 @@ from napari._vispy._tests.utils import vispy_image_scene_size from napari._vispy.layers.image import VispyImageLayer +from napari.components.dims import Dims from napari.layers import Image @@ -18,7 +19,7 @@ def test_3d_slice_of_2d_image_with_order(order): image = Image(np.zeros((4, 2)), scale=(1, 2)) vispy_image = VispyImageLayer(image) - image._slice_dims(point=(0, 0, 0), ndisplay=3, order=order) + image._slice_dims(Dims(ndim=3, ndisplay=3, order=order)) scene_size = vispy_image_scene_size(vispy_image) np.testing.assert_array_equal((4, 4, 1), scene_size) @@ -34,7 +35,7 @@ def test_2d_slice_of_3d_image_with_order(order): image = Image(np.zeros((8, 4, 2)), scale=(1, 2, 4)) vispy_image = VispyImageLayer(image) - image._slice_dims(point=(0, 0, 0), ndisplay=2, order=order) + image._slice_dims(Dims(ndim=3, ndisplay=2, order=order)) scene_size = vispy_image_scene_size(vispy_image) np.testing.assert_array_equal((8, 8, 0), scene_size) @@ -50,7 +51,7 @@ def test_3d_slice_of_3d_image_with_order(order): image = Image(np.zeros((8, 4, 2)), scale=(1, 2, 4)) vispy_image = VispyImageLayer(image) - image._slice_dims(point=(0, 0, 0), ndisplay=3, order=order) + image._slice_dims(Dims(ndim=3, ndisplay=3, order=order)) scene_size = vispy_image_scene_size(vispy_image) np.testing.assert_array_equal((8, 8, 8), scene_size) @@ -66,7 +67,7 @@ def test_3d_slice_of_4d_image_with_order(order): image = Image(np.zeros((16, 8, 4, 2)), scale=(1, 2, 4, 8)) vispy_image = VispyImageLayer(image) - image._slice_dims(point=(0, 0, 0, 0), ndisplay=3, order=order) + image._slice_dims(Dims(ndim=4, ndisplay=3, order=order)) scene_size = vispy_image_scene_size(vispy_image) np.testing.assert_array_equal((16, 16, 16), scene_size) diff --git a/napari/_vispy/_tests/test_vispy_labels.py b/napari/_vispy/_tests/test_vispy_labels.py index 10a46fa158a..fe4d7dea643 100644 --- a/napari/_vispy/_tests/test_vispy_labels.py +++ b/napari/_vispy/_tests/test_vispy_labels.py @@ -1,121 +1,24 @@ -from itertools import product -from unittest.mock import patch - import numpy as np import pytest from napari._vispy.layers.labels import ( - MAX_LOAD_FACTOR, - PRIME_NUM_TABLE, build_textures_from_dict, - hash2d_get, - idx_to_2d, ) -@pytest.fixture(scope='module', autouse=True) -def mock_max_texture_size(): - """When running tests in this file, pretend max texture size is 2^16.""" - with patch('napari._vispy.layers.labels.MAX_TEXTURE_SIZE', 2**16): - yield - - -def test_idx_to_2d(): - assert idx_to_2d(0, (100, 100)) == (0, 0) - assert idx_to_2d(1, (100, 100)) == (0, 1) - assert idx_to_2d(101, (100, 100)) == (1, 1) - assert idx_to_2d(521, (100, 100)) == (5, 21) - assert idx_to_2d(100 * 100 + 521, (100, 100)) == (5, 21) - - def test_build_textures_from_dict(): - keys, values, collision = build_textures_from_dict( - {1: (1, 1, 1, 1), 2: (2, 2, 2, 2)} + values = build_textures_from_dict( + {0: (0, 0, 0, 0), 1: (1, 1, 1, 1), 2: (2, 2, 2, 2)}, + max_size=10, ) - assert not collision - assert keys.shape == (37, 37) - assert values.shape == (37, 37, 4) - assert keys[0, 1] == 1 - assert keys[0, 2] == 2 - assert np.array_equiv(values[0, 1], (1, 1, 1, 1)) - assert np.array_equiv(values[0, 2], (2, 2, 2, 2)) + assert values.shape == (3, 1, 4) + assert np.array_equiv(values[1], (1, 1, 1, 1)) + assert np.array_equiv(values[2], (2, 2, 2, 2)) -def test_build_textures_from_dict_too_many_labels(monkeypatch): - with pytest.raises(MemoryError): - build_textures_from_dict( - {i: (i, i, i, i) for i in range(1001)}, shape=(10, 10) - ) - monkeypatch.setattr( - "napari._vispy.layers.labels.PRIME_NUM_TABLE", [[61], [127]] - ) - with pytest.raises(MemoryError): +def test_build_textures_from_dict_exc(): + with pytest.raises(ValueError, match="Cannot create a 2D texture"): build_textures_from_dict( - {i: (i, i, i, i) for i in range((251**2) // 2)}, + {0: (0, 0, 0, 0), 1: (1, 1, 1, 1), 2: (2, 2, 2, 2)}, + max_size=1, ) - - -def test_size_of_texture_square(): - count = int(127 * 127 * MAX_LOAD_FACTOR) - 1 - keys, values, *_ = build_textures_from_dict( - {i: (i, i, i, i) for i in range(count)} - ) - assert keys.shape == (127, 127) - assert values.shape == (127, 127, 4) - - -def test_size_of_texture_rectangle(): - count = int(128 * 128 * MAX_LOAD_FACTOR) + 5 - keys, values, *_ = build_textures_from_dict( - {i: (i, i, i, i) for i in range(count)} - ) - assert keys.shape == (251, 127) - assert values.shape == (251, 127, 4) - - -def test_build_textures_from_dict_collision(): - keys, values, collision = build_textures_from_dict( - {1: (1, 1, 1, 1), 26: (2, 2, 2, 2), 27: (3, 3, 3, 3)}, shape=(5, 5) - ) - assert collision - assert keys.shape == (5, 5) - assert keys[0, 1] == 1 - assert keys[0, 2] == 26 - assert keys[0, 3] == 27 - assert np.array_equiv(values[0, 1], (1, 1, 1, 1)) - assert np.array_equiv(values[0, 2], (2, 2, 2, 2)) - assert np.array_equiv(values[0, 3], (3, 3, 3, 3)) - - assert hash2d_get(1, keys) == (0, 1) - assert hash2d_get(26, keys) == (0, 2) - assert hash2d_get(27, keys) == (0, 3) - - -def test_collide_keys(): - base_keys = [x * y for x, y in product(PRIME_NUM_TABLE[0], repeat=2)] - colors = {0: (0, 0, 0, 0), 1: (1, 1, 1, 1)} - colors.update({i + 10: (1, 0, 0, 1) for i in base_keys}) - colors.update({2 * i + 10: (0, 1, 0, 1) for i in base_keys}) - keys, values, collision = build_textures_from_dict(colors) - assert not collision - assert keys.shape == (37, 61) - assert values.shape == (37, 61, 4) - - -def test_collide_keys2(): - base_keys = [x * y for x, y in product(PRIME_NUM_TABLE[0], repeat=2)] + [ - x * y for x, y in product(PRIME_NUM_TABLE[0], PRIME_NUM_TABLE[1]) - ] - colors = {0: (0, 0, 0, 0), 1: (1, 1, 1, 1)} - colors.update({i + 10: (1, 0, 0, 1) for i in base_keys}) - colors.update({2 * i + 10: (0, 1, 0, 1) for i in base_keys}) - - # enforce collision for collision table of size 31 - colors.update({31 * i + 10: (0, 0, 1, 1) for i in base_keys}) - # enforce collision for collision table of size 29 - colors.update({29 * i + 10: (0, 0, 1, 1) for i in base_keys}) - - keys, values, collision = build_textures_from_dict(colors) - assert collision - assert keys.shape == (37, 37) - assert values.shape == (37, 37, 4) diff --git a/napari/_vispy/_tests/test_vispy_labels_polygon_overlay.py b/napari/_vispy/_tests/test_vispy_labels_polygon_overlay.py index 6202439fc1b..d453e230710 100644 --- a/napari/_vispy/_tests/test_vispy_labels_polygon_overlay.py +++ b/napari/_vispy/_tests/test_vispy_labels_polygon_overlay.py @@ -74,7 +74,7 @@ def test_labels_drawing_with_polygons(MouseEvent, make_napari_viewer): ) mouse_press_callbacks(layer, event) - assert np.alltrue(data[0, :] == 0) + assert np.array_equiv(data[0, :], 0) # Draw a rectangle (the latest two points will be cancelled) points = [ @@ -115,12 +115,12 @@ def test_labels_drawing_with_polygons(MouseEvent, make_napari_viewer): # Finish drawing complete_polygon(layer) - assert np.alltrue(data[[0, 2], :] == 0) - assert np.alltrue(data[1, 1:11, 1:11] == 1) - assert np.alltrue(data[1, 0, :] == 0) - assert np.alltrue(data[1, :, 0] == 0) - assert np.alltrue(data[1, 11:, :] == 0) - assert np.alltrue(data[1, :, 11:] == 0) + assert np.array_equiv(data[[0, 2], :], 0) + assert np.array_equiv(data[1, 1:11, 1:11], 1) + assert np.array_equiv(data[1, 0, :], 0) + assert np.array_equiv(data[1, :, 0], 0) + assert np.array_equiv(data[1, 11:, :], 0) + assert np.array_equiv(data[1, :, 11:], 0) # Try to finish with an incomplete polygon for position in [(0, 1, 1)]: @@ -134,4 +134,4 @@ def test_labels_drawing_with_polygons(MouseEvent, make_napari_viewer): # Finish drawing complete_polygon(layer) - assert np.alltrue(data[0, :] == 0) + assert np.array_equiv(data[0, :], 0) diff --git a/napari/_vispy/_tests/test_vispy_surface_layer.py b/napari/_vispy/_tests/test_vispy_surface_layer.py index 5e49d4942af..4842dd3db0b 100644 --- a/napari/_vispy/_tests/test_vispy_surface_layer.py +++ b/napari/_vispy/_tests/test_vispy_surface_layer.py @@ -3,6 +3,7 @@ from vispy.geometry import create_cube from napari._vispy.layers.surface import VispySurfaceLayer +from napari.components.dims import Dims from napari.layers import Surface @@ -20,7 +21,7 @@ def test_VispySurfaceLayer(cube_layer, opacity): def test_shading(cube_layer): - cube_layer._slice_dims(ndisplay=3) + cube_layer._slice_dims(Dims(ndim=3, ndisplay=3)) cube_layer.shading = "flat" visual = VispySurfaceLayer(cube_layer) assert visual.node.shading_filter.attached @@ -89,7 +90,7 @@ def test_change_texture(cube_layer): def test_vertex_colors(cube_layer): np.random.seed(0) - cube_layer._slice_dims(ndisplay=3) + cube_layer._slice_dims(Dims(ndim=3, ndisplay=3)) visual = VispySurfaceLayer(cube_layer) n = len(cube_layer.vertices) diff --git a/napari/_vispy/camera.py b/napari/_vispy/camera.py index be1fc8234b8..03877d824b8 100644 --- a/napari/_vispy/camera.py +++ b/napari/_vispy/camera.py @@ -228,7 +228,7 @@ def add_mouse_pan_zoom_toggles( Returns ------- - A decorated VisPy camera class. + A decorated VisPy camera class. """ class _vispy_camera_cls(vispy_camera_cls): diff --git a/napari/_vispy/canvas.py b/napari/_vispy/canvas.py index b3a8916de27..5371c67ddd4 100644 --- a/napari/_vispy/canvas.py +++ b/napari/_vispy/canvas.py @@ -26,7 +26,7 @@ from napari.utils.theme import get_theme if TYPE_CHECKING: - from typing import Callable, List, Optional, Tuple, Union + from typing import Callable, Dict, Optional, Tuple, Union import numpy.typing as npt from qtpy.QtCore import Qt, pyqtBoundSignal @@ -34,8 +34,11 @@ from vispy.app.backends._qt import CanvasBackendDesktop from vispy.app.canvas import DrawEvent, MouseEvent, ResizeEvent + from napari._vispy.layers.base import VispyBaseLayer + from napari._vispy.overlays.base import VispyBaseOverlay from napari.components import ViewerModel from napari.components.overlays import Overlay + from napari.layers import Layer from napari.utils.events.event import Event from napari.utils.key_bindings import KeymapHandler @@ -112,8 +115,8 @@ def __init__( self.camera = VispyCamera( self.view, self.viewer.camera, self.viewer.dims ) - self.layer_to_visual = {} - self._overlay_to_visual = {} + self.layer_to_visual: Dict[Layer, VispyBaseLayer] = {} + self._overlay_to_visual: Dict[Overlay, VispyBaseOverlay] = {} self._key_map_handler = key_map_handler self._instances.add(self) @@ -307,7 +310,7 @@ def _on_interactive(self) -> None: ) def _map_canvas2world( - self, position: List[int, int] + self, position: Tuple[int, ...] ) -> Tuple[float, float]: """Map position from canvas pixels into world coordinates. @@ -335,6 +338,7 @@ def _map_canvas2world( position_world = list(self.viewer.dims.point) for i, d in enumerate(self.viewer.dims.displayed): position_world[d] = position_world_slice[i] + return tuple(position_world) def _process_mouse_event( @@ -384,7 +388,7 @@ def _process_mouse_event( # Update the cursor position self.viewer.cursor._view_direction = event.view_direction - self.viewer.cursor.position = self._map_canvas2world(list(event.pos)) + self.viewer.cursor.position = self._map_canvas2world(event.pos) # Add the cursor position to the event event.position = self.viewer.cursor.position @@ -494,7 +498,7 @@ def _canvas_corners_in_world(self) -> npt.NDArray: Coordinates of top left and bottom right canvas pixel in the world. """ # Find corners of canvas in world coordinates - top_left = self._map_canvas2world([0, 0]) + top_left = self._map_canvas2world((0, 0)) bottom_right = self._map_canvas2world(self._scene_canvas.size) return np.array([top_left, bottom_right]) @@ -548,14 +552,16 @@ def on_resize(self, event: ResizeEvent) -> None: """ self.viewer._canvas_size = self.size - def add_layer_visual_mapping(self, napari_layer, vispy_layer) -> None: + def add_layer_visual_mapping( + self, napari_layer: Layer, vispy_layer: VispyBaseLayer + ) -> None: """Maps a napari layer to its corresponding vispy layer and sets the parent scene of the vispy layer. - Paremeters + Parameters ---------- - napari_layer : napari.layers + napari_layer : Any napari layer, the layer type is the same as the vispy layer. - vispy_layer : napari._vispy.layers + vispy_layer : Any vispy layer, the layer type is the same as the napari layer. Returns @@ -574,9 +580,9 @@ def add_layer_visual_mapping(self, napari_layer, vispy_layer) -> None: def _remove_layer(self, event: Event) -> None: """Upon receiving event closes the Vispy visual, deletes it and reorders the still existing layers. - Parameters - ---------- - event: napari.utils.events.event.Event + Parameters + ---------- + event : napari.utils.events.event.Event The event causing a particular layer to be removed Returns diff --git a/napari/_vispy/layers/base.py b/napari/_vispy/layers/base.py index e7f8fb55b8b..300c6233ad0 100644 --- a/napari/_vispy/layers/base.py +++ b/napari/_vispy/layers/base.py @@ -1,14 +1,24 @@ from abc import ABC, abstractmethod +from typing import Dict, Generic, TypeVar, cast import numpy as np +from vispy.scene import VisualNode from vispy.visuals.transforms import MatrixTransform +from napari._vispy.overlays.base import VispyBaseOverlay from napari._vispy.utils.gl import BLENDING_MODES, get_max_texture_sizes -from napari.components.overlays.base import CanvasOverlay, SceneOverlay +from napari.components.overlays.base import ( + CanvasOverlay, + Overlay, + SceneOverlay, +) +from napari.layers import Layer from napari.utils.events import disconnect_events +_L = TypeVar("_L", bound=Layer) -class VispyBaseLayer(ABC): + +class VispyBaseLayer(ABC, Generic[_L]): """Base object for individual layer views Meant to be subclassed. @@ -42,7 +52,10 @@ class VispyBaseLayer(ABC): Transform positioning the layer visual inside the scenecanvas. """ - def __init__(self, layer, node) -> None: + layer: _L + overlays: Dict[Overlay, VispyBaseOverlay] + + def __init__(self, layer: _L, node: VisualNode) -> None: super().__init__() self.events = None # Some derived classes have events. @@ -124,7 +137,7 @@ def _on_opacity_change(self): def _on_blending_change(self, event=None): blending = self.layer.blending - blending_kwargs = BLENDING_MODES[blending].copy() + blending_kwargs = cast(dict, BLENDING_MODES[blending]).copy() if self.first_visible: # if the first layer, then we should blend differently @@ -186,7 +199,8 @@ def _on_overlays_change(self): overlay_visual.close() def _on_matrix_change(self): - transform = self.layer._transforms.simplified.set_slice( + # mypy: self.layer._transforms.simplified cannot be None + transform = self.layer._transforms.simplified.set_slice( # type: ignore [union-attr] self.layer._slice_input.displayed ) # convert NumPy axis ordering to VisPy axis ordering @@ -216,6 +230,22 @@ def _on_matrix_change(self): affine_matrix = affine_matrix @ affine_offset self._master_transform.matrix = affine_matrix + # Because of performance reason, for multiscale images + # we load only visible part of data to GPU. + # To place this part of data correctly we update transform, + # but this leads to incorrect placement of child layers. + # To fix this we need to update child layers transform. + child_matrix = np.eye(4) + child_matrix[-1, : len(translate)] = ( + self.layer.translate[self.layer._slice_input.displayed][::-1] + + self.layer.affine.translate[self.layer._slice_input.displayed][ + ::-1 + ] + - translate + ) + for child in self.node.children: + child.transform.matrix = child_matrix + def _on_experimental_clipping_planes_change(self): if hasattr(self.node, 'clipping_planes') and hasattr( self.layer, 'experimental_clipping_planes' @@ -237,7 +267,7 @@ def reset(self): self._on_overlays_change() self._on_camera_move() - def _on_poll(self, event=None): # noqa: B027 + def _on_poll(self, event=None): """Called when camera moves, before we are drawn. Optionally called for some period once the camera stops, so the diff --git a/napari/_vispy/layers/image.py b/napari/_vispy/layers/image.py index 6e703f79b5c..f8ae9f44084 100644 --- a/napari/_vispy/layers/image.py +++ b/napari/_vispy/layers/image.py @@ -1,19 +1,26 @@ +from __future__ import annotations + import warnings +from typing import Dict, Optional import numpy as np from vispy.color import Colormap as VispyColormap from vispy.scene.node import Node +from vispy.visuals import ImageVisual from napari._vispy.layers.base import VispyBaseLayer from napari._vispy.utils.gl import fix_data_dtype, get_gl_extensions from napari._vispy.visuals.image import Image as ImageNode from napari._vispy.visuals.volume import Volume as VolumeNode from napari.layers.base._base_constants import Blending +from napari.layers.image.image import _ImageBase from napari.utils.translations import trans class ImageLayerNode: - def __init__(self, custom_node: Node = None, texture_format=None) -> None: + def __init__( + self, custom_node: Node = None, texture_format: Optional[str] = None + ) -> None: if ( texture_format == 'auto' and 'texture_float' not in get_gl_extensions() @@ -37,21 +44,31 @@ def __init__(self, custom_node: Node = None, texture_format=None) -> None: texture_format=texture_format, ) - def get_node(self, ndisplay: int) -> Node: + def get_node( + self, ndisplay: int, dtype: Optional[np.dtype] = None + ) -> Node: # Return custom node if we have one. if self._custom_node is not None: return self._custom_node # Return Image or Volume node based on 2D or 3D. - if ndisplay == 2: - return self._image_node - return self._volume_node + res = self._image_node if ndisplay == 2 else self._volume_node + if ( + res.texture_format != "auto" + and dtype is not None + and _VISPY_FORMAT_TO_DTYPE[res.texture_format] != dtype + ): + # it is a bug to hit this error — it is here to catch bugs + # early when we are creating the wrong nodes or + # textures for our data + raise ValueError("dtype does not match texture_format") + return res -class VispyImageLayer(VispyBaseLayer): +class VispyImageLayer(VispyBaseLayer[_ImageBase]): def __init__( self, - layer, + layer: _ImageBase, node=None, texture_format='auto', layer_node_class=ImageLayerNode, @@ -99,14 +116,20 @@ def __init__( self.reset() self._on_data_change() - def _on_display_change(self, data=None): + def _on_display_change(self, data=None) -> None: parent = self.node.parent self.node.parent = None ndisplay = self.layer._slice_input.ndisplay - self.node = self._layer_node.get_node(ndisplay) + self.node = self._layer_node.get_node( + ndisplay, getattr(data, "dtype", None) + ) if data is None: - data = np.zeros((1,) * ndisplay, dtype=np.float32) + texture_format = self.node.texture_format + data = np.zeros( + (1,) * ndisplay, + dtype=get_dtype_from_vispy_texture_format(texture_format), + ) self.node.visible = not self.layer._slice.empty and self.layer.visible @@ -118,11 +141,14 @@ def _on_display_change(self, data=None): overlay_visual.node.parent = self.node self.reset() - def _on_data_change(self): - node = self.node + def _on_data_change(self) -> None: data = fix_data_dtype(self.layer._data_view) ndisplay = self.layer._slice_input.ndisplay + node = self._layer_node.get_node( + ndisplay, getattr(data, "dtype", None) + ) + if ndisplay == 3 and self.layer.ndim == 2: data = np.expand_dims(data, axis=0) @@ -134,43 +160,44 @@ def _on_data_change(self): # Check if ndisplay has changed current node type needs updating if (ndisplay == 3 and not isinstance(node, VolumeNode)) or ( - ndisplay == 2 and not isinstance(node, ImageNode) + ndisplay == 2 + and not isinstance(node, ImageVisual) + or node != self.node ): self._on_display_change(data) else: node.set_data(data) - - node.visible = not self.layer._slice.empty and self.layer.visible + node.visible = not self.layer._slice.empty and self.layer.visible # Call to update order of translation values with new dims: self._on_matrix_change() node.update() - def _on_interpolation_change(self): + def _on_interpolation_change(self) -> None: self.node.interpolation = ( self.layer.interpolation2d if self.layer._slice_input.ndisplay == 2 else self.layer.interpolation3d ) - def _on_custom_interpolation_kernel_2d_change(self): + def _on_custom_interpolation_kernel_2d_change(self) -> None: if self.layer._slice_input.ndisplay == 2: self.node.custom_kernel = self.layer.custom_interpolation_kernel_2d - def _on_rendering_change(self): + def _on_rendering_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.method = self.layer.rendering self._on_attenuation_change() self._on_iso_threshold_change() - def _on_depiction_change(self): + def _on_depiction_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.raycasting_mode = str(self.layer.depiction) - def _on_colormap_change(self, event=None): + def _on_colormap_change(self, event=None) -> None: self.node.cmap = VispyColormap(*self.layer.colormap) - def _update_mip_minip_cutoff(self): + def _update_mip_minip_cutoff(self) -> None: # discard fragments beyond contrast limits, but only with translucent blending if isinstance(self.node, VolumeNode): if self.layer.blending in { @@ -183,24 +210,24 @@ def _update_mip_minip_cutoff(self): self.node.mip_cutoff = None self.node.minip_cutoff = None - def _on_contrast_limits_change(self): + def _on_contrast_limits_change(self) -> None: self.node.clim = self.layer.contrast_limits # cutoffs must be updated after clims, so we can set them to the new values self._update_mip_minip_cutoff() # iso also may depend on contrast limit values self._on_iso_threshold_change() - def _on_blending_change(self): + def _on_blending_change(self, event=None) -> None: super()._on_blending_change() # cutoffs must be updated after blending, so we can know if # the new blending is a translucent one self._update_mip_minip_cutoff() - def _on_gamma_change(self): + def _on_gamma_change(self) -> None: if len(self.node.shared_program.frag._set_items) > 0: self.node.gamma = self.layer.gamma - def _on_iso_threshold_change(self): + def _on_iso_threshold_change(self) -> None: if isinstance(self.node, VolumeNode): if self.node._texture.is_normalized: cmin, cmax = self.layer.contrast_limits_range @@ -210,23 +237,23 @@ def _on_iso_threshold_change(self): else: self.node.threshold = self.layer.iso_threshold - def _on_attenuation_change(self): + def _on_attenuation_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.attenuation = self.layer.attenuation - def _on_plane_thickness_change(self): + def _on_plane_thickness_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.plane_thickness = self.layer.plane.thickness - def _on_plane_position_change(self): + def _on_plane_position_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.plane_position = self.layer.plane.position - def _on_plane_normal_change(self): + def _on_plane_normal_change(self) -> None: if isinstance(self.node, VolumeNode): self.node.plane_normal = self.layer.plane.normal - def reset(self, event=None): + def reset(self, event=None) -> None: super().reset() self._on_interpolation_change() self._on_colormap_change() @@ -239,7 +266,9 @@ def reset(self, event=None): self._on_plane_thickness_change() self._on_custom_interpolation_kernel_2d_change() - def downsample_texture(self, data, MAX_TEXTURE_SIZE): + def downsample_texture( + self, data: np.ndarray, MAX_TEXTURE_SIZE: int + ) -> np.ndarray: """Downsample data based on maximum allowed texture size. Parameters @@ -258,7 +287,9 @@ def downsample_texture(self, data, MAX_TEXTURE_SIZE): if self.layer.multiscale: raise ValueError( trans._( - "Shape of in dividual tiles in multiscale {shape} cannot exceed GL_MAX_TEXTURE_SIZE {texture_size}. Rendering is currently in {ndisplay}D mode.", + "Shape of individual tiles in multiscale {shape} cannot " + "exceed GL_MAX_TEXTURE_SIZE {texture_size}. Rendering is " + "currently in {ndisplay}D mode.", deferred=True, shape=data.shape, texture_size=MAX_TEXTURE_SIZE, @@ -267,7 +298,9 @@ def downsample_texture(self, data, MAX_TEXTURE_SIZE): ) warnings.warn( trans._( - "data shape {shape} exceeds GL_MAX_TEXTURE_SIZE {texture_size} in at least one axis and will be downsampled. Rendering is currently in {ndisplay}D mode.", + "data shape {shape} exceeds GL_MAX_TEXTURE_SIZE {texture_size}" + " in at least one axis and will be downsampled." + " Rendering is currently in {ndisplay}D mode.", deferred=True, shape=data.shape, texture_size=MAX_TEXTURE_SIZE, @@ -280,8 +313,41 @@ def downsample_texture(self, data, MAX_TEXTURE_SIZE): scale = np.ones(self.layer.ndim) for i, d in enumerate(self.layer._slice_input.displayed): scale[d] = downsample[i] + + # tile2data is a ScaleTransform thus is has a .scale attribute, but + # mypy cannot know this. self.layer._transforms['tile2data'].scale = scale + self._on_matrix_change() slices = tuple(slice(None, None, ds) for ds in downsample) data = data[slices] return data + + +_VISPY_FORMAT_TO_DTYPE: Dict[Optional[str], np.dtype] = { + "r8": np.dtype(np.uint8), + "r16": np.dtype(np.uint16), + "r32f": np.dtype(np.float32), +} + +_DTYPE_TO_VISPY_FORMAT = {v: k for k, v in _VISPY_FORMAT_TO_DTYPE.items()} + +# this is moved after reverse mapping is defined +# to always have non None values in _DTYPE_TO_VISPY_FORMAT +_VISPY_FORMAT_TO_DTYPE[None] = np.dtype(np.float32) + + +def get_dtype_from_vispy_texture_format(format_str: str) -> np.dtype: + """Get the numpy dtype from a vispy texture format string. + + Parameters + ---------- + format_str : str + The vispy texture format string. + + Returns + ------- + dtype : numpy.dtype + The numpy dtype corresponding to the vispy texture format string. + """ + return _VISPY_FORMAT_TO_DTYPE.get(format_str, np.dtype(np.float32)) diff --git a/napari/_vispy/layers/labels.py b/napari/_vispy/layers/labels.py index d1d2dcd1a64..d472cee4165 100644 --- a/napari/_vispy/layers/labels.py +++ b/napari/_vispy/layers/labels.py @@ -1,156 +1,126 @@ -from itertools import product -from math import ceil, isnan, log2, sqrt -from typing import Dict, Optional, Tuple, Union +import math +from typing import TYPE_CHECKING, Dict, Tuple import numpy as np from vispy.color import Colormap as VispyColormap from vispy.gloo import Texture2D from vispy.scene.node import Node -from vispy.scene.visuals import create_visual_node -from vispy.visuals.image import ImageVisual -from vispy.visuals.shaders import Function, FunctionChain -from napari._vispy.layers.image import ImageLayerNode, VispyImageLayer +from napari._vispy.layers.image import ( + _DTYPE_TO_VISPY_FORMAT, + _VISPY_FORMAT_TO_DTYPE, + ImageLayerNode, + VispyImageLayer, + get_dtype_from_vispy_texture_format, +) from napari._vispy.utils.gl import get_max_texture_sizes +from napari._vispy.visuals.labels import LabelNode from napari._vispy.visuals.volume import Volume as VolumeNode -from napari.utils._dtype import vispy_texture_dtype - -# We use table sizes that are prime numbers near powers of 2. -# For each power of 2, we keep three candidate sizes. This allows us to -# maximize the chances of finding a collision-free table for a given set of -# keys (which we typically know at build time). -PRIME_NUM_TABLE = [ - [37, 31, 29], - [61, 59, 53], - [127, 113, 109], - [251, 241, 239], - [509, 503, 499], - [1021, 1019, 1013], - [2039, 2029, 2027], - [4093, 4091, 4079], - [8191, 8179, 8171], - [16381, 16369, 16363], - [32749, 32719, 32717], - [65521, 65519, 65497], -] - -START_TWO_POWER = 5 - -MAX_LOAD_FACTOR = 0.25 - -MAX_TEXTURE_SIZE = None +from napari.utils.colormaps.colormap import ( + LabelColormap, + _texture_dtype, +) + +if TYPE_CHECKING: + from napari.layers import Labels + ColorTuple = Tuple[float, float, float, float] -low_disc_lookup_shader = """ -uniform sampler2D texture2D_LUT; -vec4 sample_label_color(float t) { - float phi_mod = 0.6180339887498948482; // phi - 1 - float value = 0.0; - float margin = 1.0 / 256; +auto_lookup_shader_uint8 = """ +uniform sampler2D texture2D_values; - if (t == 0) { +vec4 sample_label_color(float t) { + if (($use_selection) && ($selection != int(t * 255))) { return vec4(0); } + return texture2D( + texture2D_values, + vec2(0.0, t) + ); +} +""" - if (($use_selection) && ($selection != t)) { +auto_lookup_shader_uint16 = """ +uniform sampler2D texture2D_values; + +vec4 sample_label_color(float t) { + // uint 16 + t = t * 65535; + if (($use_selection) && ($selection != int(t))) { return vec4(0); } - - value = mod((t * phi_mod + $seed), 1.0) * (1 - 2*margin) + margin; - + float v = mod(t, 256); + float v2 = (t - v) / 256; return texture2D( - texture2D_LUT, - vec2(0.0, clamp(value, 0.0, 1.0)) + texture2D_values, + vec2((v + 0.5) / 256, (v2 + 0.5) / 256) ); } """ direct_lookup_shader = """ -uniform sampler2D texture2D_keys; uniform sampler2D texture2D_values; uniform vec2 LUT_shape; -uniform int color_count; - vec4 sample_label_color(float t) { - if (($use_selection) && ($selection != t)) { - return vec4(0); - } - - float empty = 0.; - // get position in the texture grid (same as hash2d_get) - vec2 pos = vec2( - mod(int(t / LUT_shape.y), LUT_shape.x), - mod(t, LUT_shape.y) + t = t * $scale; + return texture2D( + texture2D_values, + vec2(0.0, (t + 0.5) / $color_map_size) ); +} - // add .5 to move to the center of each texel and convert to texture coords - vec2 pos_tex = (pos + vec2(.5)) / LUT_shape; - - // sample key texture - float found = texture2D( - texture2D_keys, - pos_tex - ).r; - - // return vec4(pos_tex, 0, 1); // debug if texel is calculated correctly (correct) - // return vec4(found / 15, 0, 0, 1); // debug if key is calculated correctly (correct, should be a black-to-red gradient) - - // we get a different value: - // - if it's the empty key, exit; - // - otherwise, it's a hash collision: continue searching - float initial_t = t; - int count = 0; - while ((abs(found - initial_t) > 1e-8) && (abs(found - empty) > 1e-8)) { - count = count + 1; - t = initial_t + float(count); - if (count >= color_count) { - return vec4(0); - } - // same as above - vec2 pos = vec2( - mod(int(t / LUT_shape.y), LUT_shape.x), - mod(t, LUT_shape.y) - ); - pos_tex = (pos + vec2(.5)) / LUT_shape; - - found = texture2D( - texture2D_keys, - pos_tex - ).r; - } +""" - // return vec4(pos_tex, 0, 1); // debug if final texel is calculated correctly +direct_lookup_shader_many = """ +uniform sampler2D texture2D_values; +uniform vec2 LUT_shape; - vec4 color = vec4(0); - if (abs(found - empty) > 1e-8) { - color = texture2D( - texture2D_values, - pos_tex - ); - } - return color; +vec4 sample_label_color(float t) { + t = t * $scale; + float row = mod(t, LUT_shape.x); + float col = int(t / LUT_shape.x); + return texture2D( + texture2D_values, + vec2((col + 0.5) / LUT_shape.y, (row + 0.5) / LUT_shape.x) + ); } - """ class LabelVispyColormap(VispyColormap): def __init__( self, - colors, - controls=None, - seed=0.5, - use_selection=False, - selection=0.0, + colormap: LabelColormap, + view_dtype: np.dtype, + raw_dtype: np.dtype, ): - super().__init__(colors, controls, interpolation='zero') + super().__init__( + colors=["w", "w"], controls=None, interpolation='zero' + ) + if view_dtype.itemsize == 1: + shader = auto_lookup_shader_uint8 + elif view_dtype.itemsize == 2: + shader = auto_lookup_shader_uint16 + else: + # See https://github.com/napari/napari/issues/6397 + # Using f32 dtype for textures resulted in very slow fps + # Therefore, when we have {u,}int{8,16}, we use a texture + # of that size, but when we have higher bits, we convert + # to 8-bit on the CPU before sending to the shader. + # It should thus be impossible to reach this condition. + raise ValueError( # pragma: no cover + f"Cannot use dtype {view_dtype} with LabelVispyColormap" + ) + + selection = colormap._selection_as_minimum_dtype(raw_dtype) + self.glsl_map = ( - low_disc_lookup_shader.replace('$seed', str(seed)) - .replace('$use_selection', str(use_selection).lower()) + shader.replace('$color_map_size', str(len(colormap.colors))) + .replace('$use_selection', str(colormap.use_selection).lower()) .replace('$selection', str(selection)) ) @@ -160,259 +130,72 @@ def __init__( self, use_selection=False, selection=0.0, - collision=True, + scale=1.0, + color_map_size=255, + multi=False, ): colors = ['w', 'w'] # dummy values, since we use our own machinery super().__init__(colors, controls=None, interpolation='zero') + shader = direct_lookup_shader_many if multi else direct_lookup_shader self.glsl_map = ( - direct_lookup_shader.replace( - "$use_selection", str(use_selection).lower() - ) + shader.replace("$use_selection", str(use_selection).lower()) .replace("$selection", str(selection)) - .replace("$collision", str(collision).lower()) + .replace("$scale", str(scale)) + .replace("$color_map_size", str(color_map_size)) ) -def idx_to_2d(idx, shape): - """ - From a 1D index generate a 2D index that fits the given shape. - - The 2D index will wrap around line by line and back to the beginning. - """ - return int((idx // shape[1]) % shape[0]), int(idx % shape[1]) - - -def hash2d_get(key, keys, empty_val=0): - """ - Given a key, retrieve its location in the keys table. - """ - pos = idx_to_2d(key, keys.shape) - initial_key = key - while keys[pos] != initial_key and keys[pos] != empty_val: - if key - initial_key > keys.size: - raise KeyError('label does not exist') - key += 1 - pos = idx_to_2d(key, keys.shape) - return pos if keys[pos] == initial_key else None - - -def hash2d_set( - key: Union[float, np.floating], - value: ColorTuple, - keys: np.ndarray, - values: np.ndarray, - empty_val=0, -) -> bool: - """ - Set a value in the 2d hashmap, wrapping around to avoid collision. - """ - if key is None or isnan(key): - return False - pos = idx_to_2d(key, keys.shape) - initial_key = key - collision = False - while keys[pos] != empty_val: - collision = True - if key - initial_key > keys.size: - raise OverflowError('too many labels') - key += 1 - pos = idx_to_2d(key, keys.shape) - keys[pos] = initial_key - values[pos] = value - - return collision - - -def _get_shape_from_keys( - keys: np.ndarray, first_dim_index: int, second_dim_index: int -) -> Optional[Tuple[int, int]]: - """Get the smallest hashmap size without collisions, if any. - - This function uses precomputed prime numbers from PRIME_NUM_TABLE. - - For each index, it gets a list of prime numbers close to - ``2**(index + START_TWO_POWER)`` (where ``START_TWO_POWER=5``), that is, - the smallest table is close to ``32 * 32``. - - The function then iterates over all combinations of prime numbers from the - lists and checks for a combination that has no collisions for the - given keys, returning that combination. - - If no combination can be found, returns None. - - Although keys that collide for all table combinations are rare, they are - possible: see ``test_collide_keys`` and ``test_collide_keys2``. - - Parameters - ---------- - keys: np.ndarray - array of keys to be inserted into the hashmap, - used for collision detection - first_dim_index: int - index for first dimension of PRIME_NUM_TABLE - second_dim_index: int - index for second dimension of PRIME_NUM_TABLE - - Returns - shp : 2-tuple of int, optional - If a table shape can be found that has no collisions for the given - keys, return that shape. Otherwise, return None. - """ - for fst_size, snd_size in product( - PRIME_NUM_TABLE[first_dim_index], - PRIME_NUM_TABLE[second_dim_index], - ): - fst_crd = (keys // snd_size) % fst_size - snd_crd = keys % snd_size - - collision_set = set(zip(fst_crd, snd_crd)) - if len(collision_set) == len(set(keys)): - return fst_size, snd_size - return None - - -def _get_shape_from_dict( - color_dict: Dict[float, Tuple[float, float, float, float]] -) -> Tuple[int, int]: - """Compute the shape of a 2D hashmap based on the keys in `color_dict`. - - This function finds indices for the first and second dimensions of a - table in PRIME_NUM_TABLE based on a target load factor of 0.125-0.25, - then calls `_get_shape_from_keys` based on those indices. +def build_textures_from_dict( + color_dict: Dict[int, ColorTuple], max_size: int +) -> np.ndarray: + """This code assumes that the keys in the color_dict are sequential from 0. - This is quite a low load-factor, but, ultimately, the hash table - textures are tiny compared to most datasets, so we choose these - factors to minimize the chance of collisions and trade a bit of GPU - memory for speed. + If any keys are larger than the size of the dictionary, they will + overwrite earlier keys in the best case, or it might just crash. """ - keys = np.array([x for x in color_dict if x is not None], dtype=np.int64) - - size = len(keys) / MAX_LOAD_FACTOR - size_sqrt = sqrt(size) - size_log2 = log2(size_sqrt) - max_idx = len(PRIME_NUM_TABLE) - 1 - max_size = PRIME_NUM_TABLE[max_idx][0] ** 2 - fst_dim = min(max(int(ceil(size_log2)) - START_TWO_POWER, 0), max_idx) - snd_dim = min(max(int(round(size_log2, 0)) - START_TWO_POWER, 0), max_idx) - - if len(keys) > max_size: - raise MemoryError( - f'Too many labels: napari supports at most {max_size} labels, ' - f'got {len(keys)}.' + if len(color_dict) > 2**23: + raise ValueError( # pragma: no cover + "Cannot map more than 2**23 colors because of float32 precision. " + f"Got {len(color_dict)}" ) - - shp = _get_shape_from_keys(keys, fst_dim, snd_dim) - if shp is None and snd_dim < max_idx: - # if we still have room to grow, try the next size up to get a - # collision-free table - shp = _get_shape_from_keys(keys, fst_dim, snd_dim + 1) - if shp is None: - # at this point, if there's still collisions, we give up and return - # the largest possible table given these indices and the target load - # factor. - # (To see a set of keys that cause collision, - # and land on this branch, see test_collide_keys2.) - shp = PRIME_NUM_TABLE[fst_dim][0], PRIME_NUM_TABLE[snd_dim][0] - return shp - - -def get_shape_from_dict(color_dict): - global MAX_TEXTURE_SIZE - if MAX_TEXTURE_SIZE is None: - MAX_TEXTURE_SIZE = get_max_texture_sizes()[0] - - shape = _get_shape_from_dict(color_dict) - - if MAX_TEXTURE_SIZE is not None and ( - shape[0] > MAX_TEXTURE_SIZE or shape[1] > MAX_TEXTURE_SIZE - ): - raise MemoryError( - f'Too many labels. GPU does not support textures of this size.' - f' Requested size is {shape[0]}x{shape[1]}, but maximum supported' - f' size is {MAX_TEXTURE_SIZE}x{MAX_TEXTURE_SIZE}' + if len(color_dict) > max_size**2: + raise ValueError( + "Cannot create a 2D texture holding more than " + f"{max_size}**2={max_size ** 2} colors." + f"Got {len(color_dict)}" ) - return shape - + data = np.zeros( + ( + min(len(color_dict), max_size), + math.ceil(len(color_dict) / max_size), + 4, + ), + dtype=np.float32, + ) + for key, value in color_dict.items(): + data[key % data.shape[0], key // data.shape[0]] = value + return data -def build_textures_from_dict( - color_dict: Dict[float, ColorTuple], - empty_val=0, - shape=None, - use_selection=False, - selection=0.0, -) -> Tuple[np.ndarray, np.ndarray, bool]: - """ - This function construct hash table for fast lookup of colors. - It uses pair of textures. - First texture is a table of keys, used to determine position, - second is a table of values. - - The procedure of selection table and collision table is - implemented in hash2d_get function. - - Parameters - ---------- - color_dict: Dict[float, Tuple[float, float, float, float]] - Dictionary from labels to colors - empty_val: float - Value to use for empty cells in the hash table - shape: Optional[Tuple[int, int]] - Shape of the hash table. - If None, it is calculated from the number of - labels using _get_shape_from_dict - use_selection: bool - If True, only the selected label is shown. - The generated colormap is single-color of size (1, 1) - selection: float - used only if use_selection is True. - Determines the selected label. - - Returns - ------- - keys: np.ndarray - Texture of keys for the hash table - values: np.ndarray - Texture of values for the hash table - collision: bool - True if there are collisions in the hash table - """ - if use_selection: - keys = np.full((1, 1), selection, dtype=vispy_texture_dtype) - values = np.zeros((1, 1, 4), dtype=vispy_texture_dtype) - values[0, 0] = color_dict[selection] - return keys, values, False - - if len(color_dict) > 2**31 - 2: - raise MemoryError( - f'Too many labels ({len(color_dict)}). Maximum supported number of labels is 2^31-2' - ) - if shape is None: - shape = get_shape_from_dict(color_dict) +def _select_colormap_texture( + colormap: LabelColormap, view_dtype, raw_dtype +) -> np.ndarray: + if raw_dtype.itemsize > 2: + color_texture = colormap._get_mapping_from_cache(view_dtype) + else: + color_texture = colormap._get_mapping_from_cache(raw_dtype) - if len(color_dict) > shape[0] * shape[1]: - raise MemoryError( - f'Too many labels ({len(color_dict)}). Maximum supported number of labels for the given shape is {shape[0] * shape[1]}' + if color_texture is None: + raise ValueError( # pragma: no cover + f"Cannot build a texture for dtype {raw_dtype=} and {view_dtype=}" ) - - keys = np.full(shape, empty_val, dtype=vispy_texture_dtype) - values = np.zeros(shape + (4,), dtype=vispy_texture_dtype) - visited = set() - collision = False - for key, value in color_dict.items(): - key_ = vispy_texture_dtype(key) - if key_ in visited: - # input int keys are unique but can map to the same float. - # if so, we ignore all but the first appearance. - continue - visited.add(key_) - collision |= hash2d_set(key_, value, keys, values) - - return keys, values, collision + return color_texture.reshape(256, -1, 4) class VispyLabelsLayer(VispyImageLayer): - def __init__(self, layer, node=None, texture_format='r32f') -> None: + layer: 'Labels' + + def __init__(self, layer, node=None, texture_format='r8') -> None: super().__init__( layer, node=node, @@ -424,6 +207,9 @@ def __init__(self, layer, node=None, texture_format='r32f') -> None: self.layer.events.labels_update.connect(self._on_partial_labels_update) self.layer.events.selected_label.connect(self._on_colormap_change) self.layer.events.show_selected_label.connect(self._on_colormap_change) + self.layer.events.data.connect(self._on_colormap_change) + # as we generate colormap texture based on the data type, we need to + # update it when the data type changes def _on_rendering_change(self): # overriding the Image method, so we can maintain the same old rendering name @@ -449,40 +235,62 @@ def _on_colormap_change(self, event=None): return colormap = self.layer.colormap mode = self.layer.color_mode - - if mode == 'auto': + view_dtype = self.layer._slice.image.view.dtype + raw_dtype = self.layer._slice.image.raw.dtype + if mode == 'auto' or (mode == "direct" and raw_dtype.itemsize <= 2): + if raw_dtype.itemsize > 2 and isinstance(colormap, LabelColormap): + # If the view dtype is different from the raw dtype, it is possible + # that background pixels are not the same value as the `background_value`. + # For example, if raw_dtype is int8 and background_value is `-1` + # then in view dtype uint8, the background pixels will be 255 + # For data types with more than 16 bits we always cast + # to uint8 or uint16 and background_value is always 0 in a view array. + # The LabelColormap is EventedModel, so we need to make + # a copy instead of temporary overwrite the background_value + colormap = LabelColormap(**colormap.dict()) + colormap.background_value = ( + colormap._background_as_minimum_dtype(raw_dtype) + ) + color_texture = _select_colormap_texture( + colormap, view_dtype, raw_dtype + ) self.node.cmap = LabelVispyColormap( - colors=colormap.colors, - controls=colormap.controls, - seed=colormap.seed, - use_selection=colormap.use_selection, - selection=colormap.selection, + colormap, view_dtype=view_dtype, raw_dtype=raw_dtype ) - elif mode == 'direct': - color_dict = ( - self.layer.color - ) # TODO: should probably account for non-given labels - key_texture, val_texture, collision = build_textures_from_dict( - color_dict, - use_selection=colormap.use_selection, - selection=colormap.selection, + self.node.shared_program['texture2D_values'] = Texture2D( + color_texture, + internalformat='rgba32f', + interpolation='nearest', + ) + self.texture_data = color_texture + + elif mode == 'direct': # only for raw_dtype.itemsize > 2 + color_dict = colormap._values_mapping_to_minimum_values_set()[1] + max_size = get_max_texture_sizes()[0] + val_texture = build_textures_from_dict(color_dict, max_size) + + dtype = _texture_dtype( + self.layer._direct_colormap._num_unique_colors + 2, + raw_dtype, ) + if issubclass(dtype.type, np.integer): + scale = np.iinfo(dtype).max + else: # float32 texture + scale = 1.0 self.node.cmap = DirectLabelVispyColormap( use_selection=colormap.use_selection, selection=colormap.selection, - collision=collision, - ) - # note that textures have to be transposed here! - self.node.shared_program['texture2D_keys'] = Texture2D( - key_texture.T, internalformat='r32f', interpolation='nearest' + scale=scale, + color_map_size=val_texture.shape[0], + multi=val_texture.shape[1] > 1, ) self.node.shared_program['texture2D_values'] = Texture2D( - val_texture.swapaxes(0, 1), + val_texture, internalformat='rgba32f', interpolation='nearest', ) - self.node.shared_program['LUT_shape'] = key_texture.shape + self.node.shared_program['LUT_shape'] = val_texture.shape[:2] else: self.node.cmap = VispyColormap(*colormap) @@ -503,47 +311,40 @@ def _on_partial_labels_update(self, event): self.node.update() -class LabelVisual(ImageVisual): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def _build_color_transform(self): - fun = FunctionChain( - None, - [ - Function(self._func_templates['red_to_luminance']), - Function(self.cmap.glsl_map), - ], - ) - return fun - - class LabelLayerNode(ImageLayerNode): def __init__(self, custom_node: Node = None, texture_format=None): self._custom_node = custom_node + self._setup_nodes(texture_format) + + def _setup_nodes(self, texture_format): self._image_node = LabelNode( None if (texture_format is None or texture_format == 'auto') - else np.array([[0.0]], dtype=np.float32), + else np.zeros( + (1, 1), + dtype=get_dtype_from_vispy_texture_format(texture_format), + ), method='auto', texture_format=texture_format, ) self._volume_node = VolumeNode( - np.zeros((1, 1, 1), dtype=np.float32), + np.zeros( + (1, 1, 1), + dtype=get_dtype_from_vispy_texture_format(texture_format), + ), clim=[0, 2**23 - 1], texture_format=texture_format, ) + def get_node(self, ndisplay: int, dtype=None) -> Node: + res = self._image_node if ndisplay == 2 else self._volume_node -BaseLabel = create_visual_node(LabelVisual) - - -class LabelNode(BaseLabel): # type: ignore [valid-type,misc] - def _compute_bounds(self, axis, view): - if self._data is None: - return None - elif axis > 1: # noqa: RET505 - return 0, 0 - else: - return 0, self.size[axis] + if ( + res.texture_format != "auto" + and dtype is not None + and _VISPY_FORMAT_TO_DTYPE[res.texture_format] != dtype + ): + self._setup_nodes(_DTYPE_TO_VISPY_FORMAT[dtype]) + return self.get_node(ndisplay, dtype) + return res diff --git a/napari/_vispy/overlays/base.py b/napari/_vispy/overlays/base.py index 61a97d4ef2b..be2055c9572 100644 --- a/napari/_vispy/overlays/base.py +++ b/napari/_vispy/overlays/base.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from vispy.visuals.transforms import MatrixTransform, STTransform from napari._vispy.utils.gl import BLENDING_MODES @@ -5,6 +7,9 @@ from napari.utils.events import disconnect_events from napari.utils.translations import trans +if TYPE_CHECKING: + from napari.layers import Layer + class VispyBaseOverlay: """ @@ -143,7 +148,7 @@ def __init__(self, *, overlay, node, parent=None) -> None: class LayerOverlayMixin: - def __init__(self, *, layer, overlay, node, parent=None) -> None: + def __init__(self, *, layer: "Layer", overlay, node, parent=None) -> None: super().__init__( node=node, overlay=overlay, diff --git a/napari/_vispy/overlays/bounding_box.py b/napari/_vispy/overlays/bounding_box.py index dca12beae75..d8ad3276f62 100644 --- a/napari/_vispy/overlays/bounding_box.py +++ b/napari/_vispy/overlays/bounding_box.py @@ -23,7 +23,7 @@ def __init__(self, *, layer, overlay, parent=None): self.overlay.events.point_color.connect(self._on_point_color_change) def _on_bounds_change(self): - bounds = self.layer._display_bounding_box_augmented( + bounds = self.layer._display_bounding_box_augmented_data_level( self.layer._slice_input.displayed ) if len(bounds) == 2: diff --git a/napari/_vispy/overlays/interaction_box.py b/napari/_vispy/overlays/interaction_box.py index 26f9bddea8f..d2f382627f5 100644 --- a/napari/_vispy/overlays/interaction_box.py +++ b/napari/_vispy/overlays/interaction_box.py @@ -67,7 +67,7 @@ def __init__(self, *, layer, overlay, parent=None) -> None: def _on_bounds_change(self): if self.layer._slice_input.ndisplay == 2: - bounds = self.layer._display_bounding_box_augmented( + bounds = self.layer._display_bounding_box_augmented_data_level( self.layer._slice_input.displayed ) # invert axes for vispy diff --git a/napari/_vispy/overlays/labels_polygon.py b/napari/_vispy/overlays/labels_polygon.py index afacd58373c..75007f21a84 100644 --- a/napari/_vispy/overlays/labels_polygon.py +++ b/napari/_vispy/overlays/labels_polygon.py @@ -32,6 +32,8 @@ def decorated_callback(self, layer: Labels, event): class VispyLabelsPolygonOverlay(LayerOverlayMixin, VispySceneOverlay): + layer: Labels + def __init__( self, *, layer: Labels, overlay: LabelsPolygonOverlay, parent=None ): diff --git a/napari/_vispy/utils/gl.py b/napari/_vispy/utils/gl.py index 56fd395b11e..1f1e07a4a00 100644 --- a/napari/_vispy/utils/gl.py +++ b/napari/_vispy/utils/gl.py @@ -2,9 +2,10 @@ """ from contextlib import contextmanager from functools import lru_cache -from typing import Tuple +from typing import Any, Generator, Tuple, Union, cast import numpy as np +import numpy.typing as npt from vispy.app import Canvas from vispy.gloo import gl from vispy.gloo.context import get_current_canvas @@ -19,7 +20,7 @@ @contextmanager -def _opengl_context(): +def _opengl_context() -> Generator[None, None, None]: """Assure we are running with a valid OpenGL context. Only create a Canvas is one doesn't exist. Creating and closing a @@ -75,7 +76,7 @@ def get_max_texture_sizes() -> Tuple[int, int]: return max_size_2d, max_size_3d -def fix_data_dtype(data): +def fix_data_dtype(data: npt.NDArray) -> npt.NDArray: """Makes sure the dtype of the data is accetpable to vispy. Acceptable types are int8, uint8, int16, uint16, float32. @@ -96,12 +97,17 @@ def fix_data_dtype(data): return data try: - dtype = { - "i": np.float32, - "f": np.float32, - "u": np.uint16, - "b": np.uint8, - }[dtype.kind] + dtype_ = cast( + 'type[Union[np.unsignedinteger[Any], np.floating[Any]]]', + { + "i": np.float32, + "f": np.float32, + "u": np.uint16, + "b": np.uint8, + }[dtype.kind], + ) + if dtype_ == np.uint16 and dtype.itemsize > 2: + dtype_ = np.float32 except KeyError as e: # not an int or float raise TypeError( trans._( @@ -111,7 +117,7 @@ def fix_data_dtype(data): textures=set(texture_dtypes), ) ) from e - return data.astype(dtype) + return data.astype(dtype_) # blend_func parameters are multiplying: diff --git a/napari/_vispy/utils/visual.py b/napari/_vispy/utils/visual.py index a91bf8e6eac..54eff33d98d 100644 --- a/napari/_vispy/utils/visual.py +++ b/napari/_vispy/utils/visual.py @@ -101,7 +101,7 @@ def create_vispy_layer(layer: Layer) -> VispyBaseLayer: def create_vispy_overlay(overlay: Overlay, **kwargs) -> VispyBaseOverlay: """ - Create vispy visual for Overlay based on its type. + Create vispy visual for Overlay based on its type. Parameters ---------- @@ -177,7 +177,7 @@ def get_view_direction_in_scene_coordinates( d2 = p1 - p0 # in 3D world coordinates - d3 = d2[0:3] + d3 = d2[:3] d4 = d3 / np.linalg.norm(d3) # data are ordered xyz on vispy Volume diff --git a/napari/_vispy/visuals/image.py b/napari/_vispy/visuals/image.py index 533fb30547b..0dc3c7719a4 100644 --- a/napari/_vispy/visuals/image.py +++ b/napari/_vispy/visuals/image.py @@ -1,8 +1,10 @@ from vispy.scene.visuals import Image as BaseImage +from napari._vispy.visuals.util import TextureMixin + # If data is not present, we need bounds to be None (see napari#3517) -class Image(BaseImage): +class Image(TextureMixin, BaseImage): def _compute_bounds(self, axis, view): if self._data is None: return None diff --git a/napari/_vispy/visuals/labels.py b/napari/_vispy/visuals/labels.py new file mode 100644 index 00000000000..087773cd0cc --- /dev/null +++ b/napari/_vispy/visuals/labels.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING, Optional, Tuple + +from vispy.scene.visuals import create_visual_node +from vispy.visuals.image import ImageVisual +from vispy.visuals.shaders import Function, FunctionChain + +from napari._vispy.visuals.util import TextureMixin + +if TYPE_CHECKING: + from vispy.visuals.visual import VisualView + + +class LabelVisual(TextureMixin, ImageVisual): + """Visual subclass displaying a 2D array of labels.""" + + def _build_color_transform(self) -> FunctionChain: + """Build the color transform function chain.""" + funcs = [ + Function(self._func_templates['red_to_luminance']), + Function(self.cmap.glsl_map), + ] + + return FunctionChain( + funcs=funcs, + ) + + +BaseLabel = create_visual_node(LabelVisual) + + +class LabelNode(BaseLabel): # type: ignore [valid-type,misc] + def _compute_bounds( + self, axis: int, view: 'VisualView' + ) -> Optional[Tuple[float, float]]: + if self._data is None: + return None + elif axis > 1: # noqa: RET505 + return 0, 0 + else: + return 0, self.size[axis] diff --git a/napari/_vispy/visuals/util.py b/napari/_vispy/visuals/util.py new file mode 100644 index 00000000000..a2e514a5407 --- /dev/null +++ b/napari/_vispy/visuals/util.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from vispy.visuals.visual import Visual +else: + + class Visual: + pass + + +class TextureMixin(Visual): + """Store texture format passed to VisPy classes. + + We need to refer back to the texture format, but VisPy + stores it in a private attribute — ``node._texture.internalformat``. + This mixin is added to our Node subclasses to avoid having to + access private VisPy attributes. + """ + + def __init__(self, *args, texture_format: Optional[str], **kwargs) -> None: # type: ignore [no-untyped-def] + super().__init__(*args, texture_format=texture_format, **kwargs) + # classes using this mixin may be frozen dataclasses. + # we save the texture format between unfreeze/freeze. + self.unfreeze() + self._texture_format = texture_format + self.freeze() + + @property + def texture_format(self) -> Optional[str]: + return self._texture_format diff --git a/napari/_vispy/visuals/volume.py b/napari/_vispy/visuals/volume.py index 06f3956b9f1..4e07cf6f619 100644 --- a/napari/_vispy/visuals/volume.py +++ b/napari/_vispy/visuals/volume.py @@ -1,5 +1,7 @@ from vispy.scene.visuals import Volume as BaseVolume +from napari._vispy.visuals.util import TextureMixin + FUNCTION_DEFINITIONS = """ // the tolerance for testing equality of floats with floatEqual and floatNotEqual const float equality_tolerance = 1e-8; @@ -200,7 +202,7 @@ rendering_methods['translucent_categorical'] = TRANSLUCENT_CATEGORICAL_SNIPPETS -class Volume(BaseVolume): +class Volume(TextureMixin, BaseVolume): # add the new rendering method to the snippets dict _shaders = shaders _rendering_methods = rendering_methods diff --git a/napari/benchmarks/benchmark_image_layer.py b/napari/benchmarks/benchmark_image_layer.py index 8dc51efb0c6..cf8cfb5ccb9 100644 --- a/napari/benchmarks/benchmark_image_layer.py +++ b/napari/benchmarks/benchmark_image_layer.py @@ -47,9 +47,8 @@ def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): @@ -97,9 +96,8 @@ def time_refresh(self, n): """Time to refresh view.""" self.layer.refresh() - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return Image(self.data) def mem_data(self, n): diff --git a/napari/benchmarks/benchmark_labels_layer.py b/napari/benchmarks/benchmark_labels_layer.py index 2f35a6edf96..7c82f2c8b11 100644 --- a/napari/benchmarks/benchmark_labels_layer.py +++ b/napari/benchmarks/benchmark_labels_layer.py @@ -3,9 +3,11 @@ # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md import os +from copy import copy import numpy as np +from napari.components.dims import Dims from napari.layers import Labels from .utils import Skiper @@ -14,46 +16,48 @@ class Labels2DSuite: """Benchmarks for the Labels layer with 2D data""" - params = [2**i for i in range(4, 13)] + param_names = ['n', 'dtype'] + params = ([2**i for i in range(4, 13)], [np.uint8, np.int32]) if "PR" in os.environ: - skip_params = [(2**i,) for i in range(6, 13)] + skip_params = Skiper(lambda x: x[0] > 2**5) - def setup(self, n): + def setup(self, n, dtype): np.random.seed(0) - self.data = np.random.randint(20, size=(n, n)) + self.data = np.random.randint(20, size=(n, n), dtype=dtype) self.layer = Labels(self.data) + self.layer._raw_to_displayed(self.data, (slice(0, n), slice(0, n))) - def time_create_layer(self, n): + def time_create_layer(self, *_): """Time to create layer.""" Labels(self.data) - def time_set_view_slice(self, n): + def time_set_view_slice(self, *_): """Time to set view slice.""" self.layer._set_view_slice() - def time_refresh(self, n): + def time_refresh(self, *_): """Time to refresh view.""" self.layer.refresh() - def time_update_thumbnail(self, n): + def time_update_thumbnail(self, *_): """Time to update thumbnail.""" self.layer._update_thumbnail() - def time_get_value(self, n): + def time_get_value(self, *_): """Time to get current value.""" self.layer.get_value((0,) * 2) - def time_raw_to_displayed(self, n): + def time_raw_to_displayed(self, *_): """Time to convert raw to displayed.""" self.layer._slice.image.raw[0, :] += 1 # simulate changes self.layer._raw_to_displayed(self.layer._slice.image.raw) - def time_paint_circle(self, n): + def time_paint_circle(self, *_): """Time to paint circle.""" self.layer.paint((0,) * 2, self.layer.selected_label) - def time_fill(self, n): + def time_fill(self, *_): """Time to fill.""" self.layer.fill( (0,) * 2, @@ -61,12 +65,11 @@ def time_fill(self, n): self.layer.selected_label, ) - def _mem_layer(self, n): + def mem_layer(self, *_): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler - return self.layer + return copy(self.layer) - def mem_data(self, n): + def mem_data(self, *_): """Memory used by raw data.""" return self.data @@ -110,66 +113,81 @@ def time_draw(self, n, brush_size, color_mode, contour): class Labels2DColorDirectSuite(Labels2DSuite): - def setup(self, n): + def setup(self, n, dtype): if "PR" in os.environ and n > 32: raise NotImplementedError("Skip on PR (speedup)") np.random.seed(0) - self.data = np.random.randint(low=-10000, high=10000, size=(n, n)) - random_label_ids = np.random.randint(low=-10000, high=10000, size=20) + info = np.iinfo(dtype) + self.data = np.random.randint( + low=max(-10000, info.min), + high=min(10000, info.max), + size=(n, n), + dtype=dtype, + ) + random_label_ids = np.random.randint( + low=max(-10000, info.min), high=min(10000, info.max), size=20 + ) self.layer = Labels( self.data, color={i + 1: np.random.random(4) for i in random_label_ids}, ) - self.layer._raw_to_displayed(self.layer._slice.image.raw) + self.layer._raw_to_displayed( + self.layer._slice.image.raw, (slice(0, n), slice(0, n)) + ) class Labels3DSuite: """Benchmarks for the Labels layer with 3D data.""" - params = [2**i for i in range(4, 11)] + param_names = ['n', 'dtype'] + params = ([2**i for i in range(4, 11)], [np.uint8, np.uint32]) if "PR" in os.environ: skip_params = [(2**i,) for i in range(6, 11)] - def setup(self, n): + def setup(self, n, dtype): if "CI" in os.environ and n > 512: raise NotImplementedError("Skip on CI (not enough memory)") np.random.seed(0) - self.data = np.random.randint(20, size=(n, n, n)) + self.data = np.random.randint(20, size=(n, n, n), dtype=dtype) self.layer = Labels(self.data) - self.layer._slice_dims((0, 0, 0), 3, (0, 1, 2)) + self.layer._slice_dims(Dims(ndim=3, ndisplay=3)) + self.layer._raw_to_displayed( + self.layer._slice.image.raw, + (slice(0, n), slice(0, n), slice(0, n)), + ) # @mark.skip_params_if([(2**i,) for i in range(6, 11)], condition="PR" in os.environ) - def time_create_layer(self, n): + def time_create_layer(self, *_): """Time to create layer.""" Labels(self.data) - def time_set_view_slice(self, n): + def time_set_view_slice(self, *_): """Time to set view slice.""" self.layer._set_view_slice() - def time_refresh(self, n): + def time_refresh(self, *_): """Time to refresh view.""" self.layer.refresh() - def time_update_thumbnail(self, n): + def time_update_thumbnail(self, *_): """Time to update thumbnail.""" self.layer._update_thumbnail() - def time_get_value(self, n): + def time_get_value(self, *_): """Time to get current value.""" self.layer.get_value((0,) * 3) - def time_raw_to_displayed(self, n): + def time_raw_to_displayed(self, *_): """Time to convert raw to displayed.""" self.layer._slice.image.raw[0, 0, :] += 1 # simulate changes self.layer._raw_to_displayed(self.layer._slice.image.raw) - def time_paint_circle(self, n): + def time_paint_circle(self, *_): """Time to paint circle.""" self.layer.paint((0,) * 3, self.layer.selected_label) - def time_fill(self, n): + def time_fill(self, *_): """Time to fill.""" self.layer.fill( (0,) * 3, @@ -177,11 +195,10 @@ def time_fill(self, n): self.layer.selected_label, ) - def _mem_layer(self, n): + def mem_layer(self, *_): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on main branch and outdated asizeof in pympler - return self.layer + return copy(self.layer) - def mem_data(self, n): + def mem_data(self, *_): """Memory used by raw data.""" return self.data diff --git a/napari/benchmarks/benchmark_points_layer.py b/napari/benchmarks/benchmark_points_layer.py index 651e0de3230..2726ae84b1b 100644 --- a/napari/benchmarks/benchmark_points_layer.py +++ b/napari/benchmarks/benchmark_points_layer.py @@ -48,9 +48,8 @@ def time_get_value(self, n): def time_add(self, n): self.layer.add(self.data) - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): @@ -90,9 +89,8 @@ def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): diff --git a/napari/benchmarks/benchmark_qt_viewer_labels.py b/napari/benchmarks/benchmark_qt_viewer_labels.py index c426aaedc80..3b6a2cc0c00 100644 --- a/napari/benchmarks/benchmark_qt_viewer_labels.py +++ b/napari/benchmarks/benchmark_qt_viewer_labels.py @@ -2,13 +2,21 @@ # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md +import os from dataclasses import dataclass +from functools import lru_cache +from itertools import cycle from typing import List import numpy as np from qtpy.QtWidgets import QApplication +from skimage.morphology import diamond, octahedron import napari +from napari.components.viewer_model import ViewerModel +from napari.qt import QtViewer + +from .utils import Skiper @dataclass @@ -36,7 +44,7 @@ def setup(self): self.event = MouseEvent( type='mouse_move', is_dragging=True, - pos=(500, 500), + pos=[500, 500], view_direction=None, ) @@ -83,3 +91,125 @@ def time_fill(self): def time_on_mouse_move(self): """Time to drag paint on mouse move.""" self.viewer.window._qt_viewer.canvas._on_mouse_move(self.event) + + +@lru_cache +def setup_rendering_data(radius, dtype): + if radius < 1000: + data = octahedron(radius=radius, dtype=dtype) + else: + data = np.zeros((radius // 50, radius * 2, radius * 2), dtype=dtype) + for i in range(1, data.shape[0] // 2): + part = diamond(radius=i * 100, dtype=dtype) + shift = (data.shape[1] - part.shape[0]) // 2 + data[i, shift : -shift - 1, shift : -shift - 1] = part + data[-i - 1, shift : -shift - 1, shift : -shift - 1] = part + + count = np.count_nonzero(data) + data[data > 0] = np.random.randint( + 1, min(2000, np.iinfo(dtype).max), size=count, dtype=dtype + ) + + return data + + +class LabelRendering: + """Benchmarks for rendering the Labels layer.""" + + param_names = ["radius", "dtype", "mode"] + params = ( + [10, 30, 300, 1500], + [np.uint8, np.uint16, np.uint32], + ["auto", "direct"], + ) + if "GITHUB_ACTIONS" in os.environ: + skip_params = Skiper(lambda x: x[0] > 20) + if "PR" in os.environ: + skip_params = Skiper(lambda x: x[0] > 20) + + def setup(self, radius, dtype, label_mode): + self.steps = 4 if "GITHUB_ACTIONS" in os.environ else 10 + self.app = QApplication.instance() or QApplication([]) + self.data = setup_rendering_data(radius, dtype) + scale = self.data.shape[-1] / np.array(self.data.shape) + self.viewer = ViewerModel() + self.qt_viewr = QtViewer(self.viewer) + self.layer = self.viewer.add_labels(self.data, scale=scale) + if label_mode == "direct": + colors = dict( + zip( + range(10, 2000), + cycle(["red", "green", "blue", "pink", "magenta"]), + ) + ) + colors[None] = "yellow" + colors[0] = "transparent" + self.layer.color = colors + self.qt_viewr.show() + + @staticmethod + def teardown(self, *_): + if hasattr(self, "viewer"): + self.qt_viewr.close() + + def _time_iterate_components(self, *_): + """Time to iterate over components.""" + self.layer.show_selected_label = True + for i in range(0, 201, (200 // self.steps) or 1): + self.layer.selected_label = i + self.app.processEvents() + + def _time_zoom_change(self, *_): + """Time to zoom in and zoom out.""" + initial_zoom = self.viewer.camera.zoom + self.viewer.camera.zoom = 0.5 * initial_zoom + self.app.processEvents() + self.viewer.camera.zoom = 2 * initial_zoom + self.app.processEvents() + + +class LabelRenderingSuite2D(LabelRendering): + def setup(self, radius, dtype, label_mode): + super().setup(radius, dtype, label_mode) + self.viewer.dims.ndisplay = 2 + self.app.processEvents() + + def time_iterate_over_z(self, *_): + """Time to render the layer.""" + z_size = self.data.shape[0] + for i in range(0, z_size, z_size // (self.steps * 2)): + self.viewer.dims.set_point(0, i) + self.app.processEvents() + + def time_load_3d(self, *_): + """Time to first render of the layer in 3D.""" + self.app.processEvents() + self.viewer.dims.ndisplay = 3 + self.app.processEvents() + self.viewer.dims.ndisplay = 2 + self.app.processEvents() + + def time_iterate_components(self, *args): + self._time_iterate_components(*args) + + def time_zoom_change(self, *args): + self._time_zoom_change(*args) + + +class LabelRenderingSuite3D(LabelRendering): + def setup(self, radius, dtype, label_mode): + super().setup(radius, dtype, label_mode) + self.viewer.dims.ndisplay = 3 + self.app.processEvents() + + def time_rotate(self, *_): + """Time to rotate the layer.""" + for i in range(0, (self.steps * 20), 5): + self.viewer.camera.angles = (0, i / 2, i) + self.app.processEvents() + + def time_iterate_components(self, *args): + self._time_iterate_components(*args) + + def time_zoom_change(self, *args): + self._time_zoom_change(*args) diff --git a/napari/benchmarks/benchmark_shapes_layer.py b/napari/benchmarks/benchmark_shapes_layer.py index 448930d652d..62d7c7dfb60 100644 --- a/napari/benchmarks/benchmark_shapes_layer.py +++ b/napari/benchmarks/benchmark_shapes_layer.py @@ -48,9 +48,8 @@ def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): @@ -90,9 +89,8 @@ def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): diff --git a/napari/benchmarks/benchmark_surface_layer.py b/napari/benchmarks/benchmark_surface_layer.py index ab88511a001..02e5bc588d9 100644 --- a/napari/benchmarks/benchmark_surface_layer.py +++ b/napari/benchmarks/benchmark_surface_layer.py @@ -41,9 +41,8 @@ def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 2) - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): @@ -85,9 +84,8 @@ def time_get_value(self, n): """Time to get current value.""" self.layer.get_value((0,) * 3) - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): diff --git a/napari/benchmarks/benchmark_vectors_layer.py b/napari/benchmarks/benchmark_vectors_layer.py index 18ed9fd8498..6fdca4226a0 100644 --- a/napari/benchmarks/benchmark_vectors_layer.py +++ b/napari/benchmarks/benchmark_vectors_layer.py @@ -45,9 +45,8 @@ def time_length(self, n): """Time to update length.""" self.layer.length = 2 - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): @@ -93,9 +92,8 @@ def time_length(self, n): """Time to update length.""" self.layer.length = 2 - def _mem_layer(self, n): + def mem_layer(self, n): """Memory used by layer.""" - # Disabled because of __sizeof__ bug on the main branch and outdated asizeof in pympler return self.layer def mem_data(self, n): diff --git a/napari/components/_layer_slicer.py b/napari/components/_layer_slicer.py index 2ad5c63dd92..11e150d9ccf 100644 --- a/napari/components/_layer_slicer.py +++ b/napari/components/_layer_slicer.py @@ -11,6 +11,7 @@ from contextlib import contextmanager from threading import RLock from typing import ( + TYPE_CHECKING, Any, Dict, Iterable, @@ -20,11 +21,13 @@ runtime_checkable, ) -from napari.components import Dims from napari.layers import Layer from napari.settings import get_settings from napari.utils.events.event import EmitterGroup, Event +if TYPE_CHECKING: + from napari.components import Dims + logger = logging.getLogger("napari.components._layer_slicer") @@ -178,11 +181,11 @@ def submit( Parameters ---------- - layers: iterable of layers + layers : iterable of layers The layers to slice. - dims: Dims + dims : Dims The dimensions values associated with the view to be sliced. - force: bool + force : bool True if slicing should be forced to occur, even when some cache thinks it already has a valid slice ready. False otherwise. @@ -234,7 +237,8 @@ def submit( # Then execute sync slicing tasks to run concurrent with async ones. for layer in sync_layers: layer._slice_dims( - dims.point, dims.ndisplay, dims.order, force=force + dims=dims, + force=force, ) return task diff --git a/napari/components/_tests/test_add_layers.py b/napari/components/_tests/test_add_layers.py index 6cf0d28d0b0..03b9da6e34f 100644 --- a/napari/components/_tests/test_add_layers.py +++ b/napari/components/_tests/test_add_layers.py @@ -67,6 +67,9 @@ def test_viewer_open(): expected_source = Source(path='mock_path.tif', reader_plugin='testimpl') assert all(lay.source == expected_source for lay in viewer.layers) + viewer.open([], stack=[], plugin=None) + assert len(viewer.layers) == 2 + def test_viewer_open_no_plugin(tmp_path): viewer = ViewerModel() diff --git a/napari/components/_tests/test_layers_base.py b/napari/components/_tests/test_layers_base.py new file mode 100644 index 00000000000..25d3948ba1e --- /dev/null +++ b/napari/components/_tests/test_layers_base.py @@ -0,0 +1,20 @@ +import numpy as np +import pytest +from numpy import array + +from napari.layers.base import Layer + + +@pytest.mark.parametrize( + 'dims,nworld,nshape,expected', + [ + ([2, 1, 0, 3], 4, 2, [0, 1]), + ([2, 1, 0, 3], 4, 3, [1, 0, 2]), + ([2, 1, 0, 3], 4, 4, [2, 1, 0, 3]), + ([0, 1, 2, 3, 4, 5, 6, 7], 4, 4, [0, 1, 2, 3, 4, 5, 6, 7]), + ], +) +def test_world_to_layer(dims, nworld, nshape, expected): + assert np.array_equal( + Layer._world_to_layer_dims_impl(array(dims), nworld, nshape), expected + ) diff --git a/napari/components/_tests/test_multichannel.py b/napari/components/_tests/test_multichannel.py index 3d15b3f009f..3ca1494d2f8 100644 --- a/napari/components/_tests/test_multichannel.py +++ b/napari/components/_tests/test_multichannel.py @@ -171,7 +171,7 @@ def test_multichannel_multiscale(): for i in range(data[0].shape[-1]): assert np.all( [ - np.all(l_d == d) + np.array_equal(l_d, d) for l_d, d in zip( viewer.layers[i].data, [data[j].take(i, axis=-1) for j in range(len(data))], @@ -193,7 +193,7 @@ def test_multichannel_implicit_multiscale(): for i in range(data[0].shape[-1]): assert np.all( [ - np.all(l_d == d) + np.array_equal(l_d, d) for l_d, d in zip( viewer.layers[i].data, [data[j].take(i, axis=-1) for j in range(len(data))], diff --git a/napari/components/_viewer_key_bindings.py b/napari/components/_viewer_key_bindings.py index 849ad293660..b5dae722921 100644 --- a/napari/components/_viewer_key_bindings.py +++ b/napari/components/_viewer_key_bindings.py @@ -166,5 +166,5 @@ def hold_for_pan_zoom(viewer: ViewerModel): def show_shortcuts(viewer: Viewer): pref_list = viewer.window._open_preferences_dialog()._list for i in range(pref_list.count()): - if pref_list.item(i).text() == "Shortcuts": + if (item := pref_list.item(i)) and item.text() == "Shortcuts": pref_list.setCurrentRow(i) diff --git a/napari/components/camera.py b/napari/components/camera.py index d37b0dbf284..42e096c73ea 100644 --- a/napari/components/camera.py +++ b/napari/components/camera.py @@ -1,10 +1,10 @@ import warnings -from typing import Optional, Tuple +from typing import Optional, Tuple, Union import numpy as np -from pydantic import validator from scipy.spatial.transform import Rotation as R +from napari._pydantic_compat import validator from napari.utils.events import EventedModel from napari.utils.misc import ensure_n_tuple from napari.utils.translations import trans @@ -38,7 +38,11 @@ class Camera(EventedModel): """ # fields - center: Tuple[float, float, float] = (0.0, 0.0, 0.0) + center: Union[Tuple[float, float, float], Tuple[float, float]] = ( + 0.0, + 0.0, + 0.0, + ) zoom: float = 1.0 angles: Tuple[float, float, float] = (0.0, 0.0, 90.0) perspective: float = 0 diff --git a/napari/components/cursor.py b/napari/components/cursor.py index 5f3990a75d7..a6da757d3db 100644 --- a/napari/components/cursor.py +++ b/napari/components/cursor.py @@ -28,7 +28,7 @@ class Cursor(EventedModel): * pointing: A finger for pointing * standard: The standard cursor # crosshair: A crosshair - _view_direction : Optional[Tuple[float, ...]] + _view_direction : Optional[Tuple[float, float, float]] The vector describing the direction of the camera in the scene. This is None when viewing in 2D. """ @@ -38,4 +38,4 @@ class Cursor(EventedModel): scaled: bool = True size = 1.0 style: CursorStyle = CursorStyle.STANDARD - _view_direction: Optional[Tuple[float, ...]] = None + _view_direction: Optional[Tuple[float, float, float]] = None diff --git a/napari/components/dims.py b/napari/components/dims.py index 7e7f7c4b703..fb557e05b5e 100644 --- a/napari/components/dims.py +++ b/napari/components/dims.py @@ -11,8 +11,8 @@ ) import numpy as np -from pydantic import root_validator, validator +from napari._pydantic_compat import root_validator, validator from napari.utils.events import EventedModel from napari.utils.misc import argsort, reorder_after_dim_reduction from napari.utils.translations import trans diff --git a/napari/components/layerlist.py b/napari/components/layerlist.py index fadf5a5648c..563b9b49cd8 100644 --- a/napari/components/layerlist.py +++ b/napari/components/layerlist.py @@ -1,10 +1,14 @@ +from __future__ import annotations + import itertools +import typing import warnings from functools import cached_property from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Union import numpy as np +from napari.components.dims import RangeTuple from napari.layers import Layer from napari.layers.utils.layer_utils import Extent from napari.utils.events.containers import SelectableEventedList @@ -13,6 +17,12 @@ if TYPE_CHECKING: from npe2.manifest.io import WriterContribution + from typing_extensions import Self + + +def get_name(layer: Layer) -> str: + """Return the name of a layer.""" + return layer.name class LayerList(SelectableEventedList[Layer]): @@ -57,7 +67,7 @@ def __init__(self, data=()) -> None: super().__init__( data=data, basetype=Layer, - lookup={str: lambda e: e.name}, + lookup={str: get_name}, ) self._create_contexts() @@ -155,6 +165,17 @@ def _ensure_unique(self, values, allow=()): ) return values + @typing.overload + def __getitem__(self, item: Union[int, str]) -> Layer: + ... + + @typing.overload + def __getitem__(self, item: slice) -> Self: + ... + + def __getitem__(self, item): + return super().__getitem__(item) + def __setitem__(self, key, value): old = self._list[key] if isinstance(key, slice): @@ -312,7 +333,7 @@ def get_extent(self, layers: Iterable[Layer]) -> Extent: Returns ------- extent : Extent - extent for selected layers + extent for selected layers """ extent_list = [layer.extent for layer in layers] return Extent( @@ -332,10 +353,12 @@ def extent(self) -> Extent: return self.get_extent(list(self)) @property - def _ranges(self) -> Tuple[Tuple[float, float, float], ...]: + def _ranges(self) -> Tuple[RangeTuple, ...]: """Get ranges for Dims.range in world coordinates.""" ext = self.extent - return tuple(zip(ext.world[0], ext.world[1], ext.step)) + return tuple( + RangeTuple(*x) for x in zip(ext.world[0], ext.world[1], ext.step) + ) @property def ndim(self) -> int: @@ -387,7 +410,7 @@ def save( *, selected: bool = False, plugin: Optional[str] = None, - _writer: Optional['WriterContribution'] = None, + _writer: Optional[WriterContribution] = None, ) -> List[str]: """Save all or only selected layers to a path using writer plugins. diff --git a/napari/components/overlays/bounding_box.py b/napari/components/overlays/bounding_box.py index 77a10fad703..a1fc219e532 100644 --- a/napari/components/overlays/bounding_box.py +++ b/napari/components/overlays/bounding_box.py @@ -1,5 +1,4 @@ -from pydantic import Field - +from napari._pydantic_compat import Field from napari.components.overlays.base import SceneOverlay from napari.utils.color import ColorValue diff --git a/napari/components/overlays/labels_polygon.py b/napari/components/overlays/labels_polygon.py index 2670732957a..9afa71738da 100644 --- a/napari/components/overlays/labels_polygon.py +++ b/napari/components/overlays/labels_polygon.py @@ -1,5 +1,4 @@ -from pydantic import Field - +from napari._pydantic_compat import Field from napari.components.overlays.base import SceneOverlay from napari.layers import Labels diff --git a/napari/components/overlays/scale_bar.py b/napari/components/overlays/scale_bar.py index 85b0455537f..8b0e1f5d604 100644 --- a/napari/components/overlays/scale_bar.py +++ b/napari/components/overlays/scale_bar.py @@ -1,8 +1,7 @@ """Scale bar model.""" from typing import Optional -from pydantic import Field - +from napari._pydantic_compat import Field from napari.components.overlays.base import CanvasOverlay from napari.utils.color import ColorValue diff --git a/napari/components/overlays/text.py b/napari/components/overlays/text.py index 49ce2781e50..0ca9e0ddbf6 100644 --- a/napari/components/overlays/text.py +++ b/napari/components/overlays/text.py @@ -1,6 +1,5 @@ """Text label model.""" -from pydantic import Field - +from napari._pydantic_compat import Field from napari.components.overlays.base import CanvasOverlay from napari.utils.color import ColorValue diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py index a932518a1b6..12e648900ce 100644 --- a/napari/components/viewer_model.py +++ b/napari/components/viewer_model.py @@ -10,23 +10,29 @@ TYPE_CHECKING, Any, Dict, + Iterator, List, - Mapping, Optional, Sequence, Set, Tuple, + Type, Union, + cast, ) import numpy as np -from pydantic import Extra, Field, PrivateAttr, validator + +# This cannot be condition to TYPE_CHEKCKING or the stubgen fails +# with underfined Context. +from app_model.expressions import Context from napari import layers +from napari._pydantic_compat import Extra, Field, PrivateAttr, validator from napari.components._layer_slicer import _LayerSlicer from napari.components._viewer_mouse_bindings import dims_scroll from napari.components.camera import Camera -from napari.components.cursor import Cursor +from napari.components.cursor import Cursor, CursorStyle from napari.components.dims import Dims from napari.components.grid import GridCanvas from napari.components.layerlist import LayerList @@ -65,6 +71,14 @@ from napari.layers.vectors._vectors_key_bindings import vectors_fun_to_mode from napari.plugins.utils import get_potential_readers, get_preferred_reader from napari.settings import get_settings +from napari.types import ( + FullLayerData, + LayerData, + LayerTypeName, + PathLike, + PathOrPaths, + SampleData, +) from napari.utils._register import create_func as create_add_method from napari.utils.action_manager import action_manager from napari.utils.colormaps import ensure_colormap @@ -83,6 +97,10 @@ from napari.utils.theme import available_themes, is_theme_available from napari.utils.translations import trans +if TYPE_CHECKING: + from npe2.types import SampleDataCreator + + DEFAULT_THEME = 'dark' EXCLUDE_DICT = { 'keymap', @@ -95,11 +113,6 @@ } EXCLUDE_JSON = EXCLUDE_DICT.union({'layers', 'active_layer'}) -if TYPE_CHECKING: - from napari.types import FullLayerData, LayerData - -PathLike = Union[str, Path] -PathOrPaths = Union[PathLike, Sequence[PathLike]] __all__ = ['ViewerModel', 'valid_add_kwargs'] @@ -189,7 +202,7 @@ class ViewerModel(KeymapProvider, MousemapProvider, EventedModel): ) # 2-tuple indicating height and width _canvas_size: Tuple[int, int] = (800, 600) - _ctx: Mapping + _ctx: Context # To check if mouse is over canvas to avoid race conditions between # different events systems mouse_over_canvas: bool = False @@ -256,7 +269,17 @@ def __init__( self.dims.events.ndisplay.connect(self.reset_view) self.dims.events.order.connect(self._update_layers) self.dims.events.order.connect(self.reset_view) + self.dims.events.point.connect(self._update_layers) + # FIXME: the next line is a temporary workaround. With #5522 and #5751 Dims.point became + # the source of truth, and is now defined in world space. This exposed an existing + # bug where if a field in Dims is modified by the root_validator, events won't + # be fired for it. This won't happen for properties because we have dependency + # checks. To fix this, we need dep checks for fileds (psygnal!) and then we + # can remove the following line. Note that because of this we fire double events, + # but this should be ok because we have early returns when slices are unchanged. self.dims.events.current_step.connect(self._update_layers) + self.dims.events.margin_left.connect(self._update_layers) + self.dims.events.margin_right.connect(self._update_layers) self.cursor.events.position.connect( self._update_status_bar_from_cursor ) @@ -360,7 +383,7 @@ def _sliced_extent_world_augmented(self) -> np.ndarray: ) return self.layers._extent_world_augmented[:, self.dims.displayed] - def reset_view(self): + def reset_view(self) -> None: """Reset the camera view.""" extent = self._sliced_extent_world_augmented @@ -370,8 +393,17 @@ def reset_view(self): if len(scene_size) > len(grid_size): grid_size = [1] * (len(scene_size) - len(grid_size)) + grid_size size = np.multiply(scene_size, grid_size) - center = np.add(corner, np.divide(size, 2))[-self.dims.ndisplay :] - center = [0] * (self.dims.ndisplay - len(center)) + list(center) + center_array = np.add(corner, np.divide(size, 2))[ + -self.dims.ndisplay : + ] + center = cast( + Union[Tuple[float, float, float], Tuple[float, float]], + tuple( + [0.0] * (self.dims.ndisplay - len(center_array)) + + list(center_array) + ), + ) + assert len(center) in (2, 3) self.camera.center = center # zoom is definied as the number of canvas pixels per world pixel # The default value used below will zoom such that the whole field @@ -405,7 +437,7 @@ def _new_labels(self): np.round(s / sc).astype('int') + 1 for s, sc in zip(scene_size, scale) ] - empty_labels = np.zeros(shape, dtype=int) + empty_labels = np.zeros(shape, dtype=np.uint8) self.add_labels(empty_labels, translate=np.array(corner), scale=scale) def _on_layer_reload(self, event: Event) -> None: @@ -429,14 +461,14 @@ def _update_layers(self, *, layers=None): position = list(self.cursor.position) for ind in self.dims.order[: -self.dims.ndisplay]: position[ind] = self.dims.point[ind] - self.cursor.position = position + self.cursor.position = tuple(position) def _on_active_layer(self, event): """Update viewer state for a new active layer.""" active_layer = event.value if active_layer is None: self.help = '' - self.cursor.style = 'standard' + self.cursor.style = CursorStyle.STANDARD else: self.help = active_layer.help self.cursor.style = active_layer.cursor @@ -518,8 +550,8 @@ def _update_status_bar_from_cursor(self, event=None): self.help = active.help if self.tooltip.visible: self.tooltip.text = active._get_tooltip_text( - self.cursor.position, - view_direction=self.cursor._view_direction, + np.asarray(self.cursor.position), + view_direction=np.asarray(self.cursor._view_direction), dims_displayed=list(self.dims.displayed), world=True, ) @@ -609,7 +641,7 @@ def _layer_help_from_mode(layer: Layer): """ Update layer help text base on layer mode. """ - layer_to_func_and_mode = { + layer_to_func_and_mode: Dict[Type[Layer], List] = { Points: points_fun_to_mode, Labels: labels_fun_to_mode, Shapes: shapes_fun_to_mode, @@ -704,7 +736,7 @@ def add_image( rgb=None, colormap=None, contrast_limits=None, - gamma=1, + gamma=1.0, interpolation2d='nearest', interpolation3d='linear', rendering='mip', @@ -718,7 +750,7 @@ def add_image( rotate=None, shear=None, affine=None, - opacity=1, + opacity=1.0, blending=None, visible=True, multiscale=None, @@ -726,6 +758,7 @@ def add_image( plane=None, experimental_clipping_planes=None, custom_interpolation_kernel_2d=None, + projection_mode='none', ) -> Union[Image, List[Image]]: """Add an image layer to the layer list. @@ -903,6 +936,7 @@ def add_image( 'plane': plane, 'experimental_clipping_planes': experimental_clipping_planes, 'custom_interpolation_kernel_2d': custom_interpolation_kernel_2d, + 'projection_mode': projection_mode, } # these arguments are *already* iterables in the single-channel case. @@ -985,6 +1019,7 @@ def open_sample( from napari.plugins import _npe2, plugin_manager plugin_spec_reader = None + data: Union[None, SampleDataCreator, SampleData] # try with npe2 data, available = _npe2.get_sample_data(plugin, sample) @@ -995,7 +1030,7 @@ def open_sample( except KeyError: available += list(plugin_manager.available_samples()) # npe2 uri sample data, extract the path so we can use viewer.open - elif hasattr(data.__self__, 'uri'): + elif hasattr(data, "__self__") and hasattr(data.__self__, 'uri'): if ( hasattr(data.__self__, 'reader_plugin') and data.__self__.reader_plugin != reader_plugin @@ -1072,9 +1107,9 @@ def open( self, path: PathOrPaths, *, - stack: Union[bool, List[List[str]]] = False, + stack: Union[bool, List[List[PathLike]]] = False, plugin: Optional[str] = 'napari', - layer_type: Optional[str] = None, + layer_type: Optional[LayerTypeName] = None, **kwargs, ) -> List[Layer]: """Open a path or list of paths with plugins, and add layers to viewer. @@ -1125,18 +1160,19 @@ def open( ) plugin = 'napari' - paths: List[str | Path | List[str | Path]] = ( + paths_: List[PathLike] = ( [os.fspath(path)] if isinstance(path, (Path, str)) else [os.fspath(p) for p in path] ) + paths: Sequence[PathOrPaths] = paths_ # If stack is a bool and True, add an additional layer of nesting. if isinstance(stack, bool) and stack: - paths = [paths] - + paths = [paths_] # If stack is a list and True, extend the paths with the inner lists. elif isinstance(stack, list) and stack: + paths = [paths_] paths.extend(stack) added: List[Layer] = [] # for layers that get added @@ -1175,8 +1211,8 @@ def _open_or_raise_error( self, paths: List[Union[Path, str]], kwargs: Optional[Dict[str, Any]] = None, - layer_type: Optional[str] = None, - stack: Union[bool, List[List[str]]] = False, + layer_type: Optional[LayerTypeName] = None, + stack: bool = False, ): """Open paths if plugin choice is unambiguous, raising any errors. @@ -1299,12 +1335,12 @@ def _open_or_raise_error( def _add_layers_with_plugins( self, - paths: List[str], + paths: List[PathLike], *, stack: bool, - kwargs: Optional[dict] = None, + kwargs: Optional[Dict] = None, plugin: Optional[str] = None, - layer_type: Optional[str] = None, + layer_type: Optional[LayerTypeName] = None, ) -> List[Layer]: """Load a path or a list of paths into the viewer using plugins. @@ -1358,9 +1394,11 @@ def _add_layers_with_plugins( paths, plugin=plugin, stack=stack ) + if layer_data is None: + return [] # glean layer names from filename. These will be used as *fallback* # names, if the plugin does not return a name kwarg in their meta dict. - filenames = [] + filenames: Iterator[PathLike] if len(paths) == len(layer_data): filenames = iter(paths) @@ -1435,12 +1473,11 @@ def _add_layer_from_data( >>> viewer._add_layer_from_data(*data) """ - - layer_type = (layer_type or '').lower() - - # assumes that big integer type arrays are likely labels. - if not layer_type: + if layer_type is None or layer_type == '': + # assumes that big integer type arrays are likely labels. layer_type = guess_labels(data) + else: + layer_type = layer_type.lower() if layer_type not in layers.NAMES: raise ValueError( @@ -1525,7 +1562,7 @@ def _normalize_layer_data(data: LayerData) -> FullLayerData: def _unify_data_and_user_kwargs( data: LayerData, kwargs: Optional[dict] = None, - layer_type: Optional[str] = None, + layer_type: Optional[LayerTypeName] = None, fallback_name: Optional[str] = None, ) -> FullLayerData: """Merge data returned from plugins with options specified by user. diff --git a/napari/conftest.py b/napari/conftest.py index e37044cd648..18bf283ff72 100644 --- a/napari/conftest.py +++ b/napari/conftest.py @@ -37,8 +37,9 @@ def get_reader(path): from contextlib import suppress from itertools import chain from multiprocessing.pool import ThreadPool -from typing import TYPE_CHECKING -from unittest.mock import Mock, patch +from pathlib import Path +from typing import TYPE_CHECKING, Optional +from unittest.mock import patch from weakref import WeakKeyDictionary from npe2 import PackageMetadata @@ -46,16 +47,20 @@ def get_reader(path): with suppress(ModuleNotFoundError): __import__('dotenv').load_dotenv() +from datetime import timedelta +from time import perf_counter + import dask.threaded import numpy as np import pytest +from _pytest.pathlib import bestrelpath from IPython.core.history import HistoryManager from packaging.version import parse as parse_version +from pytest_pretty import CustomTerminalReporter from napari.components import LayerList from napari.layers import Image, Labels, Points, Shapes, Vectors from napari.utils.misc import ROOT_DIR -from napari.viewer import Viewer if TYPE_CHECKING: from npe2._pytest_plugin import TestPluginManager @@ -331,6 +336,8 @@ def pytest_generate_tests(metafunc): def pytest_collection_modifyitems(session, config, items): + test_subset = os.environ.get("NAPARI_TEST_SUBSET") + test_order_prefix = [ os.path.join("napari", "utils"), os.path.join("napari", "layers"), @@ -346,6 +353,17 @@ def pytest_collection_modifyitems(session, config, items): test_order = [[] for _ in test_order_prefix] test_order.append([]) # for not matching tests for item in items: + if test_subset: + if test_subset.lower() == "qt" and "qapp" not in item.fixturenames: + # Skip non Qt tests + continue + if ( + test_subset.lower() == "headless" + and "qapp" in item.fixturenames + ): + # Skip Qt tests + continue + index = -1 for i, prefix in enumerate(test_order_prefix): if prefix in str(item.fspath): @@ -385,38 +403,6 @@ def single_threaded_executor(): executor.shutdown() -@pytest.fixture() -def mock_console(request): - """Mock the qtconsole to avoid starting an interactive IPython session. - In-process IPython kernels can interfere with other tests and are difficult - (impossible?) to shutdown. - - This fixture is configured to be applied automatically to tests unless they - use the `enable_console` marker. It's not autouse to avoid use on headless - tests (without Qt); instead it's enabled in `pytest_runtest_setup`. - """ - if "enable_console" in request.keywords: - yield - return - - from napari_console import QtConsole - from qtconsole.rich_jupyter_widget import RichJupyterWidget - - class FakeQtConsole(RichJupyterWidget): - def __init__(self, viewer: Viewer): - super().__init__() - self.viewer = viewer - self.kernel_client = None - self.kernel_manager = None - - _update_theme = Mock() - push = Mock() - closeEvent = QtConsole.closeEvent - - with patch("napari_console.QtConsole", FakeQtConsole): - yield - - @pytest.fixture(autouse=True) def _mock_app(): """Mock clean 'test_app' `NapariApplication` instance. @@ -609,7 +595,10 @@ def start(self, time=None): else: def my_start(self, msec=None): - timer_dkt[self] = _get_calling_place() + calling_place = _get_calling_place() + if "superqt" in calling_place and "throttler" in calling_place: + calling_place += f" - {_get_calling_place(2)}" + timer_dkt[self] = calling_place if msec is not None: base_start(self, msec) else: @@ -622,6 +611,9 @@ def single_shot(msec, reciver, method=None): t.timeout.connect(reciver) else: t.timeout.connect(getattr(reciver, method)) + calling_place = _get_calling_place(2) + if "superqt" in calling_place and "throttler" in calling_place: + calling_place += f" - {_get_calling_place(3)}" single_shot_list.append((t, _get_calling_place(2))) base_start(t, msec) @@ -756,6 +748,44 @@ def pytest_runtest_setup(item): "dangling_qanimations", "dangling_qthreads", "dangling_qtimers", - "mock_console", ] ) + + +class NapariTerminalReporter(CustomTerminalReporter): + """ + This ia s custom terminal reporter to how long it takes to finish given part of tests. + It prints time each time when test from different file is started. + + It is created to be able to see if timeout is caused by long time execution, or it is just hanging. + """ + + currentfspath: Optional[Path] + + def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None: + if getattr(self, "_start_time", None) is None: + self._start_time = perf_counter() + fspath = self.config.rootpath / nodeid.split("::")[0] + if self.currentfspath is None or fspath != self.currentfspath: + if self.currentfspath is not None and self._show_progress_info: + self._write_progress_information_filling_space() + if os.environ.get("CI", False): + self.write( + f" [{timedelta(seconds=int(perf_counter() - self._start_time))}]" + ) + self.currentfspath = fspath + relfspath = bestrelpath(self.startpath, fspath) + self._tw.line() + self.write(relfspath + " ") + self.write(res, flush=True, **markup) + + +@pytest.hookimpl(trylast=True) +def pytest_configure(config): + # Get the standard terminal reporter plugin and replace it with our + standard_reporter = config.pluginmanager.getplugin('terminalreporter') + custom_reporter = NapariTerminalReporter(config, sys.stdout) + if standard_reporter._session is not None: + custom_reporter._session = standard_reporter._session + config.pluginmanager.unregister(standard_reporter) + config.pluginmanager.register(custom_reporter, 'terminalreporter') diff --git a/napari/errors/reader_errors.py b/napari/errors/reader_errors.py index bc65facd92d..fdabb5be866 100644 --- a/napari/errors/reader_errors.py +++ b/napari/errors/reader_errors.py @@ -1,5 +1,7 @@ from typing import List +from napari.types import PathLike + class MultipleReaderError(RuntimeError): """Multiple readers are available for paths and none explicitly chosen. @@ -32,7 +34,7 @@ def __init__( self, message: str, available_readers: List[str], - paths: List[str], + paths: List[PathLike], *args: object, ) -> None: super().__init__(message, *args) @@ -67,7 +69,11 @@ class ReaderPluginError(ValueError): """ def __init__( - self, message: str, reader_plugin: str, paths: List[str], *args: object + self, + message: str, + reader_plugin: str, + paths: List[PathLike], + *args: object, ) -> None: super().__init__(message, *args) self.reader_plugin = reader_plugin @@ -92,6 +98,8 @@ class NoAvailableReaderError(ValueError): file paths for reading """ - def __init__(self, message: str, paths: List[str], *args: object) -> None: + def __init__( + self, message: str, paths: List[PathLike], *args: object + ) -> None: super().__init__(message, *args) self.paths = paths diff --git a/napari/layers/__init__.py b/napari/layers/__init__.py index 382e6ad64e9..625fc6f6076 100644 --- a/napari/layers/__init__.py +++ b/napari/layers/__init__.py @@ -5,6 +5,7 @@ to the super constructor. """ import inspect as _inspect +from typing import Set from napari.layers.base import Layer from napari.layers.graph import Graph @@ -18,7 +19,7 @@ from napari.utils.misc import all_subclasses as _all_subcls # isabstact check is to exclude _ImageBase class -NAMES = { +NAMES: Set[str] = { subclass.__name__.lower() for subclass in _all_subcls(Layer) if not _inspect.isabstract(subclass) diff --git a/napari/layers/_layer_actions.py b/napari/layers/_layer_actions.py index df325d96509..34ef59a75f3 100644 --- a/napari/layers/_layer_actions.py +++ b/napari/layers/_layer_actions.py @@ -53,10 +53,15 @@ def _convert(ll: LayerList, type_: str): for lay in list(ll.selection): idx = ll.index(lay) ll.pop(idx) + if isinstance(lay, Shapes) and type_ == 'labels': data = lay.to_labels() + elif ( + not np.issubdtype(lay.data.dtype, np.integer) and type_ == 'labels' + ): + data = lay.data.astype(int) else: - data = lay.data.astype(int) if type_ == 'labels' else lay.data + data = lay.data new_layer = Layer.create(data, lay._get_base_state(), type_) ll.insert(idx, new_layer) @@ -98,6 +103,28 @@ def _toggle_visibility(ll: LayerList): layer.visible = not visibility +def _show_selected(ll: LayerList): + for lay in ll.selection: + lay.visible = True + + +def _hide_selected(ll: LayerList): + for lay in ll.selection: + lay.visible = False + + +def _show_unselected(ll: LayerList): + for lay in ll: + if lay not in ll.selection: + lay.visible = True + + +def _hide_unselected(ll: LayerList): + for lay in ll: + if lay not in ll.selection: + lay.visible = False + + def _link_selected_layers(ll: LayerList): ll.link_layers(ll.selection) diff --git a/napari/layers/_source.py b/napari/layers/_source.py index 599f1e6b3fb..8555b9954d3 100644 --- a/napari/layers/_source.py +++ b/napari/layers/_source.py @@ -6,8 +6,8 @@ from typing import Optional, Tuple from magicgui.widgets import FunctionGui -from pydantic import BaseModel, validator +from napari._pydantic_compat import BaseModel, validator from napari.layers.base.base import Layer diff --git a/napari/layers/_tests/test_layer_actions.py b/napari/layers/_tests/test_layer_actions.py index b5eb3db4989..71260dce370 100644 --- a/napari/layers/_tests/test_layer_actions.py +++ b/napari/layers/_tests/test_layer_actions.py @@ -1,5 +1,6 @@ import numpy as np import pytest +import zarr from napari.components.layerlist import LayerList from napari.layers import Image, Labels, Points, Shapes @@ -7,8 +8,12 @@ _convert, _convert_dtype, _duplicate_layer, + _hide_selected, + _hide_unselected, _link_selected_layers, _project, + _show_selected, + _show_unselected, _toggle_visibility, ) @@ -74,6 +79,84 @@ def _dummy(): assert layer_list[1].source.parent() is layer_list[0] +def test_hide_unselected_layers(): + layer_list = make_three_layer_layerlist() + layer_list[0].visible = True + layer_list[1].visible = True + layer_list[2].visible = True + + layer_list.selection.active = layer_list[1] + + assert layer_list[0].visible is True + assert layer_list[1].visible is True + assert layer_list[2].visible is True + + _hide_unselected(layer_list) + + assert layer_list[0].visible is False + assert layer_list[1].visible is True + assert layer_list[2].visible is False + + +def test_show_unselected_layers(): + layer_list = make_three_layer_layerlist() + layer_list[0].visible = False + layer_list[1].visible = True + layer_list[2].visible = True + + layer_list.selection.active = layer_list[1] + + assert layer_list[0].visible is False + assert layer_list[1].visible is True + assert layer_list[2].visible is True + + _show_unselected(layer_list) + + assert layer_list[0].visible is True + assert layer_list[1].visible is True + assert layer_list[2].visible is True + + +def test_hide_selected_layers(): + layer_list = make_three_layer_layerlist() + layer_list[0].visible = False + layer_list[1].visible = True + layer_list[2].visible = True + + layer_list.selection.active = layer_list[0] + layer_list.selection.add(layer_list[1]) + + assert layer_list[0].visible is False + assert layer_list[1].visible is True + assert layer_list[2].visible is True + + _hide_selected(layer_list) + + assert layer_list[0].visible is False + assert layer_list[1].visible is False + assert layer_list[2].visible is True + + +def test_show_selected_layers(): + layer_list = make_three_layer_layerlist() + layer_list[0].visible = False + layer_list[1].visible = True + layer_list[2].visible = True + + layer_list.selection.active = layer_list[0] + layer_list.selection.add(layer_list[1]) + + assert layer_list[0].visible is False + assert layer_list[1].visible is True + assert layer_list[2].visible is True + + _show_selected(layer_list) + + assert layer_list[0].visible is True + assert layer_list[1].visible is True + assert layer_list[2].visible is True + + @pytest.mark.parametrize( 'mode', ['max', 'min', 'std', 'sum', 'mean', 'median'] ) @@ -117,6 +200,11 @@ def test_convert_dtype(mode): 'layer, type_', [ (Image(np.random.rand(10, 10)), 'labels'), + (Image(np.array([[1, 2], [3, 4]], dtype=(int))), 'labels'), + ( + Image(zarr.array([[1, 2], [3, 4]], dtype=(int), chunks=(1, 2))), + 'labels', + ), (Labels(np.ones((10, 10), dtype=int)), 'image'), (Shapes([np.array([[0, 0], [0, 10], [10, 0], [10, 10]])]), 'labels'), ], @@ -130,3 +218,21 @@ def test_convert_layer(layer, type_): _convert(ll, type_) assert ll[0]._type_string == type_ assert np.array_equal(ll[0].scale, original_scale) + + if ( + type_ == "labels" + and isinstance(layer, Image) + and np.issubdtype(layer.data.dtype, np.integer) + ): + assert ( + layer.data is ll[0].data + ) # check array data not copied unnecessarily + + +def make_three_layer_layerlist(): + layer_list = LayerList() + layer_list.append(Points([[0, 0]], name="test")) + layer_list.append(Image(np.random.rand(8, 8, 8))) + layer_list.append(Image(np.random.rand(8, 8, 8))) + + return layer_list diff --git a/napari/layers/_tests/test_layer_attributes.py b/napari/layers/_tests/test_layer_attributes.py index f9b3b967543..309c415c28f 100644 --- a/napari/layers/_tests/test_layer_attributes.py +++ b/napari/layers/_tests/test_layer_attributes.py @@ -1,7 +1,10 @@ +from unittest.mock import MagicMock + import numpy as np import pytest from napari._tests.utils import layer_test_data +from napari.components.dims import Dims from napari.layers import Image, Labels @@ -102,7 +105,7 @@ def test_get_value_at_subpixel_offsets(ImageClass, ndim): # test using non-uniform scale per-axis layer = ImageClass(data, scale=(0.5, 1, 2)[:ndim]) - layer._slice_dims([0] * ndim, ndisplay=ndim) + layer._slice_dims(Dims(ndim=ndim, ndisplay=ndim)) # dictionary of expected values at each voxel center coordinate val_dict = { @@ -122,7 +125,7 @@ def test_get_value_3d_view_of_2d_image(ImageClass): ndisplay = 3 # test using non-uniform scale per-axis layer = ImageClass(data, scale=(0.5, 1)) - layer._slice_dims([0] * ndisplay, ndisplay=ndisplay) + layer._slice_dims(Dims(ndim=ndisplay, ndisplay=ndisplay)) # dictionary of expected values at each voxel center coordinate val_dict = { @@ -137,3 +140,37 @@ def test_get_value_3d_view_of_2d_image(ImageClass): def test_zero_scale_layer(): with pytest.raises(ValueError, match='scale values of 0'): Image(np.zeros((64, 64)), scale=(0, 1)) + + +@pytest.mark.parametrize('Layer, data, ndim', layer_test_data) +def test_sync_refresh_block(Layer, data, ndim): + my_layer = Layer(data) + my_layer.set_view_slice = MagicMock() + + with my_layer._block_refresh(): + my_layer.refresh() + my_layer.set_view_slice.assert_not_called + + my_layer.refresh() + my_layer.set_view_slice.assert_called_once() + + +@pytest.mark.parametrize('Layer, data, ndim', layer_test_data) +def test_async_refresh_block(Layer, data, ndim): + from napari import settings + + settings.get_settings().experimental.async_ = True + + my_layer = Layer(data) + + mock = MagicMock() + + my_layer.events.reload.connect(mock) + + with my_layer._block_refresh(): + my_layer.refresh() + + mock.assert_not_called() + + my_layer.refresh() + mock.assert_called_once() diff --git a/napari/layers/_tests/test_source.py b/napari/layers/_tests/test_source.py index 3b678eed336..e2b0a6cfb3d 100644 --- a/napari/layers/_tests/test_source.py +++ b/napari/layers/_tests/test_source.py @@ -1,6 +1,6 @@ -import pydantic import pytest +from napari._pydantic_compat import ValidationError from napari.layers import Points from napari.layers._source import Source, current_source, layer_source @@ -46,7 +46,7 @@ def test_source_context(): def test_source_assert_parent(): assert current_source() == Source() - with pytest.raises(pydantic.error_wrappers.ValidationError): + with pytest.raises(ValidationError): with layer_source(parent=''): current_source() assert current_source() == Source() diff --git a/napari/layers/base/_base_constants.py b/napari/layers/base/_base_constants.py index 0e8088674d9..86fc9369d71 100644 --- a/napari/layers/base/_base_constants.py +++ b/napari/layers/base/_base_constants.py @@ -129,3 +129,13 @@ class ActionType(StringEnum): ADDED = auto() REMOVED = auto() CHANGED = auto() + + +class BaseProjectionMode(StringEnum): + """ + Projection mode for aggregating a thick nD slice onto displayed dimensions. + + * NONE: ignore slice thickness, only using the dims point + """ + + NONE = auto() diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py index 9fdceb95707..e416bada99a 100644 --- a/napari/layers/base/base.py +++ b/napari/layers/base/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import itertools import logging import os.path @@ -18,18 +19,23 @@ Tuple, Type, Union, + cast, ) import magicgui as mgui import numpy as np from npe2 import plugin_manager as pm -from napari.layers.base._base_constants import Blending, Mode +from napari.layers.base._base_constants import ( + BaseProjectionMode, + Blending, + Mode, +) from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, transform_with_box, ) -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.interactivity_utils import ( drag_data_to_projected_distance, ) @@ -66,6 +72,9 @@ if TYPE_CHECKING: import numpy.typing as npt + from napari.components.dims import Dims + from napari.components.overlays.base import Overlay + logger = logging.getLogger("napari.layers.base.base") @@ -133,6 +142,9 @@ class Layer(KeymapProvider, MousemapProvider, ABC): Whether the data is multiscale or not. Multiscale data is represented by a list of data objects and should go from largest to smallest. + projection_mode : str + How data outside the viewed dimensions but inside the thick Dims slice will + be projected onto the viewed dimenions. Attributes ---------- @@ -228,6 +240,9 @@ class Layer(KeymapProvider, MousemapProvider, ABC): depends on the current zoom level. source : Source source of the layer (such as a plugin or widget) + projection_mode : str + How data outside the viewed dimensions but inside the thick Dims slice will + be projected onto the viewed dimenions. Notes ----- @@ -243,6 +258,7 @@ class Layer(KeymapProvider, MousemapProvider, ABC): """ _modeclass: Type[StringEnum] = Mode + _projectionclass: Type[StringEnum] = BaseProjectionMode _drag_modes: ClassVar[Dict[StringEnum, Callable[[Layer, Event], None]]] = { Mode.PAN_ZOOM: no_op, @@ -257,6 +273,7 @@ class Layer(KeymapProvider, MousemapProvider, ABC): Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', } + events: EmitterGroup def __init__( self, @@ -270,13 +287,14 @@ def __init__( rotate=None, shear=None, affine=None, - opacity=1, + opacity=1.0, blending='translucent', visible=True, multiscale=False, cache=True, # this should move to future "data source" object. experimental_clipping_planes=None, mode='pan_zoom', + projection_mode='none', ) -> None: super().__init__() @@ -314,12 +332,14 @@ def __init__( self.multiscale = multiscale self._experimental_clipping_planes = ClippingPlaneList() self._mode = self._modeclass('pan_zoom') + self._projection_mode = self._projectionclass(str(projection_mode)) + self._refresh_blocked = False self._ndim = ndim self._slice_input = _SliceInput( ndisplay=2, - point=(0,) * ndim, + world_slice=_ThickNDSlice.make_full(ndim=ndim), order=tuple(range(ndim)), ) self._loaded: bool = True @@ -344,7 +364,7 @@ def __init__( scale = [1] * ndim if translate is None: translate = [0] * ndim - self._transforms = TransformChain( + self._transforms: TransformChain[Affine] = TransformChain( [ Affine(np.ones(ndim), np.zeros(ndim), name='tile2data'), CompositeAffine( @@ -377,7 +397,7 @@ def __init__( TransformBoxOverlay, ) - self._overlays = EventedDict() + self._overlays: EventedDict[str, Overlay] = EventedDict() self.events = EmitterGroup( source=self, @@ -414,6 +434,7 @@ def __init__( _extent_augmented=Event, _overlays=Event, mode=Event, + projection_mode=Event, ) self.name = name self.mode = mode @@ -439,7 +460,7 @@ def __repr__(self): cls = type(self) return f"<{cls.__name__} layer {self.name!r} at {hex(id(self))}>" - def _mode_setter_helper(self, mode: Union[Mode, str]) -> Mode: + def _mode_setter_helper(self, mode_in: Union[Mode, str]) -> StringEnum: """ Helper to manage callbacks in multiple layers @@ -460,10 +481,15 @@ def _mode_setter_helper(self, mode: Union[Mode, str]) -> Mode: New mode for the current layer. """ - mode = self._modeclass(mode) + mode = self._modeclass(mode_in) + # Sub-classes can have their own Mode enum, so need to get members + # from the specific mode class set on this layer. + PAN_ZOOM = self._modeclass.PAN_ZOOM # type: ignore[attr-defined] + TRANSFORM = self._modeclass.TRANSFORM # type: ignore[attr-defined] assert mode is not None + if not self.editable: - mode = self._modeclass.PAN_ZOOM + mode = PAN_ZOOM if mode == self._mode: return mode @@ -489,16 +515,14 @@ def _mode_setter_helper(self, mode: Union[Mode, str]) -> Mode: callback_list.append(mode_dict[mode]) self.cursor = self._cursor_modes[mode] - self.mouse_pan = mode == self._modeclass.PAN_ZOOM - self._overlays['transform_box'].visible = ( - mode == self._modeclass.TRANSFORM - ) + self.mouse_pan = mode == PAN_ZOOM + self._overlays['transform_box'].visible = mode == TRANSFORM - if mode == self._modeclass.TRANSFORM: + if mode == TRANSFORM: self.help = trans._( 'hold to pan/zoom, hold to preserve aspect ratio and rotate in 45° increments' ) - elif mode == self._modeclass.PAN_ZOOM: + elif mode == PAN_ZOOM: self.help = '' return mode @@ -523,6 +547,24 @@ def mode(self, mode): self.events.mode(mode=str(mode)) + @property + def projection_mode(self): + """Mode of projection of the thick slice onto the viewed dimensions. + + The sliced data is described by an n-dimensional bounding box ("thick slice"), + which needs to be projected onto the visible dimensions to be visible. + The projection mode controls the projection logic. + """ + return self._projection_mode + + @projection_mode.setter + def projection_mode(self, mode): + mode = self._projectionclass(str(mode)) + if self._projection_mode != mode: + self._projection_mode = mode + self.events.projection_mode() + self.refresh() + @classmethod def _basename(cls): return f'{cls.__name__}' @@ -605,7 +647,7 @@ def opacity(self, opacity): ) ) - self._opacity = opacity + self._opacity = float(opacity) self._update_thumbnail() self.events.opacity() @@ -907,14 +949,15 @@ def _clear_extents_and_refresh(self): self.refresh() @property - def _slice_indices(self): - """(D, ) array: Slice indices in data coordinates.""" + def _data_slice(self) -> _ThickNDSlice: + """Slice in data coordinates.""" if len(self._slice_input.not_displayed) == 0: - # All dims are displayed dimensions - return (slice(None),) * self.ndim - return self._slice_input.data_indices( + # all dims are displayed dimensions + # early return to avoid evaluating data_to_world.inverse + return _ThickNDSlice.make_full(point=(np.nan,) * self.ndim) + + return self._slice_input.data_slice( self._data_to_world.inverse, - getattr(self, '_round_index', True), ) @abstractmethod @@ -943,6 +986,7 @@ def _get_base_state(self): 'experimental_clipping_planes': [ plane.dict() for plane in self.experimental_clipping_planes ], + 'projection_mode': self.projection_mode, } return base_dict @@ -1119,7 +1163,9 @@ def _set_view_slice(self): raise NotImplementedError def _slice_dims( - self, point=None, ndisplay=2, order=None, force: bool = False + self, + dims: Dims, + force: bool = False, ): """Slice data with values from a global dims model. @@ -1127,51 +1173,51 @@ def _slice_dims( Parameters ---------- - point : list - Values of data to slice at in world coordinates. - ndisplay : int - Number of dimensions to be displayed. - order : list of int - Order of dimensions, where last `ndisplay` will be - rendered in canvas. - force: bool + dims : Dims + The dims model to use to slice this layer. + force : bool True if slicing should be forced to occur, even when some cache thinks it already has a valid slice ready. False otherwise. """ logger.debug( - 'Layer._slice_dims: %s, point=%s, ndisplay=%s, order=%s, force=%s', + 'Layer._slice_dims: %s, dims=%s, force=%s', self, - point, - ndisplay, - order, + dims, force, ) - slice_input = self._make_slice_input(point, ndisplay, order) + slice_input = self._make_slice_input(dims) if force or (self._slice_input != slice_input): self._slice_input = slice_input self._refresh_sync() def _make_slice_input( - self, point=None, ndisplay=2, order=None + self, + dims: Dims, ) -> _SliceInput: - point = (0,) * self.ndim if point is None else tuple(point) - - ndim = len(point) - - if order is None: - order = tuple(range(ndim)) - - # Correspondence between dimensions across all layers and - # dimensions of this layer. - point = point[-self.ndim :] + world_ndim: int = self.ndim if dims is None else dims.ndim + if dims is None: + # if no dims is given, "world" has same dimensionality of self + # this happens for example if a layer is not in a viewer + # in this case, we assume all dims are displayed dimensions + world_slice = _ThickNDSlice.make_full((np.nan,) * self.ndim) + else: + world_slice = _ThickNDSlice.from_dims(dims) + order_array = ( + np.arange(world_ndim) + if dims.order is None + else np.asarray(dims.order) + ) order = tuple( - self._world_to_layer_dims(world_dims=order, ndim_world=ndim) + self._world_to_layer_dims( + world_dims=order_array, + ndim_world=world_ndim, + ) ) return _SliceInput( - ndisplay=ndisplay, - point=point, - order=order, + ndisplay=dims.ndisplay, + world_slice=world_slice[-self.ndim :], + order=order[-self.ndim :], ) @abstractmethod @@ -1270,8 +1316,8 @@ def get_value( def _get_value_3d( self, - start_point: np.ndarray, - end_point: np.ndarray, + start_point: Optional[np.ndarray], + end_point: Optional[np.ndarray], dims_displayed: List[int], ) -> Union[ float, int, None, Tuple[Union[float, int, None], Optional[int]] @@ -1361,6 +1407,7 @@ def _set_highlight(self, force=False): @contextmanager def _block_refresh(self): + """Prevent refresh calls from updating view.""" previous = self._refresh_blocked self._refresh_blocked = True try: @@ -1371,7 +1418,8 @@ def _block_refresh(self): def refresh(self, event=None): """Refresh all layer data based on current view slice.""" if self._refresh_blocked: - return + logger.debug('Layer.refresh blocked: %s', self) + return logger.debug('Layer.refresh: %s', self) # If async is enabled then emit an event that the viewer should handle. if get_settings().experimental.async_: @@ -1409,7 +1457,8 @@ def world_to_data(self, position: npt.ArrayLike) -> npt.NDArray: else: coords = [0] * (self.ndim - len(position)) + list(position) - return self._transforms[1:].simplified.inverse(coords) + simplified = cast(Affine, self._transforms[1:].simplified) + return simplified.inverse(coords) def data_to_world(self, position): """Convert from data coordinates to world coordinates. @@ -1465,7 +1514,8 @@ def _data_to_world(self) -> Affine: affine * (rotate * shear * scale + translate) """ - return self._transforms[1:3].simplified + t = self._transforms[1:3].simplified + return cast(Affine, t) def _world_to_data_ray(self, vector: npt.ArrayLike) -> npt.NDArray: """Convert a vector defining an orientation from world coordinates to data coordinates. @@ -1523,6 +1573,40 @@ def _world_to_layer_dims( as those correspond to the relative order of the last two and three world dimensions respectively. + Let's keep in mind a few facts: + + - each dimension index is present exactly once. + - the lowest represented dimension index will be 0 + + That is to say both the `world_dims` input and return results are _some_ + permutation of 0...N + + Examples + -------- + + `[2, 1, 0, 3]` sliced in N=2 dimensions. + + - we want to keep the N=2 dimensions with the biggest index + - `[2, None, None, 3]` + - we filter the None + - `[2, 3]` + - reindex so that the lowest dimension is 0 by subtracting 2 from all indices + - `[0, 1]` + + `[2, 1, 0, 3]` sliced in N=3 dimensions. + + - we want to keep the N=3 dimensions with the biggest index + - `[2, 1, None, 3]` + - we filter the None + - `[2, 1, 3]` + - reindex so that the lowest dimension is 0 by subtracting 1 from all indices + - `[1, 0, 2]` + + Conveniently if the world (layer) dimension is bigger than our displayed + dims, we can return everything + + + Parameters ---------- world_dims : ndarray @@ -1535,14 +1619,25 @@ def _world_to_layer_dims( ndarray The corresponding layer dimensions with the same ordering as the given world dimensions. """ - offset = ndim_world - self.ndim - order = np.array(world_dims) - if offset == 0: - return order - if offset < 0: - return np.concatenate(np.arange(-offset), order - offset) + return self._world_to_layer_dims_impl( + world_dims, ndim_world, self.ndim + ) - return order[order >= offset] - offset + @staticmethod + def _world_to_layer_dims_impl( + world_dims: npt.NDArray, ndim_world: int, ndim: int + ): + """ + Static for ease of testing + """ + world_dims = np.asarray(world_dims) + assert world_dims.min() == 0 + assert world_dims.max() == len(world_dims) - 1 + assert world_dims.ndim == 1 + offset = ndim_world - ndim + order = world_dims - offset + order = order[order >= 0] + return order - order.min() def _display_bounding_box(self, dims_displayed: List[int]) -> npt.NDArray: """An axis aligned (ndisplay, 2) bounding box around the data""" @@ -1553,11 +1648,20 @@ def _display_bounding_box_augmented( ) -> npt.NDArray: """An augmented, axis-aligned (ndisplay, 2) bounding box. - This bounding box for includes the "full" size of the layer, including - for example the size of points or pixels. + This bounding box includes the size of the layer in best resolution, including required padding """ return self._extent_data_augmented[:, dims_displayed].T + def _display_bounding_box_augmented_data_level( + self, dims_displayed: List[int] + ) -> npt.NDArray: + """An augmented, axis-aligned (ndisplay, 2) bounding box. + + If the layer is multiscale layer, then returns the + bounding box of the data at the current level + """ + return self._display_bounding_box_augmented(dims_displayed) + def click_plane_from_click_data( self, click_position: npt.ArrayLike, @@ -1593,7 +1697,7 @@ def get_ray_intersections( view_direction: npt.ArrayLike, dims_displayed: List[int], world: bool = True, - ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[None, None]]: + ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: """Get the start and end point for the ray extending from a point through the data bounding box. @@ -1656,7 +1760,7 @@ def _get_ray_intersections( dims_displayed: List[int], bounding_box: npt.NDArray, world: bool = True, - ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[None, None]]: + ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: """Get the start and end point for the ray extending from a point through the data bounding box. @@ -1794,13 +1898,17 @@ def _update_draw( ) if any(s == 0 for s in display_shape): return - if self.data_level != level or not np.all( - self.corner_pixels == corners + if self.data_level != level or not np.array_equal( + self.corner_pixels, corners ): self._data_level = level self.corner_pixels = corners self.refresh() else: + # set the data_level so that it is the lowest resolution in 3d view + if self.multiscale is True: + self._data_level = len(self.level_shapes) - 1 + # The stored corner_pixels attribute must contain valid indices. corners = np.zeros((2, self.ndim), dtype=int) # Some empty layers (e.g. Points) may have a data extent that only @@ -1967,6 +2075,29 @@ def save(self, path: str, plugin: Optional[str] = None) -> List[str]: return save_layers(path, [self], plugin=plugin) + def __copy__(self): + """Create a copy of this layer. + + Returns + ------- + layer : napari.layers.Layer + Copy of this layer. + + Notes + ----- + This method is defined for purpose of asv memory benchmarks. + The copy of data is intentional for properly estimating memory + usage for layer. + + If you want a to copy a layer without coping the data please use + `layer.create(*layer.as_layer_data_tuple())` + + If you change this method, validate if memory benchmarks are still + working properly. + """ + data, meta, layer_type = self.as_layer_data_tuple() + return self.create(copy.copy(data), meta=meta, layer_type=layer_type) + @classmethod def create( cls, diff --git a/napari/layers/image/_image_constants.py b/napari/layers/image/_image_constants.py index 97e80f29a86..d970cd85703 100644 --- a/napari/layers/image/_image_constants.py +++ b/napari/layers/image/_image_constants.py @@ -1,9 +1,29 @@ from collections import OrderedDict from enum import auto +from typing import Literal, Tuple from napari.utils.misc import StringEnum from napari.utils.translations import trans +InterpolationStr = Literal[ + "bessel", + "cubic", + "linear", + "blackman", + "catrom", + "gaussian", + "hamming", + "hanning", + "hermite", + "kaiser", + "lanczos", + "mitchell", + "nearest", + "spline16", + "spline36", + "custom", +] + class Interpolation(StringEnum): """INTERPOLATION: Vispy interpolation mode. @@ -32,7 +52,15 @@ class Interpolation(StringEnum): CUSTOM = auto() @classmethod - def view_subset(cls): + def view_subset( + cls, + ) -> Tuple[ + "Interpolation", + "Interpolation", + "Interpolation", + "Interpolation", + "Interpolation", + ]: return ( cls.CUBIC, cls.LINEAR, @@ -41,6 +69,9 @@ def view_subset(cls): cls.SPLINE36, ) + def __str__(self) -> InterpolationStr: + return self.value + class ImageRendering(StringEnum): """Rendering: Rendering mode for the layer. @@ -75,6 +106,17 @@ class ImageRendering(StringEnum): AVERAGE = auto() +ImageRenderingStr = Literal[ + "translucent", + "additive", + "iso", + "mip", + "minip", + "attenuated_mip", + "average", +] + + class VolumeDepiction(StringEnum): """Depiction: 3D depiction mode for images. @@ -93,3 +135,21 @@ class VolumeDepiction(StringEnum): (VolumeDepiction.PLANE, trans._('plane')), ] ) + + +class ImageProjectionMode(StringEnum): + """ + Projection mode for aggregating a thick nD slice onto displayed dimensions. + + * NONE: ignore slice thickness, only using the dims point + * SUM: sum data across the thick slice + * MEAN: average data across the thick slice + * MAX: display the maximum value across the thick slice + * MIN: display the minimum value across the thick slice + """ + + NONE = auto() + SUM = auto() + MEAN = auto() + MAX = auto() + MIN = auto() diff --git a/napari/layers/image/_image_key_bindings.py b/napari/layers/image/_image_key_bindings.py index 8fa8fe21cd1..a8c283316fc 100644 --- a/napari/layers/image/_image_key_bindings.py +++ b/napari/layers/image/_image_key_bindings.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Callable, Generator, Union + from app_model.types import KeyCode import napari @@ -9,39 +11,46 @@ orient_plane_normal_around_cursor, ) from napari.layers.utils.layer_utils import register_layer_action +from napari.utils.events import Event from napari.utils.translations import trans -def register_image_action(description: str, repeatable: bool = False): +def register_image_action( + description: str, repeatable: bool = False +) -> Callable[[Callable], Callable]: return register_layer_action(Image, description, repeatable) @Image.bind_key(KeyCode.KeyZ, overwrite=True) @register_image_action(trans._('Orient plane normal along z-axis')) -def orient_plane_normal_along_z(layer: Image): +def orient_plane_normal_along_z(layer: Image) -> None: orient_plane_normal_around_cursor(layer, plane_normal=(1, 0, 0)) @Image.bind_key(KeyCode.KeyY, overwrite=True) @register_image_action(trans._('orient plane normal along y-axis')) -def orient_plane_normal_along_y(layer: Image): +def orient_plane_normal_along_y(layer: Image) -> None: orient_plane_normal_around_cursor(layer, plane_normal=(0, 1, 0)) @Image.bind_key(KeyCode.KeyX, overwrite=True) @register_image_action(trans._('orient plane normal along x-axis')) -def orient_plane_normal_along_x(layer: Image): +def orient_plane_normal_along_x(layer: Image) -> None: orient_plane_normal_around_cursor(layer, plane_normal=(0, 0, 1)) @Image.bind_key(KeyCode.KeyO, overwrite=True) @register_image_action(trans._('orient plane normal along view direction')) -def orient_plane_normal_along_view_direction(layer: Image): +def orient_plane_normal_along_view_direction( + layer: Image, +) -> Union[None, Generator[None, None, None]]: viewer = napari.viewer.current_viewer() if viewer is None or viewer.dims.ndisplay != 3: - return + return None - def sync_plane_normal_with_view_direction(event=None): + def sync_plane_normal_with_view_direction( + event: Union[None, Event] = None + ) -> None: """Plane normal syncronisation mouse callback.""" layer.plane.normal = layer._world_to_displayed_data_ray( viewer.camera.view_direction, [-3, -2, -1] @@ -50,15 +59,16 @@ def sync_plane_normal_with_view_direction(event=None): # update plane normal and add callback to mouse drag sync_plane_normal_with_view_direction() viewer.camera.events.angles.connect(sync_plane_normal_with_view_direction) - yield + yield None # remove callback on key release viewer.camera.events.angles.disconnect( sync_plane_normal_with_view_direction ) + return None @register_image_action(trans._('orient plane normal along view direction')) -def orient_plane_normal_along_view_direction_no_gen(layer: Image): +def orient_plane_normal_along_view_direction_no_gen(layer: Image) -> None: viewer = napari.viewer.current_viewer() if viewer is None or viewer.dims.ndisplay != 3: return @@ -68,12 +78,12 @@ def orient_plane_normal_along_view_direction_no_gen(layer: Image): @register_image_action(trans._('Transform')) -def activate_image_transform_mode(layer): - layer.mode = Mode.TRANSFORM +def activate_image_transform_mode(layer: Image) -> None: + layer.mode = str(Mode.TRANSFORM) @register_image_action(trans._('Pan/zoom')) -def activate_image_pan_zoom_mode(layer: Image): +def activate_image_pan_zoom_mode(layer: Image) -> None: layer.mode = str(Mode.PAN_ZOOM) diff --git a/napari/layers/image/_image_mouse_bindings.py b/napari/layers/image/_image_mouse_bindings.py index 0af4797e10f..5766ce1c5d7 100644 --- a/napari/layers/image/_image_mouse_bindings.py +++ b/napari/layers/image/_image_mouse_bindings.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generator, Union import numpy as np @@ -14,7 +14,9 @@ from napari.utils.events import Event -def move_plane_along_normal(layer: Image, event: Event): +def move_plane_along_normal( + layer: Image, event: Event +) -> Union[None, Generator[None, None, None]]: """Move a layers slicing plane along its normal vector on click and drag.""" # early exit clauses if ( @@ -23,7 +25,7 @@ def move_plane_along_normal(layer: Image, event: Event): or layer.mouse_pan is False or len(event.dims_displayed) < 3 ): - return + return None # Store mouse position at start of drag initial_position_world = np.asarray(event.position) @@ -46,7 +48,7 @@ def move_plane_along_normal(layer: Image, event: Event): if not point_in_bounding_box( intersection, layer.extent.data[:, event.dims_displayed] ): - return + return None layer.plane.position = intersection @@ -54,7 +56,7 @@ def move_plane_along_normal(layer: Image, event: Event): original_plane_position = np.copy(layer.plane.position) layer.mouse_pan = False - yield + yield None while event.type == 'mouse_move': # Project mouse drag onto plane normal @@ -77,13 +79,14 @@ def move_plane_along_normal(layer: Image, event: Event): ) layer.plane.position = clamped_plane_position - yield + yield None # Re-enable volume_layer interactivity after the drag layer.mouse_pan = True + return None -def set_plane_position(layer: Image, event: Event): +def set_plane_position(layer: Image, event: Event) -> None: """Set plane position on double click.""" # early exit clauses if ( diff --git a/napari/layers/image/_image_utils.py b/napari/layers/image/_image_utils.py index 7920888169b..c751022ee62 100644 --- a/napari/layers/image/_image_utils.py +++ b/napari/layers/image/_image_utils.py @@ -1,15 +1,17 @@ """guess_rgb, guess_multiscale, guess_labels. """ -from typing import Sequence, Tuple, Union +from typing import Any, Callable, Literal, Sequence, Tuple, Union import numpy as np +import numpy.typing as npt from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData +from napari.layers.image._image_constants import ImageProjectionMode from napari.utils.translations import trans -def guess_rgb(shape): +def guess_rgb(shape: Tuple[int, ...]) -> bool: """Guess if the passed shape comes from rgb data. If last dim is 3 or 4 assume the data is rgb, including rgba. @@ -31,7 +33,7 @@ def guess_rgb(shape): def guess_multiscale( - data, + data: Union[MultiScaleData, list, tuple], ) -> Tuple[bool, Union[LayerDataProtocol, Sequence[LayerDataProtocol]]]: """Guess whether the passed data is multiscale, process it accordingly. @@ -93,7 +95,7 @@ def guess_multiscale( return True, MultiScaleData(data) -def guess_labels(data): +def guess_labels(data: Any) -> Literal["labels", "image"]: """Guess if array contains labels data.""" if hasattr(data, 'dtype') and data.dtype in ( @@ -105,3 +107,21 @@ def guess_labels(data): return 'labels' return 'image' + + +def project_slice( + data: npt.NDArray, axis: Tuple[int, ...], mode: ImageProjectionMode +) -> float: + """Project a thick slice along axis based on mode.""" + func: Callable + if mode == ImageProjectionMode.SUM: + func = np.sum + elif mode == ImageProjectionMode.MEAN: + func = np.mean + elif mode == ImageProjectionMode.MAX: + func = np.max + elif mode == ImageProjectionMode.MIN: + func = np.min + else: + raise NotImplementedError(f'unimplemented projection: {mode}') + return func(data, tuple(axis)) diff --git a/napari/layers/image/_slice.py b/napari/layers/image/_slice.py index 77b3efced48..f769a65b697 100644 --- a/napari/layers/image/_slice.py +++ b/napari/layers/image/_slice.py @@ -1,10 +1,13 @@ from dataclasses import dataclass, field -from typing import Any, Callable, Tuple, Union +from typing import Any, Callable, List, Tuple, Union import numpy as np from napari.layers.base._slice import _next_request_id -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.image._image_constants import ImageProjectionMode +from napari.layers.image._image_utils import project_slice +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice +from napari.types import ArrayLike from napari.utils._dask_utils import DaskIndexer from napari.utils.misc import reorder_after_dim_reduction from napari.utils.transforms import Affine @@ -65,7 +68,7 @@ class _ImageSliceResponse: tile_to_data: Affine The affine transform from the sliced data to the full data at the highest resolution. For single-scale images, this will be the identity matrix. - dims : _SliceInput + slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. request_id : int The identifier of the request from which this was generated. @@ -74,13 +77,13 @@ class _ImageSliceResponse: image: _ImageView = field(repr=False) thumbnail: _ImageView = field(repr=False) tile_to_data: Affine = field(repr=False) - dims: _SliceInput + slice_input: _SliceInput request_id: int empty: bool = False @classmethod def make_empty( - cls, *, dims: _SliceInput, rgb: bool + cls, *, slice_input: _SliceInput, rgb: bool ) -> '_ImageSliceResponse': """Returns an empty image slice response. @@ -90,19 +93,19 @@ def make_empty( Parameters ---------- - dims : _SliceInput + slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. rgb : bool True if the underlying image is an RGB or RGBA image (i.e. that the last dimension represents a color channel that should not be sliced), False otherwise. """ - shape = (1,) * dims.ndisplay + shape = (1,) * slice_input.ndisplay if rgb: shape = shape + (3,) data = np.zeros(shape) image = _ImageView.from_view(data) - ndim = dims.ndim + ndim = slice_input.ndim tile_to_data = Affine( name='tile2data', linear_matrix=np.eye(ndim), ndim=ndim ) @@ -110,7 +113,7 @@ def make_empty( image=image, thumbnail=image, tile_to_data=tile_to_data, - dims=dims, + slice_input=slice_input, request_id=_next_request_id(), empty=True, ) @@ -129,7 +132,7 @@ def to_displayed( image=image, thumbnail=thumbnail, tile_to_data=self.tile_to_data, - dims=self.dims, + slice_input=self.slice_input, request_id=self.request_id, ) @@ -147,22 +150,23 @@ class _ImageSliceRequest: Attributes ---------- - dims : _SliceInput + slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. data : Any The layer's data field, which is the main input to slicing. - indices : tuple of ints or slices - The slice indices in the layer's data space. + data_slice : _ThickNDSlice + The slicing coordinates and margins in data space. others See the corresponding attributes in `Layer` and `Image`. id : int The identifier of this slice request. """ - dims: _SliceInput + slice_input: _SliceInput data: Any = field(repr=False) dask_indexer: DaskIndexer - indices: Tuple[Union[int, slice], ...] + data_slice: _ThickNDSlice + projection_mode: ImageProjectionMode multiscale: bool = field(repr=False) corner_pixels: np.ndarray rgb: bool = field(repr=False) @@ -173,6 +177,10 @@ class _ImageSliceRequest: id: int = field(default_factory=_next_request_id) def __call__(self) -> _ImageSliceResponse: + if self._slice_out_of_bounds(): + return _ImageSliceResponse.make_empty( + slice_input=self.slice_input, rgb=self.rgb + ) with self.dask_indexer(): return ( self._call_multi_scale() @@ -182,12 +190,12 @@ def __call__(self) -> _ImageSliceResponse: def _call_single_scale(self) -> _ImageSliceResponse: order = self._get_order() - data = np.asarray(self.data[self.indices]) + data = self._project_thick_slice(self.data, self.data_slice) data = np.transpose(data, order) image = _ImageView.from_view(data) # `Layer.multiscale` is mutable so we need to pass back the identity # transform to ensure `tile2data` is properly set on the layer. - ndim = self.dims.ndim + ndim = self.slice_input.ndim tile_to_data = Affine( name='tile2data', linear_matrix=np.eye(ndim), ndim=ndim ) @@ -195,27 +203,28 @@ def _call_single_scale(self) -> _ImageSliceResponse: image=image, thumbnail=image, tile_to_data=tile_to_data, - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) def _call_multi_scale(self) -> _ImageSliceResponse: - if self.dims.ndisplay == 3: + if self.slice_input.ndisplay == 3: level = len(self.data) - 1 else: level = self.data_level - indices = self._slice_indices_at_level(level) - # Calculate the tile-to-data transform. - scale = np.ones(self.dims.ndim) - for d in self.dims.displayed: + scale = np.ones(self.slice_input.ndim) + for d in self.slice_input.displayed: scale[d] = self.downsample_factors[level][d] - translate = np.zeros(self.dims.ndim) - if self.dims.ndisplay == 2: - for d in self.dims.displayed: - indices[d] = slice( + data = self.data[level] + + translate = np.zeros(self.slice_input.ndim) + disp_slice = [slice(None) for _ in data.shape] + if self.slice_input.ndisplay == 2: + for d in self.slice_input.displayed: + disp_slice[d] = slice( self.corner_pixels[0, d], self.corner_pixels[1, d] + 1, 1, @@ -228,17 +237,22 @@ def _call_multi_scale(self) -> _ImageSliceResponse: name='tile2data', scale=scale, translate=translate, - ndim=self.dims.ndim, + ndim=self.slice_input.ndim, ) + # slice displayed dimensions to get the right tile data + data = np.asarray(data[tuple(disp_slice)]) + # project the thick slice + data_slice = self._thick_slice_at_level(level) + data = self._project_thick_slice(data, data_slice) + order = self._get_order() - data = np.asarray(self.data[level][tuple(indices)]) data = np.transpose(data, order) image = _ImageView.from_view(data) - thumbnail_indices = self._slice_indices_at_level(self.thumbnail_level) - thumbnail_data = np.asarray( - self.data[self.thumbnail_level][tuple(thumbnail_indices)] + thumbnail_data_slice = self._thick_slice_at_level(self.thumbnail_level) + thumbnail_data = self._project_thick_slice( + self.data[self.thumbnail_level], thumbnail_data_slice ) thumbnail_data = np.transpose(thumbnail_data, order) thumbnail = _ImageView.from_view(thumbnail_data) @@ -247,25 +261,110 @@ def _call_multi_scale(self) -> _ImageSliceResponse: image=image, thumbnail=thumbnail, tile_to_data=tile_to_data, - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) - def _slice_indices_at_level(self, level: int) -> np.ndarray: - indices = np.array(self.indices) - axes = self.dims.not_displayed - ds_indices = indices[axes] / self.downsample_factors[level][axes] - ds_indices = np.round(ds_indices.astype(float)).astype(int) - ds_indices = np.clip(ds_indices, 0, self.level_shapes[level][axes] - 1) - indices[axes] = ds_indices - return indices + def _thick_slice_at_level(self, level: int) -> _ThickNDSlice: + """ + Get the data_slice rescaled for a specific level. + """ + slice_arr = self.data_slice.as_array() + # downsample based on level + slice_arr /= self.downsample_factors[level] + slice_arr[0] = np.clip(slice_arr[0], 0, self.level_shapes[level] - 1) + return _ThickNDSlice.from_array(slice_arr) + + def _project_thick_slice( + self, data: ArrayLike, data_slice: _ThickNDSlice + ) -> ArrayLike: + """ + Slice the given data with the given data slice and project the extra dims. + """ + + if self.projection_mode == 'none': + # early return with only the dims point being used + slices = self._point_to_slices(data_slice.point) + return np.asarray(data[slices]) + + slices = self._data_slice_to_slices( + data_slice, self.slice_input.displayed + ) + + return project_slice( + data=np.asarray(data[slices]), + axis=tuple(self.slice_input.not_displayed), + mode=self.projection_mode, + ) def _get_order(self) -> Tuple[int, ...]: """Return the ordered displayed dimensions, but reduced to fit in the slice space.""" - order = reorder_after_dim_reduction(self.dims.displayed) + order = reorder_after_dim_reduction(self.slice_input.displayed) if self.rgb: # if rgb need to keep the final axis fixed during the # transpose. The index of the final axis depends on how many # axes are displayed. return (*order, max(order) + 1) return order + + def _slice_out_of_bounds(self) -> bool: + """Check if the data slice is out of bounds for any dimension.""" + data = self.data[0] if self.multiscale else self.data + for d in self.slice_input.not_displayed: + pt = self.data_slice.point[d] + max_idx = data.shape[d] - 1 + if self.projection_mode == 'none': + if np.round(pt) < 0 or np.round(pt) > max_idx: + return True + else: + pt = self.data_slice.point[d] + low = np.round(pt - self.data_slice.margin_left[d]) + high = np.round(pt + self.data_slice.margin_right[d]) + if high < 0 or low > max_idx: + return True + return False + + @staticmethod + def _point_to_slices( + point: Tuple[float, ...] + ) -> Tuple[Union[slice, int], ...]: + # no need to check out of bounds here cause it's guaranteed + + # values in point and margins are np.nan if no slicing should happen along that dimension + # which is always the case for displayed dims, so that becomes `slice(None)` for actually + # indexing the layer. + # For the rest, indices are rounded to the closest integer + return tuple( + slice(None) if np.isnan(p) else int(np.round(p)) for p in point + ) + + @staticmethod + def _data_slice_to_slices( + data_slice: _ThickNDSlice, dims_displayed: List[int] + ) -> Tuple[slice, ...]: + slices = [slice(None) for _ in range(data_slice.ndim)] + + for dim, (point, m_left, m_right) in enumerate(data_slice): + if dim in dims_displayed: + # leave slice(None) for displayed dimensions + # point and margin values here are np.nan; if np.nans pass through this check, + # something is likely wrong with the data_slice creation at a previous step! + continue + + # max here ensures we don't start slicing from negative values (=end of array) + low = max(int(np.round(point - m_left)), 0) + high = max(int(np.round(point + m_right)), 0) + + # if high is already exactly at an integer value, we need to round up + # to next integer because slices have non-inclusive stop + if np.isclose(high, point + m_right): + high += 1 + + # ensure we always get at least 1 slice (we're guaranteed to be + # in bounds from a previous check) + if low == high: + high += 1 + + slices[dim] = slice(low, high) + + return tuple(slices) diff --git a/napari/layers/image/_tests/test_image.py b/napari/layers/image/_tests/test_image.py index 9fe6362d8b5..480453fba9a 100644 --- a/napari/layers/image/_tests/test_image.py +++ b/napari/layers/image/_tests/test_image.py @@ -4,6 +4,7 @@ import xarray as xr from napari._tests.utils import check_layer_world_data_extent +from napari.components.dims import Dims from napari.layers import Image from napari.layers.image._image_constants import ImageRendering from napari.layers.utils.plane import ClippingPlaneList, SlicingPlane @@ -571,7 +572,7 @@ def test_value_3d(position, view_direction, dims_displayed, world): np.random.seed(0) data = np.random.random((10, 15, 15)) layer = Image(data) - layer._slice_dims([0, 0, 0], ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) value = layer.get_value( position, view_direction=view_direction, @@ -595,7 +596,7 @@ def test_message_3d(): np.random.seed(0) data = np.random.random((10, 15, 15)) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) msg = layer.get_status( (0, 0, 0), view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] ) @@ -857,7 +858,7 @@ def test_projected_distance_from_mouse_drag( start_position, end_position, view_direction, vector, expected_value ): image = Image(np.ones((32, 32, 32))) - image._slice_dims(point=[0, 0, 0], ndisplay=3) + image._slice_dims(Dims(ndim=3, ndisplay=3)) result = image.projected_distance_from_mouse_drag( start_position, end_position, @@ -874,3 +875,128 @@ def test_rendering_init(): layer = Image(data, rendering='iso') assert layer.rendering == ImageRendering.ISO.value + + +def test_thick_slice(): + data = np.ones((5, 5, 5)) * np.arange(5).reshape(-1, 1, 1) + layer = Image(data) + + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) + np.testing.assert_array_equal(layer._slice.image.raw, data[0]) + + # round down if at 0.5 and no margins + layer._slice_dims(Dims(ndim=3, point=(0.5, 0, 0))) + np.testing.assert_array_equal(layer._slice.image.raw, data[0]) + + # no changes if projection mode is 'none' + layer._slice_dims( + Dims( + ndim=3, + point=(0, 0, 0), + margin_left=(1, 0, 0), + margin_right=(1, 0, 0), + ) + ) + np.testing.assert_array_equal(layer._slice.image.raw, data[0]) + + layer.projection_mode = 'mean' + np.testing.assert_array_equal( + layer._slice.image.raw, np.mean(data[:2], axis=0) + ) + + layer._slice_dims( + Dims( + ndim=3, + point=(1, 0, 0), + margin_left=(1, 0, 0), + margin_right=(1, 0, 0), + ) + ) + np.testing.assert_array_equal( + layer._slice.image.raw, np.mean(data[:3], axis=0) + ) + + layer._slice_dims( + Dims( + ndim=3, + range=((0, 3, 1), (0, 2, 1), (0, 2, 1)), + point=(2.3, 0, 0), + margin_left=(0, 0, 0), + margin_right=(1.7, 0, 0), + ) + ) + np.testing.assert_array_equal( + layer._slice.image.raw, np.mean(data[2:5], axis=0) + ) + + layer._slice_dims( + Dims( + ndim=3, + range=((0, 3, 1), (0, 2, 1), (0, 2, 1)), + point=(2.3, 0, 0), + margin_left=(0, 0, 0), + margin_right=(1.6, 0, 0), + ) + ) + np.testing.assert_array_equal( + layer._slice.image.raw, np.mean(data[2:4], axis=0) + ) + + layer.projection_mode = 'max' + np.testing.assert_array_equal( + layer._slice.image.raw, np.max(data[2:4], axis=0) + ) + + +def test_thick_slice_multiscale(): + data = np.ones((5, 5, 5)) * np.arange(5).reshape(-1, 1, 1) + data_zoom = data.repeat(2, 0).repeat(2, 1).repeat(2, 2) + layer = Image([data_zoom, data]) + + # ensure we're slicing level 0. We also need to update corner_pixels + # to ensure the full image is in view + layer.corner_pixels = np.array([[0, 0, 0], [10, 10, 10]]) + layer.data_level = 0 + + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) + np.testing.assert_array_equal(layer._slice.image.raw, data_zoom[0]) + + layer.projection_mode = 'mean' + # NOTE that here we rescale slicing to twice the non-multiscale test + # in order to get the same results, becase the actual full scale image + # is doubled in size + layer._slice_dims( + Dims( + ndim=3, + range=((0, 5, 1), (0, 2, 1), (0, 2, 1)), + point=(4.6, 0, 0), + margin_left=(0, 0, 0), + margin_right=(3.4, 0, 0), + ) + ) + np.testing.assert_array_equal( + layer._slice.image.raw, np.mean(data_zoom[4:10], axis=0) + ) + + # check level 1 + layer.corner_pixels = np.array([[0, 0, 0], [5, 5, 5]]) + layer.data_level = 1 + + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) + np.testing.assert_array_equal(layer._slice.image.raw, data[0]) + + layer.projection_mode = 'mean' + # here we slice in the same point as earlier, but to get the expected value + # we need to slice `data` with halved indices + layer._slice_dims( + Dims( + ndim=3, + range=((0, 5, 1), (0, 2, 1), (0, 2, 1)), + point=(4.6, 0, 0), + margin_left=(0, 0, 0), + margin_right=(3.4, 0, 0), + ) + ) + np.testing.assert_array_equal( + layer._slice.image.raw, np.mean(data[2:5], axis=0) + ) diff --git a/napari/layers/image/_tests/test_image_utils.py b/napari/layers/image/_tests/test_image_utils.py index fa5b16f784f..1a69a2e5a73 100644 --- a/napari/layers/image/_tests/test_image_utils.py +++ b/napari/layers/image/_tests/test_image_utils.py @@ -10,6 +10,8 @@ from skimage.transform import pyramid_gaussian from napari.layers.image._image_utils import guess_multiscale, guess_rgb +from napari.layers.image._slice import _ImageSliceRequest +from napari.layers.utils._slice_input import _ThickNDSlice data_dask = da.random.random( size=(100_000, 1000, 1000), chunks=(1, 1000, 1000) @@ -113,3 +115,29 @@ def test_timing_multiscale_big(): assert not guess_multiscale(data_dask)[0] elapsed = time.monotonic() - now assert elapsed < 2, "test was too slow, computation was likely not lazy" + + +def test_create_data_indexing(): + point = (np.nan, 10.1, 2.6, 4) + idx = _ImageSliceRequest._point_to_slices(point) + expected = (slice(None), 10, 3, 4) + assert idx == expected + + # note that testing entirely out of bounds slices is wrong because these methods + # assume the bounds check already happened + data_slice = _ThickNDSlice( + point=(np.nan, 10.1, 2.6, 4, -1), + margin_left=(np.nan, 0, 1.6, 0.3, 1), + margin_right=(np.nan, 0.1, 0.3, 0.5, 0.6), + ) + idx = _ImageSliceRequest._data_slice_to_slices( + data_slice, dims_displayed=(0,) + ) + expected = ( + slice(None), + slice(10, 11), + slice(1, 3), + slice(4, 5), + slice(0, 1), + ) + assert idx == expected diff --git a/napari/layers/image/_tests/test_volume.py b/napari/layers/image/_tests/test_volume.py index 3820d4076e2..a6c41c5c501 100644 --- a/napari/layers/image/_tests/test_volume.py +++ b/napari/layers/image/_tests/test_volume.py @@ -1,5 +1,6 @@ import numpy as np +from napari.components.dims import Dims from napari.layers import Image from napari.layers.image._image_mouse_bindings import move_plane_along_normal @@ -10,7 +11,7 @@ def test_random_volume(): np.random.seed(0) data = np.random.random(shape) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) @@ -30,16 +31,16 @@ def test_switching_displayed_dimensions(): # check displayed data is initially 2D assert layer._data_view.shape == shape[-2:] - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) # check displayed data is now 3D assert layer._data_view.shape == shape[-3:] - layer._slice_dims(ndisplay=2) + layer._slice_dims(Dims(ndim=3, ndisplay=2)) # check displayed data is now 2D assert layer._data_view.shape == shape[-2:] layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) @@ -47,11 +48,11 @@ def test_switching_displayed_dimensions(): # check displayed data is initially 3D assert layer._data_view.shape == shape[-3:] - layer._slice_dims(ndisplay=2) + layer._slice_dims(Dims(ndim=3, ndisplay=2)) # check displayed data is now 2D assert layer._data_view.shape == shape[-2:] - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) # check displayed data is now 3D assert layer._data_view.shape == shape[-3:] @@ -61,7 +62,7 @@ def test_all_zeros_volume(): shape = (10, 15, 20) data = np.zeros(shape, dtype=float) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) @@ -74,7 +75,7 @@ def test_integer_volume(): np.random.seed(0) data = np.round(10 * np.random.random(shape)).astype(int) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) @@ -87,7 +88,7 @@ def test_3D_volume(): np.random.seed(0) data = np.random.random(shape) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) @@ -100,7 +101,7 @@ def test_4D_volume(): np.random.seed(0) data = np.random.random(shape) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=4, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal(layer.extent.data[1], [s - 1 for s in shape]) @@ -115,7 +116,7 @@ def test_changing_volume(): data_a = np.random.random(shape_a) data_b = np.random.random(shape_b) layer = Image(data_a) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) layer.data = data_b np.testing.assert_array_equal(layer.data, data_b) assert layer.ndim == len(shape_b) @@ -133,7 +134,7 @@ def test_scale(): np.random.seed(0) data = np.random.random(shape) layer = Image(data, scale=scale) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == len(shape) np.testing.assert_array_equal( @@ -149,7 +150,7 @@ def test_value(): np.random.seed(0) data = np.random.random((10, 15, 20)) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) value = layer.get_value((0,) * 3) assert value == data[0, 0, 0] @@ -159,7 +160,7 @@ def test_message(): np.random.seed(0) data = np.random.random((10, 15, 20)) layer = Image(data) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) msg = layer.get_status((0,) * 3) assert isinstance(msg, dict) diff --git a/napari/layers/image/image.py b/napari/layers/image/image.py index 104c929aa7b..25b69f52a86 100644 --- a/napari/layers/image/image.py +++ b/napari/layers/image/image.py @@ -5,7 +5,7 @@ import types import warnings from contextlib import nullcontext -from typing import TYPE_CHECKING, Tuple, Union +from typing import TYPE_CHECKING, List, Sequence, Tuple, Union, cast import numpy as np from scipy import ndimage as ndi @@ -14,8 +14,10 @@ from napari.layers._multiscale_data import MultiScaleData from napari.layers.base import Layer from napari.layers.image._image_constants import ( + ImageProjectionMode, ImageRendering, Interpolation, + InterpolationStr, VolumeDepiction, ) from napari.layers.image._image_mouse_bindings import ( @@ -25,12 +27,12 @@ from napari.layers.image._image_utils import guess_multiscale, guess_rgb from napari.layers.image._slice import _ImageSliceRequest, _ImageSliceResponse from napari.layers.intensity_mixin import IntensityVisualizationMixin -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.layer_utils import calc_data_range from napari.layers.utils.plane import SlicingPlane from napari.utils._dask_utils import DaskIndexer from napari.utils._dtype import get_dtype_limits, normalize_dtype -from napari.utils.colormaps import AVAILABLE_COLORMAPS +from napari.utils.colormaps import AVAILABLE_COLORMAPS, ensure_colormap from napari.utils.events import Event from napari.utils.events.event import WarningEmitter from napari.utils.events.event_utils import connect_no_arg @@ -220,6 +222,8 @@ class _ImageBase(IntensityVisualizationMixin, Layer): """ _colormaps = AVAILABLE_COLORMAPS + _interpolation2d: Interpolation + _interpolation3d: Interpolation @rename_argument( from_name="interpolation", @@ -234,7 +238,7 @@ def __init__( rgb=None, colormap='gray', contrast_limits=None, - gamma=1, + gamma=1.0, interpolation2d='nearest', interpolation3d='linear', rendering='mip', @@ -247,7 +251,7 @@ def __init__( rotate=None, shear=None, affine=None, - opacity=1, + opacity=1.0, blending='translucent', visible=True, multiscale=None, @@ -256,6 +260,7 @@ def __init__( plane=None, experimental_clipping_planes=None, custom_interpolation_kernel_2d=None, + projection_mode='none', ) -> None: if name is None and data is not None: name = magic_name(data) @@ -308,6 +313,7 @@ def __init__( multiscale=multiscale, cache=cache, experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, ) self.events.add( @@ -354,7 +360,7 @@ def __init__( ) self._slice = _ImageSliceResponse.make_empty( - dims=self._slice_input, rgb=self.rgb + slice_input=self._slice_input, rgb=self.rgb ) # Set contrast limits, colormaps and plane parameters @@ -386,7 +392,7 @@ def __init__( # _set_colormap method. This is important for Labels layers, because # we don't want to use get_color before set_view_slice has been # triggered (self.refresh(), below). - self._set_colormap(colormap) + self._colormap = ensure_colormap(colormap) self.contrast_limits = self._contrast_limits self._interpolation2d = Interpolation.NEAREST self._interpolation3d = Interpolation.NEAREST @@ -403,7 +409,7 @@ def __init__( self.refresh() @property - def _data_view(self): + def _data_view(self) -> np.ndarray: """Viewable image for the current slice. (compatibility)""" return self._slice.image.view @@ -432,7 +438,9 @@ def dtype(self): return self._data.dtype @property - def data_raw(self): + def data_raw( + self, + ) -> Union[LayerDataProtocol, Sequence[LayerDataProtocol]]: """Data, exactly as provided by the user.""" return self._data_raw @@ -452,7 +460,7 @@ def data(self, data: Union[LayerDataProtocol, MultiScaleData]): self.reset_contrast_limits() self._reset_editable() - def _get_ndim(self): + def _get_ndim(self) -> int: """Determine number of dimensions of the layer.""" return len(self.level_shapes[0]) @@ -473,12 +481,28 @@ def _extent_data_augmented(self) -> np.ndarray: return extent + [[-0.5], [+0.5]] @property - def data_level(self): + def _extent_level_data(self) -> np.ndarray: + """Extent of layer, accounting for current multiscale level, in data coordinates. + + Returns + ------- + extent_data : array, shape (2, D) + """ + shape = self.level_shapes[self.data_level] + return np.vstack([np.zeros(len(shape)), shape - 1]) + + @property + def _extent_level_data_augmented(self) -> np.ndarray: + extent = self._extent_level_data + return extent + [[-0.5], [+0.5]] + + @property + def data_level(self) -> int: """int: Current level of multiscale, or 0 if image.""" return self._data_level @data_level.setter - def data_level(self, level): + def data_level(self, level: int): if self._data_level == level: return self._data_level = level @@ -581,11 +605,11 @@ def interpolation(self, interpolation): self.interpolation2d = interpolation @property - def interpolation2d(self): - return str(self._interpolation2d) + def interpolation2d(self) -> InterpolationStr: + return cast(InterpolationStr, str(self._interpolation2d)) @interpolation2d.setter - def interpolation2d(self, value): + def interpolation2d(self, value: Union[InterpolationStr, Interpolation]): if value == 'bilinear': raise ValueError( trans._( @@ -604,11 +628,11 @@ def interpolation2d(self, value): self.events.interpolation(value=self._interpolation2d) @property - def interpolation3d(self): - return str(self._interpolation3d) + def interpolation3d(self) -> InterpolationStr: + return cast(InterpolationStr, str(self._interpolation3d)) @interpolation3d.setter - def interpolation3d(self, value): + def interpolation3d(self, value: Union[InterpolationStr, Interpolation]): if value == 'custom': raise NotImplementedError( 'custom interpolation is not implemented yet for 3D rendering' @@ -711,22 +735,12 @@ def _raw_to_displayed(self, raw): def _set_view_slice(self) -> None: """Set the slice output based on this layer's current state.""" - # Skip if any non-displayed data indices are out of bounds. - # This can happen when slicing layers with different extents. - indices = self._slice_indices - for d in self._slice_input.not_displayed: - if (indices[d] < 0) or (indices[d] > self._extent_data[1][d]): - self._slice = _ImageSliceResponse.make_empty( - dims=self._slice_input, rgb=self.rgb - ) - return - # The new slicing code makes a request from the existing state and # executes the request on the calling thread directly. # For async slicing, the calling thread will not be the main thread. request = self._make_slice_request_internal( slice_input=self._slice_input, - indices=indices, + data_slice=self._data_slice, dask_indexer=nullcontext, ) response = request() @@ -734,19 +748,17 @@ def _set_view_slice(self) -> None: def _make_slice_request(self, dims: Dims) -> _ImageSliceRequest: """Make an image slice request based on the given dims and this image.""" - slice_input = self._make_slice_input( - dims.point, dims.ndisplay, dims.order - ) + slice_input = self._make_slice_input(dims) # For the existing sync slicing, indices is passed through # to avoid some performance issues related to the evaluation of the # data-to-world transform and its inverse. Async slicing currently # absorbs these performance issues here, but we can likely improve # things either by caching the world-to-data transform on the layer # or by lazily evaluating it in the slice task itself. - indices = slice_input.data_indices(self._data_to_world.inverse) + indices = slice_input.data_slice(self._data_to_world.inverse) return self._make_slice_request_internal( slice_input=slice_input, - indices=indices, + data_slice=indices, dask_indexer=self.dask_optimized_slicing, ) @@ -754,7 +766,7 @@ def _make_slice_request_internal( self, *, slice_input: _SliceInput, - indices: Tuple[Union[int, slice], ...], + data_slice: _ThickNDSlice, dask_indexer: DaskIndexer, ) -> _ImageSliceRequest: """Needed to support old-style sync slicing through _slice_dims and @@ -764,10 +776,11 @@ def _make_slice_request_internal( the async slicing project: https://github.com/napari/napari/issues/4795 """ return _ImageSliceRequest( - dims=slice_input, + slice_input=slice_input, data=self.data, dask_indexer=dask_indexer, - indices=indices, + data_slice=data_slice, + projection_mode=self.projection_mode, multiscale=self.multiscale, corner_pixels=self.corner_pixels, rgb=self.rgb, @@ -781,7 +794,7 @@ def _update_slice_response(self, response: _ImageSliceResponse) -> None: """Update the slice output state currently on the layer. Currently used for both sync and async slicing. """ - self._slice_input = response.dims + self._slice_input = response.slice_input self._transforms[0] = response.tile_to_data self._slice = response @@ -900,8 +913,27 @@ def _get_offset_data_position(self, position: npt.NDArray) -> npt.NDArray: """ return position + 0.5 + def _display_bounding_box_at_level( + self, dims_displayed: List[int], data_level: int + ) -> npt.NDArray: + """An axis aligned (ndisplay, 2) bounding box around the data at a given level""" + shape = self.level_shapes[data_level] + extent_at_level = np.vstack([np.zeros(len(shape)), shape - 1]) + return extent_at_level[:, dims_displayed].T + + def _display_bounding_box_augmented_data_level( + self, dims_displayed: List[int] + ) -> npt.NDArray: + """An augmented, axis-aligned (ndisplay, 2) bounding box. + If the layer is multiscale layer, then returns the + bounding box of the data at the current level + """ + return self._extent_level_data_augmented[:, dims_displayed].T + class Image(_ImageBase): + _projectionclass = ImageProjectionMode + @property def rendering(self): """Return current rendering mode. diff --git a/napari/layers/intensity_mixin.py b/napari/layers/intensity_mixin.py index 963f622d16d..8e400a119cf 100644 --- a/napari/layers/intensity_mixin.py +++ b/napari/layers/intensity_mixin.py @@ -152,6 +152,6 @@ def gamma(self): @gamma.setter def gamma(self, value): - self._gamma = value + self._gamma = float(value) self._update_thumbnail() self.events.gamma() diff --git a/napari/layers/labels/_labels_key_bindings.py b/napari/layers/labels/_labels_key_bindings.py index fc9d9ff026b..d22b4195e53 100644 --- a/napari/layers/labels/_labels_key_bindings.py +++ b/napari/layers/labels/_labels_key_bindings.py @@ -153,4 +153,7 @@ def reset_polygon(layer: Labels): ) def complete_polygon(layer: Labels): """Complete the drawing of the current polygon.""" + # Because layer._overlays has type Overlay, mypy doesn't know that + # ._overlays["polygon"] has type LabelsPolygonOverlay, so type ignore for now + # TODO: Improve typing of layer._overlays to fix this layer._overlays["polygon"].add_polygon_to_labels(layer) diff --git a/napari/layers/labels/_labels_utils.py b/napari/layers/labels/_labels_utils.py index 508585681b6..f6b7b07859d 100644 --- a/napari/layers/labels/_labels_utils.py +++ b/napari/layers/labels/_labels_utils.py @@ -47,7 +47,7 @@ def sphere_indices(radius, scale): """Generate centered indices within circle or n-dim ellipsoid. Parameters - ------- + ---------- radius : float Radius of circle/sphere scale : tuple of float @@ -205,7 +205,7 @@ def mouse_event_to_labels_coordinate(layer, event): return coordinates -def get_contours(labels, thickness: int, background_label: int): +def get_contours(labels: np.ndarray, thickness: int, background_label: int): """Computes the contours of a 2D label image. Parameters @@ -220,7 +220,7 @@ def get_contours(labels, thickness: int, background_label: int): Returns ------- - A new label image in which only the boundaries of the input image are kept. + A new label image in which only the boundaries of the input image are kept. """ struct_elem = ndi.generate_binary_structure(labels.ndim, 1) @@ -243,12 +243,10 @@ def expand_slice( ) -> Tuple[slice, ...]: """Expands or shrinks a provided multi-axis slice by a given offset""" return tuple( - [ - slice( - max(0, min(max_size, s.start - offset)), - max(0, min(max_size, s.stop + offset)), - s.step, - ) - for s, max_size in zip(axes_slice, shape) - ] + slice( + max(0, min(max_size, s.start - offset)), + max(0, min(max_size, s.stop + offset)), + s.step, + ) + for s, max_size in zip(axes_slice, shape) ) diff --git a/napari/layers/labels/_tests/test_labels.py b/napari/layers/labels/_tests/test_labels.py index f8f11b236e7..721711016f1 100644 --- a/napari/layers/labels/_tests/test_labels.py +++ b/napari/layers/labels/_tests/test_labels.py @@ -1,21 +1,23 @@ +import copy import itertools import time -import warnings from dataclasses import dataclass from tempfile import TemporaryDirectory from typing import List import numpy as np +import numpy.testing as npt import pandas as pd import pytest import xarray as xr import zarr from numpy.core.numerictypes import issubdtype from numpy.testing import assert_array_almost_equal, assert_raises -from skimage import data +from skimage import data as sk_data from napari._tests.utils import check_layer_world_data_extent from napari.components import ViewerModel +from napari.components.dims import Dims from napari.layers import Labels from napari.layers.labels._labels_constants import LabelsRendering from napari.layers.labels._labels_utils import get_contours @@ -59,8 +61,7 @@ def test_3D_labels(): assert layer._data_view.shape == shape[-2:] assert layer.editable is True - layer._slice_dims(ndisplay=3) - assert layer._slice_input.ndisplay == 3 + layer._slice_dims(Dims(ndim=3, ndisplay=3)) assert layer.editable is True assert layer.mode == 'pan_zoom' @@ -236,6 +237,7 @@ def test_blending(): assert layer.blending == 'opaque' +@pytest.mark.filterwarnings("ignore:.*seed is deprecated.*") def test_seed(): """Test setting seed.""" np.random.seed(0) @@ -250,9 +252,9 @@ def test_seed(): assert layer.seed == 0.7 # ensure setting seed updates the random colormap - mapped_07 = layer._random_colormap.map(layer.data) + mapped_07 = layer.colormap.map(layer.data) layer.seed = 0.4 - mapped_04 = layer._random_colormap.map(layer.data) + mapped_04 = layer.colormap.map(layer.data) assert_raises( AssertionError, assert_array_almost_equal, mapped_07, mapped_04 ) @@ -271,6 +273,16 @@ def test_num_colors(): layer = Labels(data, num_colors=60) assert layer.num_colors == 60 + with pytest.raises( + ValueError, match=r".*Only up to 2\*\*16=65535 colors are supported" + ): + layer.num_colors = 2**17 + + with pytest.raises( + ValueError, match=r".*Only up to 2\*\*16=65535 colors are supported" + ): + Labels(data, num_colors=2**17) + def test_properties(): """Test adding labels with properties.""" @@ -535,6 +547,7 @@ def test_n_edit_dimensions(): np.zeros((9, 10), dtype=np.uint32), ), ], + ids=['touching objects', 'touching border', 'full array'], ) def test_contour(input_data, expected_data_view): """Test changing contour.""" @@ -582,6 +595,24 @@ def test_contour(input_data, expected_data_view): layer.contour = -1 +@pytest.mark.parametrize("background_num", [0, 1, 2, -1]) +def test_background_label(background_num): + data = np.zeros((10, 10), dtype=np.int32) + data[1:-1, 1:-1] = 1 + data[2:-2, 2:-2] = 2 + data[4:-4, 4:-4] = -1 + + layer = Labels(data) + layer._background_label = background_num + layer.num_colors = 49 + np.testing.assert_array_equal( + layer._data_view == 0, data == background_num + ) + np.testing.assert_array_equal( + layer._data_view != 0, data != background_num + ) + + def test_contour_large_new_labels(): """Check that new labels larger than the lookup table work in contour mode. @@ -627,8 +658,8 @@ def test_contour_local_updates(): layer.data_setitem(np.nonzero(painting_mask), 1, refresh=True) - assert np.alltrue( - (layer._slice.image.view > 0) == get_contours(painting_mask, 1, 0) + assert np.array_equiv( + (layer._slice.image.view > 0), get_contours(painting_mask, 1, 0) ) @@ -801,13 +832,15 @@ def test_paint_polygon(): layer = Labels(data) layer.paint_polygon([[0, 0], [0, 5], [5, 5], [5, 0]], 2) - assert np.alltrue(layer.data[:5, :5] == 2) - assert np.alltrue(layer.data[:10, 6:10] == 1) - assert np.alltrue(layer.data[6:10, :10] == 1) + assert np.array_equiv(layer.data[:5, :5], 2) + assert np.array_equiv(layer.data[:10, 6:10], 1) + assert np.array_equiv(layer.data[6:10, :10], 1) layer.paint_polygon([[7, 7], [7, 7], [7, 7]], 3) assert layer.data[7, 7] == 3 - assert np.alltrue(layer.data[[6, 7, 8, 7, 8, 6], [7, 6, 7, 8, 8, 6]] == 1) + assert np.array_equiv( + layer.data[[6, 7, 8, 7, 8, 6], [7, 6, 7, 8, 8, 6]], 1 + ) data[:10, :10] = 0 gt_pattern = np.array( @@ -848,8 +881,8 @@ def test_paint_polygon_2d_in_3d(): layer.paint_polygon([[1, 0, 0], [1, 0, 9], [1, 9, 9], [1, 9, 0]], 1) - assert np.alltrue(data[1, :] == 1) - assert np.alltrue(data[[0, 2], :] == 0) + assert np.array_equiv(data[1, :], 1) + assert np.array_equiv(data[[0, 2], :], 0) def test_fill(): @@ -889,7 +922,7 @@ def test_value_3d(position, view_direction, dims_displayed, world): data = np.zeros((20, 20, 20), dtype=int) data[0:10, 0:10, 0:10] = 1 layer = Labels(data) - layer._slice_dims([0, 0, 0], ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) value = layer.get_value( position, view_direction=view_direction, @@ -917,11 +950,22 @@ def test_thumbnail(): assert layer.thumbnail.shape == layer._thumbnail_shape +@pytest.mark.parametrize("value", [1, 10, 50, -2, -10]) +@pytest.mark.parametrize("dtype", [np.int8, np.int32]) +def test_thumbnail_single_color(value, dtype): + labels = Labels(np.full((10, 10), value, dtype=dtype), opacity=1) + labels._update_thumbnail() + mid_point = tuple(np.array(labels.thumbnail.shape[:2]) // 2) + npt.assert_array_equal( + labels.thumbnail[mid_point], labels.get_color(value) * 255 + ) + + def test_world_data_extent(): """Test extent after applying transforms.""" np.random.seed(0) shape = (6, 10, 15) - data = np.random.randint(20, size=(shape)) + data = np.random.randint(20, size=shape) layer = Labels(data) extent = np.array(((0,) * 3, [s - 1 for s in shape])) check_layer_world_data_extent(layer, extent, (3, 1, 1), (10, 20, 5)) @@ -946,7 +990,7 @@ def test_undo_redo( preserve_labels, n_dimensional, ): - blobs = data.binary_blobs(length=64, volume_fraction=0.3, n_dim=3) + blobs = sk_data.binary_blobs(length=64, volume_fraction=0.3, n_dim=3) layer = Labels(blobs) data_history = [blobs.copy()] layer.brush_size = brush_size @@ -1002,7 +1046,7 @@ def test_ndim_paint(): assert not np.any(layer.data[0]) and not np.any(layer.data[2:]) layer.n_edit_dimensions = 2 # 3x3 square - layer._slice_dims(order=[1, 2, 0, 3]) + layer._slice_dims(Dims(ndim=4, order=(1, 2, 0, 3))) layer.paint((4, 5, 6, 7), 8) assert len(np.flatnonzero(layer.data == 8)) == 4 # 2D square is in corner np.testing.assert_array_equal( @@ -1146,7 +1190,7 @@ def test_get_value_ray_3d(): labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5)) # set the dims to the slice with labels - labels._slice_dims([1, 0, 0, 0], ndisplay=3) + labels._slice_dims(Dims(ndim=4, ndisplay=3, point=(1, 0, 0, 0))) value = labels._get_value_ray( start_point=np.array([1, 0, 5, 5]), @@ -1164,7 +1208,7 @@ def test_get_value_ray_3d(): assert value is None # set the dims to a slice without labels - labels._slice_dims([0, 0, 0, 0], ndisplay=3) + labels._slice_dims(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 0))) value = labels._get_value_ray( start_point=np.array([0, 0, 5, 5]), @@ -1191,7 +1235,9 @@ def test_get_value_ray_3d_rolled(): labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5, 0)) # set the dims to the slice with labels - labels._slice_dims((0, 0, 0, 1), ndisplay=3, order=(3, 0, 1, 2)) + labels._slice_dims( + Dims(ndim=4, ndisplay=3, order=(3, 0, 1, 2), point=(0, 0, 0, 1)) + ) labels.set_view_slice() value = labels._get_value_ray( @@ -1219,7 +1265,9 @@ def test_get_value_ray_3d_transposed(): labels = Labels(data, scale=(1, 2, 1, 1), translate=(0, 5, 5, 5)) # set the dims to the slice with labels - labels._slice_dims((1, 0, 0, 0), ndisplay=3, order=(0, 1, 3, 2)) + labels._slice_dims( + Dims(ndim=4, ndisplay=3, order=(0, 1, 3, 2), point=(1, 0, 0, 0)) + ) labels.set_view_slice() value = labels._get_value_ray( @@ -1247,7 +1295,7 @@ def test_get_value_ray_2d(): labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5)) # set the dims to the slice with labels, but 2D - labels._slice_dims([1, 10, 0, 0], ndisplay=2) + labels._slice_dims(Dims(ndim=4, ndisplay=2, point=(1, 10, 0, 0))) value = labels._get_value_ray( start_point=np.empty([]), @@ -1271,7 +1319,7 @@ def test_cursor_ray_3d(): labels = Labels(data, scale=(1, 1, 2, 1), translate=(5, 5, 5)) # set the slice to one with data and the view to 3D - labels._slice_dims([1, 0, 0, 0], ndisplay=3) + labels._slice_dims(Dims(ndim=4, ndisplay=3, point=(1, 0, 0, 0))) # axis 0 : [0, 20], bounding box extents along view axis, [1, 0, 0] # click is transformed: (value - translation) / scale @@ -1309,7 +1357,7 @@ def test_cursor_ray_3d(): dims_displayed=[1, 2, 3], view_direction=[0, 1, 0, 0], ) - labels._slice_dims([0, 0, 0, 0], ndisplay=3) + labels._slice_dims(Dims(ndim=4, ndisplay=3)) start_point, end_point = labels.get_ray_intersections( mouse_event_3.position, mouse_event_3.view_direction, @@ -1336,7 +1384,7 @@ def test_cursor_ray_3d_rolled(): labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5, 0)) # set the slice to one with data and the view to 3D - labels._slice_dims([0, 0, 0, 1], ndisplay=3) + labels._slice_dims(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 1))) start_point, end_point = labels.get_ray_intersections( mouse_event_1.position, @@ -1364,7 +1412,7 @@ def test_cursor_ray_3d_transposed(): labels = Labels(data, scale=(1, 2, 1, 1), translate=(5, 5, 5, 0)) # set the slice to one with data and the view to 3D - labels._slice_dims([0, 0, 0, 1], ndisplay=3) + labels._slice_dims(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 1))) start_point, end_point = labels.get_ray_intersections( mouse_event_1.position, @@ -1437,11 +1485,11 @@ def test_invalidate_cache_when_change_color_mode(): layer = Labels(data) layer.selected_label = 0 gt_auto = layer._raw_to_displayed(layer._slice.image.raw) - assert gt_auto.dtype == np.float32 + assert gt_auto.dtype == np.uint8 layer.color_mode = 'direct' layer._cached_labels = None - assert layer._raw_to_displayed(layer._slice.image.raw).dtype == np.float32 + assert layer._raw_to_displayed(layer._slice.image.raw).dtype == np.uint8 layer.color_mode = 'auto' # If the cache is not invalidated, it returns colors for @@ -1451,6 +1499,20 @@ def test_invalidate_cache_when_change_color_mode(): ) +@pytest.mark.parametrize("dtype", np.sctypes['int'] + np.sctypes['uint']) +@pytest.mark.parametrize("mode", ["auto", "direct"]) +def test_cache_for_dtypes(dtype, mode): + data = np.zeros((10, 10), dtype=dtype) + labels = Labels(data) + labels.color_mode = mode + assert labels._cached_labels is None + labels._raw_to_displayed( + labels._slice.image.raw, (slice(None), slice(None)) + ) + assert labels._cached_labels is not None + assert labels._cached_mapped_labels.dtype == labels._slice.image.view.dtype + + def test_color_mapping_when_color_is_changed(): """Checks if the color mapping is computed correctly when the color palette is changed.""" @@ -1460,7 +1522,7 @@ def test_color_mapping_when_color_is_changed(): gt_direct_3colors = layer._raw_to_displayed(layer._slice.image.raw) layer = Labels(data, color={1: 'green', 2: 'red'}) - assert layer._raw_to_displayed(layer._slice.image.raw).dtype == np.float32 + assert layer._raw_to_displayed(layer._slice.image.raw).dtype == np.uint8 layer.color = {1: 'green', 2: 'red', 3: 'white'} assert np.allclose( @@ -1482,10 +1544,10 @@ def test_color_mapping_with_show_selected_label(): label_mask = data == selected_label mapped_colors = layer.colormap.map(data) - assert np.allclose( + npt.assert_allclose( mapped_colors[label_mask], mapped_colors_all[label_mask] ) - assert np.allclose(mapped_colors[np.logical_not(label_mask)], 0) + npt.assert_allclose(mapped_colors[np.logical_not(label_mask)], 0) layer.show_selected_label = False assert np.allclose(layer.colormap.map(data), mapped_colors_all) @@ -1507,6 +1569,24 @@ def test_color_mapping_when_seed_is_changed(): assert not np.allclose(mapped_colors1, mapped_colors2) +@pytest.mark.parametrize('num_colors', [49, 50, 254, 255, 60000, 65534]) +def test_color_shuffling_above_num_colors(num_colors): + r"""Check that the color shuffle does not result in the same collisions. + + See https://github.com/napari/napari/issues/6448. + + Note that we don't support more than 2\ :sup:`16` colors, and behavior + with more colors is undefined, so we don't test it here. + """ + labels = np.arange(1, 1 + 2 * num_colors).reshape((2, num_colors)) + layer = Labels(labels, num_colors=num_colors) + colors0 = layer.colormap.map(labels) + assert np.all(colors0[0] == colors0[1]) + layer.new_colormap() + colors1 = layer.colormap.map(labels) + assert not np.all(colors1[0] == colors1[1]) + + def test_negative_label(): """Test negative label values are supported.""" data = np.random.randint(low=-1, high=20, size=(10, 10)) @@ -1525,7 +1605,7 @@ def test_negative_label_slicing(): data = np.array([[[0, 1], [-1, -1]], [[100, 100], [-1, -2]]]) layer = Labels(data) assert tuple(layer.get_color(1)) != tuple(layer.get_color(-1)) - layer._slice_dims(point=(1, 0, 0)) + layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) assert tuple(layer.get_color(-1)) != tuple(layer.get_color(100)) assert tuple(layer.get_color(-2)) != tuple(layer.get_color(100)) @@ -1539,7 +1619,7 @@ def test_negative_label_doesnt_flicker(): ] ) layer = Labels(data) - layer._slice_dims(point=(1, 0, 0)) + layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) # This used to fail when negative values were used to index into _all_vals. assert tuple(layer.get_color(-1)) != tuple(layer.get_color(5)) minus_one_color_original = tuple(layer.get_color(-1)) @@ -1572,17 +1652,6 @@ def test_get_status_with_custom_index(): ) -def test_collision_warning(): - label = Labels(data=np.zeros((10, 10), dtype=np.uint8)) - with pytest.warns( - RuntimeWarning, match="Because integer labels are cast to less-precise" - ): - label.color = {2**25 + 1: 'red', 2**25 + 2: 'blue'} - with warnings.catch_warnings(): - warnings.simplefilter("error") - label.color = {1: 'red', 2: 'blue'} - - def test_labels_features_event(): event_emitted = False @@ -1598,6 +1667,23 @@ def on_event(): assert event_emitted +def test_invalidate_cache_when_change_slice(): + layer = Labels(np.zeros((2, 4, 5), dtype=np.uint8)) + assert layer._cached_labels is None + layer._setup_cache(layer._slice.image.raw) + assert layer._cached_labels is not None + layer._set_view_slice() + assert layer._cached_labels is None + + +def test_copy(): + l1 = Labels(np.zeros((2, 4, 5), dtype=np.uint8)) + l2 = copy.copy(l1) + l3 = Labels.create(*l1.as_layer_data_tuple()) + assert l1.data is not l2.data + assert l1.data is l3.data + + class TestLabels: @staticmethod def get_objects(): @@ -1606,5 +1692,5 @@ def get_objects(): def test_events_defined(self, event_define_check, obj): event_define_check( obj, - {"seed", "num_colors", "color"}, + {"seed", "num_colors", "color", "seed_rng"}, ) diff --git a/napari/layers/labels/_tests/test_labels_mouse_bindings.py b/napari/layers/labels/_tests/test_labels_mouse_bindings.py index 43ca9860a2c..6b264cfe3c6 100644 --- a/napari/layers/labels/_tests/test_labels_mouse_bindings.py +++ b/napari/layers/labels/_tests/test_labels_mouse_bindings.py @@ -1,6 +1,7 @@ import numpy as np from scipy import ndimage as ndi +from napari.components.dims import Dims from napari.layers import Labels from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import ( @@ -392,7 +393,7 @@ def test_paint_3d(MouseEvent): data = np.zeros((21, 21, 21), dtype=np.int32) data[10, 10, 10] = 1 layer = Labels(data) - layer._slice_dims(point=(0, 0, 0), ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) layer.n_edit_dimensions = 3 layer.mode = 'paint' @@ -445,7 +446,7 @@ def test_erase_3d_undo(MouseEvent): layer = Labels(data) layer.brush_size = 5 layer.mode = 'erase' - layer._slice_dims(point=(0, 0, 0), ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) layer.n_edit_dimensions = 3 # Simulate click @@ -514,7 +515,7 @@ def test_erase_3d_undo_empty(MouseEvent): layer = Labels(data) layer.brush_size = 5 layer.mode = 'erase' - layer._slice_dims(point=(0, 0, 0), ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) layer.n_edit_dimensions = 3 # Simulate click, outside data diff --git a/napari/layers/labels/_tests/test_labels_multiscale.py b/napari/layers/labels/_tests/test_labels_multiscale.py new file mode 100644 index 00000000000..e2249804e6c --- /dev/null +++ b/napari/layers/labels/_tests/test_labels_multiscale.py @@ -0,0 +1,117 @@ +import numpy as np + +from napari.components.dims import Dims +from napari.layers import Labels + + +def test_random_multiscale(): + """Test instantiating Labels layer with random 2D multiscale data.""" + shapes = [(40, 20), (20, 10), (10, 5)] + np.random.seed(0) + data = [np.random.randint(20, size=s) for s in shapes] + layer = Labels(data, multiscale=True) + assert layer.data == data + assert layer.multiscale is True + assert layer.editable is False + assert layer.ndim == len(shapes[0]) + np.testing.assert_array_equal( + layer.extent.data[1], [s - 1 for s in shapes[0]] + ) + assert layer.rgb is False + assert layer._data_view.ndim == 2 + + +def test_infer_multiscale(): + """Test instantiating Labels layer with random 2D multiscale data.""" + shapes = [(40, 20), (20, 10), (10, 5)] + np.random.seed(0) + data = [np.random.randint(20, size=s) for s in shapes] + layer = Labels(data) + assert layer.data == data + assert layer.multiscale is True + assert layer.editable is False + assert layer.ndim == len(shapes[0]) + np.testing.assert_array_equal( + layer.extent.data[1], [s - 1 for s in shapes[0]] + ) + assert layer.rgb is False + assert layer._data_view.ndim == 2 + + +def test_3D_multiscale_labels_in_2D(): + """Test instantiating Labels layer with 3D data, 2D dims.""" + data_multiscale, layer = instantiate_3D_multiscale_labels() + + assert layer.data == data_multiscale + assert layer.multiscale is True + assert layer.editable is False + assert layer.ndim == len(data_multiscale[0].shape) + np.testing.assert_array_equal( + layer.extent.data[1], np.array(data_multiscale[0].shape) - 1 + ) + assert layer.rgb is False + assert layer._data_view.ndim == 2 + + # check corner pixels, should be tuple of highest resolution level + assert layer.get_value([0, 0, 0]) == ( + layer.data_level, + data_multiscale[0][0, 0, 0], + ) + + +def test_3D_multiscale_labels_in_3D(): + """Test instantiating Labels layer with 3D data, 3D dims.""" + data_multiscale, layer = instantiate_3D_multiscale_labels() + + # use 3D dims + layer._slice_dims(Dims(ndim=3, ndisplay=3)) + assert layer._data_view.ndim == 3 + + # check corner pixels, should be value of lowest resolution level + # [0,0,0] has value 0, which is transparent, so the ray will hit the next point + # which is [1, 0, 0] and has value 4 + # the position array is in original data coords (no downsampling) + assert ( + layer.get_value( + [0, 0, 0], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] + ) + == 4 + ) + assert ( + layer.get_value( + [0, 0, 0], view_direction=[-1, 0, 0], dims_displayed=[0, 1, 2] + ) + == 4 + ) + assert ( + layer.get_value( + [0, 1, 1], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] + ) + == 4 + ) + assert ( + layer.get_value( + [0, 5, 5], view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] + ) + == 3 + ) + assert ( + layer.get_value( + [5, 0, 5], view_direction=[0, 0, -1], dims_displayed=[0, 1, 2] + ) + == 5 + ) + + +def instantiate_3D_multiscale_labels(): + lowest_res_scale = np.arange(8).reshape(2, 2, 2) + middle_res_scale = ( + lowest_res_scale.repeat(2, axis=0).repeat(2, axis=1).repeat(2, axis=2) + ) + highest_res_scale = ( + middle_res_scale.repeat(2, axis=0).repeat(2, axis=1).repeat(2, axis=2) + ) + + data_multiscale = [highest_res_scale, middle_res_scale, lowest_res_scale] + + return data_multiscale, Labels(data_multiscale, multiscale=True) diff --git a/napari/layers/labels/_tests/test_labels_pyramid.py b/napari/layers/labels/_tests/test_labels_pyramid.py deleted file mode 100644 index 14f5104d351..00000000000 --- a/napari/layers/labels/_tests/test_labels_pyramid.py +++ /dev/null @@ -1,54 +0,0 @@ -import numpy as np - -from napari.layers import Labels - - -def test_random_multiscale(): - """Test instantiating Labels layer with random 2D multiscale data.""" - shapes = [(40, 20), (20, 10), (10, 5)] - np.random.seed(0) - data = [np.random.randint(20, size=s) for s in shapes] - layer = Labels(data, multiscale=True) - assert layer.data == data - assert layer.multiscale is True - assert layer.editable is False - assert layer.ndim == len(shapes[0]) - np.testing.assert_array_equal( - layer.extent.data[1], [s - 1 for s in shapes[0]] - ) - assert layer.rgb is False - assert layer._data_view.ndim == 2 - - -def test_infer_multiscale(): - """Test instantiating Labels layer with random 2D multiscale data.""" - shapes = [(40, 20), (20, 10), (10, 5)] - np.random.seed(0) - data = [np.random.randint(20, size=s) for s in shapes] - layer = Labels(data) - assert layer.data == data - assert layer.multiscale is True - assert layer.editable is False - assert layer.ndim == len(shapes[0]) - np.testing.assert_array_equal( - layer.extent.data[1], [s - 1 for s in shapes[0]] - ) - assert layer.rgb is False - assert layer._data_view.ndim == 2 - - -def test_3D_multiscale(): - """Test instantiating Labels layer with 3D data.""" - shapes = [(8, 40, 20), (4, 20, 10), (2, 10, 5)] - np.random.seed(0) - data = [np.random.randint(20, size=s) for s in shapes] - layer = Labels(data, multiscale=True) - assert layer.data == data - assert layer.multiscale is True - assert layer.editable is False - assert layer.ndim == len(shapes[0]) - np.testing.assert_array_equal( - layer.extent.data[1], [s - 1 for s in shapes[0]] - ) - assert layer.rgb is False - assert layer._data_view.ndim == 2 diff --git a/napari/layers/labels/_tests/test_labels_utils.py b/napari/layers/labels/_tests/test_labels_utils.py index d4b8265b257..ae044f6a2d9 100644 --- a/napari/layers/labels/_tests/test_labels_utils.py +++ b/napari/layers/labels/_tests/test_labels_utils.py @@ -1,5 +1,6 @@ import numpy as np +from napari.components.dims import Dims from napari.layers.labels import Labels from napari.layers.labels._labels_utils import ( first_nonzero_coordinate, @@ -107,7 +108,7 @@ def test_mouse_event_to_labels_coordinate_3d(MouseEvent): data = np.zeros((11, 11, 11), dtype=int) data[4:7, 4:7, 4:7] = 1 layer = Labels(data, scale=(2, 2, 2)) - layer._slice_dims(point=(0, 0, 0), ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) # click straight down from the top # (note the scale on the layer!) diff --git a/napari/layers/labels/labels.py b/napari/layers/labels/labels.py index dc558be19dc..36158e98c30 100644 --- a/napari/layers/labels/labels.py +++ b/napari/layers/labels/labels.py @@ -5,11 +5,11 @@ Callable, ClassVar, Dict, - Iterable, List, Optional, Tuple, Union, + cast, ) import numpy as np @@ -51,10 +51,19 @@ ensure_colormap, label_colormap, ) -from napari.utils.events import Event +from napari.utils.colormaps.colormap import ( + LabelColormap, + LabelColormapBase, + _cast_labels_data_to_texture_dtype_auto, + _cast_labels_data_to_texture_dtype_direct, + _texture_dtype, +) +from napari.utils.colormaps.colormap_utils import shuffle_and_extend_colormap +from napari.utils.events import EmitterGroup, Event from napari.utils.events.custom_types import Array from napari.utils.geometry import clamp_point_to_bounding_box from napari.utils.indexing import index_in_slice +from napari.utils.migrations import deprecated_constructor_arg_by_attr from napari.utils.misc import StringEnum, _is_array_type from napari.utils.naming import magic_name from napari.utils.status_messages import generate_layer_coords_status @@ -226,6 +235,9 @@ class Labels(_ImageBase): background label `0` is selected. """ + events: EmitterGroup + _colormap: LabelColormapBase + _modeclass = Mode _drag_modes: ClassVar[Dict[Mode, Callable[["Labels", Event], None]]] = { # type: ignore[assignment] @@ -262,6 +274,7 @@ class Labels(_ImageBase): _history_limit = 100 + @deprecated_constructor_arg_by_attr("seed") def __init__( self, data, @@ -270,7 +283,7 @@ def __init__( features=None, properties=None, color=None, - seed=0.5, + seed_rng=None, name=None, metadata=None, scale=None, @@ -287,20 +300,25 @@ def __init__( cache=True, plane=None, experimental_clipping_planes=None, + projection_mode='none', ) -> None: if name is None and data is not None: name = magic_name(data) - self._seed = seed + self._seed = 0.5 + self._seed_rng: Optional[int] = seed_rng self._background_label = 0 self._num_colors = num_colors - self._random_colormap = label_colormap(self.num_colors, seed) + self._random_colormap = label_colormap( + self.num_colors, self.seed, self._background_label + ) + self._original_random_colormap = self._random_colormap self._direct_colormap = direct_colormap() self._color_mode = LabelColorMode.AUTO self._show_selected_label = False self._contour = 0 - self._cached_labels: Optional[np.ndarray] = None - self._cached_mapped_labels: Optional[np.ndarray] = None + self._cached_labels = None + self._cached_mapped_labels = np.zeros((0, 4), dtype=np.uint8) data = self._ensure_int_labels(data) @@ -328,6 +346,7 @@ def __init__( cache=cache, plane=plane, experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, ) self.events.add( @@ -463,18 +482,82 @@ def seed(self): @seed.setter def seed(self, seed): + warnings.warn( + "seed is deprecated since 0.4.19 and will be removed in 0.5.0, please use seed_rng instead", + FutureWarning, + stacklevel=2, + ) + self._seed = seed - self.colormap.seed = seed + self.colormap = label_colormap( + self.num_colors, self.seed, self._background_label + ) self._cached_labels = None # invalidate the cached color mapping self._selected_color = self.get_color(self.selected_label) self.events.colormap() # Will update the LabelVispyColormap shader self.refresh() self.events.selected_label() - @_ImageBase.colormap.setter - def colormap(self, colormap): - super()._set_colormap(colormap) + @property + def seed_rng(self) -> Optional[int]: + return self._seed_rng + + @seed_rng.setter + def seed_rng(self, seed_rng: Optional[int]) -> None: + if seed_rng == self._seed_rng: + return + self._seed_rng = seed_rng + + if self._seed_rng is None: + self.colormap = label_colormap( + self.num_colors, self.seed, self._background_label + ) + else: + self._random_colormap = shuffle_and_extend_colormap( + self._original_random_colormap, self._seed_rng + ) + self._cached_labels = None # invalidate the cached color mapping + self._selected_color = self.get_color(self.selected_label) + self.events.colormap() # Will update the LabelVispyColormap shader + self.events.selected_label() + + self.refresh() + + @property + def colormap(self) -> LabelColormapBase: + if self.color_mode == LabelColorMode.AUTO: + return self._random_colormap + return self._direct_colormap + + @colormap.setter + def colormap(self, colormap: LabelColormapBase): + self._set_colormap(colormap) + + def _set_colormap(self, colormap): + if isinstance(colormap, LabelColormap): + self._random_colormap = colormap + self._original_random_colormap = colormap + self._colormap = self._random_colormap + color_mode = LabelColorMode.AUTO + else: + self._direct_colormap = colormap + # `self._direct_colormap.color_dict` may contain just the default None and background label + # colors, in which case we need to be in AUTO color mode. Otherwise, + # `self._direct_colormap.color_dict` contains colors for all labels, and we should be in DIRECT + # mode. + + # For more information + # - https://github.com/napari/napari/issues/2479 + # - https://github.com/napari/napari/issues/2953 + if self._is_default_colors(self._direct_colormap.color_dict): + color_mode = LabelColorMode.AUTO + self._colormap = self._random_colormap + else: + color_mode = LabelColorMode.DIRECT + self._colormap = self._direct_colormap self._selected_color = self.get_color(self.selected_label) + self.events.colormap() # Will update the LabelVispyColormap shader + self.color_mode = color_mode @property def num_colors(self): @@ -483,8 +566,12 @@ def num_colors(self): @num_colors.setter def num_colors(self, num_colors): + self.colormap = label_colormap( + num_colors, self.seed, self._background_label + ) self._num_colors = num_colors - self.colormap = label_colormap(num_colors) + self._cached_labels = None # invalidate the cached color mapping + self._cached_mapped_labels = None self.refresh() self._selected_color = self.get_color(self.selected_label) self.events.selected_label() @@ -549,9 +636,9 @@ def _make_label_index(self) -> Dict[int, int]: return label_index @property - def color(self): + def color(self) -> dict: """dict: custom color dict for label coloring""" - return self._color + return {**self._direct_colormap.color_dict} @color.setter def color(self, color): @@ -561,10 +648,12 @@ def color(self, color): if self._background_label not in color: color[self._background_label] = 'transparent' - none_color = color.pop(None, 'black') - self._validate_colors(color) + default_color = color.pop(None, 'black') + # this is default color for label that is not in the color dict + # is provided as None key + # we pop it as `None` cannot be cast to float - color[None] = none_color + color[None] = default_color colors = { label: transform_color(color_str)[0] @@ -572,61 +661,7 @@ def color(self, color): } self._color = colors - self._direct_colormap = direct_colormap(colors) - - # `colors` may contain just the default None and background label - # colors, in which case we need to be in AUTO color mode. Otherwise, - # `colors` contains colors for all labels, and we should be in DIRECT - # mode. - - # For more information - # - https://github.com/napari/napari/issues/2479 - # - https://github.com/napari/napari/issues/2953 - if self._is_default_colors(colors): - color_mode = LabelColorMode.AUTO - else: - color_mode = LabelColorMode.DIRECT - - self.color_mode = color_mode - - @classmethod - def _validate_colors(cls, labels: Iterable[int]): - """Check whether any of the given labels will be aliased together. - - See https://github.com/napari/napari/issues/6084 for details. - """ - labels_int = np.fromiter(labels, dtype=int) - labels_unique = np.unique(cls._to_vispy_texture_dtype(labels_int)) - if labels_unique.size == labels_int.size: - return - - # recalculate here second time to provide best performance on colors that are not colliding - labels_unique, inverse, count = np.unique( - cls._to_vispy_texture_dtype(labels_int), - return_inverse=True, - return_counts=True, - ) - collided_idx = np.where(count > 1)[0] - aliased_list = [ - labels_int[np.where(inverse == idx)[0]] for idx in collided_idx - ] - - alias_string = "\n".join( - trans._( - 'Labels {col_li} will display as the same color as {col_la};', - col_li=",".join(str(i) for i in lst[:-1]), - col_la=str(lst[-1]), - ) - for lst in aliased_list - ) - warn_text = trans._( - "Because integer labels are cast to less-precise float for display, " - "the following label sets will render as the same color:\n" - "{alias_string}\n" - "See https://github.com/napari/napari/issues/6084 for details.", - alias_string=alias_string, - ) - warnings.warn(warn_text, category=RuntimeWarning) + self.colormap = direct_colormap(colors) def _is_default_colors(self, color): """Returns True if color contains only default colors, otherwise False. @@ -705,7 +740,7 @@ def _get_state(self): 'experimental_clipping_planes': [ plane.dict() for plane in self.experimental_clipping_planes ], - 'seed': self.seed, + 'seed_rng': self.seed_rng, 'data': self.data, 'color': self.color, 'features': self.features, @@ -891,13 +926,89 @@ def _partial_labels_refresh(self): colors_sliced = self._raw_to_displayed( raw_displayed, data_slice=updated_slice ) + # The next line is needed to make the following tests pass in + # napari/_vispy/_tests/: + # - test_vispy_labels_layer.py::test_labels_painting + # - test_vispy_labels_layer.py::test_labels_fill_slice + # See https://github.com/napari/napari/pull/6112/files#r1291613760 + # and https://github.com/napari/napari/issues/6185 + self._slice.image.view[updated_slice] = colors_sliced self.events.labels_update(data=colors_sliced, offset=offset) self._updated_slice = None + def _calculate_contour( + self, labels: np.ndarray, data_slice: Tuple[slice, ...] + ) -> Optional[np.ndarray]: + """Calculate the contour of a given label array within the specified data slice. + + Parameters + ---------- + labels : np.ndarray + The label array. + data_slice : Tuple[slice, ...] + The slice of the label array on which to calculate the contour. + + Returns + ------- + Optional[np.ndarray] + The calculated contour as a boolean mask array. + Returns None if the contour parameter is less than 1, + or if the label array has more than 2 dimensions. + """ + if self.contour < 1: + return None + if labels.ndim > 2: + warnings.warn( + trans._( + "Contours are not displayed during 3D rendering", + deferred=True, + ) + ) + return None + + expanded_slice = expand_slice(data_slice, labels.shape, 1) + sliced_labels = get_contours( + labels[expanded_slice], + self.contour, + self._background_label, + ) + + # Remove the latest one-pixel border from the result + delta_slice = tuple( + slice(s1.start - s2.start, s1.stop - s2.start) + for s1, s2 in zip(data_slice, expanded_slice) + ) + return sliced_labels[delta_slice] + + def _get_cache_dtype(self, raw_dtype: np.dtype) -> np.dtype: + if self.color_mode == LabelColorMode.DIRECT: + return _texture_dtype( + self._direct_colormap._num_unique_colors + 2, + raw_dtype, + ) + return _texture_dtype(self.num_colors, raw_dtype) + + def _setup_cache(self, labels): + """ + Initializes the cache for the Labels layer + + Parameters + ---------- + labels : numpy array + The labels data to be cached + """ + if self._cached_labels is not None: + return + + self._cached_labels = np.zeros_like(labels) + self._cached_mapped_labels = np.zeros_like( + labels, dtype=self._get_cache_dtype(labels.dtype) + ) + def _raw_to_displayed( self, raw, data_slice: Optional[Tuple[slice, ...]] = None - ): + ) -> np.ndarray: """Determine displayed image from a saved raw image and a saved seed. This function ensures that the 0 label gets mapped to the 0 displayed @@ -912,43 +1023,24 @@ def _raw_to_displayed( Slice that specifies the portion of the input image that should be computed and displayed. If None, the whole input image will be processed. + Returns ------- mapped_labels : array Encoded colors mapped between 0 and 1 to be displayed. """ + if data_slice is None: data_slice = tuple(slice(0, size) for size in raw.shape) + self._cached_labels = None + else: + self._setup_cache(raw) labels = raw # for readability - sliced_labels = None - # lookup function -> self._as_type - if self.contour > 0: - if labels.ndim == 2: - # Add one more pixel for the correct borders computation - expanded_slice = expand_slice(data_slice, labels.shape, 1) - sliced_labels = get_contours( - labels[expanded_slice], - self.contour, - self._background_label, - ) + sliced_labels = self._calculate_contour(labels, data_slice) - # Remove the latest one-pixel border from the result - delta_slice = tuple( - [ - slice(s1.start - s2.start, s1.stop - s2.start) - for s1, s2 in zip(data_slice, expanded_slice) - ] - ) - sliced_labels = sliced_labels[delta_slice] - elif labels.ndim > 2: - warnings.warn( - trans._( - "Contours are not displayed during 3D rendering", - deferred=True, - ) - ) + # lookup function -> self._as_type if sliced_labels is None: sliced_labels = labels[data_slice] @@ -966,26 +1058,30 @@ def _raw_to_displayed( # Update the cache self._cached_labels[data_slice][update_mask] = labels_to_map else: - _cached_labels = np.zeros_like(labels) - _cached_labels[data_slice] = sliced_labels.copy() - self._cached_labels = _cached_labels - self._cached_mapped_labels = np.zeros_like( - labels, dtype=np.float32 - ) labels_to_map = sliced_labels # If there are no changes, just return the cached image if labels_to_map.size == 0: return self._cached_mapped_labels[data_slice] - mapped_labels = self._to_vispy_texture_dtype(labels_to_map) - - if update_mask is not None: - self._cached_mapped_labels[data_slice][update_mask] = mapped_labels - else: - self._cached_mapped_labels[data_slice] = mapped_labels + if self.color_mode == LabelColorMode.AUTO: + mapped_labels = _cast_labels_data_to_texture_dtype_auto( + labels_to_map, self._random_colormap + ) + else: # direct + mapped_labels = _cast_labels_data_to_texture_dtype_direct( + labels_to_map, self._direct_colormap + ) - return self._cached_mapped_labels[data_slice] + if self._cached_labels is not None: + if update_mask is not None: + self._cached_mapped_labels[data_slice][ + update_mask + ] = mapped_labels + else: + self._cached_mapped_labels[data_slice] = mapped_labels + return self._cached_mapped_labels[data_slice] + return mapped_labels def _update_thumbnail(self): """Update the thumbnail with current data and colormap. @@ -999,7 +1095,7 @@ def _update_thumbnail(self): # Is there a nicer way to prevent this from getting called? return - image = self._slice.thumbnail.view + image = self._slice.thumbnail.raw if self._slice_input.ndisplay == 3 and self.ndim > 2: # we are only using the current slice so `image` will never be # bigger than 3. If we are in this clause, it is exactly 3, so we @@ -1016,17 +1112,13 @@ def _update_thumbnail(self): zoom_factor = tuple(new_shape / imshape) downsampled = ndi.zoom(image, zoom_factor, prefilter=False, order=0) - if self.color_mode == LabelColorMode.AUTO: - color_array = self.colormap.map(downsampled.ravel()) - else: # direct - color_array = self._direct_colormap.map(downsampled.ravel()) - colormapped = color_array.reshape(downsampled.shape + (4,)) - colormapped[..., 3] *= self.opacity + color_array = self.colormap.map(downsampled) + color_array[..., 3] *= self.opacity - self.thumbnail = colormapped + self.thumbnail = color_array def new_colormap(self): - self.seed = np.random.rand() + self.seed_rng = np.random.default_rng().integers(2**32 - 1) def get_color(self, label): """Return the color corresponding to a specific label.""" @@ -1035,16 +1127,15 @@ def get_color(self, label): elif label is None or ( self.show_selected_label and label != self.selected_label ): - col = self.colormap.map([0, 0, 0, 0])[0] + col = self.colormap.map(self._background_label)[0] else: - val = self._to_vispy_texture_dtype(np.array([label])) - col = self.colormap.map(val)[0] + col = self.colormap.map(label)[0] return col def _get_value_ray( self, - start_point: np.ndarray, - end_point: np.ndarray, + start_point: Optional[np.ndarray], + end_point: Optional[np.ndarray], dims_displayed: List[int], ) -> Optional[int]: """Get the first non-background value encountered along a ray. @@ -1071,8 +1162,18 @@ def _get_value_ray( # we use dims_displayed because the image slice # has its dimensions in th same order as the vispy # Volume - start_point = start_point[dims_displayed] - end_point = end_point[dims_displayed] + # Account for downsampling in the case of multiscale + # -1 means lowest resolution here. + start_point = ( + start_point[dims_displayed] + / self.downsample_factors[-1][dims_displayed] + ) + end_point = ( + end_point[dims_displayed] + / self.downsample_factors[-1][dims_displayed] + ) + start_point = cast(np.ndarray, start_point) + end_point = cast(np.ndarray, end_point) sample_ray = end_point - start_point length_sample_vector = np.linalg.norm(sample_ray) n_points = int(2 * length_sample_vector) @@ -1080,7 +1181,10 @@ def _get_value_ray( start_point, end_point, n_points, endpoint=True ) im_slice = self._slice.image.raw - bounding_box = self._display_bounding_box(dims_displayed) + # ensure the bounding box is for the proper multiscale level + bounding_box = self._display_bounding_box_at_level( + dims_displayed, self.data_level + ) # the display bounding box is returned as a closed interval # (i.e. the endpoint is included) by the method, but we need # open intervals in the code that follows, so we add 1. @@ -1100,8 +1204,8 @@ def _get_value_ray( def _get_value_3d( self, - start_point: np.ndarray, - end_point: np.ndarray, + start_point: Optional[np.ndarray], + end_point: Optional[np.ndarray], dims_displayed: List[int], ) -> Optional[int]: """Get the first non-background value encountered along a ray. @@ -1428,7 +1532,7 @@ def _paint_indices( Value of the new label to be filled in. shape : list The label data shape upon which painting is performed. - dims_to_paint: list + dims_to_paint : list List of dimensions of the label data that are used for painting. refresh : bool Whether to refresh view slice or not. Set to False to batch paint @@ -1523,9 +1627,13 @@ def data_setitem(self, indices, value, refresh=True): # array, or a NumPy-array-backed Xarray, is the slice a view and # therefore updated automatically. # For other types, we update it manually here. - dims = self._slice.dims - point = np.round(self.world_to_data(dims.point)).astype(int) - pt_not_disp = {dim: point[dim] for dim in dims.not_displayed} + slice_input = self._slice.slice_input + point = np.round( + self.world_to_data(slice_input.world_slice.point) + ).astype(int) + pt_not_disp = { + dim: point[dim] for dim in slice_input.not_displayed + } displayed_indices = index_in_slice(indices, pt_not_disp) self._slice.image.raw[displayed_indices] = value diff --git a/napari/layers/points/_points_constants.py b/napari/layers/points/_points_constants.py index b86be8b21b8..5f43389eea5 100644 --- a/napari/layers/points/_points_constants.py +++ b/napari/layers/points/_points_constants.py @@ -107,3 +107,15 @@ class Shading(StringEnum): trans._("none"): Shading.NONE, trans._("spherical"): Shading.SPHERICAL, } + + +class PointsProjectionMode(StringEnum): + """ + Projection mode for aggregating a thick nD slice onto displayed dimensions. + + * NONE: ignore slice thickness, only using the dims point + * ALL: project all points in the slice onto displayed dimensions + """ + + NONE = auto() + ALL = auto() diff --git a/napari/layers/points/_points_mouse_bindings.py b/napari/layers/points/_points_mouse_bindings.py index ee1e02bf6d3..1e0b4739123 100644 --- a/napari/layers/points/_points_mouse_bindings.py +++ b/napari/layers/points/_points_mouse_bindings.py @@ -156,7 +156,7 @@ def _toggle_selected(selection: Set[_T], value: _T) -> Set[_T]: Parameters ---------- - selection: set + selection : set Set of selected data points to be modified. value : int Index of point to add or remove from selected data set. diff --git a/napari/layers/points/_slice.py b/napari/layers/points/_slice.py index 5e4b46f6c4a..39dcfe4c757 100644 --- a/napari/layers/points/_slice.py +++ b/napari/layers/points/_slice.py @@ -1,11 +1,11 @@ from dataclasses import dataclass, field -from typing import Any, Union +from typing import Any import numpy as np -from numpy.typing import ArrayLike from napari.layers.base._slice import _next_request_id -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.points._points_constants import PointsProjectionMode +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice @dataclass(frozen=True) @@ -19,15 +19,15 @@ class _PointSliceResponse: scale: array like or none Used to scale the sliced points for visualization. Should be broadcastable to indices. - dims : _SliceInput + slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. request_id : int The identifier of the request from which this was generated. """ - indices: ArrayLike = field(repr=False) - scale: Union[ArrayLike, float] = field(repr=False) - dims: _SliceInput + indices: np.ndarray = field(repr=False) + scale: Any = field(repr=False) + slice_input: _SliceInput request_id: int @@ -48,17 +48,18 @@ class _PointSliceRequest: Describes the slicing plane or bounding box in the layer's dimensions. data : Any The layer's data field, which is the main input to slicing. - dims_indices : tuple of ints or slices - The slice indices in the layer's data space. + data_slice : _ThickNDSlice + The slicing coordinates and margins in data space. size : array like Size of each point. This is used in calculating visibility. others See the corresponding attributes in `Layer` and `Points`. """ - dims: _SliceInput + slice_input: _SliceInput data: Any = field(repr=False) - dims_indices: Any = field(repr=False) + data_slice: _ThickNDSlice = field(repr=False) + projection_mode: PointsProjectionMode size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) id: int = field(default_factory=_next_request_id) @@ -69,61 +70,70 @@ def __call__(self) -> _PointSliceResponse: return _PointSliceResponse( indices=[], scale=np.empty(0), - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) - not_disp = list(self.dims.not_displayed) + not_disp = list(self.slice_input.not_displayed) if not not_disp: # If we want to display everything, then use all indices. # scale is only impacted by not displayed data, therefore 1 return _PointSliceResponse( indices=np.arange(len(self.data), dtype=int), - scale=1.0, - dims=self.dims, + scale=1, + slice_input=self.slice_input, request_id=self.id, ) - # We want a numpy array so we can use fancy indexing with the non-displayed - # indices, but as self.dims_indices can (and often/always does) contain slice - # objects, the array has dtype=object which is then very slow for the - # arithmetic below. As Points._round_index is always False, we can safely - # convert to float to get a major performance improvement. - not_disp_indices = np.array(self.dims_indices)[not_disp].astype(float) - - if self.out_of_slice_display and self.dims.ndim > 2: - slice_indices, scale = self._get_out_of_display_slice_data( - not_disp, not_disp_indices - ) - else: - slice_indices, scale = self._get_slice_data( - not_disp, not_disp_indices - ) + slice_indices, scale = self._get_slice_data(not_disp) return _PointSliceResponse( indices=slice_indices, scale=scale, - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) - def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): - """This method slices in the out-of-display case.""" - distances = abs(self.data[:, not_disp] - not_disp_indices) - sizes = self.size[:, np.newaxis] / 2 - matches = np.all(distances <= sizes, axis=1) - if not np.any(matches): - return np.empty(0, dtype=int), 1 - size_match = sizes[matches] - scale_per_dim = (size_match - distances[matches]) / size_match - scale = np.prod(scale_per_dim, axis=1) - slice_indices = np.where(matches)[0].astype(int) - return slice_indices, scale - - def _get_slice_data(self, not_disp, not_disp_indices): - """This method slices in the simpler case.""" + def _get_slice_data(self, not_disp): data = self.data[:, not_disp] - distances = np.abs(data - not_disp_indices) - matches = np.all(distances <= 0.5, axis=1) - slice_indices = np.where(matches)[0].astype(int) - return slice_indices, 1 + scale = 1 + + point, m_left, m_right = self.data_slice[not_disp].as_array() + + if self.projection_mode == 'none': + low = point.copy() + high = point.copy() + else: + low = point - m_left + high = point + m_right + + # assume slice thickness of 1 in data pixels + # (same as before thick slices were implemented) + too_thin_slice = np.isclose(high, low) + low[too_thin_slice] -= 0.5 + high[too_thin_slice] += 0.5 + + inside_slice = np.all((data >= low) & (data <= high), axis=1) + slice_indices = np.where(inside_slice)[0].astype(int) + + if self.out_of_slice_display and self.slice_input.ndim > 2: + sizes = self.size[:, np.newaxis] / 2 + + # add out of slice points with progressively lower sizes + dist_from_low = np.abs(data - low) + dist_from_high = np.abs(data - high) + distances = np.minimum(dist_from_low, dist_from_high) + # anything inside the slice is at distance 0 + distances[inside_slice] = 0 + + # display points that "spill" into the slice + matches = np.all(distances <= sizes, axis=1) + if not np.any(matches): + return np.empty(0, dtype=int), 1 + size_match = sizes[matches] + # rescale size of spilling points based on how much they do + scale_per_dim = (size_match - distances[matches]) / size_match + scale = np.prod(scale_per_dim, axis=1) + slice_indices = np.where(matches)[0].astype(int) + + return slice_indices, scale diff --git a/napari/layers/points/_tests/test_points.py b/napari/layers/points/_tests/test_points.py index 997a8394b29..ed81b4cb963 100644 --- a/napari/layers/points/_tests/test_points.py +++ b/napari/layers/points/_tests/test_points.py @@ -6,19 +6,20 @@ import pandas as pd import pytest from psygnal.containers import Selection -from pydantic import ValidationError from vispy.color import get_colormap +from napari._pydantic_compat import ValidationError from napari._tests.utils import ( assert_colors_equal, assert_layer_state_equal, check_layer_world_data_extent, ) +from napari.components.dims import Dims from napari.layers import Points from napari.layers.base._base_constants import ActionType from napari.layers.points._points_constants import Mode from napari.layers.points._points_utils import points_to_squares -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils._text_constants import Anchor from napari.layers.utils.color_encoding import ConstantColorEncoding from napari.layers.utils.color_manager import ColorProperties @@ -327,7 +328,7 @@ def test_selecting_points(): assert layer.selected_data == data_to_select # test switching to 3D - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndisplay=3)) assert layer.selected_data == data_to_select # select different points while in 3D mode @@ -336,7 +337,7 @@ def test_selecting_points(): assert layer.selected_data == other_data_to_select # selection should persist when going back to 2D mode - layer._slice_dims(ndisplay=2) + layer._slice_dims(Dims(ndisplay=2)) assert layer.selected_data == other_data_to_select # selection should persist when switching between between select and pan_zoom @@ -1641,7 +1642,7 @@ def test_value_3d( """Test get_value in 3D with and without scale""" data = np.array([[0, 10, 15, 15], [0, 10, 5, 5], [0, 5, 15, 15]]) layer = Points(data, size=5, scale=scale) - layer._slice_dims([0, 0, 0, 0], ndisplay=3) + layer._slice_dims(Dims(ndim=4, ndisplay=3)) value = layer.get_value( position, view_direction=view_direction, @@ -1672,7 +1673,9 @@ def test_message_3d(): data = 20 * np.random.random(shape) layer = Points(data) layer._slice_input = _SliceInput( - ndisplay=3, point=(0, 0, 0), order=(0, 1, 2) + ndisplay=3, + world_slice=_ThickNDSlice.make_full(ndim=2), + order=(0, 1, 2), ) msg = layer.get_status( (0, 0, 0), view_direction=[1, 0, 0], dims_displayed=[0, 1, 2] @@ -1738,7 +1741,7 @@ def test_thumbnail_with_n_points_greater_than_max(): # #3D bigger_data_3d = np.random.randint(10, 100, (max_points, 3)) bigger_layer_3d = Points(bigger_data_3d) - bigger_layer_3d._slice_dims(ndisplay=3) + bigger_layer_3d._slice_dims(Dims(ndim=3, ndisplay=3)) bigger_layer_3d._update_thumbnail() assert bigger_layer_3d.thumbnail.shape == bigger_layer_3d._thumbnail_shape @@ -1747,34 +1750,37 @@ def test_view_data(): coords = np.array([[0, 1, 1], [0, 2, 2], [1, 3, 3], [3, 3, 3]]) layer = Points(coords) - layer._slice_dims([0, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) assert np.array_equal(layer._view_data, coords[np.ix_([0, 1], [1, 2])]) - layer._slice_dims([1, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) assert np.array_equal(layer._view_data, coords[np.ix_([2], [1, 2])]) - layer._slice_dims([1, slice(None), slice(None)], ndisplay=3) + layer._slice_dims(Dims(ndim=3, point=(1, 0, 0), ndisplay=3)) assert np.array_equal(layer._view_data, coords) def test_view_size(): """Test out of slice point rendering and slicing with no points.""" - coords = np.array([[0, 1, 1], [0, 2, 2], [1, 3, 3], [3, 3, 3]]) + coords = np.array([[0, 1, 1], [0, 2, 2], [1, 3, 3], [4, 3, 3]]) sizes = np.array([5, 5, 3, 3]) layer = Points(coords, size=sizes, out_of_slice_display=False) - layer._slice_dims([0, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) assert np.array_equal(layer._view_size, sizes[[0, 1]]) - layer._slice_dims([1, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) assert np.array_equal(layer._view_size, sizes[[2]]) layer.out_of_slice_display = True + # NOTE: since a dims slice of thickness 0 defaults back to 1, + # out_of_slice_display actually compares the half-size with + # distance + 0.5, not just distance assert len(layer._view_size) == 3 # test a slice with no points layer.out_of_slice_display = False - layer._slice_dims([2, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(2, 0, 0))) assert np.array_equal(layer._view_size, []) @@ -1788,16 +1794,16 @@ def test_view_colors(): ) layer = Points(coords, face_color=face_color, border_color=border_color) - layer._slice_dims([0, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) assert np.array_equal(layer._view_face_color, face_color[[0, 1]]) assert np.array_equal(layer._view_border_color, border_color[[0, 1]]) - layer._slice_dims([1, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(1, 0, 0))) assert np.array_equal(layer._view_face_color, face_color[[2]]) assert np.array_equal(layer._view_border_color, border_color[[2]]) # view colors should return empty array if there are no points - layer._slice_dims([2, slice(None), slice(None)]) + layer._slice_dims(Dims(ndim=3, point=(2, 0, 0))) assert len(layer._view_face_color) == 0 assert len(layer._view_border_color) == 0 @@ -2366,7 +2372,7 @@ def test_shown_view_size_and_view_data_have_the_same_dimension(): layer = Points(data, out_of_slice_display=True, shown=[True, True], size=3) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 2 - assert np.array_equal(layer._view_size, [3, 1]) + assert np.array_equiv(layer._view_size, [3, 2]) # Out of slice display == True && shown == [True, False] layer = Points( @@ -2382,7 +2388,7 @@ def test_shown_view_size_and_view_data_have_the_same_dimension(): ) assert layer._view_size.shape[0] == layer._view_data.shape[0] assert layer._view_size.shape[0] == 1 - assert np.array_equal(layer._view_size, [1]) + assert np.array_equal(layer._view_size, [2]) # Out of slice display == True && shown == [False, False] layer = Points( @@ -2453,10 +2459,10 @@ def test_set_drag_start(): @pytest.mark.parametrize( "dims_indices,target_indices", [ - ((8, slice(None), slice(None)), [2]), - ((10, slice(None), slice(None)), [0, 1, 3, 4]), - ((10 + 2 * 1e-12, slice(None), slice(None)), [0, 1, 3, 4]), - ((10.1, slice(None), slice(None)), [0, 1, 3, 4]), + ((8, np.nan, np.nan), [2]), + ((10, np.nan, np.nan), [0, 1, 3, 4]), + ((10 + 2 * 1e-12, np.nan, np.nan), [0, 1, 3, 4]), + ((10.1, np.nan, np.nan), [0, 1, 3, 4]), ], ) def test_point_slice_request_response(dims_indices, target_indices): @@ -2471,8 +2477,10 @@ def test_point_slice_request_response(dims_indices, target_indices): layer = Points(data) + data_slice = _ThickNDSlice.make_full(point=dims_indices) + request = layer._make_slice_request_internal( - layer._slice_input, dims_indices + layer._slice_input, data_slice ) response = request() @@ -2582,3 +2590,20 @@ def test_data_setter_events(): "data_indices": tuple(i for i in range(len(layer.data))), "vertex_indices": ((),), } + + +def test_thick_slice(): + data = np.array([[0, 0, 0], [10, 10, 10]]) + layer = Points(data) + + # only first point shown + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0))) + np.testing.assert_array_equal(layer._view_data, data[:1, -2:]) + + layer.projection_mode = 'all' + np.testing.assert_array_equal(layer._view_data, data[:1, -2:]) + + # if margin is thick enough and projection is `all`, + # it will take in the other point + layer._slice_dims(Dims(ndim=3, point=(0, 0, 0), margin_right=(10, 0, 0))) + np.testing.assert_array_equal(layer._view_data, data[:, -2:]) diff --git a/napari/layers/points/_tests/test_points_mouse_bindings.py b/napari/layers/points/_tests/test_points_mouse_bindings.py index a1c1819c318..02ed4186773 100644 --- a/napari/layers/points/_tests/test_points_mouse_bindings.py +++ b/napari/layers/points/_tests/test_points_mouse_bindings.py @@ -4,6 +4,7 @@ import numpy as np import pytest +from napari.components.dims import Dims from napari.layers import Points from napari.utils._proxies import ReadOnlyWrapper from napari.utils.interactions import ( @@ -82,7 +83,7 @@ def create_known_points_layer_3d(): n_points = len(data) layer = Points(data, size=1) - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) np.testing.assert_array_equal(layer.data, data) assert layer.ndim == 3 diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index cc9a85d4582..526cbb06831 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -19,7 +19,6 @@ import pandas as pd from numpy.typing import ArrayLike from psygnal.containers import Selection -from scipy.stats import gmean from napari.layers.base import Layer, no_op from napari.layers.base._base_constants import ActionType @@ -27,7 +26,11 @@ highlight_box_handles, transform_with_box, ) -from napari.layers.points._points_constants import Mode, Shading +from napari.layers.points._points_constants import ( + Mode, + PointsProjectionMode, + Shading, +) from napari.layers.points._points_mouse_bindings import add, highlight, select from napari.layers.points._points_utils import ( _create_box_from_corners_3d, @@ -38,7 +41,7 @@ ) from napari.layers.points._slice import _PointSliceRequest, _PointSliceResponse from napari.layers.utils._color_manager_constants import ColorMode -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.color_manager import ColorManager from napari.layers.utils.color_transformations import ColorType from napari.layers.utils.interactivity_utils import ( @@ -58,7 +61,6 @@ from napari.utils.geometry import project_points_onto_plane, rotate_points from napari.utils.migrations import add_deprecated_property, rename_argument from napari.utils.status_messages import generate_layer_coords_status -from napari.utils.transforms import Affine from napari.utils.translations import trans DEFAULT_COLOR_CYCLE = np.array([[1, 0, 1, 1], [0, 1, 0, 1]]) @@ -73,6 +75,7 @@ class _BasePoints(Layer): """ _modeclass = Mode + _projectionclass = PointsProjectionMode _drag_modes: ClassVar[Dict[Mode, Callable[["Points", Event], Any]]] = { Mode.PAN_ZOOM: no_op, @@ -130,7 +133,7 @@ def __init__( rotate=None, shear=None, affine=None, - opacity=1, + opacity=1.0, blending='translucent', visible=True, cache=True, @@ -140,6 +143,7 @@ def __init__( canvas_size_limits=(2, 10000), antialiasing=1, shown=True, + projection_mode='none', ) -> None: # Indices of selected points self._selected_data_stored = set() @@ -164,7 +168,6 @@ def __init__( self._drag_box_stored = None self._is_selecting = False self._clipboard = {} - self._round_index = False super().__init__( data, @@ -181,6 +184,7 @@ def __init__( visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, ) self.events.add( @@ -706,7 +710,7 @@ def border_width(self) -> np.ndarray: @border_width.setter def border_width( - self, border_width: Union[int, float, np.ndarray, list] + self, border_width: Union[float, np.ndarray, list] ) -> None: # broadcast to np.array border_width = np.broadcast_to( @@ -1323,10 +1327,12 @@ def _on_editable_changed(self) -> None: def _update_draw( self, scale_factor, corner_pixels_displayed, shape_threshold ): + prev_scale = self.scale_factor super()._update_draw( scale_factor, corner_pixels_displayed, shape_threshold ) - self._set_highlight(force=True) + # update highlight only if scale has changed, otherwise causes a cycle + self._set_highlight(force=(prev_scale != self.scale_factor)) def _get_value(self, position) -> Optional[int]: """Index of the point at a given 2D position in data coordinates. @@ -1503,32 +1509,28 @@ def _set_view_slice(self): # executes the request on the calling thread directly. # For async slicing, the calling thread will not be the main thread. request = self._make_slice_request_internal( - self._slice_input, self._slice_indices + self._slice_input, self._data_slice ) response = request() self._update_slice_response(response) def _make_slice_request(self, dims) -> Any: """Make a Points slice request based on the given dims and these data.""" - slice_input = self._make_slice_input( - dims.point, dims.ndisplay, dims.order - ) + slice_input = self._make_slice_input(dims) # See Image._make_slice_request to understand why we evaluate this here - # instead of using `self._slice_indices`. - slice_indices = slice_input.data_indices( - self._data_to_world.inverse, round_index=False - ) - return self._make_slice_request_internal(slice_input, slice_indices) + # instead of using `self._data_slice`. + data_slice = slice_input.data_slice(self._data_to_world.inverse) + return self._make_slice_request_internal(slice_input, data_slice) @abstractmethod def _make_slice_request_internal( - self, slice_input: _SliceInput, dims_indices: ArrayLike + self, slice_input: _SliceInput, data_slice: _ThickNDSlice ): raise NotImplementedError def _update_slice_response(self, response: _PointSliceResponse): """Handle a slicing response.""" - self._slice_input = response.dims + self._slice_input = response.slice_input indices = response.indices scale = response.scale @@ -2373,12 +2375,13 @@ def _get_ndim(self) -> int: return self.data.shape[1] def _make_slice_request_internal( - self, slice_input: _SliceInput, dims_indices: ArrayLike + self, slice_input: _SliceInput, data_slice: _ThickNDSlice ) -> _PointSliceRequest: return _PointSliceRequest( - dims=slice_input, + slice_input=slice_input, data=self.data, - dims_indices=dims_indices, + data_slice=data_slice, + projection_mode=self.projection_mode, out_of_slice_display=self.out_of_slice_display, size=self.size, ) @@ -2469,13 +2472,13 @@ def _move_points( def _paste_data(self): """Paste any point from clipboard and select them.""" npoints = len(self._view_data) - totpoints = len(self._points_data) + totpoints = len(self.data) if len(self._clipboard.keys()) > 0: not_disp = self._slice_input.not_displayed data = deepcopy(self._clipboard['data']) offset = [ - self._slice_indices[i] - self._clipboard['indices'][i] + self._data_slice[i] - self._clipboard['indices'][i] for i in not_disp ] data[:, not_disp] = data[:, not_disp] + np.array(offset) @@ -2494,13 +2497,13 @@ def _paste_data(self): self.text._paste(**self._clipboard['text']) - self._border_width = np.append( - self.border_width, - deepcopy(self._clipboard['border_width']), + self._edge_width = np.append( + self.edge_width, + deepcopy(self._clipboard['edge_width']), axis=0, ) - self._border._paste( - colors=self._clipboard['border_color'], + self._edge._paste( + colors=self._clipboard['edge_color'], properties=_features_to_properties( self._clipboard['features'] ), @@ -2526,14 +2529,14 @@ def _copy_data(self): index = list(self.selected_data) self._clipboard = { 'data': deepcopy(self.data[index]), - 'border_color': deepcopy(self.border_color[index]), + 'edge_color': deepcopy(self.edge_color[index]), 'face_color': deepcopy(self.face_color[index]), 'shown': deepcopy(self.shown[index]), 'size': deepcopy(self.size[index]), 'symbol': deepcopy(self.symbol[index]), - 'border_width': deepcopy(self.border_width[index]), + 'edge_width': deepcopy(self.edge_width[index]), 'features': deepcopy(self.features.iloc[index]), - 'indices': self._slice_indices, + 'indices': self._data_slice, 'text': self.text._copy(index), } else: @@ -2541,86 +2544,59 @@ def _copy_data(self): def to_mask( self, + position: Optional[Tuple] = None, *, - shape: tuple, - data_to_world: Optional[Affine] = None, - isotropic_output: bool = True, - ): - """Return a binary mask array of all the points as balls. + view_direction: Optional[np.ndarray] = None, + dims_displayed: Optional[List[int]] = None, + world: bool = False, + ) -> dict: + """Status message information of the data at a coordinate position. Parameters ---------- - shape : tuple - The shape of the mask to be generated. - data_to_world : Optional[Affine] - The data-to-world transform of the output mask image. This likely comes from a reference image. - If None, then this is the same as this layer's data-to-world transform. - isotropic_output : bool - If True, then force the output mask to always contain isotropic balls in data/pixel coordinates. - Otherwise, allow the anisotropy in the data-to-world transform to squash the balls in certain dimensions. - By default this is True, but you should set it to False if you are going to create a napari image - layer from the result with the same data-to-world transform and want the visualized balls to be - roughly isotropic. + position : tuple + Position in either data or world coordinates. + view_direction : Optional[np.ndarray] + A unit vector giving the direction of the ray in nD world coordinates. + The default value is None. + dims_displayed : Optional[List[int]] + A list of the dimensions currently being displayed in the viewer. + The default value is None. + world : bool + If True the position is taken to be in world coordinates + and converted into data coordinates. False by default. Returns ------- - np.ndarray - The output binary mask array of the given shape containing this layer's points as balls. + source_info : dict + Dict containing information that can be used in a status update. """ - if data_to_world is None: - data_to_world = self._data_to_world - mask = np.zeros(shape, dtype=bool) - mask_world_to_data = data_to_world.inverse - points_data_to_mask_data = self._data_to_world.compose( - mask_world_to_data - ) - points_in_mask_data_coords = np.atleast_2d( - points_data_to_mask_data(self.data) - ) + if position is not None: + value = self.get_value( + position, + view_direction=view_direction, + dims_displayed=dims_displayed, + world=world, + ) + else: + value = None - # Calculating the radii of the output points in the mask is complex. - radii = self.size / 2 - - # Scale each radius by the geometric mean scale of the Points layer to - # keep the balls isotropic when visualized in world coordinates. - # The geometric means are used instead of the arithmetic mean - # to maintain the volume scaling factor of the transforms. - point_data_to_world_scale = gmean(np.abs(self._data_to_world.scale)) - mask_world_to_data_scale = ( - gmean(np.abs(mask_world_to_data.scale)) - if isotropic_output - else np.abs(mask_world_to_data.scale) + source_info = self._get_source_info() + source_info['coordinates'] = generate_layer_coords_status( + position[-self.ndim :], value ) - radii_scale = point_data_to_world_scale * mask_world_to_data_scale - output_data_radii = radii[:, np.newaxis] * np.atleast_2d(radii_scale) + # if this points layer has properties + properties = self._get_properties( + position, + view_direction=view_direction, + dims_displayed=dims_displayed, + world=world, + ) + if properties: + source_info['coordinates'] += "; " + ", ".join(properties) - for coords, radii in zip( - points_in_mask_data_coords, output_data_radii - ): - # Define a minimal set of coordinates where the mask could be present - # by defining an inclusive lower and exclusive upper bound for each dimension. - lower_coords = np.maximum(np.floor(coords - radii), 0).astype(int) - upper_coords = np.minimum( - np.ceil(coords + radii) + 1, shape - ).astype(int) - # Generate every possible coordinate within the bounds defined above - # in a grid of size D1 x D2 x ... x Dd x D (e.g. for D=2, this might be 4x5x2). - submask_coords = [ - range(lower_coords[i], upper_coords[i]) - for i in range(self.ndim) - ] - submask_grids = np.stack( - np.meshgrid(*submask_coords, copy=False, indexing='ij'), - axis=-1, - ) - # Update the mask coordinates based on the normalized square distance - # using a logical or to maintain any existing positive mask locations. - normalized_square_distances = np.sum( - ((submask_grids - coords) / radii) ** 2, axis=-1 - ) - mask[np.ix_(*submask_coords)] |= normalized_square_distances <= 1 - return mask + return source_info Points._add_deprecated_properties() diff --git a/napari/layers/shapes/_shape_list.py b/napari/layers/shapes/_shape_list.py index d10a4c09f6a..c8483d9d51c 100644 --- a/napari/layers/shapes/_shape_list.py +++ b/napari/layers/shapes/_shape_list.py @@ -1,5 +1,7 @@ from collections.abc import Iterable -from typing import Sequence, Union +from contextlib import contextmanager +from functools import wraps +from typing import List, Sequence, Union import numpy as np @@ -15,6 +17,19 @@ from napari.utils.translations import trans +def _batch_dec(meth): + """ + Decorator to apply `self.batched_updates` to the current method. + """ + + @wraps(meth) + def _wrapped(self, *args, **kwargs): + with self.batched_updates(): + return meth(self, *args, **kwargs) + + return _wrapped + + class ShapeList: """List of shapes class. @@ -69,11 +84,11 @@ class ShapeList: def __init__(self, data=(), ndisplay=2) -> None: self._ndisplay = ndisplay - self.shapes = [] - self._displayed = [] - self._slice_key = [] - self.displayed_vertices = [] - self.displayed_index = [] + self.shapes: List[Shape] = [] + self._displayed = np.array([]) + self._slice_key = np.array([]) + self.displayed_vertices = np.array([]) + self.displayed_index = np.array([]) self._vertices = np.empty((0, self.ndisplay)) self._index = np.empty((0), dtype=int) self._z_index = np.empty((0), dtype=int) @@ -84,9 +99,52 @@ def __init__(self, data=(), ndisplay=2) -> None: self._edge_color = np.empty((0, 4)) self._face_color = np.empty((0, 4)) + # counter for the depth of re entrance of the context manager. + self.__batched_level = 0 + self.__batch_force_call = False + + # Counter of number of time _update_displayed has been requested + self.__update_displayed_called = 0 + for d in data: self.add(d) + @contextmanager + def batched_updates(self): + """ + Reentrant context manager to batch the display update + + `_update_displayed()` is called at _most_ once on exit of the context + manager. + + There are two reason for this: + + 1. Some updates are triggered by events, but sometimes multiple pieces + of data that trigger events must be set before the data can be + recomputed. For example changing number of dimension cause broacast + error on partially update structures. + 2. Performance. Ideally we want to update the display only once. + + If no direct or indirect call to `_update_displayed()` are made inside + the context manager, no the update logic is not called on exit. + + + + """ + assert self.__batched_level >= 0 + self.__batched_level += 1 + try: + yield + finally: + if self.__batched_level == 1 and self.__update_displayed_called: + self.__batch_force_call = True + self._update_displayed() + self.__batch_force_call = False + self.__update_displayed_called = 0 + self.__batched_level -= 1 + + assert self.__batched_level >= 0 + @property def data(self): """list of (M, D) array: data arrays for each shape.""" @@ -141,6 +199,7 @@ def face_color(self): def face_color(self, face_color): self._set_color(face_color, 'face') + @_batch_dec def _set_color(self, colors, attribute): """Set the face_color or edge_color property @@ -154,7 +213,7 @@ def _set_color(self, colors, attribute): Should be 'edge' for edge_color or 'face' for face_color. """ n_shapes = len(self.data) - if not np.all(colors.shape == (n_shapes, 4)): + if not np.array_equal(colors.shape, (n_shapes, 4)): raise ValueError( trans._( '{attribute}_color must have shape ({n_shapes}, 4)', @@ -185,14 +244,26 @@ def slice_key(self): return self._slice_key @slice_key.setter + @_batch_dec def slice_key(self, slice_key): slice_key = list(slice_key) - if not np.all(self._slice_key == slice_key): + if not np.array_equal(self._slice_key, slice_key): self._slice_key = slice_key self._update_displayed() def _update_displayed(self): - """Update the displayed data based on the slice key.""" + """Update the displayed data based on the slice key. + + This method must be called from within the `batched_updates` context + manager: + """ + assert ( + self.__batched_level >= 1 + ), "call _update_displayed from within self.batched_updates context manager" + if not self.__batch_force_call: + self.__update_displayed_called += 1 + return + # The list slice key is repeated to check against both the min and # max values stored in the shapes slice key. slice_key = np.array([self.slice_key, self.slice_key]) @@ -202,7 +273,7 @@ def _update_displayed(self): if len(self.shapes) > 0: self._displayed = np.all(self.slice_keys == slice_key, axis=(1, 2)) else: - self._displayed = [] + self._displayed = np.array([]) disp_indices = np.where(self._displayed)[0] z_order = self._mesh.triangles_z_order @@ -580,6 +651,7 @@ def _make_index(length, shape_index, cval=0): # Set z_order self._update_z_order() + @_batch_dec def remove_all(self): """Removes all shapes""" self.shapes = [] @@ -641,6 +713,7 @@ def remove(self, index, renumber=True): ) self._update_z_order() + @_batch_dec def _update_mesh_vertices(self, index, edge=False, face=False): """Updates the mesh vertex data and vertex data for a single shape located at index. @@ -674,6 +747,7 @@ def _update_mesh_vertices(self, index, edge=False, face=False): self._vertices[indices] = shape.data_displayed self._update_displayed() + @_batch_dec def _update_z_order(self): """Updates the z order of the triangles given the z_index list""" self._z_order = np.argsort(self._z_index) @@ -756,6 +830,7 @@ def update_edge_width(self, index, edge_width): self.shapes[index].edge_width = edge_width self._update_mesh_vertices(index, edge=True) + @_batch_dec def update_edge_color(self, index, edge_color, update=True): """Updates the edge color of a single shape located at index. @@ -777,6 +852,7 @@ def update_edge_color(self, index, edge_color, update=True): if update: self._update_displayed() + @_batch_dec def update_edge_colors(self, indices, edge_colors, update=True): """same as update_edge_color() but for multiple indices/edgecolors at once""" self._edge_color[indices] = edge_colors @@ -790,6 +866,7 @@ def update_edge_colors(self, indices, edge_colors, update=True): if update: self._update_displayed() + @_batch_dec def update_face_color(self, index, face_color, update=True): """Updates the face color of a single shape located at index. @@ -811,6 +888,7 @@ def update_face_color(self, index, face_color, update=True): if update: self._update_displayed() + @_batch_dec def update_face_colors(self, indices, face_colors, update=True): """same as update_face_color() but for multiple indices/facecolors at once""" self._face_color[indices] = face_colors diff --git a/napari/layers/shapes/_shapes_models/_polgyon_base.py b/napari/layers/shapes/_shapes_models/_polgyon_base.py index 1a8cf1e9e4f..cd56917d571 100644 --- a/napari/layers/shapes/_shapes_models/_polgyon_base.py +++ b/napari/layers/shapes/_shapes_models/_polgyon_base.py @@ -102,7 +102,7 @@ def _update_displayed_data(self): if self._closed: data = np.append(data, data[:1], axis=0) - tck, _ = splprep( + tck, *_ = splprep( data.T, s=0, k=self.interpolation_order, per=self._closed ) diff --git a/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py b/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py index 4627b320775..40b663544fa 100644 --- a/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py +++ b/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py @@ -1,4 +1,8 @@ +import sys + import numpy as np +import pytest +from vispy.geometry import PolygonData from napari.layers.shapes._shapes_models import ( Ellipse, @@ -7,6 +11,7 @@ Polygon, Rectangle, ) +from napari.layers.shapes._shapes_utils import triangulate_face def test_rectangle(): @@ -42,25 +47,74 @@ def test_nD_rectangle(): assert shape.data_displayed.shape == (4, 3) +def test_polygon_data_triangle(): + data = np.array( + [ + [10.97627008, 14.30378733], + [12.05526752, 10.89766366], + [8.47309599, 12.91788226], + [8.75174423, 17.83546002], + [19.27325521, 7.66883038], + [15.83450076, 10.5778984], + ] + ) + vertices, _triangles = PolygonData(vertices=data).triangulate() + + assert vertices.shape == (8, 2) + + +def test_polygon_data_triangle_module(): + pytest.importorskip("triangle") + data = np.array( + [ + [10.97627008, 14.30378733], + [12.05526752, 10.89766366], + [8.47309599, 12.91788226], + [8.75174423, 17.83546002], + [19.27325521, 7.66883038], + [15.83450076, 10.5778984], + ] + ) + vertices, _triangles = triangulate_face(data) + + assert vertices.shape == (6, 2) + + def test_polygon(): """Test creating Shape with a random polygon.""" # Test a single six vertex polygon - np.random.seed(0) - data = 20 * np.random.random((6, 2)) + data = np.array( + [ + [10.97627008, 14.30378733], + [12.05526752, 10.89766366], + [8.47309599, 12.91788226], + [8.75174423, 17.83546002], + [19.27325521, 7.66883038], + [15.83450076, 10.5778984], + ] + ) shape = Polygon(data) np.testing.assert_array_equal(shape.data, data) assert shape.data_displayed.shape == (6, 2) assert shape.slice_key.shape == (2, 0) # should get few triangles + expected_face = (6, 2) if "triangle" in sys.modules else (8, 2) assert shape._edge_vertices.shape == (16, 2) - assert shape._face_vertices.shape == (8, 2) + assert shape._face_vertices.shape == expected_face + +def test_polygon2(): data = np.array([[0, 0], [0, 1], [1, 1], [1, 0]]) shape = Polygon(data, interpolation_order=3) # should get many triangles + + expected_face = (249, 2) if "triangle" in sys.modules else (251, 2) + assert shape._edge_vertices.shape == (500, 2) - assert shape._face_vertices.shape == (251, 2) + assert shape._face_vertices.shape == expected_face + +def test_polygon3(): data = np.array([[0, 0, 0], [0, 0, 1], [0, 1, 1], [1, 1, 1]]) shape = Polygon(data, interpolation_order=3, ndisplay=3) # should get many vertices diff --git a/napari/layers/shapes/_shapes_models/ellipse.py b/napari/layers/shapes/_shapes_models/ellipse.py index 43d947207d8..43fcc0ad419 100644 --- a/napari/layers/shapes/_shapes_models/ellipse.py +++ b/napari/layers/shapes/_shapes_models/ellipse.py @@ -35,7 +35,7 @@ def __init__( data, *, edge_width=1, - opacity=1, + opacity=1.0, z_index=0, dims_order=None, ndisplay=2, diff --git a/napari/layers/shapes/_shapes_models/rectangle.py b/napari/layers/shapes/_shapes_models/rectangle.py index fd1a3413444..dd78961e609 100644 --- a/napari/layers/shapes/_shapes_models/rectangle.py +++ b/napari/layers/shapes/_shapes_models/rectangle.py @@ -59,7 +59,6 @@ def data(self, data): data = find_corners(data) if len(data) != 4: - print(data) raise ValueError( trans._( "Data shape does not match a rectangle. Rectangle expects four corner vertices, {number} provided.", @@ -78,7 +77,6 @@ def _update_displayed_data(self): self._face_vertices = self.data_displayed self._face_triangles = np.array([[0, 1, 2], [0, 2, 3]]) self._box = rectangle_to_box(self.data_displayed) - data_not_displayed = self.data[:, self.dims_not_displayed] self.slice_key = np.round( [ diff --git a/napari/layers/shapes/_shapes_mouse_bindings.py b/napari/layers/shapes/_shapes_mouse_bindings.py index 529653f154f..0e582cf4381 100644 --- a/napari/layers/shapes/_shapes_mouse_bindings.py +++ b/napari/layers/shapes/_shapes_mouse_bindings.py @@ -18,7 +18,7 @@ from napari.settings import get_settings if TYPE_CHECKING: - from typing import List, Optional, Tuple + from typing import Generator, List, Optional, Tuple import numpy.typing as npt from vispy.app.canvas import MouseEvent @@ -34,9 +34,9 @@ def highlight(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. Though not used here it is passed as argument by the shapes layer mouse move callbacks. @@ -47,7 +47,7 @@ def highlight(layer: Shapes, event: MouseEvent) -> None: layer._set_highlight() -def select(layer: Shapes, event: MouseEvent) -> None: +def select(layer: Shapes, event: MouseEvent) -> Generator[None, None, None]: """Select shapes or vertices either in select or direct select mode. Once selected shapes can be moved or resized, and vertices can be moved @@ -56,9 +56,9 @@ def select(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ shift = 'Shift' in event.modifiers @@ -148,7 +148,7 @@ def select(layer: Shapes, event: MouseEvent) -> None: layer._update_thumbnail() -def add_line(layer: Shapes, event: MouseEvent) -> None: +def add_line(layer: Shapes, event: MouseEvent) -> Generator[None, None, None]: """Add a line. Adds a line by connecting 2 ndim points. On press one point is set under the mouse cursor and a second point is @@ -157,9 +157,9 @@ def add_line(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # full size is the initial offset of the second point compared to the first point of the line. @@ -181,15 +181,17 @@ def add_line(layer: Shapes, event: MouseEvent) -> None: ) -def add_ellipse(layer: Shapes, event: MouseEvent): +def add_ellipse( + layer: Shapes, event: MouseEvent +) -> Generator[None, None, None]: """ Add an ellipse to the shapes layer. Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ size = layer._normalized_vertex_radius / 2 @@ -208,14 +210,16 @@ def add_ellipse(layer: Shapes, event: MouseEvent): ) -def add_rectangle(layer: Shapes, event: MouseEvent) -> None: +def add_rectangle( + layer: Shapes, event: MouseEvent +) -> Generator[None, None, None]: """Add a rectangle to the shapes layer. Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ size = layer._normalized_vertex_radius / 2 @@ -237,18 +241,18 @@ def add_rectangle(layer: Shapes, event: MouseEvent) -> None: def _add_line_rectangle_ellipse( layer: Shapes, event: MouseEvent, data: npt.NDArray, shape_type: str -) -> None: +) -> Generator[None, None, None]: """Helper function for adding a line, rectangle or ellipse. Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. - data: np.NDarray + data : np.NDarray Array containing the initial datapoints of the shape in image data space. - shape_type: str + shape_type : str String indicating the type of shape to be added. """ # on press @@ -280,9 +284,9 @@ def finish_drawing_shape(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. Not used here, but passed as argument due to being a double click callback of the shapes layer. """ @@ -299,9 +303,9 @@ def initiate_polygon_draw( Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - coordinates: Tuple[float, ...] + coordinates : Tuple[float, ...] A tuple with the coordinates of the initial vertex in image data space. """ data = np.array([coordinates, coordinates]) @@ -313,7 +317,9 @@ def initiate_polygon_draw( layer._set_highlight() -def add_path_polygon_lasso(layer: Shapes, event: MouseEvent) -> None: +def add_path_polygon_lasso( + layer: Shapes, event: MouseEvent +) -> Generator[None, None, None]: """Add, draw and finish drawing of polygon. Initiates, draws and finishes the lasso polygon in drag mode (tablet) or @@ -321,9 +327,9 @@ def add_path_polygon_lasso(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # on press @@ -360,15 +366,15 @@ def add_vertex_to_path( Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. - index: int + index : int The index of the shape being added, e.g. first shape in the layer has index 0. - coordinates: Tuple[float, ...] + coordinates : Tuple[float, ...] The coordinates of the vertex being added to the shape being drawn in image data space - new_type: Optional[str] + new_type : Optional[str] Type of the shape being added. """ vertices = layer._data_view.shapes[index].data @@ -392,9 +398,9 @@ def polygon_creating(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ if layer._is_creating: @@ -422,9 +428,9 @@ def add_path_polygon(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # on press @@ -446,9 +452,9 @@ def move_active_vertex_under_cursor( Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - coordinates: Tuple[float, ...] + coordinates : Tuple[float, ...] The coordinates in data space of the vertex to be potentially added, e.g. vertex tracks the mouse cursor position. """ @@ -465,9 +471,9 @@ def vertex_insert(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ # Determine all the edges in currently selected shapes @@ -555,9 +561,9 @@ def vertex_remove(layer: Shapes, event: MouseEvent) -> None: Parameters ---------- - layer: Shapes + layer : Shapes Napari shapes layer - event: MouseEvent + event : MouseEvent A proxy read only wrapper around a vispy mouse event. """ value = layer.get_value(event.position, world=True) @@ -636,7 +642,7 @@ def _drag_selection_box(layer: Shapes, coordinates: Tuple[float, ...]) -> None: def _set_drag_start( layer: Shapes, coordinates: Tuple[float, ...] -) -> List[float, ...]: +) -> List[float]: """Indicate where in data space a drag event started. Sets the coordinates relative to the center of the bounding box of a shape and returns the position @@ -644,9 +650,9 @@ def _set_drag_start( Parameters ---------- - layer: Shapes + layer : Shapes The napari layer shape - coordinates: Tuple[float, ...] + coordinates : Tuple[float, ...] The position in image data space where dragging started. Returns diff --git a/napari/layers/shapes/_shapes_utils.py b/napari/layers/shapes/_shapes_utils.py index 797568dd634..27ef6328429 100644 --- a/napari/layers/shapes/_shapes_utils.py +++ b/napari/layers/shapes/_shapes_utils.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING import numpy as np from skimage.draw import line, polygon2mask @@ -814,7 +814,7 @@ def generate_tube_meshes(path, closed=False, tube_points=10): """ points = np.array(path).astype(float) - if closed and not np.all(points[0] == points[-1]): + if closed and not np.array_equal(points[0], points[-1]): points = np.concatenate([points, [points[0]]], axis=0) tangents, normals, binormals = _frenet_frames(points, closed) @@ -1007,11 +1007,11 @@ def extract_shape_type(data, shape_type=None): type of each shape in data, or None if none was passed """ # Tuple for one shape or list of shapes with shape_type - if isinstance(data, Tuple): + if isinstance(data, tuple): shape_type = data[1] data = data[0] # List of (vertices, shape_type) tuples - elif len(data) != 0 and all(isinstance(datum, Tuple) for datum in data): + elif len(data) != 0 and all(isinstance(datum, tuple) for datum in data): shape_type = [datum[1] for datum in data] data = [datum[0] for datum in data] return data, shape_type @@ -1171,12 +1171,14 @@ def perpendicular_distance( """ if np.array_equal(line_start, line_end): - return np.linalg.norm(point - line_start) + return float(np.linalg.norm(point - line_start)) t = np.dot(point - line_end, line_start - line_end) / np.dot( line_start - line_end, line_start - line_end ) - return np.linalg.norm(t * (line_start - line_end) + line_end - point) + return float( + np.linalg.norm(t * (line_start - line_end) + line_end - point) + ) def rdp(vertices: npt.NDArray, epsilon: float) -> npt.NDArray: diff --git a/napari/layers/shapes/_tests/test_shapes.py b/napari/layers/shapes/_tests/test_shapes.py index 64b0d72d410..26c00253c3f 100644 --- a/napari/layers/shapes/_tests/test_shapes.py +++ b/napari/layers/shapes/_tests/test_shapes.py @@ -5,13 +5,14 @@ import numpy as np import pandas as pd import pytest -from pydantic import ValidationError +from napari._pydantic_compat import ValidationError from napari._tests.utils import ( assert_colors_equal, check_layer_world_data_extent, ) from napari.components import ViewerModel +from napari.components.dims import Dims from napari.layers import Shapes from napari.layers.base._base_constants import ActionType from napari.layers.utils._text_constants import Anchor @@ -386,22 +387,50 @@ def test_refresh_text(): np.testing.assert_equal(layer.text.values, new_properties['shape_type']) -def test_nd_text(): - """Test slicing of text coords with nD shapes""" +@pytest.mark.parametrize('prepend', [(), (7,), (8, 9)]) +def test_nd_text(prepend): + """Test slicing of text coords with nD shapes + + We can prepend as many dimensions as we want it should not change the result + """ shapes_data = [ - [[0, 10, 10, 10], [0, 10, 20, 20], [0, 10, 10, 20], [0, 10, 20, 10]], - [[1, 20, 30, 30], [1, 20, 50, 50], [1, 20, 50, 30], [1, 20, 30, 50]], + [ + prepend + (0, 10, 10, 10), + prepend + (0, 10, 20, 20), + prepend + (0, 10, 10, 20), + prepend + (0, 10, 20, 10), + ], + [ + prepend + (1, 20, 30, 30), + prepend + (1, 20, 50, 50), + prepend + (1, 20, 50, 30), + prepend + (1, 20, 30, 50), + ], ] - properties = {'shape_type': ['A', 'B']} - text_kwargs = {'string': 'shape_type', 'anchor': 'center'} - layer = Shapes(shapes_data, properties=properties, text=text_kwargs) - assert layer.ndim == 4 - - layer._slice_dims(point=[0, 10, 0, 0], ndisplay=2) + layer = Shapes(shapes_data) + assert layer.ndim == 4 + len(prepend) + + layer._slice_dims( + Dims( + ndim=layer.ndim, + ndisplay=2, + range=((0, 100, 1),) * layer.ndim, + point=prepend + (0, 10, 0, 0), + ) + ) np.testing.assert_equal(layer._indices_view, [0]) np.testing.assert_equal(layer._view_text_coords[0], [[15, 15]]) - layer._slice_dims(point=[1, 0, 0, 0], ndisplay=3) + # TODO: 1st bug #6205, ndisplay 3 is buggy in 5+ dimensions + # may need to call _update_dims + layer._slice_dims( + Dims( + ndim=layer.ndim, + ndisplay=3, + range=((0, 100, 1),) * layer.ndim, + point=prepend + (1, 0, 0, 0), + ) + ) np.testing.assert_equal(layer._indices_view, [1]) np.testing.assert_equal(layer._view_text_coords[0], [[20, 40, 40]]) @@ -2114,7 +2143,7 @@ def test_value_3d( ] ) layer = Shapes(data, scale=scale) - layer._slice_dims([0, 0, 0, 0], ndisplay=3) + layer._slice_dims(Dims(ndim=4, ndisplay=3, point=(0, 0, 0, 0))) value, _ = layer.get_value( position, view_direction=view_direction, @@ -2290,7 +2319,7 @@ def test_set_data_3d(): np.array([[0, 0, 0], [0, 0, 200]]), ] shapes = Shapes(lines, shape_type='line') - shapes._slice_dims(ndisplay=3) + shapes._slice_dims(Dims(ndim=3, ndisplay=3)) shapes.data = lines diff --git a/napari/layers/shapes/_tests/test_shapes_utils.py b/napari/layers/shapes/_tests/test_shapes_utils.py index 1979e1f9481..e23b90ba8b1 100644 --- a/napari/layers/shapes/_tests/test_shapes_utils.py +++ b/napari/layers/shapes/_tests/test_shapes_utils.py @@ -358,7 +358,6 @@ def test_generate_2D_edge_meshes( bevel, expected, ): - pass c, o, t = generate_2D_edge_meshes(path, closed, limit, bevel) expected_center, expected_offsets, expected_triangles = expected assert np.allclose(c, expected_center) diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py index b9f1d223bf5..cf18ad5b219 100644 --- a/napari/layers/shapes/shapes.py +++ b/napari/layers/shapes/shapes.py @@ -2,9 +2,20 @@ from contextlib import contextmanager from copy import copy, deepcopy from itertools import cycle -from typing import Any, Callable, ClassVar, Dict, List, Set, Tuple, Union +from typing import ( + Any, + Callable, + ClassVar, + Dict, + List, + Optional, + Set, + Tuple, + Union, +) import numpy as np +import numpy.typing as npt import pandas as pd from vispy.color import get_color_names @@ -324,6 +335,15 @@ class Shapes(Layer): _highlight_color = (0, 0.6, 1) _highlight_width = 1.5 + _face_color_property: str + _edge_color_property: str + _face_color_cycle: npt.NDArray + _edge_color_cycle: npt.NDArray + _face_color_cycle_values: npt.NDArray + _edge_color_cycle_values: npt.NDArray + _face_color_mode: str + _edge_color_mode: str + # If more shapes are present then they are randomly subsampled # in the thumbnail _max_shapes_thumbnail = 100 @@ -427,6 +447,7 @@ def __init__( visible=True, cache=True, experimental_clipping_planes=None, + projection_mode='none', ) -> None: if data is None or len(data) == 0: if ndim is None: @@ -459,6 +480,7 @@ def __init__( visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, ) self.events.add( @@ -497,13 +519,13 @@ def __init__( self._current_edge_width = 1 self._data_view = ShapeList(ndisplay=self._slice_input.ndisplay) - self._data_view.slice_key = np.array(self._slice_indices)[ + self._data_view.slice_key = np.array(self._data_slice.point)[ self._slice_input.not_displayed ] self._value = (None, None) self._value_stored = (None, None) - self._moving_value = (None, None) + self._moving_value: Tuple[Optional[int], Optional[int]] = (None, None) self._selected_data = set() self._selected_data_stored = set() self._selected_data_history = set() @@ -527,7 +549,7 @@ def __init__( self._drag_box = None self._drag_box_stored = None self._is_creating = False - self._clipboard = {} + self._clipboard: Dict[str, Shapes] = {} self._status = self.mode @@ -688,7 +710,7 @@ def data(self, data): self.events.data(**kwargs) self._data_view = ShapeList(ndisplay=self._slice_input.ndisplay) - self._data_view.slice_key = np.array(self._slice_indices)[ + self._data_view.slice_key = np.array(self._data_slice.point)[ self._slice_input.not_displayed ] self._add_shapes( @@ -941,10 +963,10 @@ def edge_color_cycle(self) -> np.ndarray: @edge_color_cycle.setter def edge_color_cycle(self, edge_color_cycle: Union[list, np.ndarray]): - self._set_color_cycle(edge_color_cycle, 'edge') + self._set_color_cycle(np.asarray(edge_color_cycle), 'edge') @property - def edge_colormap(self) -> Tuple[str, Colormap]: + def edge_colormap(self) -> Colormap: """Return the colormap to be applied to a property to get the edge color. Returns @@ -959,7 +981,7 @@ def edge_colormap(self, colormap: ValidColormapArg): self._edge_colormap = ensure_colormap(colormap) @property - def edge_contrast_limits(self) -> Tuple[float, float]: + def edge_contrast_limits(self) -> Union[Tuple[float, float], None]: """None, (float, float): contrast limits for mapping the edge_color colormap property to 0 and 1 """ @@ -1010,7 +1032,7 @@ def face_color_cycle(self, face_color_cycle: Union[np.ndarray, cycle]): self._set_color_cycle(face_color_cycle, 'face') @property - def face_colormap(self) -> Tuple[str, Colormap]: + def face_colormap(self) -> Colormap: """Return the colormap to be applied to a property to get the face color. Returns @@ -1112,7 +1134,9 @@ def _set_color_mode( setattr(self, f'_{attribute}_color_mode', color_mode) self.refresh_colors() - def _set_color_cycle(self, color_cycle: np.ndarray, attribute: str): + def _set_color_cycle( + self, color_cycle: Union[np.ndarray, cycle], attribute: str + ): """Set the face_color_cycle or edge_color_cycle property Parameters @@ -1611,7 +1635,8 @@ def _view_text_coords(self) -> Tuple[np.ndarray, str, str]: position[:, self._slice_input.displayed] for position in in_view_shapes_coords ] - + # TODO: fix types here with np.asarray(sliced_in_view_coords) + # but blocked by https://github.com/napari/napari/issues/6294 return self.text.compute_text_coords( sliced_in_view_coords, ndisplay, order ) @@ -1622,7 +1647,7 @@ def _view_text_color(self) -> np.ndarray: self.text.color._apply(self.features) return self.text._view_color(self._indices_view) - @Layer.mode.getter + @property def mode(self): """MODE: Interactive mode. The normal, default mode is PAN_ZOOM, which allows for normal interactivity with the canvas. @@ -1643,8 +1668,8 @@ def mode(self): return str(self._mode) @mode.setter - def mode(self, mode: Union[str, Mode]): - mode = self._mode_setter_helper(mode) + def mode(self, val: Union[str, Mode]): + mode = self._mode_setter_helper(val) if mode == self._mode: return @@ -2005,7 +2030,7 @@ def add( same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. - gui: bool + gui : bool Whether the shape is drawn by drawing in the gui. """ data, shape_type = extract_shape_type(data, shape_type) @@ -2181,7 +2206,7 @@ def _add_shapes( same length as the length of `data` and each element will be applied to each shape otherwise the same value will be used for all shapes. - n_new_shapes: int + n_new_shapes : int The number of new shapes to be added to the Shapes layer. """ if n_new_shapes > 0: @@ -2319,26 +2344,27 @@ def _normalized_vertex_radius(self): def _set_view_slice(self): """Set the view given the slicing indices.""" - ndisplay = self._slice_input.ndisplay - if ndisplay != self._ndisplay_stored: - self.selected_data = set() - self._data_view.ndisplay = min(self.ndim, ndisplay) - self._ndisplay_stored = ndisplay - self._clipboard = {} - - if self._slice_input.order != self._display_order_stored: - self.selected_data = set() - self._data_view.update_dims_order(self._slice_input.order) - self._display_order_stored = copy(self._slice_input.order) - # Clear clipboard if dimensions swap - self._clipboard = {} - - slice_key = np.array(self._slice_indices)[ - self._slice_input.not_displayed - ] - if not np.array_equal(slice_key, self._data_view.slice_key): - self.selected_data = set() - self._data_view.slice_key = slice_key + with self._data_view.batched_updates(): + ndisplay = self._slice_input.ndisplay + if ndisplay != self._ndisplay_stored: + self.selected_data = set() + self._data_view.ndisplay = min(self.ndim, ndisplay) + self._ndisplay_stored = ndisplay + self._clipboard = {} + + if self._slice_input.order != self._display_order_stored: + self.selected_data = set() + self._data_view.update_dims_order(self._slice_input.order) + self._display_order_stored = copy(self._slice_input.order) + # Clear clipboard if dimensions swap + self._clipboard = {} + + slice_key = np.array(self._data_slice.point)[ + self._slice_input.not_displayed + ] + if not np.array_equal(slice_key, self._data_view.slice_key): + self.selected_data = set() + self._data_view.slice_key = slice_key def interaction_box(self, index): """Create the interaction box around a shape or list of shapes. @@ -2517,7 +2543,7 @@ def _compute_vertices_and_box(self): return vertices, face_color, edge_color, pos, width - def _set_highlight(self, force=False): + def _set_highlight(self, force=False) -> None: """Render highlights of shapes. Includes boundaries, vertices, interaction boxes, and the drag @@ -2727,10 +2753,12 @@ def _transform_box(self, transform, center=(0, 0)): def _update_draw( self, scale_factor, corner_pixels_displayed, shape_threshold ): + prev_scale = self.scale_factor super()._update_draw( scale_factor, corner_pixels_displayed, shape_threshold ) - self._set_highlight(force=True) + # update highlight only if scale has changed, otherwise causes a cycle + self._set_highlight(force=(prev_scale != self.scale_factor)) def _get_value(self, position): """Value of the data at a position in data coordinates. @@ -2812,7 +2840,7 @@ def _get_value_3d( start_point: np.ndarray, end_point: np.ndarray, dims_displayed: List[int], - ) -> Tuple[Union[float, int], None]: + ) -> Tuple[Union[float, int, None], None]: """Get the layer data value along a ray Parameters @@ -2899,7 +2927,7 @@ def get_index_and_intersection( position: np.ndarray, view_direction: np.ndarray, dims_displayed: List[int], - ) -> Tuple[Union[float, int], None]: + ) -> Tuple[Union[float, int, None], Union[npt.NDArray, None]]: """Get the shape index and intersection point of the first shape (i.e., closest to start_point) "under" a mouse click. @@ -2935,7 +2963,7 @@ def get_index_and_intersection( dims_displayed=dims_displayed, ) else: - shape_index = (None,) + shape_index = None intersection_point = None return shape_index, intersection_point @@ -2969,7 +2997,7 @@ def _copy_data(self): 'edge_color': deepcopy(self._data_view._edge_color[index]), 'face_color': deepcopy(self._data_view._face_color[index]), 'features': deepcopy(self.features.iloc[index]), - 'indices': self._slice_indices, + 'indices': self._data_slice.point, 'text': self.text._copy(index), } else: @@ -2981,7 +3009,7 @@ def _paste_data(self): if len(self._clipboard.keys()) > 0: # Calculate offset based on dimension shifts offset = [ - self._slice_indices[i] - self._clipboard['indices'][i] + self._data_slice.point[i] - self._clipboard['indices'][i] for i in self._slice_input.not_displayed ] diff --git a/napari/layers/surface/_tests/test_surface.py b/napari/layers/surface/_tests/test_surface.py index 57643fe0f4c..2132cdce1ea 100644 --- a/napari/layers/surface/_tests/test_surface.py +++ b/napari/layers/surface/_tests/test_surface.py @@ -1,7 +1,10 @@ +import copy + import numpy as np import pytest from napari._tests.utils import check_layer_world_data_extent +from napari.components.dims import Dims from napari.layers import Surface from napari.layers.surface.normals import SurfaceNormals from napari.layers.surface.wireframe import SurfaceWireframe @@ -66,7 +69,7 @@ def test_random_3D_surface(): assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 @@ -84,7 +87,7 @@ def test_random_4D_surface(): assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=4, ndisplay=3)) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 @@ -103,14 +106,14 @@ def test_random_3D_timeseries_surface(): assert layer._view_vertex_values.ndim == 1 assert layer.extent.data[1][0] == 21 - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=4, ndisplay=3)) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 # If a values axis is made to be a displayed axis then no data should be # shown with pytest.warns(UserWarning): - layer._slice_dims(ndisplay=3, order=[3, 0, 1, 2]) + layer._slice_dims(Dims(ndim=4, ndisplay=3, order=(3, 0, 1, 2))) assert len(layer._data_view) == 0 @@ -129,7 +132,7 @@ def test_random_3D_multitimeseries_surface(): assert layer.extent.data[1][0] == 15 assert layer.extent.data[1][1] == 21 - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=5, ndisplay=3)) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 @@ -153,7 +156,7 @@ def test_changing_surface(): assert layer._data_view.shape[1] == 2 assert layer._view_vertex_values.ndim == 1 - layer._slice_dims(ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) assert layer._data_view.shape[1] == 3 assert layer._view_vertex_values.ndim == 1 @@ -299,7 +302,7 @@ def test_get_value_3d( values = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3]) surface_layer = Surface((vertices, faces, values)) - surface_layer._slice_dims([0, 0, 0], ndisplay=3) + surface_layer._slice_dims(Dims(ndim=3, ndisplay=3)) value, index = surface_layer.get_value( position=ray_start, view_direction=ray_direction, @@ -337,7 +340,7 @@ def test_get_value_3d_nd( values = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3]) surface_layer = Surface((vertices, faces, values)) - surface_layer._slice_dims([0, 0, 0, 0], ndisplay=3) + surface_layer._slice_dims(Dims(ndim=4, ndisplay=3)) value, index = surface_layer.get_value( position=ray_start, view_direction=ray_direction, @@ -416,3 +419,25 @@ def test_surface_wireframe(): assert isinstance(surface_layer.wireframe, SurfaceWireframe) assert surface_layer.wireframe.visible is True assert np.array_equal(surface_layer.wireframe.color, (1, 0, 0, 1)) + + +def test_surface_copy(): + vertices = np.array( + [ + [3, 0, 0], + [3, 0, 3], + [3, 3, 0], + [5, 0, 0], + [5, 0, 3], + [5, 3, 0], + [2, 50, 50], + [2, 50, 100], + [2, 100, 50], + ] + ) + faces = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) + values = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3]) + + l1 = Surface((vertices, faces, values)) + l2 = copy.copy(l1) + assert l1.data[0] is not l2.data[0] diff --git a/napari/layers/surface/normals.py b/napari/layers/surface/normals.py index 2df6906dbdf..af1fe32c5ee 100644 --- a/napari/layers/surface/normals.py +++ b/napari/layers/surface/normals.py @@ -1,7 +1,6 @@ from enum import Enum, auto -from pydantic import Field - +from napari._pydantic_compat import Field from napari.utils.color import ColorValue from napari.utils.events import EventedModel diff --git a/napari/layers/surface/surface.py b/napari/layers/surface/surface.py index ddccfcd9338..5fd98b51b68 100644 --- a/napari/layers/surface/surface.py +++ b/napari/layers/surface/surface.py @@ -1,3 +1,4 @@ +import copy import warnings from typing import Any, List, Optional, Tuple, Union @@ -185,7 +186,7 @@ def __init__( *, colormap='gray', contrast_limits=None, - gamma=1, + gamma=1.0, name=None, metadata=None, scale=None, @@ -193,7 +194,7 @@ def __init__( rotate=None, shear=None, affine=None, - opacity=1, + opacity=1.0, blending='translucent', shading='flat', visible=True, @@ -204,6 +205,7 @@ def __init__( texture=None, texcoords=None, vertex_colors=None, + projection_mode='none', ) -> None: ndim = data[0].shape[1] @@ -222,6 +224,7 @@ def __init__( visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, ) self.events.add( @@ -543,7 +546,10 @@ def _slice_associated_data( data_ndim = data.ndim - 1 if data_ndim >= dims: # Get indices for axes corresponding to data dimensions - data_indices = self._slice_indices[:-vertex_ndim] + data_indices: Tuple[Union[int, slice], ...] = tuple( + slice(None) if np.isnan(idx) else int(np.round(idx)) + for idx in self._data_slice.point[:-vertex_ndim] + ) data = data[data_indices] if data.ndim > dims: warnings.warn( @@ -582,7 +588,7 @@ def _set_view_slice(self): return if values_ndim > 0: - indices = np.array(self._slice_indices[-vertex_ndim:]) + indices = np.array(self._data_slice.point[-vertex_ndim:]) disp = [ d for d in np.subtract(self._slice_input.displayed, values_ndim) @@ -596,7 +602,7 @@ def _set_view_slice(self): if d >= 0 ] else: - indices = np.array(self._slice_indices) + indices = np.array(self._data_slice.point) not_disp = list(self._slice_input.not_displayed) disp = list(self._slice_input.displayed) @@ -638,8 +644,8 @@ def _get_value(self, position): def _get_value_3d( self, - start_point: np.ndarray, - end_point: np.ndarray, + start_point: Optional[np.ndarray], + end_point: Optional[np.ndarray], dims_displayed: List[int], ) -> Tuple[Union[None, float, int], Optional[int]]: """Get the layer data value along a ray @@ -700,3 +706,30 @@ def _get_value_3d( intersection_value = (barycentric_coordinates * vertex_values).sum() return intersection_value, intersection_index + + def __copy__(self): + """Create a copy of this layer. + + Returns + ------- + layer : napari.layers.Layer + Copy of this layer. + + Notes + ----- + This method is defined for purpose of asv memory benchmarks. + The copy of data is intentional for properly estimating memory + usage for layer. + + If you want a to copy a layer without coping the data please use + `layer.create(*layer.as_layer_data_tuple())` + + If you change this method, validate if memory benchmarks are still + working properly. + """ + data, meta, layer_type = self.as_layer_data_tuple() + return self.create( + tuple(copy.copy(x) for x in self.data), + meta=meta, + layer_type=layer_type, + ) diff --git a/napari/layers/surface/wireframe.py b/napari/layers/surface/wireframe.py index 3e9ed6cd041..9b376bc4c1a 100644 --- a/napari/layers/surface/wireframe.py +++ b/napari/layers/surface/wireframe.py @@ -1,5 +1,4 @@ -from pydantic import Field - +from napari._pydantic_compat import Field from napari.utils.color import ColorValue from napari.utils.events import EventedModel diff --git a/napari/layers/tracks/tracks.py b/napari/layers/tracks/tracks.py index 06e06f5b48b..a1d878ea0ae 100644 --- a/napari/layers/tracks/tracks.py +++ b/napari/layers/tracks/tracks.py @@ -111,7 +111,7 @@ def __init__( rotate=None, shear=None, affine=None, - opacity=1, + opacity=1.0, blending='additive', visible=True, colormap='turbo', @@ -119,6 +119,7 @@ def __init__( colormaps_dict=None, cache=True, experimental_clipping_planes=None, + projection_mode='none', ) -> None: # if not provided with any data, set up an empty layer in 2D+t # otherwise convert the data to an np.ndarray @@ -142,6 +143,7 @@ def __init__( visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, ) self.events.add( @@ -345,7 +347,7 @@ def _pad_display_data(self, vertices): def current_time(self): """current time according to the first dimension""" # TODO(arl): get the correct index here - time_step = self._slice_indices[0] + time_step = self._data_slice.point[0] if isinstance(time_step, slice): # if we are visualizing all time, then just set to the maximum diff --git a/napari/layers/utils/_link_layers.py b/napari/layers/utils/_link_layers.py index a7c59c8e0fb..275ee8c8b84 100644 --- a/napari/layers/utils/_link_layers.py +++ b/napari/layers/utils/_link_layers.py @@ -130,7 +130,7 @@ def setter(event=None): setter.__qualname__ = f"set_{attr}_on_layer_{id(l2)}" return setter - # acually make the connection + # actually make the connection callback = _make_l2_setter() emitter_group = getattr(lay1.events, attribute) emitter_group.connect(callback) @@ -191,7 +191,7 @@ def layers_linked(layers: Iterable[Layer], attributes: Iterable[str] = ()): def _get_common_evented_attributes( layers: Iterable[Layer], exclude: abc.Set[str] = frozenset( - ('thumbnail', 'status', 'name', 'data', 'extent') + ('thumbnail', 'status', 'name', 'data', 'extent', 'loaded') ), with_private=False, ) -> set[str]: diff --git a/napari/layers/utils/_slice_input.py b/napari/layers/utils/_slice_input.py index 62008824e92..d333b8f0c19 100644 --- a/napari/layers/utils/_slice_input.py +++ b/napari/layers/utils/_slice_input.py @@ -2,7 +2,7 @@ import warnings from dataclasses import dataclass -from typing import List, Tuple, Union +from typing import TYPE_CHECKING, Generic, List, Tuple, TypeVar, Union import numpy as np @@ -10,6 +10,121 @@ from napari.utils.transforms import Affine from napari.utils.translations import trans +if TYPE_CHECKING: + import numpy.typing as npt + + from napari.components.dims import Dims + +_T = TypeVar('_T') + + +@dataclass(frozen=True) +class _ThickNDSlice(Generic[_T]): + """Holds the point and the left and right margins of a thick nD slice.""" + + point: Tuple[_T, ...] + margin_left: Tuple[_T, ...] + margin_right: Tuple[_T, ...] + + @property + def ndim(self): + return len(self.point) + + @classmethod + def make_full( + cls, + point=None, + margin_left=None, + margin_right=None, + ndim=None, + ): + """ + Make a full slice based on minimal input. + + If ndim is provided, it will be used to crop or prepend zeros to the given values. + Values not provided will be filled zeros. + """ + for val in (point, margin_left, margin_right): + if val is not None: + val_ndim = len(val) + break + else: + if ndim is None: + raise ValueError( + 'ndim must be provided if no other value is given' + ) + val_ndim = ndim + + ndim = val_ndim if ndim is None else ndim + + # not provided arguments are just all zeros + point = (0,) * ndim if point is None else tuple(point) + margin_left = ( + (0,) * ndim if margin_left is None else tuple(margin_left) + ) + margin_right = ( + (0,) * ndim if margin_right is None else tuple(margin_right) + ) + + # prepend zeros if ndim is bigger than the given values + prepend = max(ndim - val_ndim, 0) + + point = (0,) * prepend + point + margin_left = (0,) * prepend + margin_left + margin_right = (0,) * prepend + margin_right + + # crop to ndim in case given values are longer (keeping last dims) + return cls( + point=point[-ndim:], + margin_left=margin_left[-ndim:], + margin_right=margin_right[-ndim:], + ) + + @classmethod + def from_dims(cls, dims: Dims): + """Generate from a Dims object's point and margins.""" + return cls.make_full(dims.point, dims.margin_left, dims.margin_right) + + def copy_with( + self, + point=None, + margin_left=None, + margin_right=None, + ndim=None, + ): + """Create a copy, but modifying the given fields.""" + return self.make_full( + point=point or self.point, + margin_left=margin_left or self.margin_left, + margin_right=margin_right or self.margin_right, + ndim=ndim or self.ndim, + ) + + def as_array(self) -> npt.NDArray: + """Return point and left and right margin as a (3, D) array.""" + return np.array([self.point, self.margin_left, self.margin_right]) + + @classmethod + def from_array(cls, arr: npt.NDArray) -> _ThickNDSlice: + """Construct from a (3, D) array of point, left margin and right margin.""" + return cls( + point=tuple(arr[0]), + margin_left=tuple(arr[1]), + margin_right=tuple(arr[2]), + ) + + def __getitem__(self, key): + # this allows to use numpy-like slicing on the whole object + return _ThickNDSlice( + point=tuple(np.array(self.point)[key]), + margin_left=tuple(np.array(self.margin_left)[key]), + margin_right=tuple(np.array(self.margin_right)[key]), + ) + + def __iter__(self): + # iterate all three fields dimension per dimension + yield from zip(self.point, self.margin_left, self.margin_right) + @dataclass(frozen=True) class _SliceInput: @@ -21,9 +136,9 @@ class _SliceInput: # The number of dimensions to be displayed in the slice. ndisplay: int - # The point in layer world coordinates that defines the slicing plane. + # The thick slice in world coordinates. # Only the elements in the non-displayed dimensions have meaningful values. - point: Tuple[float, ...] + world_slice: _ThickNDSlice[float] # The layer dimension indices in the order they are displayed. # A permutation of the ``range(self.ndim)``. # The last ``self.ndisplay`` dimensions are displayed in the canvas. @@ -47,23 +162,25 @@ def not_displayed(self) -> List[int]: def with_ndim(self, ndim: int) -> _SliceInput: """Returns a new instance with the given number of layer dimensions.""" old_ndim = self.ndim + world_slice = self.world_slice.copy_with(ndim=ndim) if old_ndim > ndim: - point = self.point[-ndim:] order = reorder_after_dim_reduction(self.order[-ndim:]) elif old_ndim < ndim: - point = (0,) * (ndim - old_ndim) + self.point order = tuple(range(ndim - old_ndim)) + tuple( o + ndim - old_ndim for o in self.order ) else: - point = self.point order = self.order - return _SliceInput(ndisplay=self.ndisplay, point=point, order=order) - def data_indices( - self, world_to_data: Affine, round_index: bool = True - ) -> Tuple[Union[int, slice], ...]: - """Transforms this into indices that can be used to slice layer data. + return _SliceInput( + ndisplay=self.ndisplay, world_slice=world_slice, order=order + ) + + def data_slice( + self, + world_to_data: Affine, + ) -> _ThickNDSlice[Union[float, int]]: + """Transforms this thick_slice into data coordinates with only relevant dimensions. The elements in non-displayed dimensions will be real numbers. The elements in displayed dimensions will be ``slice(None)``. @@ -71,24 +188,25 @@ def data_indices( if not self.is_orthogonal(world_to_data): warnings.warn( trans._( - 'Non-orthogonal slicing is being requested, but is not fully supported. Data is displayed without applying an out-of-slice rotation or shear component.', + 'Non-orthogonal slicing is being requested, but is not fully supported. ' + 'Data is displayed without applying an out-of-slice rotation or shear component.', deferred=True, ), category=UserWarning, ) slice_world_to_data = world_to_data.set_slice(self.not_displayed) - world_pts = [self.point[ax] for ax in self.not_displayed] - data_pts = slice_world_to_data(world_pts) - if round_index: - # A round is taken to convert these values to slicing integers - data_pts = np.round(data_pts).astype(int) + world_slice_not_disp = self.world_slice[self.not_displayed].as_array() + + data_slice = slice_world_to_data(world_slice_not_disp) + + full_data_slice = np.full((3, self.ndim), np.nan) - indices = [slice(None)] * self.ndim for i, ax in enumerate(self.not_displayed): - indices[ax] = data_pts[i] + # we cannot have nan in non-displayed dims, so we default to 0 + full_data_slice[:, ax] = np.nan_to_num(data_slice[:, i], nan=0) - return tuple(indices) + return _ThickNDSlice.from_array(full_data_slice) def is_orthogonal(self, world_to_data: Affine) -> bool: """Returns True if this slice represents an orthogonal slice through a layer's data, False otherwise.""" diff --git a/napari/layers/utils/_tests/test_color_manager.py b/napari/layers/utils/_tests/test_color_manager.py index 4e73c3b7602..ac238bc4115 100644 --- a/napari/layers/utils/_tests/test_color_manager.py +++ b/napari/layers/utils/_tests/test_color_manager.py @@ -3,8 +3,8 @@ import numpy as np import pytest -from pydantic import ValidationError +from napari._pydantic_compat import ValidationError from napari.layers.utils.color_manager import ColorManager, ColorProperties from napari.utils.colormaps.categorical_colormap import CategoricalColormap from napari.utils.colormaps.standardize_color import transform_color diff --git a/napari/layers/utils/_tests/test_link_layers.py b/napari/layers/utils/_tests/test_link_layers.py index 20af93b9485..4bff1fe6835 100644 --- a/napari/layers/utils/_tests/test_link_layers.py +++ b/napari/layers/utils/_tests/test_link_layers.py @@ -90,7 +90,7 @@ def test_removed_linked_target(): l1.opacity = 0.5 assert l1.opacity == l2.opacity == l3.opacity == 0.5 - # if we delete layer3 we shouldn't get an error when updating otherlayers + # if we delete layer3 we shouldn't get an error when updating other layers del l3 l1.opacity = 0.25 assert l1.opacity == l2.opacity @@ -142,7 +142,7 @@ def test_unlink_single_layer(): link_layers([l1, l2, l3]) assert len(l1.events.opacity.callbacks) == 2 - unlink_layers([l1], ('opacity',)) # just unlink L1 opacicity from others + unlink_layers([l1], ('opacity',)) # just unlink L1 opacity from others assert len(l1.events.opacity.callbacks) == 0 assert len(l2.events.opacity.callbacks) == 1 assert len(l3.events.opacity.callbacks) == 1 @@ -161,3 +161,17 @@ def test_mode_recursion(): l2 = layers.Points(None, name='l2') link_layers([l1, l2]) l1.mode = 'add' + + +def test_link_layers_with_images_then_loaded_not_linked(): + """See https://github.com/napari/napari/issues/6372""" + l1 = layers.Image(np.zeros((5, 5))) + l2 = layers.Image(np.ones((5, 5))) + assert l1.loaded + assert l2.loaded + + link_layers([l1, l2]) + l1._set_loaded(False) + + assert not l1.loaded + assert l2.loaded diff --git a/napari/layers/utils/_tests/test_plane.py b/napari/layers/utils/_tests/test_plane.py index 9f39949fe75..e567163a48b 100644 --- a/napari/layers/utils/_tests/test_plane.py +++ b/napari/layers/utils/_tests/test_plane.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from pydantic import ValidationError +from napari._pydantic_compat import ValidationError from napari.layers.utils.plane import ClippingPlaneList, Plane, SlicingPlane diff --git a/napari/layers/utils/_tests/test_style_encoding.py b/napari/layers/utils/_tests/test_style_encoding.py index 97916483c99..2a4b2e123d1 100644 --- a/napari/layers/utils/_tests/test_style_encoding.py +++ b/napari/layers/utils/_tests/test_style_encoding.py @@ -13,8 +13,8 @@ import numpy as np import pandas as pd import pytest -from pydantic import Field +from napari._pydantic_compat import Field from napari.layers.utils.style_encoding import ( _ConstantStyleEncoding, _DerivedStyleEncoding, diff --git a/napari/layers/utils/_tests/test_text_manager.py b/napari/layers/utils/_tests/test_text_manager.py index eae26f31388..d6657e17b75 100644 --- a/napari/layers/utils/_tests/test_text_manager.py +++ b/napari/layers/utils/_tests/test_text_manager.py @@ -3,10 +3,10 @@ import numpy as np import pandas as pd import pytest -from pydantic import ValidationError +from napari._pydantic_compat import ValidationError from napari._tests.utils import assert_colors_equal -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.string_encoding import ( ConstantStringEncoding, FormatStringEncoding, @@ -762,7 +762,9 @@ def test_compute_text_coords_with_3D_data_2D_display(order): features=pd.DataFrame(index=range(num_points)), translation=translation, ) - slice_input = _SliceInput(ndisplay=2, point=(0.0,) * 3, order=order) + slice_input = _SliceInput( + ndisplay=2, world_slice=_ThickNDSlice.make_full(ndim=3), order=order + ) np.random.seed(0) coords = np.random.rand(num_points, slice_input.ndisplay) diff --git a/napari/layers/utils/_text_utils.py b/napari/layers/utils/_text_utils.py index 3df6a8b9ac8..045dc4f69f7 100644 --- a/napari/layers/utils/_text_utils.py +++ b/napari/layers/utils/_text_utils.py @@ -30,13 +30,34 @@ def _calculate_anchor_center( def _calculate_bbox_centers(view_data: Union[np.ndarray, list]) -> np.ndarray: + """ + Calculate the bounding box of the given centers, + + Parameters + ---------- + view_data : np.ndarray | list of ndarray + if an ndarray, return the center across the 0-th axis. + if a list, return the bbox center for each items. + + Returns + ------- + An ndarray of the centers. + + """ if isinstance(view_data, np.ndarray): if view_data.ndim == 2: + # shape[1] is 2 for a 2D center, 3 for a 3D center. + # It should work is N > 3 Dimension, but this catches mistakes + # when the caller passed a transposed view_data + assert view_data.shape[1] in (2, 3), view_data.shape # if the data are a list of coordinates, just return the coord (e.g., points) bbox_centers = view_data else: + assert view_data.ndim == 3 bbox_centers = np.mean(view_data, axis=0) elif isinstance(view_data, list): + for coord in view_data: + assert coord.shape[1] in (2, 3), coord.shape bbox_centers = np.array( [np.mean(coords, axis=0) for coords in view_data] ) diff --git a/napari/layers/utils/color_encoding.py b/napari/layers/utils/color_encoding.py index 618b179c230..911e66b53bf 100644 --- a/napari/layers/utils/color_encoding.py +++ b/napari/layers/utils/color_encoding.py @@ -9,8 +9,8 @@ ) import numpy as np -from pydantic import Field, parse_obj_as, validator +from napari._pydantic_compat import Field, parse_obj_as, validator from napari.layers.utils.color_transformations import ColorType from napari.layers.utils.style_encoding import ( StyleEncoding, diff --git a/napari/layers/utils/color_manager.py b/napari/layers/utils/color_manager.py index f8b10ad7e77..32e79aef984 100644 --- a/napari/layers/utils/color_manager.py +++ b/napari/layers/utils/color_manager.py @@ -3,8 +3,8 @@ from typing import Any, Dict, Optional, Tuple, Union import numpy as np -from pydantic import Field, root_validator, validator +from napari._pydantic_compat import Field, root_validator, validator from napari.layers.utils._color_manager_constants import ColorMode from napari.layers.utils.color_manager_utils import ( _validate_colormap_mode, diff --git a/napari/layers/utils/color_manager_utils.py b/napari/layers/utils/color_manager_utils.py index e67c72c9166..fdd828c9878 100644 --- a/napari/layers/utils/color_manager_utils.py +++ b/napari/layers/utils/color_manager_utils.py @@ -22,9 +22,9 @@ def guess_continuous(color_map: np.ndarray) -> bool: True of the property is guessed to be continuous, False if not. """ # if the property is a floating type, guess continuous - return ( - issubclass(color_map.dtype.type, np.floating) - or len(np.unique(color_map)) > 16 + return issubclass(color_map.dtype.type, np.floating) or ( + len(np.unique(color_map)) > 16 + and isinstance(color_map.dtype.type, np.integer) ) diff --git a/napari/layers/utils/layer_utils.py b/napari/layers/utils/layer_utils.py index 8bea1fb594c..4101afca2b5 100644 --- a/napari/layers/utils/layer_utils.py +++ b/napari/layers/utils/layer_utils.py @@ -6,6 +6,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Dict, List, NamedTuple, @@ -60,8 +61,8 @@ def register_layer_action( keymapprovider, description: str, repeatable: bool = False, - shortcuts: Optional[str] = None, -): + shortcuts: Optional[Union[str, List[str]]] = None, +) -> Callable[[Callable], Callable]: """ Convenient decorator to register an action with the current Layers @@ -90,7 +91,7 @@ class on which to register the keybindings - this will typically be """ - def _inner(func): + def _inner(func: Callable) -> Callable: nonlocal shortcuts name = 'napari:' + func.__name__ @@ -281,7 +282,7 @@ def calc_data_range(data, rgb=False) -> Tuple[float, float]: return (float(min_val), float(max_val)) -def segment_normal(a, b, p=(0, 0, 1)): +def segment_normal(a, b, p=(0, 0, 1)) -> np.ndarray: """Determines the unit normal of the vector from a to b. Parameters @@ -301,6 +302,8 @@ def segment_normal(a, b, p=(0, 0, 1)): """ d = b - a + norm: Any # float or array or float, mypy has some difficulities. + if d.ndim == 1: normal = np.array([d[1], -d[0]]) if len(d) == 2 else np.cross(d, p) norm = np.linalg.norm(normal) @@ -315,9 +318,7 @@ def segment_normal(a, b, p=(0, 0, 1)): norm = np.linalg.norm(normal, axis=1, keepdims=True) ind = norm == 0 norm[ind] = 1 - unit_norm = normal / norm - - return unit_norm + return normal / norm def convert_to_uint8(data: np.ndarray) -> Optional[np.ndarray]: @@ -367,7 +368,7 @@ def convert_to_uint8(data: np.ndarray) -> Optional[np.ndarray]: return np.right_shift(data, (data.dtype.itemsize - 1) * 8 - 1).astype( out_dtype ) - return None + raise NotImplementedError def get_current_properties( diff --git a/napari/layers/utils/plane.py b/napari/layers/utils/plane.py index 70702d0219c..40de2e84ffd 100644 --- a/napari/layers/utils/plane.py +++ b/napari/layers/utils/plane.py @@ -1,12 +1,24 @@ -from typing import Tuple, cast +import sys +from typing import Any, Tuple, cast import numpy as np -from pydantic import validator +import numpy.typing as npt +from napari._pydantic_compat import validator from napari.utils.events import EventedModel, SelectableEventedList from napari.utils.geometry import intersect_line_with_plane_3d from napari.utils.translations import trans +if sys.version_info < (3, 10): + # Once 3.12+ there is a new syntax, type Foo = Bar[...], but + # we are not there yet. + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + + +Point3D: TypeAlias = Tuple[float, float, float] + class Plane(EventedModel): """Defines a Plane in 3D. @@ -24,22 +36,22 @@ class Plane(EventedModel): Whether the plane is considered enabled. """ - normal: Tuple[float, float, float] = (1, 0, 0) - position: Tuple[float, float, float] = (0, 0, 0) + normal: Point3D = (1, 0, 0) + position: Point3D = (0, 0, 0) @validator('normal', allow_reuse=True) - def _normalise_vector(cls, v): - return tuple(v / np.linalg.norm(v)) + def _normalise_vector(cls, v: npt.NDArray) -> Point3D: + return cast(Point3D, tuple(v / np.linalg.norm(v))) @validator('normal', 'position', pre=True, allow_reuse=True) - def _ensure_tuple(cls, v): - return tuple(v) + def _ensure_tuple(cls, v: Any) -> Point3D: + return cast(Point3D, tuple(v)) def shift_along_normal_vector(self, distance: float) -> None: """Shift the plane along its normal vector by a given distance.""" assert len(self.position) == len(self.normal) == 3 self.position = cast( - Tuple[float, float, float], + Point3D, tuple( p + (distance * n) for p, n in zip(self.position, self.normal) ), @@ -54,7 +66,13 @@ def intersect_with_line( ) @classmethod - def from_points(cls, a, b, c, enabled=True): + def from_points( + cls, + a: npt.NDArray, + b: npt.NDArray, + c: npt.NDArray, + enabled: bool = True, + ) -> 'Plane': """Derive a Plane from three points. Parameters @@ -83,7 +101,7 @@ def from_points(cls, a, b, c, enabled=True): position=plane_position, normal=plane_normal, enabled=enabled ) - def as_array(self): + def as_array(self) -> npt.NDArray: """Return a (2, 3) array representing the plane. [0, :] : plane position @@ -92,7 +110,7 @@ def as_array(self): return np.stack([self.position, self.normal]) @classmethod - def from_array(cls, array, enabled=True): + def from_array(cls, array: npt.NDArray, enabled: bool = True) -> 'Plane': """Construct a plane from a (2, 3) array. [0, :] : plane position @@ -100,7 +118,7 @@ def from_array(cls, array, enabled=True): """ return cls(position=array[0], normal=array[1], enabled=enabled) - def __hash__(self): + def __hash__(self) -> int: return id(self) @@ -147,7 +165,7 @@ class ClippingPlane(Plane): class ClippingPlaneList(SelectableEventedList): """A list of planes with some utility methods.""" - def as_array(self): + def as_array(self) -> npt.NDArray: """Return a (N, 2, 3) array of clipping planes. [i, 0, :] : ith plane position @@ -162,7 +180,9 @@ def as_array(self): return np.stack(arrays) @classmethod - def from_array(cls, array, enabled=True): + def from_array( + cls, array: npt.NDArray, enabled: bool = True + ) -> 'ClippingPlaneList': """Construct the PlaneList from an (N, 2, 3) array. [i, 0, :] : ith plane position @@ -183,7 +203,9 @@ def from_array(cls, array, enabled=True): return cls(planes) @classmethod - def from_bounding_box(cls, center, dimensions, enabled=True): + def from_bounding_box( + cls, center: Point3D, dimensions: Point3D, enabled: bool = True + ) -> 'ClippingPlaneList': """ generate 6 planes positioned to form a bounding box, with normals towards the center diff --git a/napari/layers/utils/string_encoding.py b/napari/layers/utils/string_encoding.py index 31a171c37b9..f71eeba57d8 100644 --- a/napari/layers/utils/string_encoding.py +++ b/napari/layers/utils/string_encoding.py @@ -2,8 +2,8 @@ from typing import Any, Literal, Protocol, Sequence, Union, runtime_checkable import numpy as np -from pydantic import parse_obj_as +from napari._pydantic_compat import parse_obj_as from napari.layers.utils.style_encoding import ( StyleEncoding, _ConstantStyleEncoding, diff --git a/napari/layers/utils/text_manager.py b/napari/layers/utils/text_manager.py index f28edcc542d..5cbc3d7f8f7 100644 --- a/napari/layers/utils/text_manager.py +++ b/napari/layers/utils/text_manager.py @@ -4,8 +4,8 @@ import numpy as np import pandas as pd -from pydantic import PositiveInt, validator +from napari._pydantic_compat import PositiveInt, validator from napari.layers.base._base_constants import Blending from napari.layers.utils._text_constants import Anchor from napari.layers.utils._text_utils import get_text_anchors diff --git a/napari/layers/vectors/_slice.py b/napari/layers/vectors/_slice.py index 43af3a3a806..e4b72bc3849 100644 --- a/napari/layers/vectors/_slice.py +++ b/napari/layers/vectors/_slice.py @@ -4,7 +4,8 @@ import numpy as np from napari.layers.base._slice import _next_request_id -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice +from napari.layers.vectors._vectors_constants import VectorsProjectionMode @dataclass(frozen=True) @@ -18,7 +19,7 @@ class _VectorSliceResponse: alphas : array like or scalar Used to change the opacity of the sliced vectors for visualization. Should be broadcastable to indices. - dims : _SliceInput + slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. request_id : int The identifier of the request from which this was generated. @@ -26,7 +27,7 @@ class _VectorSliceResponse: indices: np.ndarray = field(repr=False) alphas: Union[np.ndarray, float] = field(repr=False) - dims: _SliceInput + slice_input: _SliceInput request_id: int @@ -43,19 +44,20 @@ class _VectorSliceRequest: Attributes ---------- - dims : _SliceInput + slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. data : Any The layer's data field, which is the main input to slicing. - dims_indices : tuple of ints or slices - The slice indices in the layer's data space. + data_slice : _ThickNDSlice + The slicing coordinates and margins in data space. others See the corresponding attributes in `Layer` and `Vectors`. """ - dims: _SliceInput + slice_input: _SliceInput data: Any = field(repr=False) - dims_indices: Any = field(repr=False) + data_slice: _ThickNDSlice = field(repr=False) + projection_mode: VectorsProjectionMode length: float = field(repr=False) out_of_slice_display: bool = field(repr=False) id: int = field(default_factory=_next_request_id) @@ -66,41 +68,27 @@ def __call__(self) -> _VectorSliceResponse: return _VectorSliceResponse( indices=np.empty(0, dtype=int), alphas=np.empty(0), - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) - not_disp = list(self.dims.not_displayed) + not_disp = list(self.slice_input.not_displayed) if not not_disp: # If we want to display everything, then use all indices. # alpha is only impacted by not displayed data, therefore 1 return _VectorSliceResponse( indices=np.arange(len(self.data), dtype=int), alphas=1, - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) - # We want a numpy array so we can use fancy indexing with the non-displayed - # indices, but as self.dims_indices can (and often/always does) contain slice - # objects, the array has dtype=object which is then very slow for the - # arithmetic below. As Vectors._round_index is always False, we can safely - # convert to float to get a major performance improvement. - not_disp_indices = np.array(self.dims_indices)[not_disp].astype(float) - - if self.out_of_slice_display and self.dims.ndim > 2: - slice_indices, alphas = self._get_out_of_display_slice_data( - not_disp, not_disp_indices - ) - else: - slice_indices, alphas = self._get_slice_data( - not_disp, not_disp_indices - ) + slice_indices, alphas = self._get_slice_data(not_disp) return _VectorSliceResponse( indices=slice_indices, alphas=alphas, - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) @@ -120,10 +108,47 @@ def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): slice_indices = np.where(matches)[0].astype(int) return slice_indices, alpha - def _get_slice_data(self, not_disp, not_disp_indices): - """This method slices in the simpler case.""" + def _get_slice_data(self, not_disp): data = self.data[:, 0, not_disp] - distances = np.abs(data - not_disp_indices) - matches = np.all(distances <= 0.5, axis=1) - slice_indices = np.where(matches)[0].astype(int) - return slice_indices, 1 + alphas = 1 + + point, m_left, m_right = self.data_slice[not_disp].as_array() + + if self.projection_mode == 'none': + low = point.copy() + high = point.copy() + else: + low = point - m_left + high = point + m_right + + # assume slice thickness of 1 in data pixels + # (same as before thick slices were implemented) + too_thin_slice = np.isclose(high, low) + low[too_thin_slice] -= 0.5 + high[too_thin_slice] += 0.5 + + inside_slice = np.all((data >= low) & (data <= high), axis=1) + slice_indices = np.where(inside_slice)[0].astype(int) + + if self.out_of_slice_display and self.slice_input.ndim > 2: + projected_lengths = abs(self.data[:, 1, not_disp] * self.length) + + # add out of slice points with progressively lower sizes + dist_from_low = np.abs(data - low) + dist_from_high = np.abs(data - high) + distances = np.minimum(dist_from_low, dist_from_high) + # anything inside the slice is at distance 0 + distances[inside_slice] = 0 + + # display vectors that "spill" into the slice + matches = np.all(distances <= projected_lengths, axis=1) + length_match = projected_lengths[matches] + length_match[length_match == 0] = 1 + # rescale alphas of spilling vectors based on how much they do + alphas_per_dim = (length_match - distances[matches]) / length_match + alphas_per_dim[length_match == 0] = 1 + alphas = np.prod(alphas_per_dim, axis=1) + + slice_indices = np.where(matches)[0].astype(int) + + return slice_indices, alphas diff --git a/napari/layers/vectors/_tests/test_vectors.py b/napari/layers/vectors/_tests/test_vectors.py index 5303a272672..3c4cc18caa6 100644 --- a/napari/layers/vectors/_tests/test_vectors.py +++ b/napari/layers/vectors/_tests/test_vectors.py @@ -7,6 +7,7 @@ assert_colors_equal, check_layer_world_data_extent, ) +from napari.components.dims import Dims from napari.layers import Vectors from napari.utils.colormaps.standardize_color import transform_color @@ -631,7 +632,7 @@ def test_value_3d(position, view_direction, dims_displayed, world): data = np.random.random((10, 2, 3)) data[:, 0, :] = 20 * data[:, 0, :] layer = Vectors(data) - layer._slice_dims([0, 0, 0], ndisplay=3) + layer._slice_dims(Dims(ndim=3, ndisplay=3)) value = layer.get_value( position, view_direction=view_direction, diff --git a/napari/layers/vectors/_vectors_constants.py b/napari/layers/vectors/_vectors_constants.py index 622d3070709..a750ab9a946 100644 --- a/napari/layers/vectors/_vectors_constants.py +++ b/napari/layers/vectors/_vectors_constants.py @@ -30,3 +30,15 @@ class VectorStyle(StringEnum): (VectorStyle.ARROW, trans._("arrow")), ] ) + + +class VectorsProjectionMode(StringEnum): + """ + Projection mode for aggregating a thick nD slice onto displayed dimensions. + + * NONE: ignore slice thickness, only using the dims point + * ALL: project all vectors in the slice onto displayed dimensions + """ + + NONE = auto() + ALL = auto() diff --git a/napari/layers/vectors/vectors.py b/napari/layers/vectors/vectors.py index ea313312eeb..f71e86e555e 100644 --- a/napari/layers/vectors/vectors.py +++ b/napari/layers/vectors/vectors.py @@ -7,7 +7,7 @@ from napari.layers.base import Layer from napari.layers.utils._color_manager_constants import ColorMode -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.color_manager import ColorManager from napari.layers.utils.color_transformations import ColorType from napari.layers.utils.layer_utils import _FeatureTable @@ -16,7 +16,10 @@ _VectorSliceResponse, ) from napari.layers.vectors._vector_utils import fix_data_vectors -from napari.layers.vectors._vectors_constants import VectorStyle +from napari.layers.vectors._vectors_constants import ( + VectorsProjectionMode, + VectorStyle, +) from napari.utils.colormaps import Colormap, ValidColormapArg from napari.utils.events import Event from napari.utils.events.custom_types import Array @@ -163,6 +166,8 @@ class Vectors(Layer): subsampled. """ + _projectionclass = VectorsProjectionMode + # The max number of vectors that will ever be used to render the thumbnail # If more vectors are present then they are randomly subsampled _max_vectors_thumbnail = 1024 @@ -196,6 +201,7 @@ def __init__( visible=True, cache=True, experimental_clipping_planes=None, + projection_mode='none', ) -> None: if ndim is None and scale is not None: ndim = len(scale) @@ -217,6 +223,7 @@ def __init__( visible=visible, cache=cache, experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, ) # events for non-napari calculations @@ -661,16 +668,14 @@ def _set_view_slice(self): # executes the request on the calling thread directly. # For async slicing, the calling thread will not be the main thread. request = self._make_slice_request_internal( - self._slice_input, self._slice_indices + self._slice_input, self._data_slice ) response = request() self._update_slice_response(response) def _make_slice_request(self, dims) -> _VectorSliceRequest: """Make a Vectors slice request based on the given dims and these data.""" - slice_input = self._make_slice_input( - dims.point, dims.ndisplay, dims.order - ) + slice_input = self._make_slice_input(dims) # TODO: [see Image] # For the existing sync slicing, slice_indices is passed through # to avoid some performance issues related to the evaluation of the @@ -678,25 +683,24 @@ def _make_slice_request(self, dims) -> _VectorSliceRequest: # absorbs these performance issues here, but we can likely improve # things either by caching the world-to-data transform on the layer # or by lazily evaluating it in the slice task itself. - slice_indices = slice_input.data_indices( - self._data_to_world.inverse, round_index=False - ) + slice_indices = slice_input.data_slice(self._data_to_world.inverse) return self._make_slice_request_internal(slice_input, slice_indices) def _make_slice_request_internal( - self, slice_input: _SliceInput, dims_indices + self, slice_input: _SliceInput, data_slice: _ThickNDSlice ): return _VectorSliceRequest( - dims=slice_input, + slice_input=slice_input, data=self.data, - dims_indices=dims_indices, + data_slice=data_slice, + projection_mode=self.projection_mode, out_of_slice_display=self.out_of_slice_display, length=self.length, ) def _update_slice_response(self, response: _VectorSliceResponse): """Handle a slicing response.""" - self._slice_input = response.dims + self._slice_input = response.slice_input indices = response.indices alphas = response.alphas diff --git a/napari/plugins/_npe2.py b/napari/plugins/_npe2.py index b1606a7777d..8b932ee5fdf 100644 --- a/napari/plugins/_npe2.py +++ b/napari/plugins/_npe2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import defaultdict +from functools import partial from typing import ( TYPE_CHECKING, Any, @@ -28,8 +29,9 @@ from npe2.manifest.contributions import WriterContribution from npe2.plugin_manager import PluginName from npe2.types import LayerData, SampleDataCreator, WidgetCreator - from qtpy.QtWidgets import QMenu # type: ignore [attr-defined] + from qtpy.QtWidgets import QMenu + from napari._qt.qt_viewer import QtViewer from napari.layers import Layer from napari.types import SampleDict @@ -163,10 +165,12 @@ def _wrapped(*args): if isinstance(item, contributions.Submenu): subm_contrib = pm.get_submenu(item.submenu) subm = menu.addMenu(subm_contrib.label) + assert subm is not None populate_qmenu(subm, subm_contrib.id) else: cmd = pm.get_command(item.command) action = menu.addAction(cmd.title) + assert action is not None action.triggered.connect(_wrap(cmd)) @@ -353,7 +357,6 @@ def _rebuild_npe1_samples_menu() -> None: """Register submenu and actions for all npe1 plugins, clearing all first.""" from napari._app_model import get_app from napari._app_model.constants import MenuGroup, MenuId - from napari._qt.qt_viewer import QtViewer from napari.plugins import menu_item_template, plugin_manager app = get_app() @@ -395,7 +398,7 @@ def _add_sample( qt_viewer.viewer.open_sample(plugin, sample) except MultipleReaderError as e: handle_gui_reading( - e.paths, + [str(p) for p in e.paths], qt_viewer, stack=False, ) @@ -420,6 +423,21 @@ def _add_sample( plugin_manager._unreg_sample_actions = unreg_sample_actions +# Note `QtViewer` gets added to `injection_store.namespace` during +# `init_qactions` so does not need to be imported for type annotation resolution +def _add_sample(qt_viewer: QtViewer, plugin=str, sample=str) -> None: + from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading + + try: + qt_viewer.viewer.open_sample(plugin, sample) + except MultipleReaderError as e: + handle_gui_reading( + [str(p) for p in e.paths], + qt_viewer, + stack=False, + ) + + def _get_samples_submenu_actions( mf: PluginManifest, ) -> Tuple[List[Any], List[Any]]: @@ -427,9 +445,6 @@ def _get_samples_submenu_actions( from napari._app_model.constants import MenuGroup, MenuId from napari.plugins import menu_item_template - if TYPE_CHECKING: - from napari._qt.qt_viewer import QtViewer - # If no sample data, return if not mf.contributions.sample_data: return [], [] @@ -452,22 +467,11 @@ def _get_samples_submenu_actions( sample_actions: List[Action] = [] for sample in sample_data: - - def _add_sample( - qt_viewer: QtViewer, + _add_sample_partial = partial( + _add_sample, plugin=mf.name, sample=sample.key, - ): - from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading - - try: - qt_viewer.viewer.open_sample(plugin, sample) - except MultipleReaderError as e: - handle_gui_reading( - e.paths, - qt_viewer, - stack=False, - ) + ) if multiprovider: title = sample.display_name @@ -482,7 +486,7 @@ def _add_sample( id=f'{mf.name}:{sample.key}', title=title, menus=[{'id': submenu_id, 'group': MenuGroup.NAVIGATION}], - callback=_add_sample, + callback=_add_sample_partial, ) sample_actions.append(action) return submenu, sample_actions diff --git a/napari/plugins/_plugin_manager.py b/napari/plugins/_plugin_manager.py index 3dcb981eadf..f5b8946d039 100644 --- a/napari/plugins/_plugin_manager.py +++ b/napari/plugins/_plugin_manager.py @@ -22,9 +22,9 @@ PluginManager as PluginManager, ) from napari_plugin_engine.hooks import HookCaller -from pydantic import ValidationError from typing_extensions import TypedDict +from napari._pydantic_compat import ValidationError from napari.plugins import hook_specifications from napari.settings import get_settings from napari.types import AugmentedWidget, LayerData, SampleDict, WidgetCallable diff --git a/napari/plugins/_tests/_sample_manifest.yaml b/napari/plugins/_tests/_sample_manifest.yaml index 4b03cf6b539..de8b6ba821c 100644 --- a/napari/plugins/_tests/_sample_manifest.yaml +++ b/napari/plugins/_tests/_sample_manifest.yaml @@ -45,4 +45,4 @@ contributions: command: my-plugin.generate_random_data - display_name: Random internet image key: internet_image - uri: https://picsum.photos/1024 \ No newline at end of file + uri: https://picsum.photos/1024 diff --git a/napari/plugins/io.py b/napari/plugins/io.py index 2026c2166ee..30fa61712a9 100644 --- a/napari/plugins/io.py +++ b/napari/plugins/io.py @@ -10,7 +10,7 @@ from napari.layers import Layer from napari.plugins import _npe2, plugin_manager -from napari.types import LayerData +from napari.types import LayerData, PathLike from napari.utils.misc import abspath_or_url from napari.utils.translations import trans @@ -20,7 +20,7 @@ def read_data_with_plugins( - paths: Sequence[str], + paths: Sequence[PathLike], plugin: Optional[str] = None, stack: bool = False, ) -> Tuple[Optional[List[LayerData]], Optional[HookImplementation]]: diff --git a/napari/plugins/utils.py b/napari/plugins/utils.py index a7349d7892e..eb1f448a8c4 100644 --- a/napari/plugins/utils.py +++ b/napari/plugins/utils.py @@ -5,12 +5,13 @@ from fnmatch import fnmatch from functools import lru_cache from pathlib import Path -from typing import Dict, Iterable, List, Optional, Set, Tuple, Union +from typing import Dict, List, Optional, Set, Tuple, Union from npe2 import PluginManifest from napari.plugins import _npe2, plugin_manager from napari.settings import get_settings +from napari.types import PathLike class MatchFlag(IntFlag): @@ -70,7 +71,7 @@ def add(match_flag): return not osp.isabs(pattern), 1 - len(score), score -def _get_preferred_readers(path: str) -> Iterable[Tuple[str, str]]: +def _get_preferred_readers(path: PathLike) -> List[Tuple[str, str]]: """Given filepath, find matching readers from preferences. Parameters @@ -80,18 +81,24 @@ def _get_preferred_readers(path: str) -> Iterable[Tuple[str, str]]: Returns ------- - filtered_preferences : Iterable[Tuple[str, str]] + filtered_preferences : List[Tuple[str, str]] Filtered patterns and their corresponding readers. """ + path = str(path) if osp.isdir(path) and not path.endswith(os.sep): path = path + os.sep reader_settings = get_settings().plugins.extension2reader - return filter(lambda kv: fnmatch(path, kv[0]), reader_settings.items()) + def filter_fn(kv: Tuple[str, str]) -> bool: + return fnmatch(path, kv[0]) -def get_preferred_reader(path: str) -> Optional[str]: + ret = list(filter(filter_fn, reader_settings.items())) + return ret + + +def get_preferred_reader(path: PathLike) -> Optional[str]: """Given filepath, find the best matching reader from the preferences. Parameters @@ -115,7 +122,7 @@ def get_preferred_reader(path: str) -> Optional[str]: return None -def get_potential_readers(filename: str) -> Dict[str, str]: +def get_potential_readers(filename: PathLike) -> Dict[str, str]: """Given filename, returns all readers that may read the file. Original plugin engine readers are checked based on returning diff --git a/napari/resources/_icons.py b/napari/resources/_icons.py index 10073636cba..9cb004c3ac9 100644 --- a/napari/resources/_icons.py +++ b/napari/resources/_icons.py @@ -44,7 +44,7 @@ def get_raw_svg(path: str) -> str: @lru_cache def get_colorized_svg( - path_or_xml: Union[str, Path], color: Optional[str] = None, opacity=1 + path_or_xml: Union[str, Path], color: Optional[str] = None, opacity=1.0 ) -> str: """Return a colorized version of the SVG XML at ``path``. diff --git a/napari/resources/icons/add.svg b/napari/resources/icons/add.svg index 2563906fdbb..46b317f1130 100644 --- a/napari/resources/icons/add.svg +++ b/napari/resources/icons/add.svg @@ -6,7 +6,7 @@ - diff --git a/napari/resources/icons/check.svg b/napari/resources/icons/check.svg index c55c59f2bf3..6946675943b 100644 --- a/napari/resources/icons/check.svg +++ b/napari/resources/icons/check.svg @@ -1,5 +1,5 @@ - diff --git a/napari/resources/icons/circle.svg b/napari/resources/icons/circle.svg index ecc4b744140..f0f1798df22 100644 --- a/napari/resources/icons/circle.svg +++ b/napari/resources/icons/circle.svg @@ -1,5 +1,5 @@ - diff --git a/napari/resources/icons/copy_to_clipboard.svg b/napari/resources/icons/copy_to_clipboard.svg index 3540536f08e..1456c73b81e 100644 --- a/napari/resources/icons/copy_to_clipboard.svg +++ b/napari/resources/icons/copy_to_clipboard.svg @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/napari/resources/icons/debug.svg b/napari/resources/icons/debug.svg index 9e43d564f34..a5aee30c27c 100644 --- a/napari/resources/icons/debug.svg +++ b/napari/resources/icons/debug.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/napari/resources/icons/error.svg b/napari/resources/icons/error.svg index b3645a2ffc9..df43f4f95da 100644 --- a/napari/resources/icons/error.svg +++ b/napari/resources/icons/error.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/napari/resources/icons/horizontal_separator.svg b/napari/resources/icons/horizontal_separator.svg index 76f66719472..2810283ee4b 100644 --- a/napari/resources/icons/horizontal_separator.svg +++ b/napari/resources/icons/horizontal_separator.svg @@ -1,6 +1,6 @@ \ No newline at end of file + diff --git a/napari/resources/icons/none.svg b/napari/resources/icons/none.svg index 9e43d564f34..a5aee30c27c 100644 --- a/napari/resources/icons/none.svg +++ b/napari/resources/icons/none.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/napari/resources/icons/pan_arrows.svg b/napari/resources/icons/pan_arrows.svg index 02238ff364e..b2b4c9d1b71 100644 --- a/napari/resources/icons/pan_arrows.svg +++ b/napari/resources/icons/pan_arrows.svg @@ -1,4 +1,4 @@ - \ No newline at end of file + diff --git a/napari/resources/icons/plus.svg b/napari/resources/icons/plus.svg index e743222d318..13d90b53982 100644 --- a/napari/resources/icons/plus.svg +++ b/napari/resources/icons/plus.svg @@ -4,7 +4,7 @@ viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve"> - diff --git a/napari/resources/icons/vertex_insert.svg b/napari/resources/icons/vertex_insert.svg index b509f3afdaf..25cf3978600 100644 --- a/napari/resources/icons/vertex_insert.svg +++ b/napari/resources/icons/vertex_insert.svg @@ -4,7 +4,7 @@ viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve"> - diff --git a/napari/resources/icons/vertical_separator.svg b/napari/resources/icons/vertical_separator.svg index 20776ad0da4..4ee32c6d9c6 100644 --- a/napari/resources/icons/vertical_separator.svg +++ b/napari/resources/icons/vertical_separator.svg @@ -1,6 +1,6 @@ \ No newline at end of file + diff --git a/napari/settings/_appearance.py b/napari/settings/_appearance.py index 396ef0252ae..fc0302de66a 100644 --- a/napari/settings/_appearance.py +++ b/napari/settings/_appearance.py @@ -1,8 +1,9 @@ -from pydantic import Field +from typing import Union, cast +from napari._pydantic_compat import Field from napari.settings._fields import Theme -from napari.utils.events.evented_model import EventedModel -from napari.utils.theme import available_themes +from napari.utils.events.evented_model import ComparisonDelayer, EventedModel +from napari.utils.theme import available_themes, get_theme from napari.utils.translations import trans @@ -13,6 +14,13 @@ class AppearanceSettings(EventedModel): description=trans._("Select the user interface theme."), env="napari_theme", ) + font_size: int = Field( + int(get_theme("dark").font_size[:-2]), + title=trans._("Font size"), + description=trans._("Select the user interface font size."), + ge=5, + le=20, + ) highlight_thickness: int = Field( 1, title=trans._("Highlight thickness"), @@ -28,6 +36,47 @@ class AppearanceSettings(EventedModel): description=trans._("Toggle to display a tooltip on mouse hover."), ) + def update( + self, values: Union['EventedModel', dict], recurse: bool = True + ) -> None: + if isinstance(values, self.__class__): + values = values.dict() + values = cast(dict, values) + + # Check if a font_size change is needed when changing theme: + # If the font_size setting doesn't correspond to the default value + # of the current theme no change is done, otherwise + # the font_size value is set to the new selected theme font size value + if "theme" in values and values["theme"] != self.theme: + current_theme = get_theme(self.theme) + new_theme = get_theme(values["theme"]) + if values["font_size"] == int(current_theme.font_size[:-2]): + values["font_size"] = int(new_theme.font_size[:-2]) + super().update(values, recurse) + + def __setattr__(self, key, value): + # Check if a font_size change is needed when changing theme: + # If the font_size setting doesn't correspond to the default value + # of the current theme no change is done, otherwise + # the font_size value is set to the new selected theme font size value + if key == "theme" and value != self.theme: + with ComparisonDelayer(self): + new_theme = None + current_theme = None + if value in available_themes(): + new_theme = get_theme(value) + if self.theme in available_themes(): + current_theme = get_theme(self.theme) + if ( + new_theme + and current_theme + and self.font_size == int(current_theme.font_size[:-2]) + ): + self.font_size = int(new_theme.font_size[:-2]) + super().__setattr__(key, value) + else: + super().__setattr__(key, value) + class NapariConfig: # Napari specific configuration preferences_exclude = ('schema_version',) diff --git a/napari/settings/_application.py b/napari/settings/_application.py index 21a1e981ce5..2d71e042215 100644 --- a/napari/settings/_application.py +++ b/napari/settings/_application.py @@ -3,8 +3,8 @@ from typing import List, Optional, Tuple from psutil import virtual_memory -from pydantic import Field, validator +from napari._pydantic_compat import Field, validator from napari.settings._constants import BrushSizeOnMouseModifiers, LoopMode from napari.settings._fields import Language from napari.utils._base import _DEFAULT_LOCALE diff --git a/napari/settings/_base.py b/napari/settings/_base.py index bacba638f47..6ebf5a4d6a1 100644 --- a/napari/settings/_base.py +++ b/napari/settings/_base.py @@ -9,10 +9,13 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, cast from warnings import warn -from pydantic import BaseModel, BaseSettings, ValidationError -from pydantic.env_settings import SettingsError -from pydantic.error_wrappers import display_errors - +from napari._pydantic_compat import ( + BaseModel, + BaseSettings, + SettingsError, + ValidationError, + display_errors, +) from napari.settings._yaml import PydanticYamlMixin from napari.utils.events import EmitterGroup, EventedModel from napari.utils.misc import deep_update @@ -23,8 +26,10 @@ if TYPE_CHECKING: from typing import AbstractSet, Any, Union - from pydantic.env_settings import EnvSettingsSource, SettingsSourceCallable - + from napari._pydantic_compat import ( + EnvSettingsSource, + SettingsSourceCallable, + ) from napari.utils.events import Event IntStr = Union[int, str] @@ -33,7 +38,7 @@ MappingIntStrAny = Mapping[IntStr, Any] -class EventedSettings(BaseSettings, EventedModel): # type: ignore[misc] +class EventedSettings(BaseSettings, EventedModel): """A variant of EventedModel designed for settings. Pydantic's BaseSettings model will attempt to determine the values of any @@ -116,7 +121,7 @@ def config_path(self): """Return the path to/from which settings be saved/loaded.""" return self._config_path - def dict( # type: ignore [override] + def dict( self, *, include: Union[AbstractSetIntStr, MappingIntStrAny] = None, # type: ignore @@ -241,7 +246,7 @@ def customise_sources( the return list to change the priority of sources. """ cls._env_settings = nested_env_settings(env_settings) - return ( # type: ignore [return-value] + return ( init_settings, cls._env_settings, cls._config_file_settings_source, diff --git a/napari/settings/_experimental.py b/napari/settings/_experimental.py index d2fda2e6367..111fd8925c4 100644 --- a/napari/settings/_experimental.py +++ b/napari/settings/_experimental.py @@ -1,5 +1,4 @@ -from pydantic import Field - +from napari._pydantic_compat import Field from napari.settings._base import EventedSettings from napari.utils.translations import trans diff --git a/napari/settings/_napari_settings.py b/napari/settings/_napari_settings.py index c6f2a574dc8..ebd6ac0eb0f 100644 --- a/napari/settings/_napari_settings.py +++ b/napari/settings/_napari_settings.py @@ -2,8 +2,7 @@ from pathlib import Path from typing import Any, Optional -from pydantic import Field - +from napari._pydantic_compat import Field from napari.settings._appearance import AppearanceSettings from napari.settings._application import ApplicationSettings from napari.settings._base import ( diff --git a/napari/settings/_plugins.py b/napari/settings/_plugins.py index 22a0ccaf25b..1347a79373e 100644 --- a/napari/settings/_plugins.py +++ b/napari/settings/_plugins.py @@ -1,8 +1,8 @@ from typing import Dict, List, Set -from pydantic import Field from typing_extensions import TypedDict +from napari._pydantic_compat import Field from napari.settings._base import EventedSettings from napari.utils.translations import trans diff --git a/napari/settings/_shortcuts.py b/napari/settings/_shortcuts.py index 81ac92994ec..f1af3eddd76 100644 --- a/napari/settings/_shortcuts.py +++ b/napari/settings/_shortcuts.py @@ -1,7 +1,6 @@ from typing import Dict, List -from pydantic import Field, validator - +from napari._pydantic_compat import Field, validator from napari.utils.events.evented_model import EventedModel from napari.utils.key_bindings import KeyBinding, coerce_keybinding from napari.utils.shortcuts import default_shortcuts diff --git a/napari/settings/_tests/test_settings.py b/napari/settings/_tests/test_settings.py index e4e68e3f48c..e3e990fe354 100644 --- a/napari/settings/_tests/test_settings.py +++ b/napari/settings/_tests/test_settings.py @@ -2,11 +2,11 @@ import os from pathlib import Path -import pydantic import pytest from yaml import safe_load from napari import settings +from napari._pydantic_compat import Field, ValidationError from napari.settings import CURRENT_SCHEMA_VERSION, NapariSettings from napari.utils.theme import get_theme, register_theme @@ -74,7 +74,7 @@ def test_settings_load_strict(tmp_path, monkeypatch): data = "appearance:\n theme: 1" fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) - with pytest.raises(pydantic.ValidationError): + with pytest.raises(ValidationError): NapariSettings(fake_path) @@ -108,8 +108,8 @@ def test_settings_load_invalid_section(tmp_path): fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) - settings = NapariSettings(fake_path) - assert getattr(settings, "non_existing_section", None) is None + settings_ = NapariSettings(fake_path) + assert getattr(settings_, "non_existing_section", None) is None def test_settings_to_dict(test_settings): @@ -145,11 +145,11 @@ def test_settings_reset(test_settings): def test_settings_model(test_settings): - with pytest.raises(pydantic.error_wrappers.ValidationError): + with pytest.raises(ValidationError): # Should be string test_settings.appearance.theme = 1 - with pytest.raises(pydantic.error_wrappers.ValidationError): + with pytest.raises(ValidationError): # Should be a valid string test_settings.appearance.theme = "vaporwave" @@ -159,7 +159,7 @@ def test_custom_theme_settings(test_settings): custom_theme_name = "_test_blue_" # No theme registered yet, this should fail - with pytest.raises(pydantic.error_wrappers.ValidationError): + with pytest.raises(ValidationError): test_settings.appearance.theme = custom_theme_name blue_theme = get_theme('dark').to_rgb_dict() @@ -219,7 +219,7 @@ def test_settings_env_variables(monkeypatch): def test_settings_env_variables_fails(monkeypatch): monkeypatch.setenv('NAPARI_APPEARANCE_THEME', 'FOOBAR') - with pytest.raises(pydantic.ValidationError): + with pytest.raises(ValidationError): NapariSettings() @@ -228,7 +228,7 @@ def test_subfield_env_field(monkeypatch): from napari.settings._base import EventedSettings class Sub(EventedSettings): - x: int = pydantic.Field(1, env='varname') + x: int = Field(1, env='varname') class T(NapariSettings): sub: Sub diff --git a/napari/settings/_yaml.py b/napari/settings/_yaml.py index 55f68b1531a..f069c8f2182 100644 --- a/napari/settings/_yaml.py +++ b/napari/settings/_yaml.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, Type from app_model.types import KeyBinding -from pydantic import BaseModel from yaml import SafeDumper, dump_all +from napari._pydantic_compat import BaseModel from napari.settings._fields import Version if TYPE_CHECKING: @@ -81,7 +81,7 @@ def yaml( exclude_none=exclude_none, ) if self.__custom_root_type__: - from pydantic.utils import ROOT_KEY + from napari._pydantic_compat import ROOT_KEY data = data[ROOT_KEY] return self._yaml_dump(data, dumper, **dumps_kwargs) diff --git a/napari/types.py b/napari/types.py index 56ac0387a02..a34a143eaa7 100644 --- a/napari/types.py +++ b/napari/types.py @@ -30,7 +30,7 @@ import dask.array # noqa: ICN001 import zarr from magicgui.widgets import FunctionGui - from qtpy.QtWidgets import QWidget # type: ignore [attr-defined] + from qtpy.QtWidgets import QWidget __all__ = [ @@ -73,7 +73,7 @@ LayerData = Union[Tuple[Any], Tuple[Any, Dict], FullLayerData] PathLike = Union[str, Path] -PathOrPaths = Union[str, Sequence[str]] +PathOrPaths = Union[PathLike, Sequence[PathLike]] ReaderFunction = Callable[[PathOrPaths], List[LayerData]] WriterFunction = Callable[[str, List[FullLayerData]], List[str]] diff --git a/napari/utils/__init__.py b/napari/utils/__init__.py index d00c986e2e2..4a365414dc0 100644 --- a/napari/utils/__init__.py +++ b/napari/utils/__init__.py @@ -1,11 +1,12 @@ from napari.utils._dask_utils import resize_dask_cache -from napari.utils.colormaps import Colormap +from napari.utils.colormaps.colormap import Colormap, LabelColormap from napari.utils.info import citation_text, sys_info from napari.utils.notebook_display import nbscreenshot from napari.utils.progress import cancelable_progress, progrange, progress __all__ = ( "Colormap", + "LabelColormap", "resize_dask_cache", "citation_text", "sys_info", diff --git a/napari/utils/_dask_utils.py b/napari/utils/_dask_utils.py index 125f4337957..d57cba4f751 100644 --- a/napari/utils/_dask_utils.py +++ b/napari/utils/_dask_utils.py @@ -2,7 +2,7 @@ """ import collections.abc import contextlib -from typing import Callable, ContextManager, Optional, Tuple +from typing import Any, Callable, ContextManager, Iterator, Optional, Tuple import dask import dask.array as da @@ -59,14 +59,14 @@ def resize_dask_cache( from psutil import virtual_memory if nbytes is None and mem_fraction is not None: - nbytes = virtual_memory().total * mem_fraction + nbytes = int(virtual_memory().total * mem_fraction) avail = _DASK_CACHE.cache.available_bytes # if we don't have a cache already, create one. if avail == 1: # If neither nbytes nor mem_fraction was provided, use default if nbytes is None: - nbytes = virtual_memory().total * _DEFAULT_MEM_FRACTION + nbytes = int(virtual_memory().total * _DEFAULT_MEM_FRACTION) _DASK_CACHE.cache.resize(nbytes) elif nbytes is not None and nbytes != _DASK_CACHE.cache.available_bytes: # if the cache has already been registered, then calling @@ -76,7 +76,7 @@ def resize_dask_cache( return _DASK_CACHE -def _is_dask_data(data) -> bool: +def _is_dask_data(data: Any) -> bool: """Return True if data is a dask array or a list/tuple of dask arrays.""" return isinstance(data, da.Array) or ( isinstance(data, collections.abc.Sequence) @@ -84,7 +84,7 @@ def _is_dask_data(data) -> bool: ) -def configure_dask(data, cache=True) -> DaskIndexer: +def configure_dask(data: Any, cache: bool = True) -> DaskIndexer: """Spin up cache and return context manager that optimizes Dask indexing. This function determines whether data is a dask array or list of dask @@ -137,7 +137,9 @@ def configure_dask(data, cache=True) -> DaskIndexer: _cache = resize_dask_cache() if cache else contextlib.nullcontext() @contextlib.contextmanager - def dask_optimized_slicing(memfrac=0.5): + def dask_optimized_slicing( + memfrac: float = 0.5, + ) -> Iterator[Tuple[Any, Any]]: opts = {"optimization.fuse.active": False} with dask.config.set(opts) as cfg, _cache as c: yield cfg, c diff --git a/napari/utils/_magicgui.py b/napari/utils/_magicgui.py index ff16a68661a..42cc637dd77 100644 --- a/napari/utils/_magicgui.py +++ b/napari/utils/_magicgui.py @@ -367,7 +367,9 @@ def add_layer_to_viewer(gui, result: Any, return_type: Type[Layer]) -> None: add_layers_to_viewer(gui, [result], List[return_type]) -def add_layers_to_viewer(gui, result: Any, return_type: List[Layer]) -> None: +def add_layers_to_viewer( + gui: FunctionGui[Any], result: Any, return_type: Type[List[Layer]] +) -> None: """Show a magicgui result in the viewer. Parameters diff --git a/napari/utils/_proxies.py b/napari/utils/_proxies.py index 33f055fbded..d3ba99da3e5 100644 --- a/napari/utils/_proxies.py +++ b/napari/utils/_proxies.py @@ -2,7 +2,7 @@ import re import sys import warnings -from typing import Any, Callable, Generic, TypeVar, Union +from typing import Any, Callable, Generic, List, Tuple, TypeVar, Union import wrapt @@ -17,11 +17,11 @@ class ReadOnlyWrapper(wrapt.ObjectProxy): Disable item and attribute setting with the exception of ``__wrapped__``. """ - def __init__(self, wrapped, exceptions=()): + def __init__(self, wrapped: Any, exceptions: Tuple[str, ...] = ()): super().__init__(wrapped) self._self_exceptions = exceptions - def __setattr__(self, name, val): + def __setattr__(self, name: str, val: Any) -> None: if ( name not in ('__wrapped__', '_self_exceptions') and name not in self._self_exceptions @@ -36,7 +36,7 @@ def __setattr__(self, name, val): super().__setattr__(name, val) - def __setitem__(self, name, val): + def __setitem__(self, name: str, val: Any) -> None: if name not in self._self_exceptions: raise TypeError( trans._('cannot set item {name}', deferred=True, name=name) @@ -59,7 +59,7 @@ def _is_private_attr(name: str) -> bool: ) @staticmethod - def _private_attr_warning(name: str, typ: str): + def _private_attr_warning(name: str, typ: str) -> None: warnings.warn( trans._( "Private attribute access ('{typ}.{name}') in this context (e.g. inside a plugin widget or dock widget) is deprecated and will be unavailable in version 0.5.0", @@ -82,7 +82,7 @@ def _private_attr_warning(name: str, typ: str): # ) @staticmethod - def _is_called_from_napari(): + def _is_called_from_napari() -> bool: """ Check if the getter or setter is called from inner napari. """ @@ -91,7 +91,7 @@ def _is_called_from_napari(): return frame.f_code.co_filename.startswith(misc.ROOT_DIR) return False - def __getattr__(self, name: str): + def __getattr__(self, name: str) -> Any: if self._is_private_attr(name): # allow napari to access private attributes and get an non-proxy if self._is_called_from_napari(): @@ -100,10 +100,14 @@ def __getattr__(self, name: str): typ = type(self.__wrapped__).__name__ self._private_attr_warning(name, typ) + with warnings.catch_warnings(record=True) as cx_manager: + data = self.create(super().__getattr__(name)) + for warning in cx_manager: + warnings.warn(warning.message, warning.category, stacklevel=2) - return self.create(super().__getattr__(name)) + return data - def __setattr__(self, name: str, value: Any): + def __setattr__(self, name: str, value: Any) -> None: if ( os.environ.get("NAPARI_ENSURE_PLUGIN_MAIN_THREAD", "0") not in ("0", "False") @@ -140,13 +144,13 @@ def __setattr__(self, name: str, value: Any): setattr(self.__wrapped__, name, value) return None - def __getitem__(self, key): + def __getitem__(self, key: Any) -> Any: return self.create(super().__getitem__(key)) - def __repr__(self): + def __repr__(self) -> str: return repr(self.__wrapped__) - def __dir__(self): + def __dir__(self) -> List[str]: return [x for x in dir(self.__wrapped__) if not _SUNDER.match(x)] @classmethod @@ -171,16 +175,16 @@ def create(cls, obj: Any) -> Union['PublicOnlyProxy', Any]: class CallablePublicOnlyProxy(PublicOnlyProxy[Callable]): - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs): # type: ignore [no-untyped-def] # if a PublicOnlyProxy is callable, then when we call it we: # - unwrap the arguments, to avoid performance issues detailed in # PublicOnlyProxy.__setattr__, # - call the unwrapped callable on the unwrapped arguments # - wrap the result in a PublicOnlyProxy - args = [ + args = tuple( arg.__wrapped__ if isinstance(arg, PublicOnlyProxy) else arg for arg in args - ] + ) kwargs = { k: v.__wrapped__ if isinstance(v, PublicOnlyProxy) else v for k, v in kwargs.items() diff --git a/napari/utils/_tests/test_migrations.py b/napari/utils/_tests/test_migrations.py index 67acfec1ac3..778fea4cd2c 100644 --- a/napari/utils/_tests/test_migrations.py +++ b/napari/utils/_tests/test_migrations.py @@ -51,10 +51,12 @@ def new_property(self, value: int) -> int: instance.new_property = 1 - with pytest.warns(FutureWarning): + msg = "Dummy.old_property is deprecated since 0.0.0 and will be removed in 0.1.0. Please use new_property" + + with pytest.warns(FutureWarning, match=msg): assert instance.old_property == 1 - with pytest.warns(FutureWarning): + with pytest.warns(FutureWarning, match=msg): instance.old_property = 2 assert instance.new_property == 2 diff --git a/napari/utils/_tests/test_notification_manager.py b/napari/utils/_tests/test_notification_manager.py index a209a8f24b8..fa8272845ad 100644 --- a/napari/utils/_tests/test_notification_manager.py +++ b/napari/utils/_tests/test_notification_manager.py @@ -80,7 +80,9 @@ def test_notification_manager_no_gui(monkeypatch): # test that warnings that go through showwarning are catalogued # again, pytest intercepts this, so just manually trigger: assert warnings.showwarning == notification_manager.receive_warning - warnings.showwarning('this is a warning', UserWarning, '', 0) + warnings.showwarning( + UserWarning('this is a warning'), UserWarning, __file__, 83 + ) assert len(notification_manager.records) == 4 assert store[-1].type == 'warning' @@ -111,7 +113,9 @@ def test_notification_manager_no_gui_with_threading(): """ def _warn(): - warnings.showwarning('this is a warning', UserWarning, '', 0) + warnings.showwarning( + UserWarning('this is a warning'), UserWarning, __file__, 116 + ) def _raise(): with pytest.raises(PurposefulException): @@ -155,3 +159,26 @@ def _raise(): assert threading.excepthook == previous_threading_exhook assert all(isinstance(x, Notification) for x in store) + + +def test_notification_manager_no_warning_duplication(): + def fun(): + warnings.showwarning( + UserWarning('This is a warning'), + category=UserWarning, + filename=__file__, + lineno=166, + ) + + with notification_manager: + notification_manager.records.clear() + # save all of the events that get emitted + store: List[Notification] = [] + notification_manager.notification_ready.connect(store.append) + + fun() + assert len(notification_manager.records) == 1 + assert store[-1].type == 'warning' + + fun() + assert len(notification_manager.records) == 1 diff --git a/napari/utils/_tests/test_theme.py b/napari/utils/_tests/test_theme.py index 9e649bf17ab..414c151e32c 100644 --- a/napari/utils/_tests/test_theme.py +++ b/napari/utils/_tests/test_theme.py @@ -5,8 +5,8 @@ from npe2 import PluginManager, PluginManifest, __version__ as npe2_version from npe2.manifest.schema import ContributionPoints from packaging.version import parse as parse_version -from pydantic import ValidationError +from napari._pydantic_compat import ValidationError from napari.resources._icons import PLUGIN_FILE_NAME from napari.settings import get_settings from napari.utils.theme import ( diff --git a/napari/utils/_testsupport.py b/napari/utils/_testsupport.py index 98095e7c2f2..f20b5500005 100644 --- a/napari/utils/_testsupport.py +++ b/napari/utils/_testsupport.py @@ -173,7 +173,7 @@ def make_napari_viewer( >>> def test_something_with_strict_qt_tests(make_napari_viewer): ... viewer = make_napari_viewer(strict_qt=True) """ - from qtpy.QtWidgets import QApplication + from qtpy.QtWidgets import QApplication, QWidget from napari import Viewer from napari._qt._qapp_model.qactions import init_qactions @@ -210,7 +210,7 @@ def make_napari_viewer( viewers: WeakSet[Viewer] = WeakSet() - # may be overridden by using `make_napari_viewer(strict=True)` + # may be overridden by using the parameter `strict_qt` _strict = False initial = QApplication.topLevelWidgets() @@ -223,6 +223,17 @@ def make_napari_viewer( _empty, ) + if "enable_console" not in request.keywords: + + def _dummy_widget(*_): + w = QWidget() + w._update_theme = _empty + return w + + monkeypatch.setattr( + "napari._qt.qt_viewer.QtViewer._get_console", _dummy_widget + ) + def actual_factory( *model_args, ViewerClass=Viewer, @@ -283,7 +294,7 @@ def actual_factory( assert _do_not_inline_below == 0 # only check for leaked widgets if an exception was raised during the test, - # or "strict" mode was used. + # and "strict" mode was used. if _strict and getattr(sys, 'last_value', None) is prior_exception: QApplication.processEvents() leak = set(QApplication.topLevelWidgets()).difference(initial) @@ -304,7 +315,7 @@ def actual_factory( # in particular with VisPyCanvas, it looks like if a traceback keeps # contains the type, then instances are still attached to the type. # I'm not too sure why this is the case though. - if _strict: + if _strict == 'raise': raise AssertionError(msg) else: warnings.warn(msg) diff --git a/napari/utils/color.py b/napari/utils/color.py index 8890ddabecb..5fcc76044ff 100644 --- a/napari/utils/color.py +++ b/napari/utils/color.py @@ -1,6 +1,6 @@ """Contains napari color constants and utilities.""" -from typing import Union +from typing import Callable, Iterator, Union import numpy as np @@ -18,11 +18,13 @@ class ColorValue(np.ndarray): use the ``validate`` method to coerce a value to a single color. """ - def __new__(cls, value: ColorValueParam): + def __new__(cls, value: ColorValueParam) -> 'ColorValue': return cls.validate(value) @classmethod - def __get_validators__(cls): + def __get_validators__( + cls, + ) -> Iterator[Callable[[ColorValueParam], 'ColorValue']]: yield cls.validate @classmethod @@ -82,14 +84,16 @@ class ColorArray(np.ndarray): use the ``validate`` method to coerce a value to an array of colors. """ - def __new__(cls, value: ColorArrayParam): + def __new__(cls, value: ColorArrayParam) -> 'ColorArray': return cls.validate(value) @classmethod - def __get_validators__(cls): + def __get_validators__( + cls, + ) -> Iterator[Callable[[ColorArrayParam], 'ColorArray']]: yield cls.validate - def __sizeof__(self): + def __sizeof__(self) -> int: return super().__sizeof__() + self.nbytes @classmethod diff --git a/napari/utils/colormaps/_tests/test_colormap.py b/napari/utils/colormaps/_tests/test_colormap.py index 7a39c0378df..95200fa5eee 100644 --- a/napari/utils/colormaps/_tests/test_colormap.py +++ b/napari/utils/colormaps/_tests/test_colormap.py @@ -1,7 +1,18 @@ +import importlib +from itertools import product +from unittest.mock import patch + import numpy as np +import numpy.testing as npt import pytest -from napari.utils.colormaps import Colormap +from napari.utils.color import ColorArray +from napari.utils.colormaps import Colormap, colormap +from napari.utils.colormaps.colormap import ( + MAPPING_OF_UNKNOWN_VALUE, + DirectLabelColormap, +) +from napari.utils.colormaps.colormap_utils import label_colormap def test_linear_colormap(): @@ -110,3 +121,312 @@ def test_mapped_shape(ndim): cmap = Colormap(colors=['red']) mapped = cmap.map(img) assert mapped.shape == img.shape + (4,) + + +@pytest.mark.parametrize( + "num,dtype", [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] +) +def test_minimum_dtype_for_labels(num, dtype): + assert colormap.minimum_dtype_for_labels(num) == dtype + + +@pytest.fixture() +def disable_jit(monkeypatch): + pytest.importorskip("numba") + with patch("numba.core.config.DISABLE_JIT", True): + importlib.reload(colormap) + yield + importlib.reload(colormap) # revert to original state + + +@pytest.mark.parametrize("num,dtype", [(40, np.uint8), (1000, np.uint16)]) +@pytest.mark.usefixtures("disable_jit") +def test_cast_labels_to_minimum_type_auto(num: int, dtype, monkeypatch): + cmap = label_colormap(num) + data = np.zeros(3, dtype=np.uint32) + data[1] = 10 + data[2] = 10**6 + 5 + cast_arr = colormap._cast_labels_data_to_texture_dtype_auto(data, cmap) + assert cast_arr.dtype == dtype + assert cast_arr[0] == 0 + assert cast_arr[1] == 10 + assert cast_arr[2] == 10**6 % num + 5 + + +@pytest.fixture +def direct_label_colormap(): + return DirectLabelColormap( + color_dict={ + 0: np.array([0, 0, 0, 0]), + 1: np.array([255, 0, 0, 255]), + 2: np.array([0, 255, 0, 255]), + 3: np.array([0, 0, 255, 255]), + 12: np.array([0, 0, 255, 255]), + None: np.array([255, 255, 255, 255]), + }, + ) + + +def test_direct_label_colormap_simple(direct_label_colormap): + np.testing.assert_array_equal( + direct_label_colormap.map([0, 2, 7]), + np.array([[0, 0, 0, 0], [0, 255, 0, 255], [255, 255, 255, 255]]), + ) + assert direct_label_colormap._num_unique_colors == 5 + + ( + label_mapping, + color_dict, + ) = direct_label_colormap._values_mapping_to_minimum_values_set() + + assert len(label_mapping) == 6 + assert len(color_dict) == 5 + assert label_mapping[None] == MAPPING_OF_UNKNOWN_VALUE + assert label_mapping[12] == label_mapping[3] + np.testing.assert_array_equal( + color_dict[label_mapping[0]], direct_label_colormap.color_dict[0] + ) + np.testing.assert_array_equal( + color_dict[0], direct_label_colormap.color_dict[None] + ) + + +def test_direct_label_colormap_selection(direct_label_colormap): + direct_label_colormap.selection = 2 + direct_label_colormap.use_selection = True + + np.testing.assert_array_equal( + direct_label_colormap.map([0, 2, 7]), + np.array([[0, 0, 0, 0], [0, 255, 0, 255], [0, 0, 0, 0]]), + ) + + ( + label_mapping, + color_dict, + ) = direct_label_colormap._values_mapping_to_minimum_values_set() + + assert len(label_mapping) == 2 + assert len(color_dict) == 2 + + +@pytest.mark.usefixtures("disable_jit") +def test_cast_direct_labels_to_minimum_type(direct_label_colormap): + data = np.arange(15, dtype=np.uint32) + cast = colormap._labels_raw_to_texture_direct(data, direct_label_colormap) + label_mapping = ( + direct_label_colormap._values_mapping_to_minimum_values_set()[0] + ) + assert cast.dtype == np.uint8 + np.testing.assert_array_equal( + cast, + np.array( + [ + label_mapping[0], + label_mapping[1], + label_mapping[2], + label_mapping[3], + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + label_mapping[3], + MAPPING_OF_UNKNOWN_VALUE, + MAPPING_OF_UNKNOWN_VALUE, + ] + ), + ) + + +@pytest.mark.parametrize( + "num,dtype", [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] +) +@pytest.mark.usefixtures("disable_jit") +def test_test_cast_direct_labels_to_minimum_type_no_jit(num, dtype): + cmap = DirectLabelColormap( + color_dict={ + k: np.array([*v, 255]) + for k, v in zip(range(num), product(range(256), repeat=3)) + }, + ) + cmap.color_dict[None] = np.array([255, 255, 255, 255]) + data = np.arange(10, dtype=np.uint32) + data[2] = 80005 + cast = colormap._labels_raw_to_texture_direct(data, cmap) + assert cast.dtype == dtype + + +def test_zero_preserving_modulo_naive(): + pytest.importorskip("numba") + data = np.arange(1000, dtype=np.uint32) + res1 = colormap._zero_preserving_modulo_numpy(data, 49, np.uint8) + res2 = colormap._zero_preserving_modulo(data, 49, np.uint8) + npt.assert_array_equal(res1, res2) + + +@pytest.mark.parametrize( + 'dtype', [np.uint8, np.uint16, np.int8, np.int16, np.float32, np.float64] +) +def test_label_colormap_map_with_uint8_values(dtype): + cmap = colormap.LabelColormap( + colors=ColorArray(np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]])) + ) + values = np.array([0, 1, 2], dtype=dtype) + expected = np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) + npt.assert_array_equal(cmap.map(values), expected) + + +@pytest.mark.parametrize("selection", [1, -1]) +@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.int64]) +def test_label_colormap_map_with_selection(selection, dtype): + cmap = colormap.LabelColormap( + colors=ColorArray( + np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) + ), + use_selection=True, + selection=selection, + ) + values = np.array([0, selection, 2], dtype=np.int8) + expected = np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 0, 0, 0]]) + npt.assert_array_equal(cmap.map(values), expected) + + +@pytest.mark.parametrize("background", [1, -1]) +@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.int64]) +def test_label_colormap_map_with_background(background, dtype): + cmap = colormap.LabelColormap( + colors=ColorArray( + np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) + ), + background_value=background, + ) + values = np.array([3, background, 2], dtype=dtype) + expected = np.array([[1, 0, 0, 1], [0, 0, 0, 0], [0, 1, 0, 1]]) + npt.assert_array_equal(cmap.map(values), expected) + + +@pytest.mark.parametrize("dtype", [np.uint8, np.uint16]) +def test_label_colormap_using_cache(dtype, monkeypatch): + cmap = colormap.LabelColormap( + colors=ColorArray(np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]])) + ) + values = np.array([0, 1, 2], dtype=dtype) + expected = np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]]) + map1 = cmap.map(values) + npt.assert_array_equal(map1, expected) + monkeypatch.setattr(colormap, '_zero_preserving_modulo_numpy', None) + map2 = cmap.map(values) + npt.assert_array_equal(map1, map2) + + +@pytest.mark.parametrize("size", [100, 1000]) +def test_cast_direct_labels_to_minimum_type_naive(size): + pytest.importorskip("numba") + data = np.arange(size, dtype=np.uint32) + dtype = colormap.minimum_dtype_for_labels(size) + cmap = DirectLabelColormap( + color_dict={ + k: np.array([*v, 255]) + for k, v in zip(range(size - 2), product(range(256), repeat=3)) + }, + ) + cmap.color_dict[None] = np.array([255, 255, 255, 255]) + res1 = colormap._labels_raw_to_texture_direct(data, cmap) + res2 = colormap._labels_raw_to_texture_direct_numpy(data, cmap) + npt.assert_array_equal(res1, res2) + assert res1.dtype == dtype + assert res2.dtype == dtype + + +def test_direct_colormap_with_no_selection(): + # Create a DirectLabelColormap with a simple color_dict + color_dict = {1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1])} + cmap = DirectLabelColormap(color_dict=color_dict) + + # Map a single value + mapped = cmap.map(1) + npt.assert_array_equal(mapped[0], np.array([1, 0, 0, 1])) + + # Map multiple values + mapped = cmap.map(np.array([1, 2])) + npt.assert_array_equal(mapped, np.array([[1, 0, 0, 1], [0, 1, 0, 1]])) + + +def test_direct_colormap_with_selection(): + # Create a DirectLabelColormap with a simple color_dict and a selection + color_dict = {1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1])} + cmap = DirectLabelColormap( + color_dict=color_dict, use_selection=True, selection=1 + ) + + # Map a single value + mapped = cmap.map(1) + npt.assert_array_equal(mapped[0], np.array([1, 0, 0, 1])) + + # Map a value that is not the selection + mapped = cmap.map(2) + npt.assert_array_equal(mapped[0], np.array([0, 0, 0, 0])) + + +def test_direct_colormap_with_invalid_values(): + # Create a DirectLabelColormap with a simple color_dict + color_dict = {1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1])} + cmap = DirectLabelColormap(color_dict=color_dict) + + # Map a value that is not in the color_dict + mapped = cmap.map(3) + npt.assert_array_equal(mapped[0], np.array([0, 0, 0, 0])) + + +def test_direct_colormap_with_empty_color_dict(): + # Create a DirectLabelColormap with an empty color_dict + cmap = DirectLabelColormap(color_dict={}) + + # Map a value + mapped = cmap.map(1) + npt.assert_array_equal(mapped[0], np.array([0, 0, 0, 0])) + + +def test_direct_colormap_with_non_integer_values(): + # Create a DirectLabelColormap with a simple color_dict + color_dict = {1: np.array([1, 0, 0, 1]), 2: np.array([0, 1, 0, 1])} + cmap = DirectLabelColormap(color_dict=color_dict) + + # Map a float value + with pytest.raises(TypeError, match='DirectLabelColormap can only'): + cmap.map(1.5) + + # Map a string value + with pytest.raises(TypeError, match='DirectLabelColormap can only'): + cmap.map('1') + + +def test_direct_colormap_with_collision(): + # this test assumes that the the selected prime number for hash map size is 11 + color_dict = { + 1: np.array([1, 0, 0, 1]), + 12: np.array([0, 1, 0, 1]), + 23: np.array([0, 0, 1, 1]), + } + cmap = DirectLabelColormap(color_dict=color_dict) + + npt.assert_array_equal(cmap.map(1)[0], np.array([1, 0, 0, 1])) + npt.assert_array_equal(cmap.map(12)[0], np.array([0, 1, 0, 1])) + npt.assert_array_equal(cmap.map(23)[0], np.array([0, 0, 1, 1])) + + +def test_direct_colormap_negative_values(): + # Create a DirectLabelColormap with a simple color_dict + color_dict = {-1: np.array([1, 0, 0, 1]), -2: np.array([0, 1, 0, 1])} + cmap = DirectLabelColormap(color_dict=color_dict) + + # Map a single value + mapped = cmap.map(np.int8(-1)) + npt.assert_array_equal(mapped[0], np.array([1, 0, 0, 1])) + + # Map multiple values + mapped = cmap.map(np.array([-1, -2], dtype=np.int8)) + npt.assert_array_equal(mapped, np.array([[1, 0, 0, 1], [0, 1, 0, 1]])) diff --git a/napari/utils/colormaps/_tests/test_colormap_utils.py b/napari/utils/colormaps/_tests/test_colormap_utils.py new file mode 100644 index 00000000000..798ed1bb80e --- /dev/null +++ b/napari/utils/colormaps/_tests/test_colormap_utils.py @@ -0,0 +1,40 @@ +import numpy as np +import pytest + +from napari.utils.colormaps.colormap_utils import label_colormap + +FIRST_COLORS = [ + [0.47058824, 0.14509805, 0.02352941, 1.0], + [0.35686275, 0.8352941, 0.972549, 1.0], + [0.57254905, 0.5372549, 0.9098039, 1.0], + [0.42352942, 0.00784314, 0.75686276, 1.0], + [0.2784314, 0.22745098, 0.62352943, 1.0], + [0.67058825, 0.9254902, 0.5411765, 1.0], + [0.56078434, 0.6784314, 0.69803923, 1.0], + [0.5254902, 0.5647059, 0.6039216, 1.0], + [0.99607843, 0.96862745, 0.10980392, 1.0], + [0.96862745, 0.26666668, 0.23137255, 1.0], +] + + +@pytest.mark.parametrize("index, expected", enumerate(FIRST_COLORS, start=1)) +def test_label_colormap(index, expected): + """Test the label colormap. + + Make sure that the default label colormap colors are identical + to past versions, for UX consistency. + """ + np.testing.assert_almost_equal(label_colormap(49).map(index), [expected]) + + +def test_label_colormap_exception(): + with pytest.raises(ValueError, match="num_colors must be >= 1"): + label_colormap(0) + + with pytest.raises(ValueError, match="num_colors must be >= 1"): + label_colormap(-1) + + with pytest.raises( + ValueError, match=r".*Only up to 2\*\*16=65535 colors are supported" + ): + label_colormap(2**16 + 1) diff --git a/napari/utils/colormaps/_tests/test_colormaps.py b/napari/utils/colormaps/_tests/test_colormaps.py index fe090c85980..5cbd035c195 100644 --- a/napari/utils/colormaps/_tests/test_colormaps.py +++ b/napari/utils/colormaps/_tests/test_colormaps.py @@ -20,9 +20,9 @@ @pytest.mark.parametrize("name", list(AVAILABLE_COLORMAPS.keys())) def test_colormap(name): - if name == 'label_colormap': + if name in {'label_colormap', 'custom'}: pytest.skip( - 'label_colormap is inadvertantly added to AVAILABLE_COLORMAPS but is not a normal colormap' + 'label_colormap and custom are inadvertantly added to AVAILABLE_COLORMAPS but is not a normal colormap' ) np.random.seed(0) diff --git a/napari/utils/colormaps/categorical_colormap.py b/napari/utils/colormaps/categorical_colormap.py index 2f286ed0bb8..29ab96fa4f6 100644 --- a/napari/utils/colormaps/categorical_colormap.py +++ b/napari/utils/colormaps/categorical_colormap.py @@ -1,8 +1,8 @@ from typing import Any, Dict, Union import numpy as np -from pydantic import Field +from napari._pydantic_compat import Field from napari.utils.color import ColorValue from napari.utils.colormaps.categorical_colormap_utils import ( ColorCycle, diff --git a/napari/utils/colormaps/colorbars.py b/napari/utils/colormaps/colorbars.py index 7f9294bba22..cb1c414db94 100644 --- a/napari/utils/colormaps/colorbars.py +++ b/napari/utils/colormaps/colorbars.py @@ -1,7 +1,15 @@ +from typing import TYPE_CHECKING, Tuple + import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from vispy.color import Colormap -def make_colorbar(cmap, size=(18, 28), horizontal=True): +def make_colorbar( + cmap: 'Colormap', size: Tuple[int, int] = (18, 28), horizontal: bool = True +) -> npt.NDArray[np.uint8]: """Make a colorbar from a colormap. Parameters diff --git a/napari/utils/colormaps/colormap.py b/napari/utils/colormaps/colormap.py index 4026933dbbe..89a31605365 100644 --- a/napari/utils/colormaps/colormap.py +++ b/napari/utils/colormaps/colormap.py @@ -1,9 +1,21 @@ from collections import defaultdict -from typing import Optional, cast +from functools import cached_property +from typing import ( + TYPE_CHECKING, + Any, + DefaultDict, + Dict, + List, + Optional, + Tuple, + Union, + cast, + overload, +) import numpy as np -from pydantic import Field, PrivateAttr, validator +from napari._pydantic_compat import Field, PrivateAttr, validator from napari.utils.color import ColorArray from napari.utils.colormaps.colorbars import make_colorbar from napari.utils.compat import StrEnum @@ -11,6 +23,13 @@ from napari.utils.events.custom_types import Array from napari.utils.translations import trans +if TYPE_CHECKING: + from numba import typed + +MAPPING_OF_UNKNOWN_VALUE = 0 +# For direct mode we map all unknown values to single value +# for simplicity of implementation we select 0 + class ColormapInterpolationMode(StrEnum): """INTERPOLATION: Interpolation mode for colormaps. @@ -35,7 +54,7 @@ class Colormap(EventedModel): Data used in the colormap. name : str Name of the colormap. - display_name : str + _display_name : str Display name of the colormap. controls : array, shape (N,) or (N+1,) Control points of the colormap. @@ -76,7 +95,8 @@ def _check_controls(cls, v, values): if v[0] != 0 or (len(v) > 1 and v[-1] != 1): raise ValueError( trans._( - 'Control points must start with 0.0 and end with 1.0. Got {start_control_point} and {end_control_point}', + 'Control points must start with 0.0 and end with 1.0. ' + 'Got {start_control_point} and {end_control_point}', deferred=True, start_control_point=v[0], end_control_point=v[-1], @@ -93,7 +113,7 @@ def _check_controls(cls, v, values): ) # Check number of control points is correct - n_controls_target = len(values['colors']) + int( + n_controls_target = len(values.get('colors', [])) + int( values['interpolation'] == ColormapInterpolationMode.ZERO ) n_controls = len(v) @@ -145,68 +165,772 @@ def colorbar(self): return make_colorbar(self) -class LabelColormap(Colormap): +class LabelColormapBase(Colormap): + use_selection: bool = False + selection: int = 0 + background_value: int = 0 + interpolation: ColormapInterpolationMode = ColormapInterpolationMode.ZERO + _cache_mapping: Dict[Tuple[np.dtype, np.dtype], np.ndarray] = PrivateAttr( + default={} + ) + _cache_other: Dict[str, Any] = PrivateAttr(default={}) + + class Config(Colormap.Config): + # this config is to avoid deepcopy of cached_property + # see https://github.com/pydantic/pydantic/issues/2763 + # it is required until we drop Pydantic 1 or Python 3.11 and older + # need to validate after drop pydantic 1 + keep_untouched = (cached_property,) + + def _cmap_without_selection(self) -> "LabelColormapBase": + if self.use_selection: + cmap = self.__class__(**self.dict()) + cmap.use_selection = False + return cmap + return self + + def _get_mapping_from_cache( + self, data_dtype: np.dtype + ) -> Optional[np.ndarray]: + """For given dtype, return precomputed array mapping values to colors. + + Returns None if the dtype itemsize is greater than 2. + """ + target_dtype = _texture_dtype(self._num_unique_colors, data_dtype) + key = (data_dtype, target_dtype) + if key not in self._cache_mapping and data_dtype.itemsize <= 2: + data = np.arange( + np.iinfo(target_dtype).max + 1, dtype=target_dtype + ).astype(data_dtype) + self._cache_mapping[key] = self._map_without_cache(data) + return self._cache_mapping.get(key) + + def _clear_cache(self): + """Mechanism to clean cached properties""" + self._cache_mapping = {} + self._cache_other = {} + + @property + def _num_unique_colors(self) -> int: + """Number of unique colors, not counting transparent black.""" + return len(self.colors) - 1 + + def _map_without_cache(self, values: np.ndarray) -> np.ndarray: + """Function that maps values to colors without selection or cache""" + raise NotImplementedError + + def _selection_as_minimum_dtype(self, dtype: np.dtype) -> int: + """Treat selection as given dtype and calculate value with min dtype. + + Parameters + ---------- + dtype : np.dtype + The dtype to convert the selection to. + + Returns + ------- + int + The selection converted. + """ + raise NotImplementedError + + +class LabelColormap(LabelColormapBase): """Colormap that shuffles values before mapping to colors. Attributes ---------- seed : float use_selection : bool - selection : float + selection : int """ seed: float = 0.5 - use_selection: bool = False - selection: float = 0.0 - interpolation: ColormapInterpolationMode = ColormapInterpolationMode.ZERO - def map(self, values): - from napari.utils.colormaps.colormap_utils import low_discrepancy_image + @validator('colors', allow_reuse=True) + def _validate_color(cls, v): + if len(v) > 2**16: + raise ValueError( + "Only up to 2**16=65535 colors are supported for LabelColormap" + ) + return v + + def _selection_as_minimum_dtype(self, dtype: np.dtype) -> int: + return int( + _cast_labels_data_to_texture_dtype_auto( + dtype.type(self.selection), self + ) + ) + + def _background_as_minimum_dtype(self, dtype: np.dtype) -> int: + """Treat background as given dtype and calculate value with min dtype. + + Parameters + ---------- + dtype : np.dtype + The dtype to convert the background to. + + Returns + ------- + int + The background converted. + """ + return int( + _cast_labels_data_to_texture_dtype_auto( + dtype.type(self.background_value), self + ) + ) - # Convert to float32 to match the current GL shader implementation - values = np.atleast_1d(values).astype(np.float32) + def _map_without_cache(self, values) -> np.ndarray: + texture_dtype_values = _zero_preserving_modulo_numpy( + values, + len(self.colors) - 1, + values.dtype, + self.background_value, + ) + mapped = self.colors[texture_dtype_values] + mapped[texture_dtype_values == 0] = 0 + return mapped - values_low_discr = low_discrepancy_image(values, seed=self.seed) - mapped = super().map(values_low_discr) + def map(self, values) -> np.ndarray: + """Map values to colors. - # If using selected, disable all others + Parameters + ---------- + values : np.ndarray or float + Values to be mapped. + + Returns + ------- + np.ndarray of same shape as values, but with last dimension of size 4 + Mapped colors. + """ + values = np.atleast_1d(values) + + if values.dtype.kind == 'f': + values = values.astype(np.int64) + mapper = self._get_mapping_from_cache(values.dtype) + if mapper is not None: + mapped = mapper[values] + else: + mapped = self._map_without_cache(values) if self.use_selection: - mapped[~np.isclose(values, self.selection)] = 0 + mapped[(values != self.selection)] = 0 return mapped + def shuffle(self, seed: int): + """Shuffle the colormap colors. + + Parameters + ---------- + seed : int + Seed for the random number generator. + """ + np.random.default_rng(seed).shuffle(self.colors[1:]) + self.events.colors(value=self.colors) + -class DirectLabelColormap(Colormap): +class DirectLabelColormap(LabelColormapBase): """Colormap using a direct mapping from labels to color using a dict. Attributes ---------- - color_dict: defaultdict + color_dict: dict from int to (3,) or (4,) array The dictionary mapping labels to colors. - use_selection: bool + use_selection : bool Whether to color using the selected label. - selection: float + selection : int The selected label. """ - color_dict: defaultdict = Field( + color_dict: DefaultDict[Optional[int], np.ndarray] = Field( default_factory=lambda: defaultdict(lambda: np.zeros(4)) ) use_selection: bool = False - selection: float = 0.0 + selection: int = 0 - def map(self, values): - # Convert to float32 to match the current GL shader implementation - values = np.atleast_1d(values).astype(np.float32) + def __init__(self, *args, **kwargs) -> None: + if "colors" not in kwargs and not args: + kwargs["colors"] = np.zeros(3) + super().__init__(*args, **kwargs) + + def _selection_as_minimum_dtype(self, dtype: np.dtype) -> int: + return int( + _cast_labels_data_to_texture_dtype_direct( + dtype.type(self.selection), self + ) + ) + + def map(self, values) -> np.ndarray: + """Map values to colors. + + Parameters + ---------- + values : np.ndarray or int + Values to be mapped. + + Returns + ------- + np.ndarray of same shape as values, but with last dimension of size 4 + Mapped colors. + """ + values = np.atleast_1d(values) + if values.dtype.kind in {'f', 'U'}: + raise TypeError("DirectLabelColormap can only be used with int") + mapper = self._get_mapping_from_cache(values.dtype) + if mapper is not None: + mapped = mapper[values] + else: + values_cast = _labels_raw_to_texture_direct(values, self) + mapped = self._map_precast(values_cast, apply_selection=True) + + if self.use_selection: + mapped[(values != self.selection)] = 0 + return mapped + + def _map_without_cache(self, values: np.ndarray) -> np.ndarray: + cmap = self._cmap_without_selection() + cast = _labels_raw_to_texture_direct(values, cmap) + return self._map_precast(cast, apply_selection=False) + + def _map_precast(self, values, apply_selection) -> np.ndarray: + """Map values to colors. + + Parameters + ---------- + values : np.ndarray + Values to be mapped. It need to be already cast using + cast_labels_to_minimum_type_auto + + Returns + ------- + np.ndarray of shape (N, M, 4) + Mapped colors. + + Notes + ----- + it is implemented for thumbnail labels, + where we already have cast values + """ mapped = np.zeros(values.shape + (4,), dtype=np.float32) + colors = self._values_mapping_to_minimum_values_set(apply_selection)[1] for idx in np.ndindex(values.shape): value = values[idx] - if value in self.color_dict: - color = self.color_dict[value] - if len(color) == 3: - color = np.append(color, 1) - mapped[idx] = color - # If using selected, disable all others - if self.use_selection: - mapped[~np.isclose(values, self.selection)] = 0 + mapped[idx] = colors[value] return mapped + + @cached_property + def _num_unique_colors(self) -> int: + """Count the number of unique colors in the colormap.""" + return len({tuple(x) for x in self.color_dict.values()}) + + def _clear_cache(self): + super()._clear_cache() + if "_num_unique_colors" in self.__dict__: + del self.__dict__["_num_unique_colors"] + if "_label_mapping_and_color_dict" in self.__dict__: + del self.__dict__["_label_mapping_and_color_dict"] + if "_array_map" in self.__dict__: + del self.__dict__["_array_map"] + + def _values_mapping_to_minimum_values_set( + self, apply_selection=True + ) -> Tuple[Dict[Optional[int], int], Dict[int, np.ndarray]]: + """Create mapping from original values to minimum values set. + To use minimum possible dtype for labels. + + Returns + ------- + Dict[Optional[int], int] + Mapping from original values to minimum values set. + Dict[int, np.ndarray] + Mapping from new values to colors. + + """ + if self.use_selection and apply_selection: + return {self.selection: 1, None: 0}, { + 0: np.array((0, 0, 0, 0)), + 1: self.color_dict.get( + self.selection, + self.default_color, + ), + } + + return self._label_mapping_and_color_dict + + @cached_property + def _label_mapping_and_color_dict( + self, + ) -> Tuple[Dict[Optional[int], int], Dict[int, np.ndarray]]: + color_to_labels: Dict[Tuple[int, ...], List[Optional[int]]] = {} + labels_to_new_labels: Dict[Optional[int], int] = { + None: MAPPING_OF_UNKNOWN_VALUE + } + new_color_dict: Dict[int, np.ndarray] = { + MAPPING_OF_UNKNOWN_VALUE: self.default_color, + } + + for label, color in self.color_dict.items(): + if label is None: + continue + color_tup = tuple(color) + if color_tup not in color_to_labels: + color_to_labels[color_tup] = [label] + labels_to_new_labels[label] = len(new_color_dict) + new_color_dict[labels_to_new_labels[label]] = color + else: + color_to_labels[color_tup].append(label) + labels_to_new_labels[label] = labels_to_new_labels[ + color_to_labels[color_tup][0] + ] + + return labels_to_new_labels, new_color_dict + + def _get_typed_dict_mapping(self, data_dtype: np.dtype) -> 'typed.Dict': + """Create mapping from label values to texture values of smaller dtype. + + In https://github.com/napari/napari/issues/6397, we noticed that using + float32 textures was much slower than uint8 or uint16 textures. When + labels data is (u)int(8,16), we simply use the labels data directly. + But when it is higher-precision, we need to compress the labels into + the smallest dtype that can still achieve the goal of the + visualisation. This corresponds to the smallest dtype that can map to + the number of unique colors in the colormap. Even if we have a + million labels, if they map to one of two colors, we can map them to + a uint8 array with values 1 and 2; then, the texture can map those + two values to each of the two possible colors. + + Returns + ------- + Dict[Optional[int], int] + Mapping from original values to minimal texture value set. + """ + + # we cache the result to avoid recomputing it on each slice; + # check first if it's already in the cache. + key = f"_{data_dtype}_typed_dict" + if key in self._cache_other: + return self._cache_other[key] + + from numba import typed, types + + # num_unique_colors + 2 because we need to map None and background + target_type = minimum_dtype_for_labels(self._num_unique_colors + 2) + + dkt = typed.Dict.empty( + key_type=getattr(types, data_dtype.name), + value_type=getattr(types, target_type.name), + ) + for k, v in self._label_mapping_and_color_dict[0].items(): + if k is None: + continue + dkt[data_dtype.type(k)] = target_type.type(v) + + self._cache_other[key] = dkt + + return dkt + + @cached_property + def _array_map(self): + """Create an array to map labels to texture values of smaller dtype.""" + max_value = max(x for x in self.color_dict if x is not None) + if max_value > 2**16: + raise RuntimeError( # pragma: no cover + "Cannot use numpy implementation for large values of labels " + "direct colormap. Please install numba." + ) + dtype = minimum_dtype_for_labels(self._num_unique_colors + 2) + label_mapping = self._values_mapping_to_minimum_values_set()[0] + + # We need 2 + the max value: one because we will be indexing with the + # max value, and an extra one so that higher values get clipped to + # that index and map to the default value, rather than to the max + # value in the map. + mapper = np.full( + (max_value + 2), MAPPING_OF_UNKNOWN_VALUE, dtype=dtype + ) + for key, val in label_mapping.items(): + if key is None: + continue + mapper[key] = val + return mapper + + @property + def default_color(self) -> np.ndarray: + return self.color_dict.get(None, np.array((0, 0, 0, 0))) + # we provided here default color for backward compatibility + # if someone is using DirectLabelColormap directly, not through Label layer + + +def _convert_small_ints_to_unsigned( + data: Union[np.ndarray, np.integer], +) -> Union[np.ndarray, np.integer]: + """Convert (u)int8 to uint8 and (u)int16 to uint16. + + Otherwise, return the original array. + + Parameters + ---------- + data : np.ndarray | np.integer + Data to be converted. + + Returns + ------- + np.ndarray | np.integer + Converted data. + """ + if data.dtype.itemsize == 1: + # for fast rendering of int8 + return data.view(np.uint8) + if data.dtype.itemsize == 2: + # for fast rendering of int16 + return data.view(np.uint16) + return data + + +@overload +def _cast_labels_data_to_texture_dtype_auto( + data: np.ndarray, + colormap: LabelColormap, +) -> np.ndarray: + ... + + +@overload +def _cast_labels_data_to_texture_dtype_auto( + data: np.integer, + colormap: LabelColormap, +) -> np.integer: + ... + + +def _cast_labels_data_to_texture_dtype_auto( + data: Union[np.ndarray, np.integer], + colormap: LabelColormap, +) -> Union[np.ndarray, np.integer]: + """Convert labels data to the data type used in the texture. + + In https://github.com/napari/napari/issues/6397, we noticed that using + float32 textures was much slower than uint8 or uint16 textures. Here we + convert the labels data to uint8 or uint16, based on the following rules: + + - uint8 and uint16 labels data are unchanged. (No copy of the arrays.) + - int8 and int16 data are converted with a *view* to uint8 and uint16. + (This again does not involve a copy so is fast, and lossless.) + - higher precision integer data (u)int{32,64} are hashed to uint8, uint16, + or float32, depending on the number of colors in the input colormap. (See + `minimum_dtype_for_labels`.) Since the hashing can result in collisions, + this conversion *has* to happen in the CPU to correctly map the + background and selection values. + + Parameters + ---------- + data : np.ndarray + Labels data to be converted. + colormap : LabelColormap + Colormap used to display the labels data. + + Returns + ------- + np.ndarray | np.integer + Converted labels data. + """ + original_shape = np.shape(data) + if data.itemsize <= 2: + return _convert_small_ints_to_unsigned(data) + + data_arr = np.atleast_1d(data) + num_colors = len(colormap.colors) - 1 + + dtype = minimum_dtype_for_labels(num_colors + 1) + + if colormap.use_selection: + selection_in_texture = _zero_preserving_modulo( + np.array([colormap.selection]), num_colors, dtype + ) + converted = np.where( + data_arr == colormap.selection, selection_in_texture, dtype.type(0) + ) + else: + converted = _zero_preserving_modulo( + data_arr, num_colors, dtype, colormap.background_value + ) + + return np.reshape(converted, original_shape) + + +def _zero_preserving_modulo_numpy( + values: np.ndarray, n: int, dtype: np.dtype, to_zero: int = 0 +) -> np.ndarray: + """``(values - 1) % n + 1``, but with one specific value mapped to 0. + + This ensures (1) an output value in [0, n] (inclusive), and (2) that + no nonzero values in the input are zero in the output, other than the + ``to_zero`` value. + + Parameters + ---------- + values : np.ndarray + The dividend of the modulo operator. + n : int + The divisor. + dtype : np.dtype + The desired dtype for the output array. + to_zero : int, optional + A specific value to map to 0. (By default, 0 itself.) + + Returns + ------- + np.ndarray + The result: 0 for the ``to_zero`` value, ``values % n + 1`` + everywhere else. + """ + res = ((values - 1) % n + 1).astype(dtype) + res[values == to_zero] = 0 + return res + + +def _zero_preserving_modulo_loop( + values: np.ndarray, n: int, dtype: np.dtype, to_zero: int = 0 +) -> np.ndarray: + """``(values - 1) % n + 1``, but with one specific value mapped to 0. + + This ensures (1) an output value in [0, n] (inclusive), and (2) that + no nonzero values in the input are zero in the output, other than the + ``to_zero`` value. + + Parameters + ---------- + values : np.ndarray + The dividend of the modulo operator. + n : int + The divisor. + dtype : np.dtype + The desired dtype for the output array. + to_zero : int, optional + A specific value to map to 0. (By default, 0 itself.) + + Returns + ------- + np.ndarray + The result: 0 for the ``to_zero`` value, ``values % n + 1`` + everywhere else. + """ + result = np.empty_like(values, dtype=dtype) + # need to preallocate numpy array for asv memory benchmarks + return _zero_preserving_modulo_inner_loop(values, n, to_zero, out=result) + + +def _zero_preserving_modulo_inner_loop( + values: np.ndarray, n: int, to_zero: int, out: np.ndarray +) -> np.ndarray: + """``(values - 1) % n + 1``, but with one specific value mapped to 0. + + This ensures (1) an output value in [0, n] (inclusive), and (2) that + no nonzero values in the input are zero in the output, other than the + ``to_zero`` value. + + Parameters + ---------- + values : np.ndarray + The dividend of the modulo operator. + n : int + The divisor. + to_zero : int + A specific value to map to 0. (Usually, 0 itself.) + out : np.ndarray + Preallocated output array + + Returns + ------- + np.ndarray + The result: 0 for the ``to_zero`` value, ``values % n + 1`` + everywhere else. + """ + for i in prange(values.size): + if values.flat[i] == to_zero: + out.flat[i] = 0 + else: + out.flat[i] = (values.flat[i] - 1) % n + 1 + + return out + + +@overload +def _cast_labels_data_to_texture_dtype_direct( + data: np.ndarray, direct_colormap: DirectLabelColormap +) -> np.ndarray: + ... + + +@overload +def _cast_labels_data_to_texture_dtype_direct( + data: np.integer, direct_colormap: DirectLabelColormap +) -> np.integer: + ... + + +def _cast_labels_data_to_texture_dtype_direct( + data: Union[np.ndarray, np.integer], direct_colormap: DirectLabelColormap +) -> Union[np.ndarray, np.integer]: + """Convert labels data to the data type used in the texture. + + In https://github.com/napari/napari/issues/6397, we noticed that using + float32 textures was much slower than uint8 or uint16 textures. Here we + convert the labels data to uint8 or uint16, based on the following rules: + + - uint8 and uint16 labels data are unchanged. (No copy of the arrays.) + - int8 and int16 data are converted with a *view* to uint8 and uint16. + (This again does not involve a copy so is fast, and lossless.) + - higher precision integer data (u)int{32,64} are mapped to an intermediate + space of sequential values based on the colors they map to. As an + example, if the values are [1, 2**25, and 2**50], and the direct + colormap maps them to ['red', 'green', 'red'], then the intermediate map + is {1: 1, 2**25: 2, 2**50: 1}. The labels can then be converted to a + uint8 texture and a smaller direct colormap with only two values. + + This function calls `_labels_raw_to_texture_direct`, but makes sure that + signed ints are first viewed as their unsigned counterparts. + + Parameters + ---------- + data : np.ndarray | np.integer + Labels data to be converted. + direct_colormap : LabelColormap + Colormap used to display the labels data. + + Returns + ------- + np.ndarray | np.integer + Converted labels data. + """ + data = _convert_small_ints_to_unsigned(data) + + if data.itemsize <= 2: + return data + + original_shape = np.shape(data) + array_data = np.atleast_1d(data) + return np.reshape( + _labels_raw_to_texture_direct(array_data, direct_colormap), + original_shape, + ) + + +def _labels_raw_to_texture_direct_numpy( + data: np.ndarray, direct_colormap: DirectLabelColormap +) -> np.ndarray: + """Convert labels data to the data type used in the texture. + + This implementation uses numpy vectorized operations. + + See `_cast_labels_data_to_texture_dtype_direct` for more details. + """ + mapper = direct_colormap._array_map + + if data.dtype.itemsize > 2: + data = np.clip(data, 0, mapper.shape[0] - 1) + return mapper[data] + + +def _labels_raw_to_texture_direct_loop( + data: np.ndarray, direct_colormap: DirectLabelColormap +) -> np.ndarray: + """ + Cast direct labels to the minimum type. + + Parameters + ---------- + data : np.ndarray + The input data array. + direct_colormap : DirectLabelColormap + The direct colormap. + + Returns + ------- + np.ndarray + The cast data array. + """ + if direct_colormap.use_selection: + return (data == direct_colormap.selection).astype(np.uint8) + + dkt = direct_colormap._get_typed_dict_mapping(data.dtype) + target_dtype = minimum_dtype_for_labels( + direct_colormap._num_unique_colors + 2 + ) + result_array = np.full_like( + data, MAPPING_OF_UNKNOWN_VALUE, dtype=target_dtype + ) + return _labels_raw_to_texture_direct_inner_loop(data, dkt, result_array) + + +def _labels_raw_to_texture_direct_inner_loop( + data: np.ndarray, dkt: 'typed.Dict', out: np.ndarray +) -> np.ndarray: + """ + Relabel data using typed dict with mapping unknown labels to default value + """ + # The numba typed dict does not provide official Api for + # determine key and value types + for i in prange(data.size): + val = data.flat[i] + if val in dkt: + out.flat[i] = dkt[data.flat[i]] + + return out + + +def _texture_dtype(num_colors: int, dtype: np.dtype) -> np.dtype: + """Compute VisPy texture dtype given number of colors and raw data dtype. + + - for data of type int8 and uint8 we can use uint8 directly, with no copy. + - for int16 and uint16 we can use uint16 with no copy. + - for any other dtype, we fall back on `minimum_dtype_for_labels`, which + will require on-CPU mapping between the raw data and the texture dtype. + """ + if dtype.itemsize == 1: + return np.dtype(np.uint8) + if dtype.itemsize == 2: + return np.dtype(np.uint16) + return minimum_dtype_for_labels(num_colors) + + +def minimum_dtype_for_labels(num_colors: int) -> np.dtype: + """Return the minimum texture dtype that can hold given number of colors. + + Parameters + ---------- + num_colors : int + Number of unique colors in the data. + + Returns + ------- + np.dtype + Minimum dtype that can hold the number of colors. + """ + if num_colors <= np.iinfo(np.uint8).max: + return np.dtype(np.uint8) + if num_colors <= np.iinfo(np.uint16).max: + return np.dtype(np.uint16) + return np.dtype(np.float32) + + +try: + import numba +except ModuleNotFoundError: + _zero_preserving_modulo = _zero_preserving_modulo_numpy + _labels_raw_to_texture_direct = _labels_raw_to_texture_direct_numpy + prange = range +else: + _zero_preserving_modulo_inner_loop = numba.njit(parallel=True)( + _zero_preserving_modulo_inner_loop + ) + _zero_preserving_modulo = _zero_preserving_modulo_loop + _labels_raw_to_texture_direct = _labels_raw_to_texture_direct_loop + _labels_raw_to_texture_direct_inner_loop = numba.njit(parallel=True)( + _labels_raw_to_texture_direct_inner_loop + ) + prange = numba.prange # type: ignore [misc] + + del numba diff --git a/napari/utils/colormaps/colormap_utils.py b/napari/utils/colormaps/colormap_utils.py index d544fa01873..dadd5239e88 100644 --- a/napari/utils/colormaps/colormap_utils.py +++ b/napari/utils/colormaps/colormap_utils.py @@ -1,5 +1,6 @@ import warnings from collections import OrderedDict +from functools import lru_cache from threading import Lock from typing import Dict, Iterable, List, Optional, Tuple, Union @@ -20,6 +21,7 @@ ColormapInterpolationMode, DirectLabelColormap, LabelColormap, + minimum_dtype_for_labels, ) from napari.utils.colormaps.inverse_colormaps import inverse_cmaps from napari.utils.colormaps.standardize_color import transform_color @@ -237,7 +239,7 @@ def _validate_rgb(colors, *, tolerance=0.0): return filtered_colors -def low_discrepancy_image(image, seed=0.5, margin=1 / 256): +def low_discrepancy_image(image, seed=0.5, margin=1 / 256) -> np.ndarray: """Generate a 1d low discrepancy sequence of coordinates. Parameters @@ -405,7 +407,9 @@ def _color_random(n, *, colorspace='lab', tolerance=0.0, seed=0.5): return rgb[:n] -def label_colormap(num_colors=256, seed=0.5): +def label_colormap( + num_colors=256, seed=0.5, background_value=0 +) -> LabelColormap: """Produce a colormap suitable for use with a given label set. Parameters @@ -418,18 +422,24 @@ def label_colormap(num_colors=256, seed=0.5): Returns ------- - colormap : napari.utils.Colormap + colormap : napari.utils.LabelColormap A colormap for use with labels remapped to [0, 1]. Notes ----- 0 always maps to fully transparent. """ + if num_colors < 1: + raise ValueError("num_colors must be >= 1") + # Starting the control points slightly above 0 and below 1 is necessary # to ensure that the background pixel 0 is transparent midpoints = np.linspace(0.00001, 1 - 0.00001, num_colors + 1) - control_points = np.concatenate(([0], midpoints, [1.0])) + control_points = np.concatenate( + (np.array([0]), midpoints, np.array([1.0])) + ) # make sure to add an alpha channel to the colors + colors = np.concatenate( ( _color_random(num_colors + 2, seed=seed), @@ -437,16 +447,121 @@ def label_colormap(num_colors=256, seed=0.5): ), axis=1, ) - # Insert alpha at layer 0 - colors[0, :] = 0 # ensure alpha is 0 for label 0 + + # from here + values_ = np.arange(num_colors + 2) + randomized_values = low_discrepancy_image(values_, seed=seed) + + indices = np.clip( + np.searchsorted(control_points, randomized_values, side="right") - 1, + 0, + len(control_points) - 1, + ) + + # here is an ugly hack to restore classical napari color order. + colors = colors[indices][:-1] + + # ensure that we not need to deal with differences in float rounding for + # CPU and GPU. + uint8_max = np.iinfo(np.uint8).max + rgb8_colors = (colors * uint8_max).astype(np.uint8) + colors = rgb8_colors.astype(np.float32) / uint8_max + return LabelColormap( name='label_colormap', display_name=trans._p('colormap', 'low discrepancy colors'), colors=colors, - controls=control_points, + controls=np.linspace(0, 1, len(colors) + 1), + interpolation='zero', + background_value=background_value, + ) + + +@lru_cache +def _primes(upto=2**16): + """Generate primes up to a given number. + + Parameters + ---------- + upto : int + The upper limit of the primes to generate. + + Returns + ------- + primes : np.ndarray + The primes up to the upper limit. + """ + primes = np.arange(3, upto + 1, 2) + isprime = np.ones((upto - 1) // 2, dtype=bool) + max_factor = int(np.sqrt(upto)) + for factor in primes[: max_factor // 2]: + if isprime[(factor - 2) // 2]: + isprime[(factor * 3 - 2) // 2 : None : factor] = 0 + return np.concatenate(([2], primes[isprime])) + + +def shuffle_and_extend_colormap( + colormap: LabelColormap, seed: int, min_random_choices: int = 5 +) -> LabelColormap: + """Shuffle the colormap colors and extend it to more colors. + + The new number of colors will be a prime number that fits into the same + dtype as the current number of colors in the colormap. + + Parameters + ---------- + colormap : napari.utils.LabelColormap + Colormap to shuffle and extend. + seed : int + Seed for the random number generator. + min_random_choices : int + Minimum number of new table sizes to choose from. When choosing table + sizes, every choice gives a 1/size chance of two labels being mapped + to the same color. Since we try to stay within the same dtype as the + original colormap, if the number of original colors is close to the + maximum value for the dtype, there will not be enough prime numbers + up to the dtype max to ensure that two labels can always be + distinguished after one or few shuffles. In that case, we discard + some colors and choose the `min_random_choices` largest primes to fit + within the dtype. + + Returns + ------- + colormap : napari.utils.LabelColormap + Shuffled and extended colormap. + """ + rng = np.random.default_rng(seed) + n_colors_prev = len(colormap.colors) + dtype = minimum_dtype_for_labels(n_colors_prev) + indices = np.arange(n_colors_prev) + rng.shuffle(indices) + shuffled_colors = colormap.colors[indices] + + primes = _primes( + np.iinfo(dtype).max if np.issubdtype(dtype, np.integer) else 2**24 + ) + valid_primes = primes[primes > n_colors_prev] + if len(valid_primes) < min_random_choices: + valid_primes = primes[-min_random_choices:] + n_colors = rng.choice(valid_primes) + n_color_diff = n_colors - n_colors_prev + + extended_colors = np.concatenate( + ( + shuffled_colors[: min(n_color_diff, 0) or None], + shuffled_colors[rng.choice(indices, size=max(n_color_diff, 0))], + ), + axis=0, + ) + + new_colormap = LabelColormap( + name=colormap.name, + colors=extended_colors, + controls=np.linspace(0, 1, len(extended_colors) + 1), interpolation='zero', - seed=seed, + background_value=colormap.background_value, ) + return new_colormap def direct_colormap(color_dict=None): diff --git a/napari/utils/config.py b/napari/utils/config.py index eaaf0aa7841..1c0c11dce09 100644 --- a/napari/utils/config.py +++ b/napari/utils/config.py @@ -2,6 +2,7 @@ """ import os import warnings +from typing import Optional from napari.utils.translations import trans @@ -32,7 +33,7 @@ def _set(env_var: str) -> bool: # fixed values in the module level __getattr__ # https://peps.python.org/pep-0562/ # Other module attributes are defined as normal. -def __getattr__(name): +def __getattr__(name: str) -> Optional[bool]: if name == 'octree_config': warnings.warn( trans._( diff --git a/napari/utils/events/_tests/test_event_migrations.py b/napari/utils/events/_tests/test_event_migrations.py index ba95f900cea..83857837480 100644 --- a/napari/utils/events/_tests/test_event_migrations.py +++ b/napari/utils/events/_tests/test_event_migrations.py @@ -8,9 +8,18 @@ def test_deprecation_warning_event() -> None: "obj.events", "old", "new", "0.1.0", "0.0.0" ) - def _print(msg: str) -> None: - print(msg) + class Counter: + def __init__(self) -> None: + self.count = 0 - with pytest.warns(FutureWarning): - event.connect(_print) - event(msg="test") + def add(self, event) -> None: + self.count += event.value + + counter = Counter() + msg = "obj.events.old is deprecated since 0.0.0 and will be removed in 0.1.0. Please use obj.events.new" + + with pytest.warns(FutureWarning, match=msg): + event.connect(counter.add) + event(value=1) + + assert counter.count == 1 diff --git a/napari/utils/events/_tests/test_evented_model.py b/napari/utils/events/_tests/test_evented_model.py index c7b74f1d3c7..78477ac2049 100644 --- a/napari/utils/events/_tests/test_evented_model.py +++ b/napari/utils/events/_tests/test_evented_model.py @@ -9,8 +9,8 @@ import pytest from dask import delayed from dask.delayed import Delayed -from pydantic import Field +from napari._pydantic_compat import Field from napari.utils.events import EmitterGroup, EventedModel from napari.utils.events.custom_types import Array from napari.utils.misc import StringEnum diff --git a/napari/utils/events/_tests/test_selection.py b/napari/utils/events/_tests/test_selection.py index d2f8354b294..11d6e23b3a7 100644 --- a/napari/utils/events/_tests/test_selection.py +++ b/napari/utils/events/_tests/test_selection.py @@ -1,8 +1,8 @@ from unittest.mock import Mock import pytest -from pydantic import ValidationError +from napari._pydantic_compat import ValidationError from napari.utils.events import EventedModel, Selection diff --git a/napari/utils/events/containers/_selection.py b/napari/utils/events/containers/_selection.py index 5e003771f3f..23364c73dc7 100644 --- a/napari/utils/events/containers/_selection.py +++ b/napari/utils/events/containers/_selection.py @@ -5,7 +5,7 @@ from napari.utils.translations import trans if TYPE_CHECKING: - from pydantic.fields import ModelField + from napari._pydantic_compat import ModelField _T = TypeVar("_T") _S = TypeVar("_S") @@ -140,7 +140,7 @@ def __get_validators__(cls): @classmethod def validate(cls, v, field: 'ModelField'): """Pydantic validator.""" - from pydantic.utils import sequence_like + from napari._pydantic_compat import sequence_like if isinstance(v, dict): data = v.get("selection", []) @@ -180,7 +180,7 @@ def validate(cls, v, field: 'ModelField'): errors.append(error) if errors: - from pydantic import ValidationError + from napari._pydantic_compat import ValidationError raise ValidationError(errors, cls) # type: ignore obj = cls(data=data) diff --git a/napari/utils/events/containers/_set.py b/napari/utils/events/containers/_set.py index edb0f982f0d..4b671d71992 100644 --- a/napari/utils/events/containers/_set.py +++ b/napari/utils/events/containers/_set.py @@ -8,7 +8,7 @@ _T = TypeVar("_T") if TYPE_CHECKING: - from pydantic.fields import ModelField + from napari._pydantic_compat import ModelField class EventedSet(MutableSet[_T]): @@ -165,7 +165,7 @@ def __get_validators__(cls): @classmethod def validate(cls, v, field: ModelField): """Pydantic validator.""" - from pydantic.utils import sequence_like + from napari._pydantic_compat import sequence_like if not sequence_like(v): raise TypeError( @@ -185,9 +185,9 @@ def validate(cls, v, field: ModelField): if error: errors.append(error) if errors: - from pydantic import ValidationError + from napari._pydantic_compat import ValidationError - raise ValidationError(errors, cls) # type: ignore + raise ValidationError(errors, cls) return cls(v) def _json_encode(self): diff --git a/napari/utils/events/containers/_typed.py b/napari/utils/events/containers/_typed.py index f6bc12fafe3..65f59e0b854 100644 --- a/napari/utils/events/containers/_typed.py +++ b/napari/utils/events/containers/_typed.py @@ -113,6 +113,10 @@ def __contains__(self, key): return True return super().__contains__(key) + @overload + def __getitem__(self, key: str) -> _T: + ... # pragma: no cover + @overload def __getitem__(self, key: int) -> _T: ... # pragma: no cover @@ -242,7 +246,7 @@ def index( ) ) - def _iter_indices(self, start=0, stop=None): + def _iter_indices(self, start=0, stop=None) -> Iterable[int]: """Iter indices from start to stop. While this is trivial for this basic sequence type, this method lets diff --git a/napari/utils/events/custom_types.py b/napari/utils/events/custom_types.py index aa8a6b181b0..b7c69a4e156 100644 --- a/napari/utils/events/custom_types.py +++ b/napari/utils/events/custom_types.py @@ -11,12 +11,13 @@ ) import numpy as np -from pydantic import errors, types + +from napari._pydantic_compat import errors, types if TYPE_CHECKING: from decimal import Decimal - from pydantic.fields import ModelField + from napari._pydantic_compat import ModelField Number = Union[int, float, Decimal] diff --git a/napari/utils/events/debugging.py b/napari/utils/events/debugging.py index cc032b96f27..639bd0a14b6 100644 --- a/napari/utils/events/debugging.py +++ b/napari/utils/events/debugging.py @@ -4,8 +4,7 @@ from textwrap import indent from typing import TYPE_CHECKING, ClassVar, Set -from pydantic import BaseSettings, Field, PrivateAttr - +from napari._pydantic_compat import BaseSettings, Field, PrivateAttr from napari.utils.misc import ROOT_DIR from napari.utils.translations import trans @@ -39,8 +38,12 @@ class EventDebugSettings(BaseSettings): # to include/exclude when printing events. include_emitters: Set[str] = Field(default_factory=set) include_events: Set[str] = Field(default_factory=set) - exclude_emitters: Set[str] = {'TransformChain', 'Context'} - exclude_events: Set[str] = {'status', 'position'} + exclude_emitters: Set[str] = Field( + default_factory=lambda: {'TransformChain', 'Context'} + ) + exclude_events: Set[str] = Field( + default_factory=lambda: {'status', 'position'} + ) # stack depth to show stack_depth: int = 20 # how many sub-emit nesting levels to show diff --git a/napari/utils/events/event.py b/napari/utils/events/event.py index f90511573ba..65b5d55a0c4 100644 --- a/napari/utils/events/event.py +++ b/napari/utils/events/event.py @@ -1111,7 +1111,7 @@ def unblock_all(self): def connect( self, - callback: Union[Callback, CallbackRef, 'EmitterGroup'], + callback: Union[Callback, CallbackRef, EventEmitter, 'EmitterGroup'], ref: Union[bool, str] = False, position: Literal['first', 'last'] = 'first', before: Union[str, Callback, List[Union[str, Callback]], None] = None, diff --git a/napari/utils/events/evented_model.py b/napari/utils/events/evented_model.py index b30c181c9be..a66523476bd 100644 --- a/napari/utils/events/evented_model.py +++ b/napari/utils/events/evented_model.py @@ -5,8 +5,14 @@ import numpy as np from app_model.types import KeyBinding -from pydantic import BaseModel, PrivateAttr, main, utils +from napari._pydantic_compat import ( + BaseModel, + ModelMetaclass, + PrivateAttr, + main, + utils, +) from napari.utils.events.event import EmitterGroup, Event from napari.utils.misc import pick_equality_operator from napari.utils.translations import trans @@ -66,7 +72,7 @@ def _return2(x, y): main.ClassAttribute = utils.ClassAttribute -class EventedMetaclass(main.ModelMetaclass): +class EventedMetaclass(ModelMetaclass): """pydantic ModelMetaclass that preps "equality checking" operations. A metaclass is the thing that "constructs" a class, and ``ModelMetaclass`` diff --git a/napari/utils/events/migrations.py b/napari/utils/events/migrations.py index 7281a8e7fbb..cf970a33cc4 100644 --- a/napari/utils/events/migrations.py +++ b/napari/utils/events/migrations.py @@ -36,8 +36,12 @@ def deprecation_warning_event( new_path = f"{prefix}.{new_name}" return WarningEmitter( trans._( - f"{previous_path} is deprecated since {since_version} and will be removed in {version}. Please use {new_path}", + "{previous_path} is deprecated since {since_version} and will be removed in {version}. Please use {new_path}", deferred=True, + previous_path=previous_path, + since_version=since_version, + version=version, + new_path=new_path, ), type_name=previous_name, ) diff --git a/napari/utils/history.py b/napari/utils/history.py index af4f8defcc5..55cd0afc284 100644 --- a/napari/utils/history.py +++ b/napari/utils/history.py @@ -1,10 +1,11 @@ import os from pathlib import Path +from typing import List from napari.settings import get_settings -def update_open_history(filename): +def update_open_history(filename: str) -> None: """Updates open history of files in settings. Parameters @@ -24,7 +25,7 @@ def update_open_history(filename): settings.application.open_history = folders -def update_save_history(filename): +def update_save_history(filename: str) -> None: """Updates save history of files in settings. Parameters @@ -44,7 +45,7 @@ def update_save_history(filename): settings.application.save_history = folders -def get_open_history(): +def get_open_history() -> List[str]: """A helper for history handling.""" settings = get_settings() folders = settings.application.open_history @@ -52,7 +53,7 @@ def get_open_history(): return folders or [str(Path.home())] -def get_save_history(): +def get_save_history() -> List[str]: """A helper for history handling.""" settings = get_settings() folders = settings.application.save_history diff --git a/napari/utils/indexing.py b/napari/utils/indexing.py index cba8fcd79c4..44606f794e4 100644 --- a/napari/utils/indexing.py +++ b/napari/utils/indexing.py @@ -1,7 +1,12 @@ +from typing import Dict, Tuple + import numpy as np +import numpy.typing as npt -def index_in_slice(index, position_in_axes): +def index_in_slice( + index: Tuple[npt.NDArray[np.int_], ...], position_in_axes: Dict[int, int] +) -> Tuple[npt.NDArray[np.int_], ...]: """Convert a NumPy fancy indexing expression from data to sliced space. Parameters diff --git a/napari/utils/info.py b/napari/utils/info.py index deb6007482f..55165d81206 100644 --- a/napari/utils/info.py +++ b/napari/utils/info.py @@ -9,7 +9,7 @@ OS_RELEASE_PATH = "/etc/os-release" -def _linux_sys_name(): +def _linux_sys_name() -> str: """ Try to discover linux system name base on /etc/os-release file or lsb_release command output https://www.freedesktop.org/software/systemd/man/os-release.html @@ -32,7 +32,7 @@ def _linux_sys_name(): return _linux_sys_name_lsb_release() -def _linux_sys_name_lsb_release(): +def _linux_sys_name_lsb_release() -> str: """ Try to discover linux system name base on lsb_release command output """ @@ -52,7 +52,7 @@ def _linux_sys_name_lsb_release(): return "" -def _sys_name(): +def _sys_name() -> str: """ Discover MacOS or Linux Human readable information. For Linux provide information about distribution. """ @@ -70,7 +70,7 @@ def _sys_name(): return "" -def sys_info(as_html=False): +def sys_info(as_html: bool = False) -> str: """Gathers relevant module versions for troubleshooting purposes. Parameters diff --git a/napari/utils/interactions.py b/napari/utils/interactions.py index 3907d19921a..88366f38e46 100644 --- a/napari/utils/interactions.py +++ b/napari/utils/interactions.py @@ -239,10 +239,10 @@ def hello_world(layer, event): } -joinchar = '+' +JOINCHAR = '+' if sys.platform.startswith('darwin'): KEY_SYMBOLS.update({'Ctrl': '⌃', 'Alt': '⌥', 'Meta': '⌘'}) - joinchar = '' + JOINCHAR = '' elif sys.platform.startswith('linux'): KEY_SYMBOLS.update({'Meta': 'Super'}) @@ -323,14 +323,13 @@ def parse_platform(text: str) -> str: # as you can't get two non-modifier keys, or alone. if text == '+': return text - if joinchar == "+": - text.replace('++', '+Plus') - text.replace('+', '') - text.replace('Plus', '+') + if JOINCHAR == "+": + text = text.replace('++', '+Plus') + text = text.replace('+', '') + text = text.replace('Plus', '+') for k, v in KEY_SYMBOLS.items(): if text.endswith(v): text = text.replace(v, k) - assert v not in text else: text = text.replace(v, k + '-') @@ -360,7 +359,7 @@ def platform(self) -> str: Shortcut formatted to be displayed on current paltform. """ return ' '.join( - joinchar.join( + JOINCHAR.join( KEY_SYMBOLS.get(x, x) for x in ([*_kb2mods(part), str(part.key)]) ) diff --git a/napari/utils/migrations.py b/napari/utils/migrations.py index f09ff2d589a..ccc5b3e8184 100644 --- a/napari/utils/migrations.py +++ b/napari/utils/migrations.py @@ -1,13 +1,15 @@ import warnings from functools import wraps -from typing import Any +from typing import Any, Callable from napari.utils.translations import trans +_UNSET = object() + def rename_argument( from_name: str, to_name: str, version: str, since_version: str = "" -): +) -> Callable: """ This is decorator for simple rename function argument without break backward compatibility. @@ -27,7 +29,10 @@ def rename_argument( if not since_version: since_version = "unknown" warnings.warn( - "The since_version argument was added in napari 0.4.18 and will be mandatory since 0.6.0 release.", + trans._( + "The since_version argument was added in napari 0.4.18 and will be mandatory since 0.6.0 release.", + deferred=True, + ), stacklevel=2, category=FutureWarning, ) @@ -79,9 +84,9 @@ def add_deprecated_property( obj: Class instances to add property previous_name : str - Name of previous property, it methods must be removed. + Name of previous property, its methods must be removed. new_name : str - Name of new property, must have its setter and getter implemented. + Name of new property, must have its getter (and setter if applicable) implemented. version : str Version where deprecated property will be removed. since_version : str @@ -89,14 +94,31 @@ def add_deprecated_property( """ if hasattr(obj, previous_name): - raise RuntimeError(f"{previous_name} attribute already exists.") + raise RuntimeError( + trans._( + "{previous_name} property already exists.", + deferred=True, + previous_name=previous_name, + ) + ) if not hasattr(obj, new_name): - raise RuntimeError(f"{new_name} property must exists.") + raise RuntimeError( + trans._( + "{new_name} property must exist.", + deferred=True, + new_name=new_name, + ) + ) + name = f"{obj.__name__}.{previous_name}" msg = trans._( - f"{previous_name} is deprecated since {since_version} and will be removed in {version}. Please use {new_name}", + "{name} is deprecated since {since_version} and will be removed in {version}. Please use {new_name}", deferred=True, + name=name, + since_version=since_version, + version=version, + new_name=new_name, ) def _getter(instance) -> Any: @@ -108,3 +130,38 @@ def _setter(instance, value: Any) -> None: setattr(instance, new_name, value) setattr(obj, previous_name, property(_getter, _setter)) + + +def deprecated_constructor_arg_by_attr(name: str) -> Callable: + """ + Decorator to deprecate a constructor argument and remove it from the signature. + + It works by popping the argument from kwargs, and setting it later via setattr. + The property setter should take care of issuing the deprecation warning. + + Parameters + ---------- + name : str + Name of the argument to deprecate. + + Returns + ------- + function + decorated function + """ + + def wrapper(func): + @wraps(func) + def _wrapper(*args, **kwargs): + value = _UNSET + if name in kwargs: + value = kwargs.pop(name) + res = func(*args, **kwargs) + + if value is not _UNSET: + setattr(args[0], name, value) + return res + + return _wrapper + + return wrapper diff --git a/napari/utils/misc.py b/napari/utils/misc.py index 6859c3e778c..bd519b553b9 100644 --- a/napari/utils/misc.py +++ b/napari/utils/misc.py @@ -147,11 +147,11 @@ def is_iterable(arg, color=False, allow_none=False): provided and the argument is a 1-D array of length 3 or 4 then the input is taken to not be iterable. If allow_none is True, `None` is considered iterable. """ - if ( - (arg is None and not allow_none) - or isinstance(arg, str) - or np.isscalar(arg) - ): + if arg is None and not allow_none: + return False + if isinstance(arg, (str, Enum)): + return False + if np.isscalar(arg): return False if color and isinstance(arg, (list, np.ndarray)): return np.array(arg).ndim != 1 or len(arg) not in [3, 4] @@ -307,27 +307,28 @@ def __call__( start=start, ) - def keys(self): + def keys(self) -> List[str]: return list(map(str, self)) class StringEnum(Enum, metaclass=StringEnumMeta): + @staticmethod def _generate_next_value_(name, start, count, last_values): """autonaming function assigns each value its own name as a value""" return name.lower() - def __str__(self): + def __str__(self) -> str: """String representation: The string method returns the lowercase string of the Enum name """ return self.value - def __eq__(self, other): + def __eq__(self, other) -> bool: if type(self) is type(other): return self is other if isinstance(other, str): return str(self) == other - return NotImplemented + return False def __hash__(self): return hash(str(self)) diff --git a/napari/utils/naming.py b/napari/utils/naming.py index 8a70e66b626..1d0bc1f86ec 100644 --- a/napari/utils/naming.py +++ b/napari/utils/naming.py @@ -3,8 +3,15 @@ import inspect import re from collections import ChainMap -from types import FrameType -from typing import Any, Callable, Dict, Optional +from types import FrameType, TracebackType +from typing import ( + Any, + Callable, + ChainMap as ChainMapType, + Optional, + Tuple, + Type, +) from napari.utils.misc import ROOT_DIR, formatdoc @@ -16,7 +23,7 @@ numbered_patt = re.compile(r'((?<=\A\[)|(?<=\s\[))(?:\d+|)(?=\]$)|$') -def _inc_name_count_sub(match): +def _inc_name_count_sub(match: re.Match) -> str: count = match.group(0) try: @@ -84,21 +91,26 @@ def skip_napari_frames(index, frame): """ + names: Tuple[str, ...] + namespace: ChainMapType[str, Any] + predicate: Callable[[int, FrameType], bool] + def __init__( self, skip_predicate: Callable[[int, FrameType], bool] ) -> None: self.predicate = skip_predicate - self.namespace: ChainMap[Dict[str, Any], Dict[str, Any]] = ChainMap() + self.namespace = ChainMap() self.names = () - def __enter__(self): - frame = inspect.currentframe().f_back + def __enter__(self) -> 'CallerFrame': + frame = inspect.currentframe() try: # See issue #1635 regarding potential AttributeError # since frame could be None. # https://github.com/napari/napari/pull/1635 - if inspect.isframe(frame): - frame = frame.f_back + for _ in range(2): + if inspect.isframe(frame): + frame = frame.f_back # Iterate frames while filename starts with path_prefix (part of Napari) n = 1 @@ -130,7 +142,12 @@ def __enter__(self): return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: del self.namespace del self.names diff --git a/napari/utils/notifications.py b/napari/utils/notifications.py index af61b36c572..5a5c689f361 100644 --- a/napari/utils/notifications.py +++ b/napari/utils/notifications.py @@ -7,7 +7,7 @@ from datetime import datetime from enum import auto from types import TracebackType -from typing import Callable, List, Optional, Sequence, Tuple, Type, Union +from typing import Callable, List, Optional, Sequence, Set, Tuple, Type, Union from napari.utils.events import Event, EventEmitter from napari.utils.misc import StringEnum @@ -245,6 +245,7 @@ def __init__(self) -> None: self._originals_except_hooks: List[Callable] = [] self._original_showwarnings_hooks: List[Callable] = [] self._originals_thread_except_hooks: List[Callable] = [] + self._seen_warnings: Set[Tuple[str, Type, str, int]] = set() def __enter__(self): self.install_hooks() @@ -329,6 +330,10 @@ def receive_warning( file=None, line=None, ): + msg = message if isinstance(message, str) else message.args[0] + if (msg, category, filename, lineno) in self._seen_warnings: + return + self._seen_warnings.add((msg, category, filename, lineno)) self.dispatch( Notification.from_warning( message, filename=filename, lineno=lineno diff --git a/napari/utils/stubgen.py b/napari/utils/stubgen.py index 65ecc790b80..bd704519af3 100644 --- a/napari/utils/stubgen.py +++ b/napari/utils/stubgen.py @@ -76,7 +76,7 @@ def _iter_imports(hint) -> Iterator[str]: if isinstance(hint, list): for i in hint: yield from _iter_imports(i) - elif getattr(hint, '__module__', None) != 'builtins': + elif hasattr(hint, '__module__') and hint.__module__ != 'builtins': yield hint.__module__ diff --git a/napari/utils/theme.py b/napari/utils/theme.py index 097681903d9..f375c5a3b92 100644 --- a/napari/utils/theme.py +++ b/napari/utils/theme.py @@ -6,12 +6,11 @@ import warnings from ast import literal_eval from contextlib import suppress -from typing import Any, Dict, List, Literal, Optional, Union, overload +from typing import Any, Dict, List, Literal, Optional, Tuple, Union, overload import npe2 -from pydantic import validator -from pydantic.color import Color +from napari._pydantic_compat import Color, validator from napari._vendor import darkdetect from napari.resources._icons import ( PLUGIN_FILE_NAME, @@ -25,7 +24,7 @@ try: from qtpy import QT_VERSION - major, minor, *_ = QT_VERSION.split('.') + major, minor, *_ = QT_VERSION.split('.') # type: ignore[attr-defined] use_gradients = (int(major) >= 5) and (int(minor) >= 12) del major, minor, QT_VERSION except (ImportError, RuntimeError): @@ -87,7 +86,7 @@ class Theme(EventedModel): font_size: str = '12pt' if sys.platform == 'darwin' else '9pt' @validator("syntax_style", pre=True, allow_reuse=True) - def _ensure_syntax_style(value: str) -> str: + def _ensure_syntax_style(cls, value: str) -> str: from pygments.styles import STYLE_MAP assert value in STYLE_MAP, trans._( @@ -99,7 +98,7 @@ def _ensure_syntax_style(value: str) -> str: return value @validator("font_size", pre=True) - def _ensure_font_size(value: str) -> str: + def _ensure_font_size(cls, value: str) -> str: assert value.endswith('pt'), trans._( "Font size must be in points (pt).", deferred=True ) @@ -127,52 +126,48 @@ def to_rgb_dict(self) -> Dict[str, Any]: opacity_pattern = re.compile(r'{{\s?opacity\((\w+),?\s?([-\d]+)?\)\s?}}') -def decrease(font_size: str, pt: int): +def decrease(font_size: str, pt: int) -> str: """Decrease fontsize.""" return f"{int(font_size[:-2]) - int(pt)}pt" -def increase(font_size: str, pt: int): +def increase(font_size: str, pt: int) -> str: """Increase fontsize.""" return f"{int(font_size[:-2]) + int(pt)}pt" -def darken(color: Union[str, Color], percentage=10): - if isinstance(color, str) and color.startswith('rgb('): - color = literal_eval(color.lstrip('rgb(').rstrip(')')) - else: - color = color.as_rgb_tuple() +def _parse_color_as_rgb(color: Union[str, Color]) -> Tuple[int, int, int]: + if isinstance(color, str): + if color.startswith('rgb('): + return literal_eval(color.lstrip('rgb(').rstrip(')')) + return Color(color).as_rgb_tuple()[:3] + return color.as_rgb_tuple()[:3] + + +def darken(color: Union[str, Color], percentage: float = 10) -> str: ratio = 1 - float(percentage) / 100 - red, green, blue = color + red, green, blue = _parse_color_as_rgb(color) red = min(max(int(red * ratio), 0), 255) green = min(max(int(green * ratio), 0), 255) blue = min(max(int(blue * ratio), 0), 255) return f'rgb({red}, {green}, {blue})' -def lighten(color: Union[str, Color], percentage=10): - if isinstance(color, str) and color.startswith('rgb('): - color = literal_eval(color.lstrip('rgb(').rstrip(')')) - else: - color = color.as_rgb_tuple() +def lighten(color: Union[str, Color], percentage: float = 10) -> str: ratio = float(percentage) / 100 - red, green, blue = color + red, green, blue = _parse_color_as_rgb(color) red = min(max(int(red + (255 - red) * ratio), 0), 255) green = min(max(int(green + (255 - green) * ratio), 0), 255) blue = min(max(int(blue + (255 - blue) * ratio), 0), 255) return f'rgb({red}, {green}, {blue})' -def opacity(color: Union[str, Color], value=255): - if isinstance(color, str) and color.startswith('rgb('): - color = literal_eval(color.lstrip('rgb(').rstrip(')')) - else: - color = color.as_rgb_tuple() - red, green, blue = color +def opacity(color: Union[str, Color], value: int = 255) -> str: + red, green, blue = _parse_color_as_rgb(color) return f'rgba({red}, {green}, {blue}, {max(min(int(value), 255), 0)})' -def gradient(stops, horizontal=True): +def gradient(stops, horizontal: bool = True) -> str: if not use_gradients: return stops[-1] diff --git a/napari/utils/transforms/transforms.py b/napari/utils/transforms/transforms.py index 0e51b8f12a5..958ef20ba3b 100644 --- a/napari/utils/transforms/transforms.py +++ b/napari/utils/transforms/transforms.py @@ -1,5 +1,5 @@ from functools import cached_property -from typing import Iterable, Optional, Sequence +from typing import Generic, Iterable, Optional, Sequence, TypeVar, overload import numpy as np import numpy.typing as npt @@ -110,7 +110,10 @@ def _clean_cache(self): [self.__dict__.pop(p, None) for p in cached_properties] -class TransformChain(EventedList[Transform], Transform): +_T = TypeVar('_T', bound=Transform) + + +class TransformChain(EventedList[_T], Transform, Generic[_T]): def __init__( self, transforms: Optional[Iterable[Transform]] = None ) -> None: @@ -132,6 +135,21 @@ def __call__(self, coords): def __newlike__(self, iterable): return TransformChain(iterable) + @overload + def __getitem__(self, key: int) -> _T: + ... + + @overload + def __getitem__(self, key: str) -> _T: + ... + + @overload + def __getitem__(self, key: slice) -> 'TransformChain[_T]': + ... + + def __getitem__(self, value): + return super().__getitem__(value) + @property def inverse(self) -> 'TransformChain': """Return the inverse transform chain.""" @@ -144,7 +162,7 @@ def _is_diagonal(self): return getattr(self.simplified, '_is_diagonal', False) @property - def simplified(self) -> Optional['Transform']: + def simplified(self) -> Optional[_T]: """Return the composite of the transforms inside the transform chain.""" if len(self) == 0: return None diff --git a/napari/view_layers.py b/napari/view_layers.py index b240a984e7d..0162edefcfe 100644 --- a/napari/view_layers.py +++ b/napari/view_layers.py @@ -227,7 +227,7 @@ def imshow( rgb=None, colormap=None, contrast_limits=None, - gamma=1, + gamma=1.0, interpolation2d='nearest', interpolation3d='linear', rendering='mip', @@ -241,7 +241,7 @@ def imshow( rotate=None, shear=None, affine=None, - opacity=1, + opacity=1.0, blending=None, visible=True, multiscale=None, @@ -249,6 +249,7 @@ def imshow( plane=None, experimental_clipping_planes=None, custom_interpolation_kernel_2d=None, + projection_mode='none', viewer=None, title='napari', ndisplay=2, @@ -441,6 +442,7 @@ def imshow( plane=plane, experimental_clipping_planes=experimental_clipping_planes, custom_interpolation_kernel_2d=custom_interpolation_kernel_2d, + projection_mode=projection_mode, title=title, ndisplay=ndisplay, order=order, diff --git a/pyproject.toml b/pyproject.toml index ab1e9dcdb4a..f0f274994e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,7 +123,7 @@ fix = true "napari/_vispy/__init__.py" = ["E402"] "**/_tests/*.py" = ["B011", "INP001", "TRY301", "B018", "RUF012"] "napari/utils/_testsupport.py" = ["B011"] -"tools/test_strings.py" = ["F401"] +"tools/validate_strings.py" = ["F401"] "tools/**" = ["INP001", "T20"] "examples/**" = ["ICN001", "INP001", "T20"] "**/vendored/**" = ["TID"] @@ -150,6 +150,10 @@ xarray = "xr" # These follow standard library warnings filters syntax. See more here: # https://docs.python.org/3/library/warnings.html#describing-warning-filters addopts = "--maxfail=5 --durations=10 -rXxs" +console_output_style = "count" +pystack_threshold=60 +pystack_args="--native-all" + # NOTE: only put things that will never change in here. # napari deprecation and future warnings should NOT go in here. @@ -191,12 +195,22 @@ plugins = "numpy.typing.mypy_plugin, pydantic.mypy" ignore_missing_imports = true show_error_codes = true warn_redundant_casts = true +disallow_incomplete_defs = true +disallow_untyped_calls = true +disallow_untyped_defs = true +warn_unused_ignores = true +check_untyped_defs = true +no_implicit_optional = true disable_error_code = [ # See discussion at https://github.com/python/mypy/issues/2427; # mypy cannot run type checking on method assignment, but we use # that in several places, so ignore the error 'method-assign' ] +# see `$ qtpy mypy-args` and qtpy readme. This will be used by `tox -e mypy` +# to properly infer with PyQt6 installed +always_false=['PYSIDE6', 'PYSIDE2', 'PYQT5'] +always_true=['PYQT6'] # gloabl ignore error @@ -211,21 +225,6 @@ module = [ ignore_errors = true -[[tool.mypy.overrides]] -module = [ - 'napari.plugins.*', - 'napari.settings.*', - 'napari.types.*', -] -ignore_errors = false -no_implicit_optional = true -warn_unused_ignores = true -check_untyped_defs = true - -# # maybe someday :) -# disallow_any_generics = true -# no_implicit_reexport = true -# disallow_untyped_defs = true # individual ignore error @@ -235,7 +234,6 @@ check_untyped_defs = true # mypy napari | cut -f1 -d: | sort | uniq | tr '/' '.' | sed 's/\.py//' | awk '{ print " \047" $0 "\047," }' [[tool.mypy.overrides]] module = [ - 'napari._app_model.injection._processors', 'napari._qt.code_syntax_highlight', 'napari._qt.containers._base_item_model', 'napari._qt.containers._base_item_view', @@ -288,6 +286,7 @@ module = [ 'napari._qt.widgets.qt_dims_slider', 'napari._qt.widgets.qt_dims_sorter', 'napari._qt.widgets.qt_extension2reader', + 'napari._qt.widgets.qt_font_size', 'napari._qt.widgets.qt_highlight_preview', 'napari._qt.widgets.qt_keyboard_settings', 'napari._qt.widgets.qt_message_popup', @@ -310,23 +309,17 @@ module = [ 'napari._vispy.experimental.tile_set', 'napari._vispy.experimental.tiled_image_visual', 'napari._vispy.experimental.vispy_tiled_image_layer', - 'napari._vispy.layers.base', 'napari._vispy.overlays.base', 'napari._vispy.utils.cursor', 'napari.components.layerlist', - 'napari.components.viewer_model', 'napari.layers._layer_actions', 'napari.layers._multiscale_data', - 'napari.layers.base.base', 'napari.layers.intensity_mixin', 'napari.layers.points._points_key_bindings', 'napari.layers.points._points_utils', 'napari.layers.points._slice', 'napari.layers.points.points', - 'napari.layers.shapes._shape_list', - 'napari.layers.shapes._shapes_key_bindings', 'napari.layers.shapes._shapes_mouse_bindings', - 'napari.layers.shapes._shapes_utils', 'napari.layers.shapes.shapes', 'napari.layers.utils._link_layers', 'napari.layers.utils.color_encoding', @@ -352,8 +345,241 @@ module = [ 'napari.utils.progress', 'napari.utils.shortcuts', 'napari.utils.stubgen', - 'napari.utils.theme', 'napari.utils.transforms.transforms', 'napari.utils.tree.group', + 'napari.view_layers', + 'napari._app_model.injection._processors', ] ignore_errors = true + +[[tool.mypy.overrides]] +module = [ + "napari.settings", + "napari.settings._yaml", + "napari.plugins.exceptions", + "napari._app_model.actions._toggle_action", + "napari._vispy.filters.tracks", + "napari._vispy.utils.text", + "napari._vispy.utils.visual", + "napari._vispy.visuals.clipping_planes_mixin", + "napari._vispy.visuals.markers", + "napari._vispy.visuals.surface", + "napari.layers._data_protocols", + "napari.layers._source", + "napari.layers.shapes._shapes_models.path", + "napari.layers.shapes._shapes_models.polygon", + "napari.layers.utils.interactivity_utils", + "napari.layers.vectors._vector_utils", + "napari.resources._icons", + "napari.utils.color", + "napari.utils.events.containers._dict", + "napari.utils.events.event_utils", + "napari.utils.migrations", + "napari.utils.perf._patcher", + "napari.utils.validators", + "napari.window" +] +disallow_incomplete_defs = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "napari.settings._utils", + "napari.settings._appearance", + "napari.settings._shortcuts", + "napari.settings._application", + "napari._app_model.actions._view_actions", + "napari._event_loop", + "napari._vispy.utils.quaternion", + "napari._vispy.visuals.bounding_box", + "napari._vispy.visuals.image", + "napari._vispy.visuals.interaction_box", + "napari._vispy.visuals.points", + "napari._vispy.visuals.scale_bar", + "napari.components._layer_slicer", + "napari.components._viewer_mouse_bindings", + "napari.components.overlays.base", + "napari.components.overlays.interaction_box", + "napari.layers.base._base_constants", + "napari.utils.colormaps.categorical_colormap_utils", + "napari.utils.perf._event", + "napari.utils.perf._trace_file", +] +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "napari.components.viewer_model", + "napari.settings._fields", + "napari.settings._migrations", + "napari.settings._base", + "napari.types", + "napari.plugins._npe2", + "napari.settings._napari_settings", + "napari.plugins._plugin_manager", + "napari.plugins.utils", + "napari._qt._qapp_model.qactions._file", + "napari._qt._qapp_model.qactions._help", + "napari._qt._qapp_model.qactions._view", + "napari._vispy.camera", + "napari._vispy.layers.image", + "napari._vispy.layers.tracks", + "napari._vispy.layers.vectors", + "napari._vispy.overlays.axes", + "napari._vispy.overlays.interaction_box", + "napari._vispy.overlays.labels_polygon", + "napari._vispy.overlays.scale_bar", + "napari._vispy.overlays.text", + "napari.layers.labels._labels_key_bindings", + "napari.layers.shapes._mesh", + "napari.layers.surface._surface_key_bindings", + "napari.layers.tracks._tracks_key_bindings", + "napari.layers.utils._slice_input", + "napari.layers.vectors._vectors_key_bindings", + "napari.utils._register", + "napari.utils.colormaps.categorical_colormap", + "napari.utils.colormaps.standardize_color", + "napari.utils.events.containers._set", + "napari.utils.geometry", + "napari.utils.io", + "napari.utils.notebook_display", + "napari.utils.perf._config", + "napari.utils.transforms.transform_utils", + "napari.utils.translations", + "napari.utils.tree.node", + "napari.viewer", + "napari.layers.shapes._shapes_key_bindings", + "napari.layers.shapes._shape_list" +] +disallow_incomplete_defs = false +disallow_untyped_calls = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "napari.plugins", + "napari._vispy.layers.base", + "napari._vispy.visuals.axes", + "napari.layers.labels._labels_mouse_bindings", + "napari.layers.utils.color_manager_utils", + "napari.layers.vectors._slice", + "napari.utils.colormaps.vendored._cm", + "napari.utils.colormaps.vendored.cm", + "napari.utils.status_messages", + "napari.layers.shapes._shapes_utils" +] +disallow_untyped_calls = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "napari._app_model._app", + "napari.utils.events.containers._selection", + "napari.utils.theme", +] +disallow_incomplete_defs = false +disallow_untyped_calls = false +disallow_untyped_defs = false +warn_unused_ignores = false + +[[tool.mypy.overrides]] +module = [ + "napari._app_model.context._context", + "napari._qt.containers._factory" +] +disallow_incomplete_defs = false +disallow_untyped_defs = false +warn_unused_ignores = false + +[[tool.mypy.overrides]] +module = [ + "napari._qt.menus.plugins_menu", + "napari._vispy.layers.labels", + "napari._vispy.layers.points", + "napari._vispy.layers.shapes", + "napari._vispy.layers.surface", + "napari.components._viewer_key_bindings", + "napari.layers.image.image", + "napari.layers.labels.labels", + "napari.layers.surface.surface", + "napari.layers.tracks.tracks", + "napari.layers.utils.layer_utils", + "napari.layers.vectors.vectors", + "napari.utils._dtype", + "napari.utils.colormaps.colormap_utils", + "napari.utils.misc", + "napari.utils.perf._timers" +] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_calls = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "napari.components.camera", + "napari.components.dims", + "napari.conftest", + "napari.layers.labels._labels_utils", + "napari.layers.points._points_mouse_bindings", + "napari.layers.shapes._shapes_models._polgyon_base", + "napari.layers.shapes._shapes_models.ellipse", + "napari.layers.shapes._shapes_models.line", + "napari.layers.shapes._shapes_models.rectangle", + "napari.layers.shapes._shapes_models.shape", + "napari.layers.tracks._track_utils", + "napari.utils.colormaps.colormap", + "napari.utils.events.containers._selectable_list", + "napari.utils.notifications", +] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "napari.utils.events.containers._typed", + "napari.layers.base.base", +] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_calls = false +disallow_untyped_defs = false +warn_unused_ignores = false + +[[tool.mypy.overrides]] +module = [ + "napari.__main__", + "napari.utils.colormaps.vendored.colors" +] +check_untyped_defs = false +disallow_untyped_calls = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "napari._app_model.context._layerlist_context", + "napari.components.overlays.labels_polygon", + "napari.plugins.io", + "napari.utils.colormaps.vendored._cm_listed" +] +disallow_untyped_calls = false + +[[tool.mypy.overrides]] +module = [ + "napari._qt.containers.qt_layer_list" +] +check_untyped_defs = false +disallow_untyped_calls = false +disallow_untyped_defs = false +warn_unused_ignores = false + +[[tool.mypy.overrides]] +module = [ + "napari._vispy.overlays.bounding_box", + "napari._vispy.overlays.brush_circle", + "napari.layers.base._base_mouse_bindings", + "napari.utils._test_utils", +] +check_untyped_defs = false +disallow_untyped_defs = false diff --git a/resources/bundle_license.rtf b/resources/bundle_license.rtf index 3abe77306e3..7190a8fcd48 100644 --- a/resources/bundle_license.rtf +++ b/resources/bundle_license.rtf @@ -28,4 +28,4 @@ Redistribution and use in source and binary forms, with or without modification, \cf0 \ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ \ -This installer bundles other packages, which are distributed under their own license terms. {\field{\*\fldinst{HYPERLINK "https://github.com/napari/napari/blob/latest/EULA.md"}}{\fldrslt Check the full list here}}.} \ No newline at end of file +This installer bundles other packages, which are distributed under their own license terms. {\field{\*\fldinst{HYPERLINK "https://github.com/napari/napari/blob/latest/EULA.md"}}{\fldrslt Check the full list here}}.} diff --git a/resources/constraints/constraints_py3.10.txt b/resources/constraints/constraints_py3.10.txt index 34854b08969..34bd30cfabe 100644 --- a/resources/constraints/constraints_py3.10.txt +++ b/resources/constraints/constraints_py3.10.txt @@ -2,10 +2,12 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --allow-unsafe --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --output-file=napari_repo/resources/constraints/constraints_py3.10.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg # alabaster==0.7.13 # via sphinx +annotated-types==0.6.0 + # via pydantic app-model==0.2.2 # via napari (napari_repo/setup.cfg) appdirs==1.4.4 @@ -14,47 +16,47 @@ appdirs==1.4.4 # npe2 asciitree==0.3.3 # via zarr -asttokens==2.4.0 +asttokens==2.4.1 # via stack-data attrs==23.1.0 # via # hypothesis # jsonschema # referencing -babel==2.12.1 +babel==2.13.1 # via # napari (napari_repo/setup.cfg) # sphinx -backcall==0.2.0 - # via ipython build==1.0.3 # via npe2 cachey==0.2.1 # via napari (napari_repo/setup.cfg) -certifi==2023.7.22 +certifi==2023.11.17 # via # napari (napari_repo/setup.cfg) # requests -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests click==8.1.7 # via # dask # typer -cloudpickle==2.2.1 +cloudpickle==3.0.0 # via dask -cmake==3.27.5 - # via triton -comm==0.1.4 +comm==0.2.0 # via ipykernel -contourpy==1.1.1 +contourpy==1.2.0 # via matplotlib -coverage==7.3.1 - # via pytest-cov -cycler==0.11.0 +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 # via matplotlib -dask==2023.9.2 - # via napari (napari_repo/setup.cfg) +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) debugpy==1.8.0 # via ipykernel decorator==5.1.1 @@ -65,39 +67,38 @@ docstring-parser==0.15 # via magicgui docutils==0.20.1 # via sphinx -entrypoints==0.4 - # via numcodecs -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via # hypothesis # ipython # pytest -executing==1.2.0 +executing==2.0.1 # via stack-data fasteners==0.19 # via zarr -filelock==3.12.4 +filelock==3.13.1 # via # torch # triton # virtualenv -fonttools==4.42.1 +fonttools==4.45.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2023.9.2 +fsspec==2023.10.0 # via # dask # napari (napari_repo/setup.cfg) + # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.87.0 +hypothesis==6.90.0 # via napari (napari_repo/setup.cfg) idna==3.4 # via requests -imageio==2.31.4 +imageio==2.33.0 # via # napari (napari_repo/setup.cfg) # napari-svg @@ -106,37 +107,35 @@ imagesize==1.4.1 # via sphinx importlib-metadata==6.8.0 # via dask -in-n-out==0.1.8 +in-n-out==0.1.9 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.25.2 +ipykernel==6.27.0 # via # napari-console # qtconsole -ipython==8.15.0 +ipython==8.17.2 # via # ipykernel # napari (napari_repo/setup.cfg) # napari-console -ipython-genutils==0.2.0 - # via qtconsole -jedi==0.19.0 +jedi==0.19.1 # via ipython jinja2==3.1.2 # via # numpydoc # sphinx # torch -jsonschema==4.19.1 +jsonschema==4.20.0 # via napari (napari_repo/setup.cfg) -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.1 # via jsonschema -jupyter-client==8.3.1 +jupyter-client==8.6.0 # via # ipykernel # qtconsole -jupyter-core==5.3.2 +jupyter-core==5.5.0 # via # ipykernel # jupyter-client @@ -149,19 +148,19 @@ lazy-loader==0.3 # via # napari (napari_repo/setup.cfg) # scikit-image -lit==17.0.1 - # via triton +llvmlite==0.41.1 + # via numba locket==1.0.0 # via partd lxml==4.9.3 # via napari (napari_repo/setup.cfg) -magicgui==0.7.3 +magicgui==0.8.0 # via napari (napari_repo/setup.cfg) markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 # via jinja2 -matplotlib==3.8.0 +matplotlib==3.8.2 # via napari (napari_repo/setup.cfg) matplotlib-inline==0.1.6 # via @@ -169,16 +168,17 @@ matplotlib-inline==0.1.6 # ipython mdurl==0.1.2 # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore mpmath==1.3.0 # via sympy mypy-extensions==1.0.0 # via psygnal -napari-console==0.0.8 +napari-console==0.0.9 # via napari (napari_repo/setup.cfg) napari-plugin-engine==0.2.0 # via # napari (napari_repo/setup.cfg) - # napari-console # napari-svg napari-plugin-manager==0.1.0a2 # via napari (napari_repo/setup.cfg) @@ -186,62 +186,72 @@ napari-svg==0.1.10 # via napari (napari_repo/setup.cfg) nest-asyncio==1.5.8 # via ipykernel -networkx==3.1 +networkx==3.2.1 # via # scikit-image # torch -npe2==0.7.2 +npe2==0.7.3 # via # napari (napari_repo/setup.cfg) # napari-plugin-manager -numcodecs==0.11.0 +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 # via zarr -numpy==1.26.0 +numpy==1.26.2 # via # contourpy # dask # imageio # matplotlib + # ml-dtypes # napari (napari_repo/setup.cfg) # napari-svg + # numba # numcodecs # pandas - # pywavelets # scikit-image # scipy # tensorstore # tifffile + # triangle # vispy # xarray # zarr numpydoc==1.6.0 # via napari (napari_repo/setup.cfg) -nvidia-cublas-cu11==11.10.3.66 +nvidia-cublas-cu12==12.1.3.1 # via - # nvidia-cudnn-cu11 - # nvidia-cusolver-cu11 + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 # torch -nvidia-cuda-cupti-cu11==11.7.101 - # via torch -nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-cupti-cu12==12.1.105 # via torch -nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cuda-nvrtc-cu12==12.1.105 # via torch -nvidia-cudnn-cu11==8.5.0.96 +nvidia-cuda-runtime-cu12==12.1.105 # via torch -nvidia-cufft-cu11==10.9.0.58 +nvidia-cudnn-cu12==8.9.2.26 # via torch -nvidia-curand-cu11==10.2.10.91 +nvidia-cufft-cu12==11.0.2.54 # via torch -nvidia-cusolver-cu11==11.4.0.1 +nvidia-curand-cu12==10.3.2.106 # via torch -nvidia-cusparse-cu11==11.7.4.91 +nvidia-cusolver-cu12==11.4.5.107 # via torch -nvidia-nccl-cu11==2.14.3 +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 # via torch -nvidia-nvtx-cu11==11.7.91 +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.1 +packaging==23.2 # via # build # dask @@ -256,7 +266,7 @@ packaging==23.1 # superqt # vispy # xarray -pandas==2.1.1 ; python_version >= "3.9" +pandas==2.1.3 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # xarray @@ -266,9 +276,7 @@ partd==1.4.1 # via dask pexpect==4.8.0 # via ipython -pickleshare==0.7.5 - # via ipython -pillow==10.0.1 +pillow==10.1.0 # via # imageio # matplotlib @@ -276,24 +284,26 @@ pillow==10.0.1 # scikit-image pint==0.22 # via napari (napari_repo/setup.cfg) -platformdirs==3.10.0 +platformdirs==4.0.0 # via # jupyter-core # pooch # virtualenv pluggy==1.3.0 # via pytest -pooch==1.7.0 +pooch==1.8.0 # via # napari (napari_repo/setup.cfg) # scikit-image -prompt-toolkit==3.0.39 +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 # via ipython -psutil==5.9.5 +psutil==5.9.6 # via # ipykernel # napari (napari_repo/setup.cfg) -psygnal==0.9.4 +psygnal==0.9.5 # via # app-model # magicgui @@ -303,12 +313,16 @@ ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -pydantic==1.10.13 +pyconify==0.1.6 + # via superqt +pydantic==2.5.2 # via # app-model # napari (napari_repo/setup.cfg) # npe2 -pygments==2.16.1 +pydantic-core==2.14.5 + # via pydantic +pygments==2.17.2 # via # ipython # napari (napari_repo/setup.cfg) @@ -324,17 +338,17 @@ pyparsing==3.1.1 # via matplotlib pyproject-hooks==1.0.0 # via build -pyqt5==5.15.9 +pyqt5==5.15.10 # via napari (napari_repo/setup.cfg) pyqt5-qt5==5.15.2 # via pyqt5 -pyqt5-sip==12.12.2 +pyqt5-sip==12.13.0 # via pyqt5 -pyqt6==6.5.2 +pyqt6==6.6.0 # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.5.2 +pyqt6-qt6==6.6.0 # via pyqt6 -pyqt6-sip==13.5.2 +pyqt6-sip==13.6.0 # via pyqt6 pyside2==5.15.2.1 ; python_version != "3.8" # via napari (napari_repo/setup.cfg) @@ -348,7 +362,7 @@ pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==7.4.2 +pytest==7.4.3 # via # napari (napari_repo/setup.cfg) # pytest-cov @@ -357,9 +371,7 @@ pytest==7.4.2 # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.0.0 @@ -375,8 +387,6 @@ python-dateutil==2.8.2 # pandas pytz==2023.3.post1 # via pandas -pywavelets==1.4.1 - # via scikit-image pyyaml==6.0.1 # via # dask @@ -387,11 +397,11 @@ pyzmq==25.1.1 # ipykernel # jupyter-client # qtconsole -qtconsole==5.4.4 +qtconsole==5.5.1 # via # napari (napari_repo/setup.cfg) # napari-console -qtpy==2.4.0 +qtpy==2.4.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -399,26 +409,29 @@ qtpy==2.4.0 # napari-plugin-manager # qtconsole # superqt -referencing==0.30.2 +referencing==0.31.0 # via # jsonschema # jsonschema-specifications requests==2.31.0 # via # pooch + # pyconify # sphinx -rich==13.5.3 +rich==13.7.0 # via # napari (napari_repo/setup.cfg) # npe2 # pytest-pretty -rpds-py==0.10.3 +rpds-py==0.13.1 # via # jsonschema # referencing -scikit-image==0.21.0 - # via napari (napari_repo/setup.cfg) -scipy==1.11.3 ; python_version >= "3.9" +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scipy==1.11.4 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # scikit-image @@ -457,9 +470,9 @@ sphinxcontrib-qthelp==1.0.6 # via sphinx sphinxcontrib-serializinghtml==1.1.9 # via sphinx -stack-data==0.6.2 +stack-data==0.6.3 # via ipython -superqt==0.6.0 +superqt==0.6.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -468,7 +481,7 @@ sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.44 +tensorstore==0.1.50 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/setup.cfg) @@ -491,17 +504,15 @@ toolz==0.12.0 # dask # napari (napari_repo/setup.cfg) # partd -torch==2.0.1 - # via - # napari (napari_repo/setup.cfg) - # triton +torch==2.1.1 + # via napari (napari_repo/setup.cfg) tornado==6.3.3 # via # ipykernel # jupyter-client tqdm==4.66.1 # via napari (napari_repo/setup.cfg) -traitlets==5.10.1 +traitlets==5.13.0 # via # comm # ipykernel @@ -510,7 +521,9 @@ traitlets==5.10.1 # jupyter-core # matplotlib-inline # qtconsole -triton==2.0.0 +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 # via torch typer==0.9.0 # via npe2 @@ -522,32 +535,25 @@ typing-extensions==4.8.0 # pint # psygnal # pydantic + # pydantic-core # superqt # torch # typer tzdata==2023.3 # via pandas -urllib3==2.0.5 +urllib3==2.1.0 # via requests -virtualenv==20.24.5 +virtualenv==20.24.7 # via napari (napari_repo/setup.cfg) -vispy==0.13.0 +vispy==0.14.1 # via # napari (napari_repo/setup.cfg) # napari-svg -wcwidth==0.2.6 +wcwidth==0.2.12 # via prompt-toolkit -wheel==0.41.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 -wrapt==1.15.0 +wrapt==1.16.0 # via napari (napari_repo/setup.cfg) -xarray==2023.9.0 +xarray==2023.11.0 # via napari (napari_repo/setup.cfg) zarr==2.16.1 # via napari (napari_repo/setup.cfg) @@ -555,13 +561,5 @@ zipp==3.17.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3.1 # via napari-plugin-manager -setuptools==68.2.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 diff --git a/resources/constraints/constraints_py3.10_docs.txt b/resources/constraints/constraints_py3.10_docs.txt index 2177edc0c7a..9857cb56de6 100644 --- a/resources/constraints/constraints_py3.10_docs.txt +++ b/resources/constraints/constraints_py3.10_docs.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --allow-unsafe --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --output-file=napari_repo/resources/constraints/constraints_py3.10_docs.txt --strip-extras docs/requirements.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg resources/constraints/version_denylist_examples.txt +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10_docs.txt --strip-extras docs/requirements.txt napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg resources/constraints/version_denylist_examples.txt # alabaster==0.7.13 # via sphinx @@ -14,7 +14,7 @@ appdirs==1.4.4 # npe2 asciitree==0.3.3 # via zarr -asttokens==2.4.0 +asttokens==2.4.1 # via stack-data attrs==23.1.0 # via @@ -22,25 +22,23 @@ attrs==23.1.0 # jsonschema # jupyter-cache # referencing -babel==2.12.1 +babel==2.13.1 # via # napari (napari_repo/setup.cfg) # sphinx -backcall==0.2.0 - # via ipython beautifulsoup4==4.12.2 # via napari-sphinx-theme build==1.0.3 # via npe2 cachey==0.2.1 # via napari (napari_repo/setup.cfg) -certifi==2023.7.22 +certifi==2023.11.17 # via # napari (napari_repo/setup.cfg) # requests cfgv==3.4.0 # via pre-commit -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests click==8.1.7 # via @@ -48,22 +46,24 @@ click==8.1.7 # jupyter-cache # sphinx-external-toc # typer -cloudpickle==2.2.1 +cloudpickle==3.0.0 # via dask -cmake==3.27.5 - # via triton colorama==0.4.6 # via sphinx-autobuild -comm==0.1.4 +comm==0.2.0 # via ipykernel -contourpy==1.1.1 +contourpy==1.2.0 # via matplotlib -coverage==7.3.1 - # via pytest-cov -cycler==0.11.0 +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 # via matplotlib -dask==2023.9.2 - # via napari (napari_repo/setup.cfg) +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) debugpy==1.8.0 # via ipykernel decorator==5.1.1 @@ -78,45 +78,44 @@ docutils==0.17.1 # napari-sphinx-theme # sphinx # sphinx-tabs -entrypoints==0.4 - # via numcodecs -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via # hypothesis # ipython # pytest -executing==1.2.0 +executing==2.0.1 # via stack-data fasteners==0.19 # via zarr -fastjsonschema==2.18.0 +fastjsonschema==2.19.0 # via nbformat -filelock==3.12.4 +filelock==3.13.1 # via # torch # triton # virtualenv -fonttools==4.42.1 +fonttools==4.45.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2023.9.2 +fsspec==2023.10.0 # via # dask # napari (napari_repo/setup.cfg) -greenlet==2.0.2 + # torch +greenlet==3.0.1 # via sqlalchemy heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.87.0 +hypothesis==6.90.0 # via napari (napari_repo/setup.cfg) -identify==2.5.29 +identify==2.5.32 # via pre-commit idna==3.4 # via requests -imageio==2.31.4 +imageio==2.33.0 # via # napari (napari_repo/setup.cfg) # napari-svg @@ -130,24 +129,22 @@ importlib-metadata==6.8.0 # dask # jupyter-cache # myst-nb -in-n-out==0.1.8 +in-n-out==0.1.9 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.25.2 +ipykernel==6.27.0 # via # myst-nb # napari-console # qtconsole -ipython==8.15.0 +ipython==8.17.2 # via # ipykernel # myst-nb # napari (napari_repo/setup.cfg) # napari-console -ipython-genutils==0.2.0 - # via qtconsole -jedi==0.19.0 +jedi==0.19.1 # via ipython jinja2==3.0.3 # via @@ -160,20 +157,20 @@ joblib==1.3.2 # via # nilearn # scikit-learn -jsonschema==4.19.1 +jsonschema==4.20.0 # via # napari (napari_repo/setup.cfg) # nbformat -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.1 # via jsonschema jupyter-cache==0.6.1 # via myst-nb -jupyter-client==8.3.1 +jupyter-client==8.6.0 # via # ipykernel # nbclient # qtconsole -jupyter-core==5.3.2 +jupyter-core==5.5.0 # via # ipykernel # jupyter-client @@ -188,10 +185,10 @@ lazy-loader==0.3 # via # napari (napari_repo/setup.cfg) # scikit-image -lit==17.0.1 - # via triton livereload==2.6.3 # via sphinx-autobuild +llvmlite==0.41.1 + # via numba locket==1.0.0 # via partd lxml==4.9.3 @@ -199,7 +196,7 @@ lxml==4.9.3 # -r docs/requirements.txt # napari (napari_repo/setup.cfg) # nilearn -magicgui==0.7.3 +magicgui==0.8.0 # via napari (napari_repo/setup.cfg) markdown-it-py==2.2.0 # via @@ -208,7 +205,7 @@ markdown-it-py==2.2.0 # rich markupsafe==2.1.3 # via jinja2 -matplotlib==3.8.0 +matplotlib==3.8.2 # via # -r docs/requirements.txt # napari (napari_repo/setup.cfg) @@ -220,6 +217,8 @@ mdit-py-plugins==0.3.5 # via myst-parser mdurl==0.1.2 # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore mpmath==1.3.0 # via sympy mypy-extensions==1.0.0 @@ -228,12 +227,11 @@ myst-nb==0.17.2 # via -r docs/requirements.txt myst-parser==0.18.1 # via myst-nb -napari-console==0.0.8 +napari-console==0.0.9 # via napari (napari_repo/setup.cfg) napari-plugin-engine==0.2.0 # via # napari (napari_repo/setup.cfg) - # napari-console # napari-svg napari-plugin-manager==0.1.0a2 # via napari (napari_repo/setup.cfg) @@ -252,21 +250,23 @@ nbformat==5.9.2 # nbclient nest-asyncio==1.5.8 # via ipykernel -networkx==3.1 +networkx==3.2.1 # via # scikit-image # torch nibabel==5.1.0 # via nilearn -nilearn==0.10.1 +nilearn==0.10.2 # via -r resources/constraints/version_denylist_examples.txt nodeenv==1.8.0 # via pre-commit -npe2==0.7.2 +npe2==0.7.3 # via # napari (napari_repo/setup.cfg) # napari-plugin-manager -numcodecs==0.11.0 +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 # via zarr numpy==1.23.5 # via @@ -275,49 +275,57 @@ numpy==1.23.5 # dask # imageio # matplotlib + # ml-dtypes # napari (napari_repo/setup.cfg) # napari-svg # nibabel # nilearn + # numba # numcodecs # pandas - # pywavelets # scikit-image # scikit-learn # scipy # tensorstore # tifffile + # triangle # vispy # xarray # zarr numpydoc==1.5.0 # via napari (napari_repo/setup.cfg) -nvidia-cublas-cu11==11.10.3.66 +nvidia-cublas-cu12==12.1.3.1 # via - # nvidia-cudnn-cu11 - # nvidia-cusolver-cu11 + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 # torch -nvidia-cuda-cupti-cu11==11.7.101 - # via torch -nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-cupti-cu12==12.1.105 # via torch -nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cuda-nvrtc-cu12==12.1.105 # via torch -nvidia-cudnn-cu11==8.5.0.96 +nvidia-cuda-runtime-cu12==12.1.105 # via torch -nvidia-cufft-cu11==10.9.0.58 +nvidia-cudnn-cu12==8.9.2.26 # via torch -nvidia-curand-cu11==10.2.10.91 +nvidia-cufft-cu12==11.0.2.54 # via torch -nvidia-cusolver-cu11==11.4.0.1 +nvidia-curand-cu12==10.3.2.106 # via torch -nvidia-cusparse-cu11==11.7.4.91 +nvidia-cusolver-cu12==11.4.5.107 # via torch -nvidia-nccl-cu11==2.14.3 +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 # via torch -nvidia-nvtx-cu11==11.7.91 +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.1 +packaging==23.2 # via # -r resources/constraints/version_denylist_examples.txt # build @@ -336,7 +344,7 @@ packaging==23.1 # superqt # vispy # xarray -pandas==2.1.1 ; python_version >= "3.9" +pandas==2.1.3 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # nilearn @@ -347,36 +355,37 @@ partd==1.4.1 # via dask pexpect==4.8.0 # via ipython -pickleshare==0.7.5 - # via ipython -pillow==10.0.1 +pillow==10.1.0 # via # imageio # matplotlib # napari (napari_repo/setup.cfg) # scikit-image + # sphinx-gallery pint==0.22 # via napari (napari_repo/setup.cfg) -platformdirs==3.10.0 +platformdirs==4.0.0 # via # jupyter-core # pooch # virtualenv pluggy==1.3.0 # via pytest -pooch==1.7.0 +pooch==1.8.0 # via # napari (napari_repo/setup.cfg) # scikit-image -pre-commit==3.4.0 +pre-commit==3.5.0 # via sphinx-tags -prompt-toolkit==3.0.39 +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 # via ipython -psutil==5.9.5 +psutil==5.9.6 # via # ipykernel # napari (napari_repo/setup.cfg) -psygnal==0.9.4 +psygnal==0.9.5 # via # app-model # magicgui @@ -386,12 +395,15 @@ ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyconify==0.1.6 + # via superqt pydantic==1.10.13 # via + # -r napari_repo/resources/constraints/pydantic_le_2.txt # app-model # napari (napari_repo/setup.cfg) # npe2 -pygments==2.16.1 +pygments==2.17.2 # via # ipython # napari (napari_repo/setup.cfg) @@ -408,17 +420,17 @@ pyparsing==3.1.1 # via matplotlib pyproject-hooks==1.0.0 # via build -pyqt5==5.15.9 +pyqt5==5.15.10 # via napari (napari_repo/setup.cfg) pyqt5-qt5==5.15.2 # via pyqt5 -pyqt5-sip==12.12.2 +pyqt5-sip==12.13.0 # via pyqt5 -pyqt6==6.5.2 +pyqt6==6.6.0 # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.5.2 +pyqt6-qt6==6.6.0 # via pyqt6 -pyqt6-sip==13.5.2 +pyqt6-sip==13.6.0 # via pyqt6 pyside2==5.15.2.1 ; python_version != "3.8" # via napari (napari_repo/setup.cfg) @@ -432,7 +444,7 @@ pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==7.4.2 +pytest==7.4.3 # via # napari (napari_repo/setup.cfg) # pytest-cov @@ -441,9 +453,7 @@ pytest==7.4.2 # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.0.0 @@ -459,8 +469,6 @@ python-dateutil==2.8.2 # pandas pytz==2023.3.post1 # via pandas -pywavelets==1.4.1 - # via scikit-image pyyaml==6.0.1 # via # dask @@ -476,11 +484,11 @@ pyzmq==25.1.1 # ipykernel # jupyter-client # qtconsole -qtconsole==5.4.4 +qtconsole==5.5.1 # via # napari (napari_repo/setup.cfg) # napari-console -qtpy==2.4.0 +qtpy==2.4.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -488,7 +496,7 @@ qtpy==2.4.0 # napari-plugin-manager # qtconsole # superqt -referencing==0.30.2 +referencing==0.31.0 # via # jsonschema # jsonschema-specifications @@ -496,21 +504,24 @@ requests==2.31.0 # via # nilearn # pooch + # pyconify # sphinx -rich==13.5.3 +rich==13.7.0 # via # napari (napari_repo/setup.cfg) # npe2 # pytest-pretty -rpds-py==0.10.3 +rpds-py==0.13.1 # via # jsonschema # referencing -scikit-image==0.21.0 - # via napari (napari_repo/setup.cfg) -scikit-learn==1.3.1 +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scikit-learn==1.3.2 # via nilearn -scipy==1.11.3 ; python_version >= "3.9" +scipy==1.11.4 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # nilearn @@ -562,7 +573,7 @@ sphinx-external-toc==0.3.1 # via -r docs/requirements.txt sphinx-favicon==1.0.1 # via -r docs/requirements.txt -sphinx-gallery==0.14.0 +sphinx-gallery==0.15.0 # via -r docs/requirements.txt sphinx-tabs==3.4.0 # via -r docs/requirements.txt @@ -580,11 +591,11 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -sqlalchemy==2.0.21 +sqlalchemy==2.0.23 # via jupyter-cache -stack-data==0.6.2 +stack-data==0.6.3 # via ipython -superqt==0.6.0 +superqt==0.6.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -593,7 +604,7 @@ sympy==1.12 # via torch tabulate==0.9.0 # via jupyter-cache -tensorstore==0.1.44 +tensorstore==0.1.50 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/setup.cfg) @@ -617,10 +628,8 @@ toolz==0.12.0 # dask # napari (napari_repo/setup.cfg) # partd -torch==2.0.1 - # via - # napari (napari_repo/setup.cfg) - # triton +torch==2.1.1 + # via napari (napari_repo/setup.cfg) tornado==6.3.3 # via # ipykernel @@ -628,7 +637,7 @@ tornado==6.3.3 # livereload tqdm==4.66.1 # via napari (napari_repo/setup.cfg) -traitlets==5.10.1 +traitlets==5.13.0 # via # comm # ipykernel @@ -639,7 +648,9 @@ traitlets==5.10.1 # nbclient # nbformat # qtconsole -triton==2.0.0 +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 # via torch typer==0.9.0 # via npe2 @@ -659,29 +670,21 @@ typing-extensions==4.8.0 # typer tzdata==2023.3 # via pandas -urllib3==2.0.5 +urllib3==2.1.0 # via requests -virtualenv==20.24.5 +virtualenv==20.24.7 # via # napari (napari_repo/setup.cfg) # pre-commit -vispy==0.13.0 +vispy==0.14.1 # via # napari (napari_repo/setup.cfg) # napari-svg -wcwidth==0.2.6 +wcwidth==0.2.12 # via prompt-toolkit -wheel==0.41.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 -wrapt==1.15.0 +wrapt==1.16.0 # via napari (napari_repo/setup.cfg) -xarray==2023.9.0 +xarray==2023.11.0 # via napari (napari_repo/setup.cfg) zarr==2.16.1 # via napari (napari_repo/setup.cfg) @@ -689,15 +692,9 @@ zipp==3.17.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3.1 # via napari-plugin-manager -setuptools==68.2.2 +setuptools==69.0.2 # via # imageio-ffmpeg # nodeenv - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 diff --git a/resources/constraints/constraints_py3.10_pydantic_1.txt b/resources/constraints/constraints_py3.10_pydantic_1.txt new file mode 100644 index 00000000000..5885af54936 --- /dev/null +++ b/resources/constraints/constraints_py3.10_pydantic_1.txt @@ -0,0 +1,561 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# +alabaster==0.7.13 + # via sphinx +app-model==0.2.2 + # via napari (napari_repo/setup.cfg) +appdirs==1.4.4 + # via + # napari (napari_repo/setup.cfg) + # npe2 +asciitree==0.3.3 + # via zarr +asttokens==2.4.1 + # via stack-data +attrs==23.1.0 + # via + # hypothesis + # jsonschema + # referencing +babel==2.13.1 + # via + # napari (napari_repo/setup.cfg) + # sphinx +build==1.0.3 + # via npe2 +cachey==0.2.1 + # via napari (napari_repo/setup.cfg) +certifi==2023.11.17 + # via + # napari (napari_repo/setup.cfg) + # requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # dask + # typer +cloudpickle==3.0.0 + # via dask +comm==0.2.0 + # via ipykernel +contourpy==1.2.0 + # via matplotlib +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 + # via matplotlib +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) +debugpy==1.8.0 + # via ipykernel +decorator==5.1.1 + # via ipython +distlib==0.3.7 + # via virtualenv +docstring-parser==0.15 + # via magicgui +docutils==0.20.1 + # via sphinx +exceptiongroup==1.2.0 + # via + # hypothesis + # ipython + # pytest +executing==2.0.1 + # via stack-data +fasteners==0.19 + # via zarr +filelock==3.13.1 + # via + # torch + # triton + # virtualenv +fonttools==4.45.0 + # via matplotlib +freetype-py==2.4.0 + # via vispy +fsspec==2023.10.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # torch +heapdict==1.0.1 + # via cachey +hsluv==5.0.4 + # via vispy +hypothesis==6.90.0 + # via napari (napari_repo/setup.cfg) +idna==3.4 + # via requests +imageio==2.33.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg + # scikit-image +imagesize==1.4.1 + # via sphinx +importlib-metadata==6.8.0 + # via dask +in-n-out==0.1.9 + # via app-model +iniconfig==2.0.0 + # via pytest +ipykernel==6.27.0 + # via + # napari-console + # qtconsole +ipython==8.17.2 + # via + # ipykernel + # napari (napari_repo/setup.cfg) + # napari-console +jedi==0.19.1 + # via ipython +jinja2==3.1.2 + # via + # numpydoc + # sphinx + # torch +jsonschema==4.20.0 + # via napari (napari_repo/setup.cfg) +jsonschema-specifications==2023.11.1 + # via jsonschema +jupyter-client==8.6.0 + # via + # ipykernel + # qtconsole +jupyter-core==5.5.0 + # via + # ipykernel + # jupyter-client + # qtconsole +kiwisolver==1.4.5 + # via + # matplotlib + # vispy +lazy-loader==0.3 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +llvmlite==0.41.1 + # via numba +locket==1.0.0 + # via partd +lxml==4.9.3 + # via napari (napari_repo/setup.cfg) +magicgui==0.8.0 + # via napari (napari_repo/setup.cfg) +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.3 + # via jinja2 +matplotlib==3.8.2 + # via napari (napari_repo/setup.cfg) +matplotlib-inline==0.1.6 + # via + # ipykernel + # ipython +mdurl==0.1.2 + # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore +mpmath==1.3.0 + # via sympy +mypy-extensions==1.0.0 + # via psygnal +napari-console==0.0.9 + # via napari (napari_repo/setup.cfg) +napari-plugin-engine==0.2.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +napari-plugin-manager==0.1.0a2 + # via napari (napari_repo/setup.cfg) +napari-svg==0.1.10 + # via napari (napari_repo/setup.cfg) +nest-asyncio==1.5.8 + # via ipykernel +networkx==3.2.1 + # via + # scikit-image + # torch +npe2==0.7.3 + # via + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 + # via zarr +numpy==1.26.2 + # via + # contourpy + # dask + # imageio + # matplotlib + # ml-dtypes + # napari (napari_repo/setup.cfg) + # napari-svg + # numba + # numcodecs + # pandas + # scikit-image + # scipy + # tensorstore + # tifffile + # triangle + # vispy + # xarray + # zarr +numpydoc==1.6.0 + # via napari (napari_repo/setup.cfg) +nvidia-cublas-cu12==12.1.3.1 + # via + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 + # torch +nvidia-cuda-cupti-cu12==12.1.105 + # via torch +nvidia-cuda-nvrtc-cu12==12.1.105 + # via torch +nvidia-cuda-runtime-cu12==12.1.105 + # via torch +nvidia-cudnn-cu12==8.9.2.26 + # via torch +nvidia-cufft-cu12==11.0.2.54 + # via torch +nvidia-curand-cu12==10.3.2.106 + # via torch +nvidia-cusolver-cu12==11.4.5.107 + # via torch +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 + # via torch +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 + # via torch +packaging==23.2 + # via + # build + # dask + # ipykernel + # matplotlib + # pooch + # pytest + # qtconsole + # qtpy + # scikit-image + # sphinx + # superqt + # vispy + # xarray +pandas==2.1.3 ; python_version >= "3.9" + # via + # napari (napari_repo/setup.cfg) + # xarray +parso==0.8.3 + # via jedi +partd==1.4.1 + # via dask +pexpect==4.8.0 + # via ipython +pillow==10.1.0 + # via + # imageio + # matplotlib + # napari (napari_repo/setup.cfg) + # scikit-image +pint==0.22 + # via napari (napari_repo/setup.cfg) +platformdirs==4.0.0 + # via + # jupyter-core + # pooch + # virtualenv +pluggy==1.3.0 + # via pytest +pooch==1.8.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 + # via ipython +psutil==5.9.6 + # via + # ipykernel + # napari (napari_repo/setup.cfg) +psygnal==0.9.5 + # via + # app-model + # magicgui + # napari (napari_repo/setup.cfg) + # npe2 +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pyconify==0.1.6 + # via superqt +pydantic==1.10.13 + # via + # -r napari_repo/resources/constraints/pydantic_le_2.txt + # app-model + # napari (napari_repo/setup.cfg) + # npe2 +pygments==2.17.2 + # via + # ipython + # napari (napari_repo/setup.cfg) + # qtconsole + # rich + # sphinx + # superqt +pyopengl==3.1.6 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyparsing==3.1.1 + # via matplotlib +pyproject-hooks==1.0.0 + # via build +pyqt5==5.15.10 + # via napari (napari_repo/setup.cfg) +pyqt5-qt5==5.15.2 + # via pyqt5 +pyqt5-sip==12.13.0 + # via pyqt5 +pyqt6==6.6.0 + # via napari (napari_repo/setup.cfg) +pyqt6-qt6==6.6.0 + # via pyqt6 +pyqt6-sip==13.6.0 + # via pyqt6 +pyside2==5.15.2.1 ; python_version != "3.8" + # via napari (napari_repo/setup.cfg) +pyside6==6.4.2 ; python_version >= "3.10" + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyside6-addons==6.4.2 + # via pyside6 +pyside6-essentials==6.4.2 + # via + # pyside6 + # pyside6-addons +pytest==7.4.3 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov + # pytest-json-report + # pytest-metadata + # pytest-pretty + # pytest-qt +pytest-cov==4.1.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-json-report==1.5.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-metadata==3.0.0 + # via pytest-json-report +pytest-pretty==1.2.0 + # via napari (napari_repo/setup.cfg) +pytest-qt==4.2.0 + # via napari (napari_repo/setup.cfg) +python-dateutil==2.8.2 + # via + # jupyter-client + # matplotlib + # pandas +pytz==2023.3.post1 + # via pandas +pyyaml==6.0.1 + # via + # dask + # napari (napari_repo/setup.cfg) + # npe2 +pyzmq==25.1.1 + # via + # ipykernel + # jupyter-client + # qtconsole +qtconsole==5.5.1 + # via + # napari (napari_repo/setup.cfg) + # napari-console +qtpy==2.4.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-console + # napari-plugin-manager + # qtconsole + # superqt +referencing==0.31.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.31.0 + # via + # pooch + # pyconify + # sphinx +rich==13.7.0 + # via + # napari (napari_repo/setup.cfg) + # npe2 + # pytest-pretty +rpds-py==0.13.1 + # via + # jsonschema + # referencing +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scipy==1.11.4 ; python_version >= "3.9" + # via + # napari (napari_repo/setup.cfg) + # scikit-image +shiboken2==5.15.2.1 + # via pyside2 +shiboken6==6.4.2 + # via + # pyside6 + # pyside6-addons + # pyside6-essentials +six==1.16.0 + # via + # asttokens + # python-dateutil +snowballstemmer==2.2.0 + # via sphinx +sortedcontainers==2.4.0 + # via hypothesis +sphinx==7.2.6 + # via + # numpydoc + # sphinxcontrib-applehelp + # sphinxcontrib-devhelp + # sphinxcontrib-htmlhelp + # sphinxcontrib-qthelp + # sphinxcontrib-serializinghtml +sphinxcontrib-applehelp==1.0.7 + # via sphinx +sphinxcontrib-devhelp==1.0.5 + # via sphinx +sphinxcontrib-htmlhelp==2.0.4 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.6 + # via sphinx +sphinxcontrib-serializinghtml==1.1.9 + # via sphinx +stack-data==0.6.3 + # via ipython +superqt==0.6.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +sympy==1.12 + # via torch +tabulate==0.9.0 + # via numpydoc +tensorstore==0.1.50 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +tifffile==2023.9.26 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +tomli==2.0.1 + # via + # build + # coverage + # npe2 + # numpydoc + # pyproject-hooks + # pytest +tomli-w==1.0.0 + # via npe2 +toolz==0.12.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # partd +torch==2.1.1 + # via napari (napari_repo/setup.cfg) +tornado==6.3.3 + # via + # ipykernel + # jupyter-client +tqdm==4.66.1 + # via napari (napari_repo/setup.cfg) +traitlets==5.13.0 + # via + # comm + # ipykernel + # ipython + # jupyter-client + # jupyter-core + # matplotlib-inline + # qtconsole +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 + # via torch +typer==0.9.0 + # via npe2 +typing-extensions==4.8.0 + # via + # app-model + # magicgui + # napari (napari_repo/setup.cfg) + # pint + # psygnal + # pydantic + # superqt + # torch + # typer +tzdata==2023.3 + # via pandas +urllib3==2.1.0 + # via requests +virtualenv==20.24.7 + # via napari (napari_repo/setup.cfg) +vispy==0.14.1 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +wcwidth==0.2.12 + # via prompt-toolkit +wrapt==1.16.0 + # via napari (napari_repo/setup.cfg) +xarray==2023.11.0 + # via napari (napari_repo/setup.cfg) +zarr==2.16.1 + # via napari (napari_repo/setup.cfg) +zipp==3.17.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +pip==23.3.1 + # via napari-plugin-manager diff --git a/resources/constraints/constraints_py3.11.txt b/resources/constraints/constraints_py3.11.txt index b4e903adf35..fe96f2b5bf9 100644 --- a/resources/constraints/constraints_py3.11.txt +++ b/resources/constraints/constraints_py3.11.txt @@ -2,10 +2,12 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --allow-unsafe --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --output-file=napari_repo/resources/constraints/constraints_py3.11.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.11.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg # alabaster==0.7.13 # via sphinx +annotated-types==0.6.0 + # via pydantic app-model==0.2.2 # via napari (napari_repo/setup.cfg) appdirs==1.4.4 @@ -14,47 +16,47 @@ appdirs==1.4.4 # npe2 asciitree==0.3.3 # via zarr -asttokens==2.4.0 +asttokens==2.4.1 # via stack-data attrs==23.1.0 # via # hypothesis # jsonschema # referencing -babel==2.12.1 +babel==2.13.1 # via # napari (napari_repo/setup.cfg) # sphinx -backcall==0.2.0 - # via ipython build==1.0.3 # via npe2 cachey==0.2.1 # via napari (napari_repo/setup.cfg) -certifi==2023.7.22 +certifi==2023.11.17 # via # napari (napari_repo/setup.cfg) # requests -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests click==8.1.7 # via # dask # typer -cloudpickle==2.2.1 +cloudpickle==3.0.0 # via dask -cmake==3.27.5 - # via triton -comm==0.1.4 +comm==0.2.0 # via ipykernel -contourpy==1.1.1 +contourpy==1.2.0 # via matplotlib -coverage==7.3.1 - # via pytest-cov -cycler==0.11.0 +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 # via matplotlib -dask==2023.9.2 - # via napari (napari_repo/setup.cfg) +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) debugpy==1.8.0 # via ipykernel decorator==5.1.1 @@ -65,34 +67,33 @@ docstring-parser==0.15 # via magicgui docutils==0.20.1 # via sphinx -entrypoints==0.4 - # via numcodecs -executing==1.2.0 +executing==2.0.1 # via stack-data fasteners==0.19 # via zarr -filelock==3.12.4 +filelock==3.13.1 # via # torch # triton # virtualenv -fonttools==4.42.1 +fonttools==4.45.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2023.9.2 +fsspec==2023.10.0 # via # dask # napari (napari_repo/setup.cfg) + # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.87.0 +hypothesis==6.90.0 # via napari (napari_repo/setup.cfg) idna==3.4 # via requests -imageio==2.31.4 +imageio==2.33.0 # via # napari (napari_repo/setup.cfg) # napari-svg @@ -101,37 +102,35 @@ imagesize==1.4.1 # via sphinx importlib-metadata==6.8.0 # via dask -in-n-out==0.1.8 +in-n-out==0.1.9 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.25.2 +ipykernel==6.27.0 # via # napari-console # qtconsole -ipython==8.15.0 +ipython==8.17.2 # via # ipykernel # napari (napari_repo/setup.cfg) # napari-console -ipython-genutils==0.2.0 - # via qtconsole -jedi==0.19.0 +jedi==0.19.1 # via ipython jinja2==3.1.2 # via # numpydoc # sphinx # torch -jsonschema==4.19.1 +jsonschema==4.20.0 # via napari (napari_repo/setup.cfg) -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.1 # via jsonschema -jupyter-client==8.3.1 +jupyter-client==8.6.0 # via # ipykernel # qtconsole -jupyter-core==5.3.2 +jupyter-core==5.5.0 # via # ipykernel # jupyter-client @@ -144,19 +143,19 @@ lazy-loader==0.3 # via # napari (napari_repo/setup.cfg) # scikit-image -lit==17.0.1 - # via triton +llvmlite==0.41.1 + # via numba locket==1.0.0 # via partd lxml==4.9.3 # via napari (napari_repo/setup.cfg) -magicgui==0.7.3 +magicgui==0.8.0 # via napari (napari_repo/setup.cfg) markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 # via jinja2 -matplotlib==3.8.0 +matplotlib==3.8.2 # via napari (napari_repo/setup.cfg) matplotlib-inline==0.1.6 # via @@ -164,16 +163,17 @@ matplotlib-inline==0.1.6 # ipython mdurl==0.1.2 # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore mpmath==1.3.0 # via sympy mypy-extensions==1.0.0 # via psygnal -napari-console==0.0.8 +napari-console==0.0.9 # via napari (napari_repo/setup.cfg) napari-plugin-engine==0.2.0 # via # napari (napari_repo/setup.cfg) - # napari-console # napari-svg napari-plugin-manager==0.1.0a2 # via napari (napari_repo/setup.cfg) @@ -181,62 +181,72 @@ napari-svg==0.1.10 # via napari (napari_repo/setup.cfg) nest-asyncio==1.5.8 # via ipykernel -networkx==3.1 +networkx==3.2.1 # via # scikit-image # torch -npe2==0.7.2 +npe2==0.7.3 # via # napari (napari_repo/setup.cfg) # napari-plugin-manager -numcodecs==0.11.0 +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 # via zarr -numpy==1.26.0 +numpy==1.26.2 # via # contourpy # dask # imageio # matplotlib + # ml-dtypes # napari (napari_repo/setup.cfg) # napari-svg + # numba # numcodecs # pandas - # pywavelets # scikit-image # scipy # tensorstore # tifffile + # triangle # vispy # xarray # zarr numpydoc==1.6.0 # via napari (napari_repo/setup.cfg) -nvidia-cublas-cu11==11.10.3.66 +nvidia-cublas-cu12==12.1.3.1 # via - # nvidia-cudnn-cu11 - # nvidia-cusolver-cu11 + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 # torch -nvidia-cuda-cupti-cu11==11.7.101 - # via torch -nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-cupti-cu12==12.1.105 # via torch -nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cuda-nvrtc-cu12==12.1.105 # via torch -nvidia-cudnn-cu11==8.5.0.96 +nvidia-cuda-runtime-cu12==12.1.105 # via torch -nvidia-cufft-cu11==10.9.0.58 +nvidia-cudnn-cu12==8.9.2.26 # via torch -nvidia-curand-cu11==10.2.10.91 +nvidia-cufft-cu12==11.0.2.54 # via torch -nvidia-cusolver-cu11==11.4.0.1 +nvidia-curand-cu12==10.3.2.106 # via torch -nvidia-cusparse-cu11==11.7.4.91 +nvidia-cusolver-cu12==11.4.5.107 # via torch -nvidia-nccl-cu11==2.14.3 +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 # via torch -nvidia-nvtx-cu11==11.7.91 +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.1 +packaging==23.2 # via # build # dask @@ -251,7 +261,7 @@ packaging==23.1 # superqt # vispy # xarray -pandas==2.1.1 ; python_version >= "3.9" +pandas==2.1.3 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # xarray @@ -261,9 +271,7 @@ partd==1.4.1 # via dask pexpect==4.8.0 # via ipython -pickleshare==0.7.5 - # via ipython -pillow==10.0.1 +pillow==10.1.0 # via # imageio # matplotlib @@ -271,24 +279,26 @@ pillow==10.0.1 # scikit-image pint==0.22 # via napari (napari_repo/setup.cfg) -platformdirs==3.10.0 +platformdirs==4.0.0 # via # jupyter-core # pooch # virtualenv pluggy==1.3.0 # via pytest -pooch==1.7.0 +pooch==1.8.0 # via # napari (napari_repo/setup.cfg) # scikit-image -prompt-toolkit==3.0.39 +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 # via ipython -psutil==5.9.5 +psutil==5.9.6 # via # ipykernel # napari (napari_repo/setup.cfg) -psygnal==0.9.4 +psygnal==0.9.5 # via # app-model # magicgui @@ -298,12 +308,16 @@ ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -pydantic==1.10.13 +pyconify==0.1.6 + # via superqt +pydantic==2.5.2 # via # app-model # napari (napari_repo/setup.cfg) # npe2 -pygments==2.16.1 +pydantic-core==2.14.5 + # via pydantic +pygments==2.17.2 # via # ipython # napari (napari_repo/setup.cfg) @@ -319,17 +333,17 @@ pyparsing==3.1.1 # via matplotlib pyproject-hooks==1.0.0 # via build -pyqt5==5.15.9 +pyqt5==5.15.10 # via napari (napari_repo/setup.cfg) pyqt5-qt5==5.15.2 # via pyqt5 -pyqt5-sip==12.12.2 +pyqt5-sip==12.13.0 # via pyqt5 -pyqt6==6.5.2 +pyqt6==6.6.0 # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.5.2 +pyqt6-qt6==6.6.0 # via pyqt6 -pyqt6-sip==13.5.2 +pyqt6-sip==13.6.0 # via pyqt6 pyside2==5.13.2 ; python_version != "3.8" # via napari (napari_repo/setup.cfg) @@ -343,7 +357,7 @@ pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==7.4.2 +pytest==7.4.3 # via # napari (napari_repo/setup.cfg) # pytest-cov @@ -352,9 +366,7 @@ pytest==7.4.2 # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.0.0 @@ -370,8 +382,6 @@ python-dateutil==2.8.2 # pandas pytz==2023.3.post1 # via pandas -pywavelets==1.4.1 - # via scikit-image pyyaml==6.0.1 # via # dask @@ -382,11 +392,11 @@ pyzmq==25.1.1 # ipykernel # jupyter-client # qtconsole -qtconsole==5.4.4 +qtconsole==5.5.1 # via # napari (napari_repo/setup.cfg) # napari-console -qtpy==2.4.0 +qtpy==2.4.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -394,26 +404,29 @@ qtpy==2.4.0 # napari-plugin-manager # qtconsole # superqt -referencing==0.30.2 +referencing==0.31.0 # via # jsonschema # jsonschema-specifications requests==2.31.0 # via # pooch + # pyconify # sphinx -rich==13.5.3 +rich==13.7.0 # via # napari (napari_repo/setup.cfg) # npe2 # pytest-pretty -rpds-py==0.10.3 +rpds-py==0.13.1 # via # jsonschema # referencing -scikit-image==0.21.0 - # via napari (napari_repo/setup.cfg) -scipy==1.11.3 ; python_version >= "3.9" +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scipy==1.11.4 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # scikit-image @@ -452,9 +465,9 @@ sphinxcontrib-qthelp==1.0.6 # via sphinx sphinxcontrib-serializinghtml==1.1.9 # via sphinx -stack-data==0.6.2 +stack-data==0.6.3 # via ipython -superqt==0.6.0 +superqt==0.6.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -463,7 +476,7 @@ sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.44 +tensorstore==0.1.50 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/setup.cfg) @@ -478,17 +491,15 @@ toolz==0.12.0 # dask # napari (napari_repo/setup.cfg) # partd -torch==2.0.1 - # via - # napari (napari_repo/setup.cfg) - # triton +torch==2.1.1 + # via napari (napari_repo/setup.cfg) tornado==6.3.3 # via # ipykernel # jupyter-client tqdm==4.66.1 # via napari (napari_repo/setup.cfg) -traitlets==5.10.1 +traitlets==5.13.0 # via # comm # ipykernel @@ -497,7 +508,9 @@ traitlets==5.10.1 # jupyter-core # matplotlib-inline # qtconsole -triton==2.0.0 +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 # via torch typer==0.9.0 # via npe2 @@ -509,32 +522,25 @@ typing-extensions==4.8.0 # pint # psygnal # pydantic + # pydantic-core # superqt # torch # typer tzdata==2023.3 # via pandas -urllib3==2.0.5 +urllib3==2.1.0 # via requests -virtualenv==20.24.5 +virtualenv==20.24.7 # via napari (napari_repo/setup.cfg) -vispy==0.13.0 +vispy==0.14.1 # via # napari (napari_repo/setup.cfg) # napari-svg -wcwidth==0.2.6 +wcwidth==0.2.12 # via prompt-toolkit -wheel==0.41.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 -wrapt==1.15.0 +wrapt==1.16.0 # via napari (napari_repo/setup.cfg) -xarray==2023.9.0 +xarray==2023.11.0 # via napari (napari_repo/setup.cfg) zarr==2.16.1 # via napari (napari_repo/setup.cfg) @@ -542,13 +548,5 @@ zipp==3.17.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3.1 # via napari-plugin-manager -setuptools==68.2.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 diff --git a/resources/constraints/constraints_py3.11_pydantic_1.txt b/resources/constraints/constraints_py3.11_pydantic_1.txt new file mode 100644 index 00000000000..c4dbd319990 --- /dev/null +++ b/resources/constraints/constraints_py3.11_pydantic_1.txt @@ -0,0 +1,548 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.11_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# +alabaster==0.7.13 + # via sphinx +app-model==0.2.2 + # via napari (napari_repo/setup.cfg) +appdirs==1.4.4 + # via + # napari (napari_repo/setup.cfg) + # npe2 +asciitree==0.3.3 + # via zarr +asttokens==2.4.1 + # via stack-data +attrs==23.1.0 + # via + # hypothesis + # jsonschema + # referencing +babel==2.13.1 + # via + # napari (napari_repo/setup.cfg) + # sphinx +build==1.0.3 + # via npe2 +cachey==0.2.1 + # via napari (napari_repo/setup.cfg) +certifi==2023.11.17 + # via + # napari (napari_repo/setup.cfg) + # requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # dask + # typer +cloudpickle==3.0.0 + # via dask +comm==0.2.0 + # via ipykernel +contourpy==1.2.0 + # via matplotlib +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 + # via matplotlib +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) +debugpy==1.8.0 + # via ipykernel +decorator==5.1.1 + # via ipython +distlib==0.3.7 + # via virtualenv +docstring-parser==0.15 + # via magicgui +docutils==0.20.1 + # via sphinx +executing==2.0.1 + # via stack-data +fasteners==0.19 + # via zarr +filelock==3.13.1 + # via + # torch + # triton + # virtualenv +fonttools==4.45.0 + # via matplotlib +freetype-py==2.4.0 + # via vispy +fsspec==2023.10.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # torch +heapdict==1.0.1 + # via cachey +hsluv==5.0.4 + # via vispy +hypothesis==6.90.0 + # via napari (napari_repo/setup.cfg) +idna==3.4 + # via requests +imageio==2.33.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg + # scikit-image +imagesize==1.4.1 + # via sphinx +importlib-metadata==6.8.0 + # via dask +in-n-out==0.1.9 + # via app-model +iniconfig==2.0.0 + # via pytest +ipykernel==6.27.0 + # via + # napari-console + # qtconsole +ipython==8.17.2 + # via + # ipykernel + # napari (napari_repo/setup.cfg) + # napari-console +jedi==0.19.1 + # via ipython +jinja2==3.1.2 + # via + # numpydoc + # sphinx + # torch +jsonschema==4.20.0 + # via napari (napari_repo/setup.cfg) +jsonschema-specifications==2023.11.1 + # via jsonschema +jupyter-client==8.6.0 + # via + # ipykernel + # qtconsole +jupyter-core==5.5.0 + # via + # ipykernel + # jupyter-client + # qtconsole +kiwisolver==1.4.5 + # via + # matplotlib + # vispy +lazy-loader==0.3 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +llvmlite==0.41.1 + # via numba +locket==1.0.0 + # via partd +lxml==4.9.3 + # via napari (napari_repo/setup.cfg) +magicgui==0.8.0 + # via napari (napari_repo/setup.cfg) +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.3 + # via jinja2 +matplotlib==3.8.2 + # via napari (napari_repo/setup.cfg) +matplotlib-inline==0.1.6 + # via + # ipykernel + # ipython +mdurl==0.1.2 + # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore +mpmath==1.3.0 + # via sympy +mypy-extensions==1.0.0 + # via psygnal +napari-console==0.0.9 + # via napari (napari_repo/setup.cfg) +napari-plugin-engine==0.2.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +napari-plugin-manager==0.1.0a2 + # via napari (napari_repo/setup.cfg) +napari-svg==0.1.10 + # via napari (napari_repo/setup.cfg) +nest-asyncio==1.5.8 + # via ipykernel +networkx==3.2.1 + # via + # scikit-image + # torch +npe2==0.7.3 + # via + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 + # via zarr +numpy==1.26.2 + # via + # contourpy + # dask + # imageio + # matplotlib + # ml-dtypes + # napari (napari_repo/setup.cfg) + # napari-svg + # numba + # numcodecs + # pandas + # scikit-image + # scipy + # tensorstore + # tifffile + # triangle + # vispy + # xarray + # zarr +numpydoc==1.6.0 + # via napari (napari_repo/setup.cfg) +nvidia-cublas-cu12==12.1.3.1 + # via + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 + # torch +nvidia-cuda-cupti-cu12==12.1.105 + # via torch +nvidia-cuda-nvrtc-cu12==12.1.105 + # via torch +nvidia-cuda-runtime-cu12==12.1.105 + # via torch +nvidia-cudnn-cu12==8.9.2.26 + # via torch +nvidia-cufft-cu12==11.0.2.54 + # via torch +nvidia-curand-cu12==10.3.2.106 + # via torch +nvidia-cusolver-cu12==11.4.5.107 + # via torch +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 + # via torch +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 + # via torch +packaging==23.2 + # via + # build + # dask + # ipykernel + # matplotlib + # pooch + # pytest + # qtconsole + # qtpy + # scikit-image + # sphinx + # superqt + # vispy + # xarray +pandas==2.1.3 ; python_version >= "3.9" + # via + # napari (napari_repo/setup.cfg) + # xarray +parso==0.8.3 + # via jedi +partd==1.4.1 + # via dask +pexpect==4.8.0 + # via ipython +pillow==10.1.0 + # via + # imageio + # matplotlib + # napari (napari_repo/setup.cfg) + # scikit-image +pint==0.22 + # via napari (napari_repo/setup.cfg) +platformdirs==4.0.0 + # via + # jupyter-core + # pooch + # virtualenv +pluggy==1.3.0 + # via pytest +pooch==1.8.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 + # via ipython +psutil==5.9.6 + # via + # ipykernel + # napari (napari_repo/setup.cfg) +psygnal==0.9.5 + # via + # app-model + # magicgui + # napari (napari_repo/setup.cfg) + # npe2 +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pyconify==0.1.6 + # via superqt +pydantic==1.10.13 + # via + # -r napari_repo/resources/constraints/pydantic_le_2.txt + # app-model + # napari (napari_repo/setup.cfg) + # npe2 +pygments==2.17.2 + # via + # ipython + # napari (napari_repo/setup.cfg) + # qtconsole + # rich + # sphinx + # superqt +pyopengl==3.1.6 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyparsing==3.1.1 + # via matplotlib +pyproject-hooks==1.0.0 + # via build +pyqt5==5.15.10 + # via napari (napari_repo/setup.cfg) +pyqt5-qt5==5.15.2 + # via pyqt5 +pyqt5-sip==12.13.0 + # via pyqt5 +pyqt6==6.6.0 + # via napari (napari_repo/setup.cfg) +pyqt6-qt6==6.6.0 + # via pyqt6 +pyqt6-sip==13.6.0 + # via pyqt6 +pyside2==5.13.2 ; python_version != "3.8" + # via napari (napari_repo/setup.cfg) +pyside6==6.4.2 ; python_version >= "3.10" + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyside6-addons==6.4.2 + # via pyside6 +pyside6-essentials==6.4.2 + # via + # pyside6 + # pyside6-addons +pytest==7.4.3 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov + # pytest-json-report + # pytest-metadata + # pytest-pretty + # pytest-qt +pytest-cov==4.1.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-json-report==1.5.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-metadata==3.0.0 + # via pytest-json-report +pytest-pretty==1.2.0 + # via napari (napari_repo/setup.cfg) +pytest-qt==4.2.0 + # via napari (napari_repo/setup.cfg) +python-dateutil==2.8.2 + # via + # jupyter-client + # matplotlib + # pandas +pytz==2023.3.post1 + # via pandas +pyyaml==6.0.1 + # via + # dask + # napari (napari_repo/setup.cfg) + # npe2 +pyzmq==25.1.1 + # via + # ipykernel + # jupyter-client + # qtconsole +qtconsole==5.5.1 + # via + # napari (napari_repo/setup.cfg) + # napari-console +qtpy==2.4.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-console + # napari-plugin-manager + # qtconsole + # superqt +referencing==0.31.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.31.0 + # via + # pooch + # pyconify + # sphinx +rich==13.7.0 + # via + # napari (napari_repo/setup.cfg) + # npe2 + # pytest-pretty +rpds-py==0.13.1 + # via + # jsonschema + # referencing +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scipy==1.11.4 ; python_version >= "3.9" + # via + # napari (napari_repo/setup.cfg) + # scikit-image +shiboken2==5.13.2 + # via pyside2 +shiboken6==6.4.2 + # via + # pyside6 + # pyside6-addons + # pyside6-essentials +six==1.16.0 + # via + # asttokens + # python-dateutil +snowballstemmer==2.2.0 + # via sphinx +sortedcontainers==2.4.0 + # via hypothesis +sphinx==7.2.6 + # via + # numpydoc + # sphinxcontrib-applehelp + # sphinxcontrib-devhelp + # sphinxcontrib-htmlhelp + # sphinxcontrib-qthelp + # sphinxcontrib-serializinghtml +sphinxcontrib-applehelp==1.0.7 + # via sphinx +sphinxcontrib-devhelp==1.0.5 + # via sphinx +sphinxcontrib-htmlhelp==2.0.4 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.6 + # via sphinx +sphinxcontrib-serializinghtml==1.1.9 + # via sphinx +stack-data==0.6.3 + # via ipython +superqt==0.6.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +sympy==1.12 + # via torch +tabulate==0.9.0 + # via numpydoc +tensorstore==0.1.50 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +tifffile==2023.9.26 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +tomli-w==1.0.0 + # via npe2 +toolz==0.12.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # partd +torch==2.1.1 + # via napari (napari_repo/setup.cfg) +tornado==6.3.3 + # via + # ipykernel + # jupyter-client +tqdm==4.66.1 + # via napari (napari_repo/setup.cfg) +traitlets==5.13.0 + # via + # comm + # ipykernel + # ipython + # jupyter-client + # jupyter-core + # matplotlib-inline + # qtconsole +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 + # via torch +typer==0.9.0 + # via npe2 +typing-extensions==4.8.0 + # via + # app-model + # magicgui + # napari (napari_repo/setup.cfg) + # pint + # psygnal + # pydantic + # superqt + # torch + # typer +tzdata==2023.3 + # via pandas +urllib3==2.1.0 + # via requests +virtualenv==20.24.7 + # via napari (napari_repo/setup.cfg) +vispy==0.14.1 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +wcwidth==0.2.12 + # via prompt-toolkit +wrapt==1.16.0 + # via napari (napari_repo/setup.cfg) +xarray==2023.11.0 + # via napari (napari_repo/setup.cfg) +zarr==2.16.1 + # via napari (napari_repo/setup.cfg) +zipp==3.17.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +pip==23.3.1 + # via napari-plugin-manager diff --git a/resources/constraints/constraints_py3.8.txt b/resources/constraints/constraints_py3.8.txt index 9527369eeda..c9445c0a612 100644 --- a/resources/constraints/constraints_py3.8.txt +++ b/resources/constraints/constraints_py3.8.txt @@ -2,10 +2,12 @@ # This file is autogenerated by pip-compile with Python 3.8 # by the following command: # -# pip-compile --allow-unsafe --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --output-file=napari_repo/resources/constraints/constraints_py3.8.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.8.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg # alabaster==0.7.13 # via sphinx +annotated-types==0.6.0 + # via pydantic app-model==0.2.2 # via napari (napari_repo/setup.cfg) appdirs==1.4.4 @@ -14,14 +16,14 @@ appdirs==1.4.4 # npe2 asciitree==0.3.3 # via zarr -asttokens==2.4.0 +asttokens==2.4.1 # via stack-data attrs==23.1.0 # via # hypothesis # jsonschema # referencing -babel==2.12.1 +babel==2.13.1 # via # napari (napari_repo/setup.cfg) # sphinx @@ -31,30 +33,32 @@ build==1.0.3 # via npe2 cachey==0.2.1 # via napari (napari_repo/setup.cfg) -certifi==2023.7.22 +certifi==2023.11.17 # via # napari (napari_repo/setup.cfg) # requests -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests click==8.1.7 # via # dask # typer -cloudpickle==2.2.1 +cloudpickle==3.0.0 # via dask -cmake==3.27.5 - # via triton -comm==0.1.4 +comm==0.2.0 # via ipykernel contourpy==1.1.1 # via matplotlib -coverage==7.3.1 - # via pytest-cov -cycler==0.11.0 +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 # via matplotlib dask==2023.5.0 - # via napari (napari_repo/setup.cfg) + # via + # dask + # napari (napari_repo/setup.cfg) debugpy==1.8.0 # via ipykernel decorator==5.1.1 @@ -65,38 +69,37 @@ docstring-parser==0.15 # via magicgui docutils==0.20.1 # via sphinx -entrypoints==0.4 - # via numcodecs -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via # hypothesis # pytest -executing==1.2.0 +executing==2.0.1 # via stack-data fasteners==0.19 # via zarr -filelock==3.12.4 +filelock==3.13.1 # via # torch # triton # virtualenv -fonttools==4.42.1 +fonttools==4.45.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2023.9.2 +fsspec==2023.10.0 # via # dask # napari (napari_repo/setup.cfg) + # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.87.0 +hypothesis==6.90.0 # via napari (napari_repo/setup.cfg) idna==3.4 # via requests -imageio==2.31.4 +imageio==2.33.0 # via # napari (napari_repo/setup.cfg) # napari-svg @@ -108,43 +111,42 @@ importlib-metadata==6.8.0 # build # dask # jupyter-client + # numba # sphinx -importlib-resources==6.1.0 +importlib-resources==6.1.1 # via # jsonschema # jsonschema-specifications # matplotlib -in-n-out==0.1.8 +in-n-out==0.1.9 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.25.2 +ipykernel==6.27.0 # via # napari-console # qtconsole -ipython==8.12.2 +ipython==8.12.3 # via # ipykernel # napari (napari_repo/setup.cfg) # napari-console -ipython-genutils==0.2.0 - # via qtconsole -jedi==0.19.0 +jedi==0.19.1 # via ipython jinja2==3.1.2 # via # numpydoc # sphinx # torch -jsonschema==4.19.1 +jsonschema==4.20.0 # via napari (napari_repo/setup.cfg) -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.1 # via jsonschema -jupyter-client==8.3.1 +jupyter-client==8.6.0 # via # ipykernel # qtconsole -jupyter-core==5.3.2 +jupyter-core==5.5.0 # via # ipykernel # jupyter-client @@ -157,19 +159,19 @@ lazy-loader==0.3 # via # napari (napari_repo/setup.cfg) # scikit-image -lit==17.0.1 - # via triton +llvmlite==0.41.1 + # via numba locket==1.0.0 # via partd lxml==4.9.3 # via napari (napari_repo/setup.cfg) -magicgui==0.7.3 +magicgui==0.8.0 # via napari (napari_repo/setup.cfg) markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 # via jinja2 -matplotlib==3.7.3 +matplotlib==3.7.4 # via napari (napari_repo/setup.cfg) matplotlib-inline==0.1.6 # via @@ -181,12 +183,11 @@ mpmath==1.3.0 # via sympy mypy-extensions==1.0.0 # via psygnal -napari-console==0.0.8 +napari-console==0.0.9 # via napari (napari_repo/setup.cfg) napari-plugin-engine==0.2.0 # via # napari (napari_repo/setup.cfg) - # napari-console # napari-svg napari-plugin-manager==0.1.0a2 # via napari (napari_repo/setup.cfg) @@ -198,11 +199,13 @@ networkx==3.1 # via # scikit-image # torch -npe2==0.7.2 +npe2==0.7.3 # via # napari (napari_repo/setup.cfg) # napari-plugin-manager -numcodecs==0.11.0 +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 # via zarr numpy==1.24.4 # via @@ -212,6 +215,7 @@ numpy==1.24.4 # matplotlib # napari (napari_repo/setup.cfg) # napari-svg + # numba # numcodecs # pandas # pywavelets @@ -219,37 +223,44 @@ numpy==1.24.4 # scipy # tensorstore # tifffile + # triangle # vispy # xarray # zarr numpydoc==1.6.0 # via napari (napari_repo/setup.cfg) -nvidia-cublas-cu11==11.10.3.66 +nvidia-cublas-cu12==12.1.3.1 # via - # nvidia-cudnn-cu11 - # nvidia-cusolver-cu11 + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 # torch -nvidia-cuda-cupti-cu11==11.7.101 - # via torch -nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-cupti-cu12==12.1.105 # via torch -nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cuda-nvrtc-cu12==12.1.105 # via torch -nvidia-cudnn-cu11==8.5.0.96 +nvidia-cuda-runtime-cu12==12.1.105 # via torch -nvidia-cufft-cu11==10.9.0.58 +nvidia-cudnn-cu12==8.9.2.26 # via torch -nvidia-curand-cu11==10.2.10.91 +nvidia-cufft-cu12==11.0.2.54 # via torch -nvidia-cusolver-cu11==11.4.0.1 +nvidia-curand-cu12==10.3.2.106 # via torch -nvidia-cusparse-cu11==11.7.4.91 +nvidia-cusolver-cu12==11.4.5.107 # via torch -nvidia-nccl-cu11==2.14.3 +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 # via torch -nvidia-nvtx-cu11==11.7.91 +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.1 +packaging==23.2 # via # build # dask @@ -276,7 +287,7 @@ pexpect==4.8.0 # via ipython pickleshare==0.7.5 # via ipython -pillow==10.0.1 +pillow==10.1.0 # via # imageio # matplotlib @@ -286,24 +297,26 @@ pint==0.21.1 # via napari (napari_repo/setup.cfg) pkgutil-resolve-name==1.3.10 # via jsonschema -platformdirs==3.10.0 +platformdirs==4.0.0 # via # jupyter-core # pooch # virtualenv pluggy==1.3.0 # via pytest -pooch==1.7.0 +pooch==1.8.0 # via # napari (napari_repo/setup.cfg) # scikit-image -prompt-toolkit==3.0.39 +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 # via ipython -psutil==5.9.5 +psutil==5.9.6 # via # ipykernel # napari (napari_repo/setup.cfg) -psygnal==0.9.4 +psygnal==0.9.5 # via # app-model # magicgui @@ -313,12 +326,16 @@ ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -pydantic==1.10.13 +pyconify==0.1.6 + # via superqt +pydantic==2.5.2 # via # app-model # napari (napari_repo/setup.cfg) # npe2 -pygments==2.16.1 +pydantic-core==2.14.5 + # via pydantic +pygments==2.17.2 # via # ipython # napari (napari_repo/setup.cfg) @@ -334,17 +351,17 @@ pyparsing==3.1.1 # via matplotlib pyproject-hooks==1.0.0 # via build -pyqt5==5.15.9 +pyqt5==5.15.10 # via napari (napari_repo/setup.cfg) pyqt5-qt5==5.15.2 # via pyqt5 -pyqt5-sip==12.12.2 +pyqt5-sip==12.13.0 # via pyqt5 -pyqt6==6.5.2 +pyqt6==6.6.0 # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.5.2 +pyqt6-qt6==6.6.0 # via pyqt6 -pyqt6-sip==13.5.2 +pyqt6-sip==13.6.0 # via pyqt6 pyside2==5.15.2.1 ; python_version == "3.8" # via napari (napari_repo/setup.cfg) @@ -358,7 +375,7 @@ pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons -pytest==7.4.2 +pytest==7.4.3 # via # napari (napari_repo/setup.cfg) # pytest-cov @@ -367,9 +384,7 @@ pytest==7.4.2 # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.0.0 @@ -399,11 +414,11 @@ pyzmq==25.1.1 # ipykernel # jupyter-client # qtconsole -qtconsole==5.4.4 +qtconsole==5.5.1 # via # napari (napari_repo/setup.cfg) # napari-console -qtpy==2.4.0 +qtpy==2.4.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -411,25 +426,28 @@ qtpy==2.4.0 # napari-plugin-manager # qtconsole # superqt -referencing==0.30.2 +referencing==0.31.0 # via # jsonschema # jsonschema-specifications requests==2.31.0 # via # pooch + # pyconify # sphinx -rich==13.5.3 +rich==13.7.0 # via # napari (napari_repo/setup.cfg) # npe2 # pytest-pretty -rpds-py==0.10.3 +rpds-py==0.13.1 # via # jsonschema # referencing scikit-image==0.21.0 - # via napari (napari_repo/setup.cfg) + # via + # napari (napari_repo/setup.cfg) + # scikit-image scipy==1.10.1 ; python_version < "3.9" # via # napari (napari_repo/setup.cfg) @@ -463,9 +481,9 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -stack-data==0.6.2 +stack-data==0.6.3 # via ipython -superqt==0.6.0 +superqt==0.6.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -474,7 +492,7 @@ sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.44 +tensorstore==0.1.45 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/setup.cfg) @@ -497,17 +515,15 @@ toolz==0.12.0 # dask # napari (napari_repo/setup.cfg) # partd -torch==2.0.1 - # via - # napari (napari_repo/setup.cfg) - # triton +torch==2.1.1 + # via napari (napari_repo/setup.cfg) tornado==6.3.3 # via # ipykernel # jupyter-client tqdm==4.66.1 # via napari (napari_repo/setup.cfg) -traitlets==5.10.1 +traitlets==5.13.0 # via # comm # ipykernel @@ -516,43 +532,39 @@ traitlets==5.10.1 # jupyter-core # matplotlib-inline # qtconsole -triton==2.0.0 +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 # via torch typer==0.9.0 # via npe2 typing-extensions==4.8.0 # via + # annotated-types # app-model # ipython # magicgui # napari (napari_repo/setup.cfg) # psygnal # pydantic + # pydantic-core # rich # superqt # torch # typer tzdata==2023.3 # via pandas -urllib3==2.0.5 +urllib3==2.1.0 # via requests -virtualenv==20.24.5 +virtualenv==20.24.7 # via napari (napari_repo/setup.cfg) -vispy==0.13.0 +vispy==0.14.1 # via # napari (napari_repo/setup.cfg) # napari-svg -wcwidth==0.2.6 +wcwidth==0.2.12 # via prompt-toolkit -wheel==0.41.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 -wrapt==1.15.0 +wrapt==1.16.0 # via napari (napari_repo/setup.cfg) xarray==2023.1.0 # via napari (napari_repo/setup.cfg) @@ -564,13 +576,5 @@ zipp==3.17.0 # importlib-resources # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3.1 # via napari-plugin-manager -setuptools==68.2.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 diff --git a/resources/constraints/constraints_py3.8_pydantic_1.txt b/resources/constraints/constraints_py3.8_pydantic_1.txt new file mode 100644 index 00000000000..51c18da6d0c --- /dev/null +++ b/resources/constraints/constraints_py3.8_pydantic_1.txt @@ -0,0 +1,575 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.8_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# +alabaster==0.7.13 + # via sphinx +app-model==0.2.2 + # via napari (napari_repo/setup.cfg) +appdirs==1.4.4 + # via + # napari (napari_repo/setup.cfg) + # npe2 +asciitree==0.3.3 + # via zarr +asttokens==2.4.1 + # via stack-data +attrs==23.1.0 + # via + # hypothesis + # jsonschema + # referencing +babel==2.13.1 + # via + # napari (napari_repo/setup.cfg) + # sphinx +backcall==0.2.0 + # via ipython +build==1.0.3 + # via npe2 +cachey==0.2.1 + # via napari (napari_repo/setup.cfg) +certifi==2023.11.17 + # via + # napari (napari_repo/setup.cfg) + # requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # dask + # typer +cloudpickle==3.0.0 + # via dask +comm==0.2.0 + # via ipykernel +contourpy==1.1.1 + # via matplotlib +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 + # via matplotlib +dask==2023.5.0 + # via + # dask + # napari (napari_repo/setup.cfg) +debugpy==1.8.0 + # via ipykernel +decorator==5.1.1 + # via ipython +distlib==0.3.7 + # via virtualenv +docstring-parser==0.15 + # via magicgui +docutils==0.20.1 + # via sphinx +exceptiongroup==1.2.0 + # via + # hypothesis + # pytest +executing==2.0.1 + # via stack-data +fasteners==0.19 + # via zarr +filelock==3.13.1 + # via + # torch + # triton + # virtualenv +fonttools==4.45.0 + # via matplotlib +freetype-py==2.4.0 + # via vispy +fsspec==2023.10.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # torch +heapdict==1.0.1 + # via cachey +hsluv==5.0.4 + # via vispy +hypothesis==6.90.0 + # via napari (napari_repo/setup.cfg) +idna==3.4 + # via requests +imageio==2.33.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg + # scikit-image +imagesize==1.4.1 + # via sphinx +importlib-metadata==6.8.0 + # via + # build + # dask + # jupyter-client + # numba + # sphinx +importlib-resources==6.1.1 + # via + # jsonschema + # jsonschema-specifications + # matplotlib +in-n-out==0.1.9 + # via app-model +iniconfig==2.0.0 + # via pytest +ipykernel==6.27.0 + # via + # napari-console + # qtconsole +ipython==8.12.3 + # via + # ipykernel + # napari (napari_repo/setup.cfg) + # napari-console +jedi==0.19.1 + # via ipython +jinja2==3.1.2 + # via + # numpydoc + # sphinx + # torch +jsonschema==4.20.0 + # via napari (napari_repo/setup.cfg) +jsonschema-specifications==2023.11.1 + # via jsonschema +jupyter-client==8.6.0 + # via + # ipykernel + # qtconsole +jupyter-core==5.5.0 + # via + # ipykernel + # jupyter-client + # qtconsole +kiwisolver==1.4.5 + # via + # matplotlib + # vispy +lazy-loader==0.3 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +llvmlite==0.41.1 + # via numba +locket==1.0.0 + # via partd +lxml==4.9.3 + # via napari (napari_repo/setup.cfg) +magicgui==0.8.0 + # via napari (napari_repo/setup.cfg) +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.3 + # via jinja2 +matplotlib==3.7.4 + # via napari (napari_repo/setup.cfg) +matplotlib-inline==0.1.6 + # via + # ipykernel + # ipython +mdurl==0.1.2 + # via markdown-it-py +mpmath==1.3.0 + # via sympy +mypy-extensions==1.0.0 + # via psygnal +napari-console==0.0.9 + # via napari (napari_repo/setup.cfg) +napari-plugin-engine==0.2.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +napari-plugin-manager==0.1.0a2 + # via napari (napari_repo/setup.cfg) +napari-svg==0.1.10 + # via napari (napari_repo/setup.cfg) +nest-asyncio==1.5.8 + # via ipykernel +networkx==3.1 + # via + # scikit-image + # torch +npe2==0.7.3 + # via + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 + # via zarr +numpy==1.24.4 + # via + # contourpy + # dask + # imageio + # matplotlib + # napari (napari_repo/setup.cfg) + # napari-svg + # numba + # numcodecs + # pandas + # pywavelets + # scikit-image + # scipy + # tensorstore + # tifffile + # triangle + # vispy + # xarray + # zarr +numpydoc==1.6.0 + # via napari (napari_repo/setup.cfg) +nvidia-cublas-cu12==12.1.3.1 + # via + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 + # torch +nvidia-cuda-cupti-cu12==12.1.105 + # via torch +nvidia-cuda-nvrtc-cu12==12.1.105 + # via torch +nvidia-cuda-runtime-cu12==12.1.105 + # via torch +nvidia-cudnn-cu12==8.9.2.26 + # via torch +nvidia-cufft-cu12==11.0.2.54 + # via torch +nvidia-curand-cu12==10.3.2.106 + # via torch +nvidia-cusolver-cu12==11.4.5.107 + # via torch +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 + # via torch +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 + # via torch +packaging==23.2 + # via + # build + # dask + # ipykernel + # matplotlib + # pooch + # pytest + # qtconsole + # qtpy + # scikit-image + # sphinx + # superqt + # vispy + # xarray +pandas==2.0.3 ; python_version < "3.9" + # via + # napari (napari_repo/setup.cfg) + # xarray +parso==0.8.3 + # via jedi +partd==1.4.1 + # via dask +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython +pillow==10.1.0 + # via + # imageio + # matplotlib + # napari (napari_repo/setup.cfg) + # scikit-image +pint==0.21.1 + # via napari (napari_repo/setup.cfg) +pkgutil-resolve-name==1.3.10 + # via jsonschema +platformdirs==4.0.0 + # via + # jupyter-core + # pooch + # virtualenv +pluggy==1.3.0 + # via pytest +pooch==1.8.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 + # via ipython +psutil==5.9.6 + # via + # ipykernel + # napari (napari_repo/setup.cfg) +psygnal==0.9.5 + # via + # app-model + # magicgui + # napari (napari_repo/setup.cfg) + # npe2 +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pyconify==0.1.6 + # via superqt +pydantic==1.10.13 + # via + # -r napari_repo/resources/constraints/pydantic_le_2.txt + # app-model + # napari (napari_repo/setup.cfg) + # npe2 +pygments==2.17.2 + # via + # ipython + # napari (napari_repo/setup.cfg) + # qtconsole + # rich + # sphinx + # superqt +pyopengl==3.1.6 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyparsing==3.1.1 + # via matplotlib +pyproject-hooks==1.0.0 + # via build +pyqt5==5.15.10 + # via napari (napari_repo/setup.cfg) +pyqt5-qt5==5.15.2 + # via pyqt5 +pyqt5-sip==12.13.0 + # via pyqt5 +pyqt6==6.6.0 + # via napari (napari_repo/setup.cfg) +pyqt6-qt6==6.6.0 + # via pyqt6 +pyqt6-sip==13.6.0 + # via pyqt6 +pyside2==5.15.2.1 ; python_version == "3.8" + # via napari (napari_repo/setup.cfg) +pyside6==6.3.1 ; python_version < "3.10" + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyside6-addons==6.3.1 + # via pyside6 +pyside6-essentials==6.3.1 + # via + # pyside6 + # pyside6-addons +pytest==7.4.3 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov + # pytest-json-report + # pytest-metadata + # pytest-pretty + # pytest-qt +pytest-cov==4.1.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-json-report==1.5.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-metadata==3.0.0 + # via pytest-json-report +pytest-pretty==1.2.0 + # via napari (napari_repo/setup.cfg) +pytest-qt==4.2.0 + # via napari (napari_repo/setup.cfg) +python-dateutil==2.8.2 + # via + # jupyter-client + # matplotlib + # pandas +pytz==2023.3.post1 + # via + # babel + # pandas +pywavelets==1.4.1 + # via scikit-image +pyyaml==6.0.1 + # via + # dask + # napari (napari_repo/setup.cfg) + # npe2 +pyzmq==25.1.1 + # via + # ipykernel + # jupyter-client + # qtconsole +qtconsole==5.5.1 + # via + # napari (napari_repo/setup.cfg) + # napari-console +qtpy==2.4.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-console + # napari-plugin-manager + # qtconsole + # superqt +referencing==0.31.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.31.0 + # via + # pooch + # pyconify + # sphinx +rich==13.7.0 + # via + # napari (napari_repo/setup.cfg) + # npe2 + # pytest-pretty +rpds-py==0.13.1 + # via + # jsonschema + # referencing +scikit-image==0.21.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scipy==1.10.1 ; python_version < "3.9" + # via + # napari (napari_repo/setup.cfg) + # scikit-image +shiboken2==5.15.2.1 + # via pyside2 +shiboken6==6.3.1 + # via + # pyside6 + # pyside6-addons + # pyside6-essentials +six==1.16.0 + # via + # asttokens + # python-dateutil +snowballstemmer==2.2.0 + # via sphinx +sortedcontainers==2.4.0 + # via hypothesis +sphinx==7.1.2 + # via numpydoc +sphinxcontrib-applehelp==1.0.4 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.1 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +stack-data==0.6.3 + # via ipython +superqt==0.6.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +sympy==1.12 + # via torch +tabulate==0.9.0 + # via numpydoc +tensorstore==0.1.45 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +tifffile==2023.7.10 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +tomli==2.0.1 + # via + # build + # coverage + # npe2 + # numpydoc + # pyproject-hooks + # pytest +tomli-w==1.0.0 + # via npe2 +toolz==0.12.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # partd +torch==2.1.1 + # via napari (napari_repo/setup.cfg) +tornado==6.3.3 + # via + # ipykernel + # jupyter-client +tqdm==4.66.1 + # via napari (napari_repo/setup.cfg) +traitlets==5.13.0 + # via + # comm + # ipykernel + # ipython + # jupyter-client + # jupyter-core + # matplotlib-inline + # qtconsole +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 + # via torch +typer==0.9.0 + # via npe2 +typing-extensions==4.8.0 + # via + # app-model + # ipython + # magicgui + # napari (napari_repo/setup.cfg) + # psygnal + # pydantic + # rich + # superqt + # torch + # typer +tzdata==2023.3 + # via pandas +urllib3==2.1.0 + # via requests +virtualenv==20.24.7 + # via napari (napari_repo/setup.cfg) +vispy==0.14.1 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +wcwidth==0.2.12 + # via prompt-toolkit +wrapt==1.16.0 + # via napari (napari_repo/setup.cfg) +xarray==2023.1.0 + # via napari (napari_repo/setup.cfg) +zarr==2.16.1 + # via napari (napari_repo/setup.cfg) +zipp==3.17.0 + # via + # importlib-metadata + # importlib-resources + +# The following packages are considered to be unsafe in a requirements file: +pip==23.3.1 + # via napari-plugin-manager diff --git a/resources/constraints/constraints_py3.9.txt b/resources/constraints/constraints_py3.9.txt index e406ca18d73..2ebe0943034 100644 --- a/resources/constraints/constraints_py3.9.txt +++ b/resources/constraints/constraints_py3.9.txt @@ -2,10 +2,12 @@ # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # -# pip-compile --allow-unsafe --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --output-file=napari_repo/resources/constraints/constraints_py3.9.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg # alabaster==0.7.13 # via sphinx +annotated-types==0.6.0 + # via pydantic app-model==0.2.2 # via napari (napari_repo/setup.cfg) appdirs==1.4.4 @@ -14,47 +16,47 @@ appdirs==1.4.4 # npe2 asciitree==0.3.3 # via zarr -asttokens==2.4.0 +asttokens==2.4.1 # via stack-data attrs==23.1.0 # via # hypothesis # jsonschema # referencing -babel==2.12.1 +babel==2.13.1 # via # napari (napari_repo/setup.cfg) # sphinx -backcall==0.2.0 - # via ipython build==1.0.3 # via npe2 cachey==0.2.1 # via napari (napari_repo/setup.cfg) -certifi==2023.7.22 +certifi==2023.11.17 # via # napari (napari_repo/setup.cfg) # requests -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests click==8.1.7 # via # dask # typer -cloudpickle==2.2.1 +cloudpickle==3.0.0 # via dask -cmake==3.27.5 - # via triton -comm==0.1.4 +comm==0.2.0 # via ipykernel -contourpy==1.1.1 +contourpy==1.2.0 # via matplotlib -coverage==7.3.1 - # via pytest-cov -cycler==0.11.0 +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 # via matplotlib -dask==2023.9.2 - # via napari (napari_repo/setup.cfg) +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) debugpy==1.8.0 # via ipykernel decorator==5.1.1 @@ -65,39 +67,38 @@ docstring-parser==0.15 # via magicgui docutils==0.20.1 # via sphinx -entrypoints==0.4 - # via numcodecs -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via # hypothesis # ipython # pytest -executing==1.2.0 +executing==2.0.1 # via stack-data fasteners==0.19 # via zarr -filelock==3.12.4 +filelock==3.13.1 # via # torch # triton # virtualenv -fonttools==4.42.1 +fonttools==4.45.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2023.9.2 +fsspec==2023.10.0 # via # dask # napari (napari_repo/setup.cfg) + # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.87.0 +hypothesis==6.90.0 # via napari (napari_repo/setup.cfg) idna==3.4 # via requests -imageio==2.31.4 +imageio==2.33.0 # via # napari (napari_repo/setup.cfg) # napari-svg @@ -110,39 +111,37 @@ importlib-metadata==6.8.0 # dask # jupyter-client # sphinx -importlib-resources==6.1.0 +importlib-resources==6.1.1 # via matplotlib -in-n-out==0.1.8 +in-n-out==0.1.9 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.25.2 +ipykernel==6.27.0 # via # napari-console # qtconsole -ipython==8.15.0 +ipython==8.17.2 # via # ipykernel # napari (napari_repo/setup.cfg) # napari-console -ipython-genutils==0.2.0 - # via qtconsole -jedi==0.19.0 +jedi==0.19.1 # via ipython jinja2==3.1.2 # via # numpydoc # sphinx # torch -jsonschema==4.19.1 +jsonschema==4.20.0 # via napari (napari_repo/setup.cfg) -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.1 # via jsonschema -jupyter-client==8.3.1 +jupyter-client==8.6.0 # via # ipykernel # qtconsole -jupyter-core==5.3.2 +jupyter-core==5.5.0 # via # ipykernel # jupyter-client @@ -155,19 +154,19 @@ lazy-loader==0.3 # via # napari (napari_repo/setup.cfg) # scikit-image -lit==17.0.1 - # via triton +llvmlite==0.41.1 + # via numba locket==1.0.0 # via partd lxml==4.9.3 # via napari (napari_repo/setup.cfg) -magicgui==0.7.3 +magicgui==0.8.0 # via napari (napari_repo/setup.cfg) markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 # via jinja2 -matplotlib==3.8.0 +matplotlib==3.8.2 # via napari (napari_repo/setup.cfg) matplotlib-inline==0.1.6 # via @@ -175,16 +174,17 @@ matplotlib-inline==0.1.6 # ipython mdurl==0.1.2 # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore mpmath==1.3.0 # via sympy mypy-extensions==1.0.0 # via psygnal -napari-console==0.0.8 +napari-console==0.0.9 # via napari (napari_repo/setup.cfg) napari-plugin-engine==0.2.0 # via # napari (napari_repo/setup.cfg) - # napari-console # napari-svg napari-plugin-manager==0.1.0a2 # via napari (napari_repo/setup.cfg) @@ -192,62 +192,72 @@ napari-svg==0.1.10 # via napari (napari_repo/setup.cfg) nest-asyncio==1.5.8 # via ipykernel -networkx==3.1 +networkx==3.2.1 # via # scikit-image # torch -npe2==0.7.2 +npe2==0.7.3 # via # napari (napari_repo/setup.cfg) # napari-plugin-manager -numcodecs==0.11.0 +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 # via zarr -numpy==1.26.0 +numpy==1.26.2 # via # contourpy # dask # imageio # matplotlib + # ml-dtypes # napari (napari_repo/setup.cfg) # napari-svg + # numba # numcodecs # pandas - # pywavelets # scikit-image # scipy # tensorstore # tifffile + # triangle # vispy # xarray # zarr numpydoc==1.6.0 # via napari (napari_repo/setup.cfg) -nvidia-cublas-cu11==11.10.3.66 +nvidia-cublas-cu12==12.1.3.1 # via - # nvidia-cudnn-cu11 - # nvidia-cusolver-cu11 + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 # torch -nvidia-cuda-cupti-cu11==11.7.101 - # via torch -nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-cupti-cu12==12.1.105 # via torch -nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cuda-nvrtc-cu12==12.1.105 # via torch -nvidia-cudnn-cu11==8.5.0.96 +nvidia-cuda-runtime-cu12==12.1.105 # via torch -nvidia-cufft-cu11==10.9.0.58 +nvidia-cudnn-cu12==8.9.2.26 # via torch -nvidia-curand-cu11==10.2.10.91 +nvidia-cufft-cu12==11.0.2.54 # via torch -nvidia-cusolver-cu11==11.4.0.1 +nvidia-curand-cu12==10.3.2.106 # via torch -nvidia-cusparse-cu11==11.7.4.91 +nvidia-cusolver-cu12==11.4.5.107 # via torch -nvidia-nccl-cu11==2.14.3 +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 # via torch -nvidia-nvtx-cu11==11.7.91 +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.1 +packaging==23.2 # via # build # dask @@ -262,7 +272,7 @@ packaging==23.1 # superqt # vispy # xarray -pandas==2.1.1 ; python_version >= "3.9" +pandas==2.1.3 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # xarray @@ -272,9 +282,7 @@ partd==1.4.1 # via dask pexpect==4.8.0 # via ipython -pickleshare==0.7.5 - # via ipython -pillow==10.0.1 +pillow==10.1.0 # via # imageio # matplotlib @@ -282,24 +290,26 @@ pillow==10.0.1 # scikit-image pint==0.22 # via napari (napari_repo/setup.cfg) -platformdirs==3.10.0 +platformdirs==4.0.0 # via # jupyter-core # pooch # virtualenv pluggy==1.3.0 # via pytest -pooch==1.7.0 +pooch==1.8.0 # via # napari (napari_repo/setup.cfg) # scikit-image -prompt-toolkit==3.0.39 +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 # via ipython -psutil==5.9.5 +psutil==5.9.6 # via # ipykernel # napari (napari_repo/setup.cfg) -psygnal==0.9.4 +psygnal==0.9.5 # via # app-model # magicgui @@ -309,12 +319,16 @@ ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -pydantic==1.10.13 +pyconify==0.1.6 + # via superqt +pydantic==2.5.2 # via # app-model # napari (napari_repo/setup.cfg) # npe2 -pygments==2.16.1 +pydantic-core==2.14.5 + # via pydantic +pygments==2.17.2 # via # ipython # napari (napari_repo/setup.cfg) @@ -330,17 +344,17 @@ pyparsing==3.1.1 # via matplotlib pyproject-hooks==1.0.0 # via build -pyqt5==5.15.9 +pyqt5==5.15.10 # via napari (napari_repo/setup.cfg) pyqt5-qt5==5.15.2 # via pyqt5 -pyqt5-sip==12.12.2 +pyqt5-sip==12.13.0 # via pyqt5 -pyqt6==6.5.2 +pyqt6==6.6.0 # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.5.2 +pyqt6-qt6==6.6.0 # via pyqt6 -pyqt6-sip==13.5.2 +pyqt6-sip==13.6.0 # via pyqt6 pyside2==5.15.2.1 ; python_version != "3.8" # via napari (napari_repo/setup.cfg) @@ -354,7 +368,7 @@ pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons -pytest==7.4.2 +pytest==7.4.3 # via # napari (napari_repo/setup.cfg) # pytest-cov @@ -363,9 +377,7 @@ pytest==7.4.2 # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.0.0 @@ -381,8 +393,6 @@ python-dateutil==2.8.2 # pandas pytz==2023.3.post1 # via pandas -pywavelets==1.4.1 - # via scikit-image pyyaml==6.0.1 # via # dask @@ -393,11 +403,11 @@ pyzmq==25.1.1 # ipykernel # jupyter-client # qtconsole -qtconsole==5.4.4 +qtconsole==5.5.1 # via # napari (napari_repo/setup.cfg) # napari-console -qtpy==2.4.0 +qtpy==2.4.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -405,26 +415,29 @@ qtpy==2.4.0 # napari-plugin-manager # qtconsole # superqt -referencing==0.30.2 +referencing==0.31.0 # via # jsonschema # jsonschema-specifications requests==2.31.0 # via # pooch + # pyconify # sphinx -rich==13.5.3 +rich==13.7.0 # via # napari (napari_repo/setup.cfg) # npe2 # pytest-pretty -rpds-py==0.10.3 +rpds-py==0.13.1 # via # jsonschema # referencing -scikit-image==0.21.0 - # via napari (napari_repo/setup.cfg) -scipy==1.11.3 ; python_version >= "3.9" +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scipy==1.11.4 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # scikit-image @@ -463,9 +476,9 @@ sphinxcontrib-qthelp==1.0.6 # via sphinx sphinxcontrib-serializinghtml==1.1.9 # via sphinx -stack-data==0.6.2 +stack-data==0.6.3 # via ipython -superqt==0.6.0 +superqt==0.6.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -474,7 +487,7 @@ sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.44 +tensorstore==0.1.50 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/setup.cfg) @@ -497,17 +510,15 @@ toolz==0.12.0 # dask # napari (napari_repo/setup.cfg) # partd -torch==2.0.1 - # via - # napari (napari_repo/setup.cfg) - # triton +torch==2.1.1 + # via napari (napari_repo/setup.cfg) tornado==6.3.3 # via # ipykernel # jupyter-client tqdm==4.66.1 # via napari (napari_repo/setup.cfg) -traitlets==5.10.1 +traitlets==5.13.0 # via # comm # ipykernel @@ -516,7 +527,9 @@ traitlets==5.10.1 # jupyter-core # matplotlib-inline # qtconsole -triton==2.0.0 +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 # via torch typer==0.9.0 # via npe2 @@ -529,32 +542,25 @@ typing-extensions==4.8.0 # pint # psygnal # pydantic + # pydantic-core # superqt # torch # typer tzdata==2023.3 # via pandas -urllib3==2.0.5 +urllib3==2.1.0 # via requests -virtualenv==20.24.5 +virtualenv==20.24.7 # via napari (napari_repo/setup.cfg) -vispy==0.13.0 +vispy==0.14.1 # via # napari (napari_repo/setup.cfg) # napari-svg -wcwidth==0.2.6 +wcwidth==0.2.12 # via prompt-toolkit -wheel==0.41.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 -wrapt==1.15.0 +wrapt==1.16.0 # via napari (napari_repo/setup.cfg) -xarray==2023.9.0 +xarray==2023.11.0 # via napari (napari_repo/setup.cfg) zarr==2.16.1 # via napari (napari_repo/setup.cfg) @@ -564,13 +570,5 @@ zipp==3.17.0 # importlib-resources # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3.1 # via napari-plugin-manager -setuptools==68.2.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 diff --git a/resources/constraints/constraints_py3.9_examples.txt b/resources/constraints/constraints_py3.9_examples.txt index e5a8de7e359..95df203e81b 100644 --- a/resources/constraints/constraints_py3.9_examples.txt +++ b/resources/constraints/constraints_py3.9_examples.txt @@ -2,10 +2,12 @@ # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # -# pip-compile --allow-unsafe --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --output-file=napari_repo/resources/constraints/constraints_py3.9_examples.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg resources/constraints/version_denylist_examples.txt +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9_examples.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg resources/constraints/version_denylist_examples.txt # alabaster==0.7.13 # via sphinx +annotated-types==0.6.0 + # via pydantic app-model==0.2.2 # via napari (napari_repo/setup.cfg) appdirs==1.4.4 @@ -14,47 +16,47 @@ appdirs==1.4.4 # npe2 asciitree==0.3.3 # via zarr -asttokens==2.4.0 +asttokens==2.4.1 # via stack-data attrs==23.1.0 # via # hypothesis # jsonschema # referencing -babel==2.12.1 +babel==2.13.1 # via # napari (napari_repo/setup.cfg) # sphinx -backcall==0.2.0 - # via ipython build==1.0.3 # via npe2 cachey==0.2.1 # via napari (napari_repo/setup.cfg) -certifi==2023.7.22 +certifi==2023.11.17 # via # napari (napari_repo/setup.cfg) # requests -charset-normalizer==3.2.0 +charset-normalizer==3.3.2 # via requests click==8.1.7 # via # dask # typer -cloudpickle==2.2.1 +cloudpickle==3.0.0 # via dask -cmake==3.27.5 - # via triton -comm==0.1.4 +comm==0.2.0 # via ipykernel -contourpy==1.1.1 +contourpy==1.2.0 # via matplotlib -coverage==7.3.1 - # via pytest-cov -cycler==0.11.0 +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 # via matplotlib -dask==2023.9.2 - # via napari (napari_repo/setup.cfg) +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) debugpy==1.8.0 # via ipykernel decorator==5.1.1 @@ -65,39 +67,38 @@ docstring-parser==0.15 # via magicgui docutils==0.20.1 # via sphinx -entrypoints==0.4 - # via numcodecs -exceptiongroup==1.1.3 +exceptiongroup==1.2.0 # via # hypothesis # ipython # pytest -executing==1.2.0 +executing==2.0.1 # via stack-data fasteners==0.19 # via zarr -filelock==3.12.4 +filelock==3.13.1 # via # torch # triton # virtualenv -fonttools==4.42.1 +fonttools==4.45.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2023.9.2 +fsspec==2023.10.0 # via # dask # napari (napari_repo/setup.cfg) + # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.87.0 +hypothesis==6.90.0 # via napari (napari_repo/setup.cfg) idna==3.4 # via requests -imageio==2.31.4 +imageio==2.33.0 # via # napari (napari_repo/setup.cfg) # napari-svg @@ -110,24 +111,22 @@ importlib-metadata==6.8.0 # dask # jupyter-client # sphinx -importlib-resources==6.1.0 +importlib-resources==6.1.1 # via matplotlib -in-n-out==0.1.8 +in-n-out==0.1.9 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.25.2 +ipykernel==6.27.0 # via # napari-console # qtconsole -ipython==8.15.0 +ipython==8.17.2 # via # ipykernel # napari (napari_repo/setup.cfg) # napari-console -ipython-genutils==0.2.0 - # via qtconsole -jedi==0.19.0 +jedi==0.19.1 # via ipython jinja2==3.1.2 # via @@ -138,15 +137,15 @@ joblib==1.3.2 # via # nilearn # scikit-learn -jsonschema==4.19.1 +jsonschema==4.20.0 # via napari (napari_repo/setup.cfg) -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2023.11.1 # via jsonschema -jupyter-client==8.3.1 +jupyter-client==8.6.0 # via # ipykernel # qtconsole -jupyter-core==5.3.2 +jupyter-core==5.5.0 # via # ipykernel # jupyter-client @@ -159,21 +158,21 @@ lazy-loader==0.3 # via # napari (napari_repo/setup.cfg) # scikit-image -lit==17.0.1 - # via triton +llvmlite==0.41.1 + # via numba locket==1.0.0 # via partd lxml==4.9.3 # via # napari (napari_repo/setup.cfg) # nilearn -magicgui==0.7.3 +magicgui==0.8.0 # via napari (napari_repo/setup.cfg) markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 # via jinja2 -matplotlib==3.8.0 +matplotlib==3.8.2 # via napari (napari_repo/setup.cfg) matplotlib-inline==0.1.6 # via @@ -181,16 +180,17 @@ matplotlib-inline==0.1.6 # ipython mdurl==0.1.2 # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore mpmath==1.3.0 # via sympy mypy-extensions==1.0.0 # via psygnal -napari-console==0.0.8 +napari-console==0.0.9 # via napari (napari_repo/setup.cfg) napari-plugin-engine==0.2.0 # via # napari (napari_repo/setup.cfg) - # napari-console # napari-svg napari-plugin-manager==0.1.0a2 # via napari (napari_repo/setup.cfg) @@ -198,19 +198,21 @@ napari-svg==0.1.10 # via napari (napari_repo/setup.cfg) nest-asyncio==1.5.8 # via ipykernel -networkx==3.1 +networkx==3.2.1 # via # scikit-image # torch nibabel==5.1.0 # via nilearn -nilearn==0.10.1 +nilearn==0.10.2 # via -r resources/constraints/version_denylist_examples.txt -npe2==0.7.2 +npe2==0.7.3 # via # napari (napari_repo/setup.cfg) # napari-plugin-manager -numcodecs==0.11.0 +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 # via zarr numpy==1.23.5 # via @@ -219,49 +221,57 @@ numpy==1.23.5 # dask # imageio # matplotlib + # ml-dtypes # napari (napari_repo/setup.cfg) # napari-svg # nibabel # nilearn + # numba # numcodecs # pandas - # pywavelets # scikit-image # scikit-learn # scipy # tensorstore # tifffile + # triangle # vispy # xarray # zarr numpydoc==1.6.0 # via napari (napari_repo/setup.cfg) -nvidia-cublas-cu11==11.10.3.66 +nvidia-cublas-cu12==12.1.3.1 # via - # nvidia-cudnn-cu11 - # nvidia-cusolver-cu11 + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 # torch -nvidia-cuda-cupti-cu11==11.7.101 - # via torch -nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-cupti-cu12==12.1.105 # via torch -nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cuda-nvrtc-cu12==12.1.105 # via torch -nvidia-cudnn-cu11==8.5.0.96 +nvidia-cuda-runtime-cu12==12.1.105 # via torch -nvidia-cufft-cu11==10.9.0.58 +nvidia-cudnn-cu12==8.9.2.26 # via torch -nvidia-curand-cu11==10.2.10.91 +nvidia-cufft-cu12==11.0.2.54 # via torch -nvidia-cusolver-cu11==11.4.0.1 +nvidia-curand-cu12==10.3.2.106 # via torch -nvidia-cusparse-cu11==11.7.4.91 +nvidia-cusolver-cu12==11.4.5.107 # via torch -nvidia-nccl-cu11==2.14.3 +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 # via torch -nvidia-nvtx-cu11==11.7.91 +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.1 +packaging==23.2 # via # -r resources/constraints/version_denylist_examples.txt # build @@ -279,7 +289,7 @@ packaging==23.1 # superqt # vispy # xarray -pandas==2.1.1 ; python_version >= "3.9" +pandas==2.1.3 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # nilearn @@ -290,9 +300,7 @@ partd==1.4.1 # via dask pexpect==4.8.0 # via ipython -pickleshare==0.7.5 - # via ipython -pillow==10.0.1 +pillow==10.1.0 # via # imageio # matplotlib @@ -300,24 +308,26 @@ pillow==10.0.1 # scikit-image pint==0.22 # via napari (napari_repo/setup.cfg) -platformdirs==3.10.0 +platformdirs==4.0.0 # via # jupyter-core # pooch # virtualenv pluggy==1.3.0 # via pytest -pooch==1.7.0 +pooch==1.8.0 # via # napari (napari_repo/setup.cfg) # scikit-image -prompt-toolkit==3.0.39 +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 # via ipython -psutil==5.9.5 +psutil==5.9.6 # via # ipykernel # napari (napari_repo/setup.cfg) -psygnal==0.9.4 +psygnal==0.9.5 # via # app-model # magicgui @@ -327,12 +337,16 @@ ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data -pydantic==1.10.13 +pyconify==0.1.6 + # via superqt +pydantic==2.5.2 # via # app-model # napari (napari_repo/setup.cfg) # npe2 -pygments==2.16.1 +pydantic-core==2.14.5 + # via pydantic +pygments==2.17.2 # via # ipython # napari (napari_repo/setup.cfg) @@ -348,17 +362,17 @@ pyparsing==3.1.1 # via matplotlib pyproject-hooks==1.0.0 # via build -pyqt5==5.15.9 +pyqt5==5.15.10 # via napari (napari_repo/setup.cfg) pyqt5-qt5==5.15.2 # via pyqt5 -pyqt5-sip==12.12.2 +pyqt5-sip==12.13.0 # via pyqt5 -pyqt6==6.5.2 +pyqt6==6.6.0 # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.5.2 +pyqt6-qt6==6.6.0 # via pyqt6 -pyqt6-sip==13.5.2 +pyqt6-sip==13.6.0 # via pyqt6 pyside2==5.15.2.1 ; python_version != "3.8" # via napari (napari_repo/setup.cfg) @@ -372,7 +386,7 @@ pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons -pytest==7.4.2 +pytest==7.4.3 # via # napari (napari_repo/setup.cfg) # pytest-cov @@ -381,9 +395,7 @@ pytest==7.4.2 # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-metadata==3.0.0 @@ -399,8 +411,6 @@ python-dateutil==2.8.2 # pandas pytz==2023.3.post1 # via pandas -pywavelets==1.4.1 - # via scikit-image pyyaml==6.0.1 # via # dask @@ -411,11 +421,11 @@ pyzmq==25.1.1 # ipykernel # jupyter-client # qtconsole -qtconsole==5.4.4 +qtconsole==5.5.1 # via # napari (napari_repo/setup.cfg) # napari-console -qtpy==2.4.0 +qtpy==2.4.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -423,7 +433,7 @@ qtpy==2.4.0 # napari-plugin-manager # qtconsole # superqt -referencing==0.30.2 +referencing==0.31.0 # via # jsonschema # jsonschema-specifications @@ -431,21 +441,24 @@ requests==2.31.0 # via # nilearn # pooch + # pyconify # sphinx -rich==13.5.3 +rich==13.7.0 # via # napari (napari_repo/setup.cfg) # npe2 # pytest-pretty -rpds-py==0.10.3 +rpds-py==0.13.1 # via # jsonschema # referencing -scikit-image==0.21.0 - # via napari (napari_repo/setup.cfg) -scikit-learn==1.3.1 +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scikit-learn==1.3.2 # via nilearn -scipy==1.11.3 ; python_version >= "3.9" +scipy==1.11.4 ; python_version >= "3.9" # via # napari (napari_repo/setup.cfg) # nilearn @@ -486,9 +499,9 @@ sphinxcontrib-qthelp==1.0.6 # via sphinx sphinxcontrib-serializinghtml==1.1.9 # via sphinx -stack-data==0.6.2 +stack-data==0.6.3 # via ipython -superqt==0.6.0 +superqt==0.6.1 # via # magicgui # napari (napari_repo/setup.cfg) @@ -497,7 +510,7 @@ sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.44 +tensorstore==0.1.50 # via # -r napari_repo/resources/constraints/version_denylist.txt # napari (napari_repo/setup.cfg) @@ -522,17 +535,15 @@ toolz==0.12.0 # dask # napari (napari_repo/setup.cfg) # partd -torch==2.0.1 - # via - # napari (napari_repo/setup.cfg) - # triton +torch==2.1.1 + # via napari (napari_repo/setup.cfg) tornado==6.3.3 # via # ipykernel # jupyter-client tqdm==4.66.1 # via napari (napari_repo/setup.cfg) -traitlets==5.10.1 +traitlets==5.13.0 # via # comm # ipykernel @@ -541,7 +552,9 @@ traitlets==5.10.1 # jupyter-core # matplotlib-inline # qtconsole -triton==2.0.0 +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 # via torch typer==0.9.0 # via npe2 @@ -554,32 +567,25 @@ typing-extensions==4.8.0 # pint # psygnal # pydantic + # pydantic-core # superqt # torch # typer tzdata==2023.3 # via pandas -urllib3==2.0.5 +urllib3==2.1.0 # via requests -virtualenv==20.24.5 +virtualenv==20.24.7 # via napari (napari_repo/setup.cfg) -vispy==0.13.0 +vispy==0.14.1 # via # napari (napari_repo/setup.cfg) # napari-svg -wcwidth==0.2.6 +wcwidth==0.2.12 # via prompt-toolkit -wheel==0.41.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 -wrapt==1.15.0 +wrapt==1.16.0 # via napari (napari_repo/setup.cfg) -xarray==2023.9.0 +xarray==2023.11.0 # via napari (napari_repo/setup.cfg) zarr==2.16.1 # via napari (napari_repo/setup.cfg) @@ -589,13 +595,5 @@ zipp==3.17.0 # importlib-resources # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3.1 # via napari-plugin-manager -setuptools==68.2.2 - # via - # nvidia-cublas-cu11 - # nvidia-cuda-cupti-cu11 - # nvidia-cuda-runtime-cu11 - # nvidia-curand-cu11 - # nvidia-cusparse-cu11 - # nvidia-nvtx-cu11 diff --git a/resources/constraints/constraints_py3.9_pydantic_1.txt b/resources/constraints/constraints_py3.9_pydantic_1.txt new file mode 100644 index 00000000000..ae2a824edac --- /dev/null +++ b/resources/constraints/constraints_py3.9_pydantic_1.txt @@ -0,0 +1,570 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# +alabaster==0.7.13 + # via sphinx +app-model==0.2.2 + # via napari (napari_repo/setup.cfg) +appdirs==1.4.4 + # via + # napari (napari_repo/setup.cfg) + # npe2 +asciitree==0.3.3 + # via zarr +asttokens==2.4.1 + # via stack-data +attrs==23.1.0 + # via + # hypothesis + # jsonschema + # referencing +babel==2.13.1 + # via + # napari (napari_repo/setup.cfg) + # sphinx +build==1.0.3 + # via npe2 +cachey==0.2.1 + # via napari (napari_repo/setup.cfg) +certifi==2023.11.17 + # via + # napari (napari_repo/setup.cfg) + # requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # dask + # typer +cloudpickle==3.0.0 + # via dask +comm==0.2.0 + # via ipykernel +contourpy==1.2.0 + # via matplotlib +coverage==7.3.2 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov +cycler==0.12.1 + # via matplotlib +dask==2023.11.0 + # via + # dask + # napari (napari_repo/setup.cfg) +debugpy==1.8.0 + # via ipykernel +decorator==5.1.1 + # via ipython +distlib==0.3.7 + # via virtualenv +docstring-parser==0.15 + # via magicgui +docutils==0.20.1 + # via sphinx +exceptiongroup==1.2.0 + # via + # hypothesis + # ipython + # pytest +executing==2.0.1 + # via stack-data +fasteners==0.19 + # via zarr +filelock==3.13.1 + # via + # torch + # triton + # virtualenv +fonttools==4.45.0 + # via matplotlib +freetype-py==2.4.0 + # via vispy +fsspec==2023.10.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # torch +heapdict==1.0.1 + # via cachey +hsluv==5.0.4 + # via vispy +hypothesis==6.90.0 + # via napari (napari_repo/setup.cfg) +idna==3.4 + # via requests +imageio==2.33.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg + # scikit-image +imagesize==1.4.1 + # via sphinx +importlib-metadata==6.8.0 + # via + # build + # dask + # jupyter-client + # sphinx +importlib-resources==6.1.1 + # via matplotlib +in-n-out==0.1.9 + # via app-model +iniconfig==2.0.0 + # via pytest +ipykernel==6.27.0 + # via + # napari-console + # qtconsole +ipython==8.17.2 + # via + # ipykernel + # napari (napari_repo/setup.cfg) + # napari-console +jedi==0.19.1 + # via ipython +jinja2==3.1.2 + # via + # numpydoc + # sphinx + # torch +jsonschema==4.20.0 + # via napari (napari_repo/setup.cfg) +jsonschema-specifications==2023.11.1 + # via jsonschema +jupyter-client==8.6.0 + # via + # ipykernel + # qtconsole +jupyter-core==5.5.0 + # via + # ipykernel + # jupyter-client + # qtconsole +kiwisolver==1.4.5 + # via + # matplotlib + # vispy +lazy-loader==0.3 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +llvmlite==0.41.1 + # via numba +locket==1.0.0 + # via partd +lxml==4.9.3 + # via napari (napari_repo/setup.cfg) +magicgui==0.8.0 + # via napari (napari_repo/setup.cfg) +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.3 + # via jinja2 +matplotlib==3.8.2 + # via napari (napari_repo/setup.cfg) +matplotlib-inline==0.1.6 + # via + # ipykernel + # ipython +mdurl==0.1.2 + # via markdown-it-py +ml-dtypes==0.3.1 + # via tensorstore +mpmath==1.3.0 + # via sympy +mypy-extensions==1.0.0 + # via psygnal +napari-console==0.0.9 + # via napari (napari_repo/setup.cfg) +napari-plugin-engine==0.2.0 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +napari-plugin-manager==0.1.0a2 + # via napari (napari_repo/setup.cfg) +napari-svg==0.1.10 + # via napari (napari_repo/setup.cfg) +nest-asyncio==1.5.8 + # via ipykernel +networkx==3.2.1 + # via + # scikit-image + # torch +npe2==0.7.3 + # via + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +numba==0.58.1 + # via napari (napari_repo/setup.cfg) +numcodecs==0.12.1 + # via zarr +numpy==1.26.2 + # via + # contourpy + # dask + # imageio + # matplotlib + # ml-dtypes + # napari (napari_repo/setup.cfg) + # napari-svg + # numba + # numcodecs + # pandas + # scikit-image + # scipy + # tensorstore + # tifffile + # triangle + # vispy + # xarray + # zarr +numpydoc==1.6.0 + # via napari (napari_repo/setup.cfg) +nvidia-cublas-cu12==12.1.3.1 + # via + # nvidia-cudnn-cu12 + # nvidia-cusolver-cu12 + # torch +nvidia-cuda-cupti-cu12==12.1.105 + # via torch +nvidia-cuda-nvrtc-cu12==12.1.105 + # via torch +nvidia-cuda-runtime-cu12==12.1.105 + # via torch +nvidia-cudnn-cu12==8.9.2.26 + # via torch +nvidia-cufft-cu12==11.0.2.54 + # via torch +nvidia-curand-cu12==10.3.2.106 + # via torch +nvidia-cusolver-cu12==11.4.5.107 + # via torch +nvidia-cusparse-cu12==12.1.0.106 + # via + # nvidia-cusolver-cu12 + # torch +nvidia-nccl-cu12==2.18.1 + # via torch +nvidia-nvjitlink-cu12==12.3.101 + # via + # nvidia-cusolver-cu12 + # nvidia-cusparse-cu12 +nvidia-nvtx-cu12==12.1.105 + # via torch +packaging==23.2 + # via + # build + # dask + # ipykernel + # matplotlib + # pooch + # pytest + # qtconsole + # qtpy + # scikit-image + # sphinx + # superqt + # vispy + # xarray +pandas==2.1.3 ; python_version >= "3.9" + # via + # napari (napari_repo/setup.cfg) + # xarray +parso==0.8.3 + # via jedi +partd==1.4.1 + # via dask +pexpect==4.8.0 + # via ipython +pillow==10.1.0 + # via + # imageio + # matplotlib + # napari (napari_repo/setup.cfg) + # scikit-image +pint==0.22 + # via napari (napari_repo/setup.cfg) +platformdirs==4.0.0 + # via + # jupyter-core + # pooch + # virtualenv +pluggy==1.3.0 + # via pytest +pooch==1.8.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +pretend==1.0.9 + # via napari (napari_repo/setup.cfg) +prompt-toolkit==3.0.41 + # via ipython +psutil==5.9.6 + # via + # ipykernel + # napari (napari_repo/setup.cfg) +psygnal==0.9.5 + # via + # app-model + # magicgui + # napari (napari_repo/setup.cfg) + # npe2 +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pyconify==0.1.6 + # via superqt +pydantic==1.10.13 + # via + # -r napari_repo/resources/constraints/pydantic_le_2.txt + # app-model + # napari (napari_repo/setup.cfg) + # npe2 +pygments==2.17.2 + # via + # ipython + # napari (napari_repo/setup.cfg) + # qtconsole + # rich + # sphinx + # superqt +pyopengl==3.1.6 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyparsing==3.1.1 + # via matplotlib +pyproject-hooks==1.0.0 + # via build +pyqt5==5.15.10 + # via napari (napari_repo/setup.cfg) +pyqt5-qt5==5.15.2 + # via pyqt5 +pyqt5-sip==12.13.0 + # via pyqt5 +pyqt6==6.6.0 + # via napari (napari_repo/setup.cfg) +pyqt6-qt6==6.6.0 + # via pyqt6 +pyqt6-sip==13.6.0 + # via pyqt6 +pyside2==5.15.2.1 ; python_version != "3.8" + # via napari (napari_repo/setup.cfg) +pyside6==6.3.1 ; python_version < "3.10" + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +pyside6-addons==6.3.1 + # via pyside6 +pyside6-essentials==6.3.1 + # via + # pyside6 + # pyside6-addons +pytest==7.4.3 + # via + # napari (napari_repo/setup.cfg) + # pytest-cov + # pytest-json-report + # pytest-metadata + # pytest-pretty + # pytest-qt +pytest-cov==4.1.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-json-report==1.5.0 + # via -r napari_repo/resources/constraints/version_denylist.txt +pytest-metadata==3.0.0 + # via pytest-json-report +pytest-pretty==1.2.0 + # via napari (napari_repo/setup.cfg) +pytest-qt==4.2.0 + # via napari (napari_repo/setup.cfg) +python-dateutil==2.8.2 + # via + # jupyter-client + # matplotlib + # pandas +pytz==2023.3.post1 + # via pandas +pyyaml==6.0.1 + # via + # dask + # napari (napari_repo/setup.cfg) + # npe2 +pyzmq==25.1.1 + # via + # ipykernel + # jupyter-client + # qtconsole +qtconsole==5.5.1 + # via + # napari (napari_repo/setup.cfg) + # napari-console +qtpy==2.4.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-console + # napari-plugin-manager + # qtconsole + # superqt +referencing==0.31.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.31.0 + # via + # pooch + # pyconify + # sphinx +rich==13.7.0 + # via + # napari (napari_repo/setup.cfg) + # npe2 + # pytest-pretty +rpds-py==0.13.1 + # via + # jsonschema + # referencing +scikit-image==0.22.0 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +scipy==1.11.4 ; python_version >= "3.9" + # via + # napari (napari_repo/setup.cfg) + # scikit-image +shiboken2==5.15.2.1 + # via pyside2 +shiboken6==6.3.1 + # via + # pyside6 + # pyside6-addons + # pyside6-essentials +six==1.16.0 + # via + # asttokens + # python-dateutil +snowballstemmer==2.2.0 + # via sphinx +sortedcontainers==2.4.0 + # via hypothesis +sphinx==7.2.6 + # via + # numpydoc + # sphinxcontrib-applehelp + # sphinxcontrib-devhelp + # sphinxcontrib-htmlhelp + # sphinxcontrib-qthelp + # sphinxcontrib-serializinghtml +sphinxcontrib-applehelp==1.0.7 + # via sphinx +sphinxcontrib-devhelp==1.0.5 + # via sphinx +sphinxcontrib-htmlhelp==2.0.4 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.6 + # via sphinx +sphinxcontrib-serializinghtml==1.1.9 + # via sphinx +stack-data==0.6.3 + # via ipython +superqt==0.6.1 + # via + # magicgui + # napari (napari_repo/setup.cfg) + # napari-plugin-manager +sympy==1.12 + # via torch +tabulate==0.9.0 + # via numpydoc +tensorstore==0.1.50 + # via + # -r napari_repo/resources/constraints/version_denylist.txt + # napari (napari_repo/setup.cfg) +tifffile==2023.9.26 + # via + # napari (napari_repo/setup.cfg) + # scikit-image +tomli==2.0.1 + # via + # build + # coverage + # npe2 + # numpydoc + # pyproject-hooks + # pytest +tomli-w==1.0.0 + # via npe2 +toolz==0.12.0 + # via + # dask + # napari (napari_repo/setup.cfg) + # partd +torch==2.1.1 + # via napari (napari_repo/setup.cfg) +tornado==6.3.3 + # via + # ipykernel + # jupyter-client +tqdm==4.66.1 + # via napari (napari_repo/setup.cfg) +traitlets==5.13.0 + # via + # comm + # ipykernel + # ipython + # jupyter-client + # jupyter-core + # matplotlib-inline + # qtconsole +triangle==20230923 + # via napari (napari_repo/setup.cfg) +triton==2.1.0 + # via torch +typer==0.9.0 + # via npe2 +typing-extensions==4.8.0 + # via + # app-model + # ipython + # magicgui + # napari (napari_repo/setup.cfg) + # pint + # psygnal + # pydantic + # superqt + # torch + # typer +tzdata==2023.3 + # via pandas +urllib3==2.1.0 + # via requests +virtualenv==20.24.7 + # via napari (napari_repo/setup.cfg) +vispy==0.14.1 + # via + # napari (napari_repo/setup.cfg) + # napari-svg +wcwidth==0.2.12 + # via prompt-toolkit +wrapt==1.16.0 + # via napari (napari_repo/setup.cfg) +xarray==2023.11.0 + # via napari (napari_repo/setup.cfg) +zarr==2.16.1 + # via napari (napari_repo/setup.cfg) +zipp==3.17.0 + # via + # importlib-metadata + # importlib-resources + +# The following packages are considered to be unsafe in a requirements file: +pip==23.3.1 + # via napari-plugin-manager diff --git a/resources/constraints/pydantic_le_2.txt b/resources/constraints/pydantic_le_2.txt new file mode 100644 index 00000000000..fd72c657717 --- /dev/null +++ b/resources/constraints/pydantic_le_2.txt @@ -0,0 +1 @@ +pydantic<2 diff --git a/resources/constraints/version_denylist.txt b/resources/constraints/version_denylist.txt index 049adef291c..5d97481d6a0 100644 --- a/resources/constraints/version_denylist.txt +++ b/resources/constraints/version_denylist.txt @@ -1,6 +1,6 @@ pytest-cov PySide6 < 6.3.2 ; python_version < '3.10' -PySide6 != 6.4.3, !=6.5.0, !=6.5.1, !=6.5.1.1, !=6.5.2; python_version >= '3.10' +PySide6 != 6.4.3, !=6.5.0, !=6.5.1, !=6.5.1.1, !=6.5.2, != 6.5.3, != 6.6.0; python_version >= '3.10' pytest-json-report pyopengl!=3.1.7 tensorstore!=0.1.38 diff --git a/resources/osx_pkg_welcome.rtf.tmpl b/resources/osx_pkg_welcome.rtf.tmpl index 078a165fbc7..6df9d618a57 100644 --- a/resources/osx_pkg_welcome.rtf.tmpl +++ b/resources/osx_pkg_welcome.rtf.tmpl @@ -12,4 +12,4 @@ The installation will begin shortly.\ \ If at any point an error is shown, please save the logs (\uc0\u8984+L) before closing the installer and submit the resulting file along with your report in {\field{\*\fldinst{HYPERLINK "https://github.com/napari/napari/issues"}}{\fldrslt our issue tracker}}. Thank you!\ -} \ No newline at end of file +} diff --git a/resources/requirements_mypy.in b/resources/requirements_mypy.in index c9175969cc9..6368ebd50f8 100644 --- a/resources/requirements_mypy.in +++ b/resources/requirements_mypy.in @@ -4,6 +4,7 @@ numpy npe2 pydantic qtpy +pyqt6 types-PyYAML types-setuptools types-requests diff --git a/resources/requirements_mypy.txt b/resources/requirements_mypy.txt index 4a13df89d4f..05edc3719b8 100644 --- a/resources/requirements_mypy.txt +++ b/resources/requirements_mypy.txt @@ -8,7 +8,7 @@ appdirs==1.4.4 # via npe2 build==0.10.0 # via npe2 -click==8.1.6 +click==8.1.7 # via typer docstring-parser==0.15 # via magicgui @@ -18,7 +18,7 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -mypy==1.5.0 +mypy==1.6.0 # via -r resources/requirements_mypy.in mypy-extensions==1.0.0 # via @@ -33,7 +33,7 @@ packaging==23.1 # build # qtpy # superqt -psygnal==0.9.2 +psygnal==0.9.3 # via # magicgui # npe2 @@ -47,6 +47,12 @@ pygments==2.16.1 # superqt pyproject-hooks==1.0.0 # via build +pyqt6==6.5.2 + # via -r resources/requirements_mypy.in +pyqt6-qt6==6.5.2 + # via pyqt6 +pyqt6-sip==13.5.2 + # via pyqt6 pyyaml==6.0.1 # via npe2 qtpy==2.3.1 @@ -56,7 +62,7 @@ qtpy==2.3.1 # superqt rich==13.5.2 # via npe2 -superqt==0.5.0 +superqt==0.5.2 # via magicgui tomli-w==1.0.0 # via npe2 @@ -66,7 +72,7 @@ types-pyyaml==6.0.12.11 # via -r resources/requirements_mypy.in types-requests==2.31.0.2 # via -r resources/requirements_mypy.in -types-setuptools==68.0.0.3 +types-setuptools==68.1.0.0 # via -r resources/requirements_mypy.in types-urllib3==1.26.25.14 # via types-requests diff --git a/setup.cfg b/setup.cfg index 5cbdf2bd62d..767462f9d46 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,15 +44,15 @@ python_requires = >=3.8 include_package_data = True install_requires = appdirs>=1.4.4 - app-model>=0.1.2,<0.3.0 # as per @czaki request. app-model v0.3.0 can drop napari v0.4.17 + app-model>=0.2.2,<0.3.0 # as per @czaki request. app-model v0.3.0 can drop napari v0.4.17 cachey>=0.2.1 certifi>=2018.1.18 dask[array]>=2.15.0,!=2.28.0 # https://github.com/napari/napari/issues/1656 imageio>=2.20,!=2.22.1 jsonschema>=3.2.0 lazy_loader>=0.2 - magicgui>=0.3.6 - napari-console>=0.0.6 + magicgui>=0.7.0 + napari-console>=0.0.9 napari-plugin-engine>=0.1.9 napari-svg>=0.1.8 npe2>=0.7.2 @@ -63,8 +63,8 @@ install_requires = Pillow!=7.1.0,!=7.1.1 # not a direct dependency, but 7.1.0 and 7.1.1 broke imageio pint>=0.17 psutil>=5.0 - psygnal>=0.3.4 - pydantic>=1.9.0,<2 + psygnal>=0.5.0 + pydantic>=1.9.0 pygments>=2.6.0 PyOpenGL>=3.1.0 PyYAML>=5.1 @@ -77,7 +77,7 @@ install_requires = toolz>=0.10.0 tqdm>=4.56.0 typing_extensions>=4.2.0 - vispy>=0.13.0,<0.14 + vispy>=0.14.1,<0.15 wrapt>=1.11.1 napari-graph>=0.2.0 @@ -111,27 +111,27 @@ qt = # alias for pyqt5 # all is the full "batteries included" extra. all = %(pyqt5)s + %(performance)s napari-plugin-manager >=0.1.0a1, <0.2.0 # optional (i.e. opt-in) packages, see https://github.com/napari/napari/pull/3867#discussion_r864354854 -optional = - triangle performance = triangle - numba + numba>=0.57.1 testing = babel>=2.9.0 fsspec hypothesis>=6.8.0 lxml - matplotlib + matplotlib >= 3.6.1 networkx>=2.7.0 + numba>=0.57.1 pooch>=1.6.0 - pytest-cov + coverage + pretend pytest-qt pytest-pretty>=1.1.0 pytest>=7.0.0 tensorstore>=0.1.13 - torch>=1.7 virtualenv xarray>=0.16.2 zarr>=2.12.0 @@ -139,7 +139,9 @@ testing = qtconsole>=4.5.1 rich>=12.0.0 napari-plugin-manager >=0.1.0a2, <0.2.0 -release = +testing_extra = + torch>=1.7 +release = PyGithub>=1.44.1 twine>=3.1.1 gitpython>=3.1.0 @@ -176,8 +178,28 @@ exclude_lines = [coverage:run] +parallel = true omit = */_vendor/* + */_version.py + */benchmarks/* +source = + napari + napari_builtins + +[coverage:paths] +source = + napari/ + D:\a\napari\napari\napari + /home/runner/work/napari/napari/napari + /Users/runner/work/napari/napari/napari +builtins = + napari_builtins/ + D:\a\napari\napari\napari_builtins + /home/runner/work/napari/napari/napari_builtins + /Users/runner/work/napari/napari/napari_builtins + + [importlinter] root_package = napari @@ -193,8 +215,6 @@ forbidden_modules = PyQt5 PySide2 ignore_imports = - napari._qt.qt_resources._icons -> PyQt5 - napari._qt.qt_resources._icons -> PySide2 napari._qt -> PySide2 diff --git a/tools/create_pr_or_update_existing_one.py b/tools/create_pr_or_update_existing_one.py index 3da7fd998b3..8b98f41cad8 100644 --- a/tools/create_pr_or_update_existing_one.py +++ b/tools/create_pr_or_update_existing_one.py @@ -196,6 +196,18 @@ def create_pr( response = requests.post(pull_request_url, headers=headers, json=payload) response.raise_for_status() logging.info("PR created: %s", response.json()["html_url"]) + add_label(repo, response.json()["number"], "maintenance", access_token) + + +def add_label(repo, pr_num, label, access_token): + pull_request_url = f"{BASE_URL}/repos/{repo}/issues/{pr_num}/labels" + headers = {"Authorization": f"token {access_token}"} + payload = {"labels": [label]} + + logging.info("Add labels: %s in %s", str(payload), pull_request_url) + response = requests.post(pull_request_url, headers=headers, json=payload) + response.raise_for_status() + logging.info("Labels added: %s", response.json()) def add_comment_to_pr( @@ -238,7 +250,7 @@ def update_pr(branch_name: str): comment_content = long_description(f"origin/{branch_name}") try: - push(new_branch_name, update=True) + push(new_branch_name, update=branch_name != DEFAULT_BRANCH_NAME) except subprocess.CalledProcessError as e: if "create or update workflow" in e.stderr.decode(): logging.info("Workflow file changed. Skip PR create.") diff --git a/tools/perfmon/README.md b/tools/perfmon/README.md index da47bf4ac8c..f1d0c23f7ab 100644 --- a/tools/perfmon/README.md +++ b/tools/perfmon/README.md @@ -60,4 +60,3 @@ was an improvement: ```shell python tools/perfmon/compare_callable.py slicing Layer.refresh latest test ``` - diff --git a/tools/split_qt_backend.py b/tools/split_qt_backend.py new file mode 100644 index 00000000000..11e26d1fce9 --- /dev/null +++ b/tools/split_qt_backend.py @@ -0,0 +1,10 @@ +import sys + +names = ["MAIN", "SECOND", "THIRD", "FOURTH"] +num = int(sys.argv[1]) +values = sys.argv[2].split(",") + +if num < len(values): + print(f"{names[num]}={values[num]}") +else: + print(f"{names[num]}=none") diff --git a/tools/test_strings.py b/tools/validate_strings.py similarity index 99% rename from tools/test_strings.py rename to tools/validate_strings.py index db7187cdae3..8a778aed455 100644 --- a/tools/test_strings.py +++ b/tools/validate_strings.py @@ -706,7 +706,7 @@ def _compute_autosugg(raw_code, text): print(f"{RED}e{NORMAL} : EDIT - using {edit_cmd!r}") else: print( - "- : Edit not available, call with python tools/test_strings.py '$COMMAND {filename} {linenumber} '" + "- : Edit not available, call with python tools/validate_strings.py '$COMMAND {filename} {linenumber} '" ) print(f"{RED}s{NORMAL} : save and quit") print('> ', end='') diff --git a/tox.ini b/tox.ini index 493481c7d0e..c34a35528d8 100644 --- a/tox.ini +++ b/tox.ini @@ -15,9 +15,9 @@ # "tox -e py38-macos-pyqt" will test python3.8 with pyqt on macos # (even if a combination of factors is not in the default envlist # you can run it manually... like py39-linux-pyside2) -envlist = py{38,39,310,311}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6,headless},mypy +envlist = py{38,39,310,311}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6,headless}-{cov,no_cov},mypy isolated_build = true -toxworkdir=/tmp/.tox +toxworkdir={env:TOX_WORK_DIR:/tmp/.tox} [gh-actions] python = @@ -44,6 +44,9 @@ BACKEND = pyside2: pyside2 pyside6: pyside6 headless: headless +COVERAGE = + cov: cov + no_cov: no_cov # Settings defined in the top-level testenv section are automatically # inherited by individual environments unless overridden. @@ -66,6 +69,9 @@ passenv = CONDA FORCE_COLOR QT_QPA_PLATFORM + NAPARI_TEST_SUBSET + COVERAGE_* + COVERAGE # Set various environment variables, depending on the factors in # the tox environment being run setenv = @@ -73,38 +79,46 @@ setenv = # Avoid pyside6 6.4.3 due to issue described in: # https://github.com/napari/napari/issues/5657 deps = - pytest-cov pyqt6: PyQt6 pyside6: PySide6 < 6.3.2 ; python_version < '3.10' pyside6: PySide6 != 6.4.3, !=6.5.0 ; python_version >= '3.10' pytest-json-report + pytest-pystack ; sys_platform == 'linux' # use extras specified in setup.cfg for certain test envs extras = testing + {env:TOX_EXTRAS} pyqt5: pyqt5 pyside2: pyside2 +allowlist_externals = + echo + mypy indexserver = # we use Spec 4 index server that contain nightly wheel. # this will be used only when using --pre with tox/pip as it only contains nightly. extra = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple +commands = + echo "COVERAGE: {env:COVERAGE:}" + cov: coverage run \ + !cov: python \ + -m pytest {env:PYTEST_PATH:} --color=yes --basetemp={envtmpdir} \ + --ignore tools --maxfail=5 --json-report \ + --json-report-file={toxinidir}/report-{envname}.json {posargs} + + + +[testenv:py{38,39,310,311}-{linux,macos,windows}-headless-{cov,no_cov}] commands_pre = - # strictly only need to uninstall pytest-qt (which will raise without a backend) - # the rest is for good measure - headless: pip uninstall -y pytest-qt qtpy pyqt5 pyside2 pyside6 pyqt6 + pip uninstall -y pytest-qt qtpy pyqt5 pyside2 pyside6 pyqt6 + commands = - !headless: python -m pytest {env:PYTEST_PATH:} --color=yes --basetemp={envtmpdir} \ - --cov-report=xml --cov={env:PYTEST_PATH:napari} --ignore tools --maxfail=5 \ - --json-report --json-report-file={toxinidir}/report-{envname}.json \ - {posargs} - - # do not add ignores to this line just to make headless tests pass. - # nothing outside of _qt or _vispy should require Qt or make_napari_viewer - headless: python -m pytest --color=yes --basetemp={envtmpdir} --ignore napari/_vispy \ + cov: coverage run \ + !cov: python \ + -m pytest --color=yes --basetemp={envtmpdir} --ignore napari/_vispy \ --ignore napari/_qt --ignore napari/_tests --ignore tools \ --json-report --json-report-file={toxinidir}/report-{envname}.json {posargs} - -[testenv:py{38,39,310,311}-{linux,macos,windows}-{pyqt5,pyside2}-examples] +[testenv:py{38,39,310,311}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}-examples-{cov,no_cov}] deps = # For surface_timeseries_.py example nilearn @@ -112,7 +126,9 @@ deps = numpy < 1.24 packaging commands = - python -m pytest napari/_tests/test_examples.py -v --color=yes --basetemp={envtmpdir} {posargs} + cov: coverage run \ + !cov: python \ + -m pytest napari/_tests/test_examples.py -v --color=yes --basetemp={envtmpdir} {posargs} [testenv:ruff] skip_install = True From 68ad3178e6e8bed846a5c02690fd5f3f363f69d9 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Wed, 20 Dec 2023 15:18:48 +1100 Subject: [PATCH 074/105] pass through projection mode --- napari/layers/points/points.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 526cbb06831..b769841fbe9 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2194,6 +2194,7 @@ def __init__( canvas_size_limits=(2, 10000), antialiasing=1, shown=True, + projection_mode='none', ) -> None: if ndim is None and scale is not None: ndim = len(scale) @@ -2241,6 +2242,7 @@ def __init__( canvas_size_limits=canvas_size_limits, antialiasing=antialiasing, shown=shown, + projection_mode=projection_mode, ) deprecated_events = {} From d055ceb769be97ef7c959d0ed33d02ba0bec09e8 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Wed, 20 Dec 2023 15:29:48 +1100 Subject: [PATCH 075/105] use indices instead of self.selected_data --- napari/layers/graph/graph.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 8b62327b600..97a8ad6d317 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -447,24 +447,24 @@ def _remove_nodes( value=self.data, action=ActionType.REMOVING, data_indices=tuple( - self.selected_data, + indices, ), vertex_indices=((),), ) - indices = np.atleast_1d(indices) - if indices.ndim > 1: + indices_1d = np.atleast_1d(indices) + if indices_1d.ndim > 1: raise ValueError( trans._( "Indices for removal must be 1-dim. Found {ndim}", - ndim=indices.ndim, + ndim=indices_1d.ndim, ) ) prev_size = self.data.n_allocated_nodes # it got error missing __iter__ attribute, but we guarantee by np.atleast_1d call - for idx in indices: # type: ignore[union-attr] + for idx in indices_1d: # type: ignore[union-attr] self.data.remove_node(idx, is_buffer_domain) self._data_changed(prev_size) @@ -473,7 +473,7 @@ def _remove_nodes( value=self.data, action=ActionType.REMOVED, data_indices=tuple( - self.selected_data, + indices, ), vertex_indices=((),), ) From b30dc39b1fc032a22330146481a1200d6322cc63 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Wed, 20 Dec 2023 19:29:49 +1100 Subject: [PATCH 076/105] Something kinda working --- napari/layers/graph/_slice.py | 26 ++++++++++++++------------ napari/layers/graph/graph.py | 26 +++++++++++++++++--------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 342264aa00c..8d4d9551cf1 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -7,7 +7,7 @@ from napari.layers.base._slice import _next_request_id from napari.layers.points._slice import _PointSliceResponse -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice @dataclass(frozen=True) @@ -23,7 +23,7 @@ class _GraphSliceResponse(_PointSliceResponse): scale: array like or none Used to scale the sliced points for visualization. Should be broadcastable to indices. - dims : _SliceInput + slice_input : _SliceInput Describes the slicing plane or bounding box in the layer's dimensions. request_id : int The identifier of the request from which this was generated. @@ -49,17 +49,17 @@ class _GraphSliceRequest: Describes the slicing plane or bounding box in the layer's dimensions. data : BaseGraph The layer's data field, which is the main input to slicing. - dims_indices : tuple of ints or slices - The slice indices in the layer's data space. + data_slice : _ThickNDSlice + The slicing coordinates and margins in data space. size : array like Size of each node. This is used in calculating visibility. others See the corresponding attributes in `Layer` and `Image`. """ - dims: _SliceInput + slice_input: _SliceInput data: BaseGraph = field(repr=False) - dims_indices: Any = field(repr=False) + data_slice: _ThickNDSlice = field(repr=False) size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) id: int = field(default_factory=_next_request_id) @@ -71,11 +71,11 @@ def __call__(self) -> _GraphSliceResponse: indices=[], edges_indices=[], scale=np.empty(0), - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) - not_disp = list(self.dims.not_displayed) + not_disp = list(self.slice_input.not_displayed) if not not_disp: # If we want to display everything, then use all indices. # scale is only impacted by not displayed data, therefore 1 @@ -86,7 +86,7 @@ def __call__(self) -> _GraphSliceResponse: indices=node_indices, edges_indices=edges, scale=1, - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) @@ -95,9 +95,11 @@ def __call__(self) -> _GraphSliceResponse: # objects, the array has dtype=object which is then very slow for the # arithmetic below. As Points._round_index is always False, we can safely # convert to float to get a major performance improvement. - not_disp_indices = np.array(self.dims_indices)[not_disp].astype(float) + not_disp_indices, m_left, m_right = self.data_slice[ + not_disp + ].as_array() - if self.out_of_slice_display and self.dims.ndim > 2: + if self.out_of_slice_display and self.slice_input.ndim > 2: ( node_indices, edges_indices, @@ -112,7 +114,7 @@ def __call__(self) -> _GraphSliceResponse: indices=node_indices, edges_indices=edges_indices, scale=scale, - dims=self.dims, + slice_input=self.slice_input, request_id=self.id, ) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index a5e5c84f2da..3f96c489f40 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -7,7 +7,7 @@ from napari.layers.graph._slice import _GraphSliceRequest, _GraphSliceResponse from napari.layers.points.points import _BasePoints -from napari.layers.utils._slice_input import _SliceInput +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.utils.events import Event from napari.utils.translations import trans @@ -370,12 +370,13 @@ def _get_ndim(self) -> int: return self.data.ndim def _make_slice_request_internal( - self, slice_input: _SliceInput, dims_indices: ArrayLike + self, slice_input: _SliceInput, data_slice: _ThickNDSlice ) -> _GraphSliceRequest: + print(data_slice) return _GraphSliceRequest( - dims=slice_input, + slice_input=slice_input, data=self.data, - dims_indices=dims_indices, + data_slice=data_slice, out_of_slice_display=self.out_of_slice_display, size=self.size, ) @@ -488,17 +489,25 @@ def _update_props_and_style(self, data_size: int, prev_size: int) -> None: self._border._add(n_colors=adding) self._face._update_current_properties(current_properties) self._face._add(n_colors=adding) - + # ensure each attribute is updated before refreshing with self._block_refresh(): - for attribute in ("shown", "size", "symbol", "border_width"): + for attribute in ( + "shown", + "size", + "symbol", + "border_width", + ): if attribute == "shown": default_value = True else: - default_value = getattr(self, f"current_{attribute}") + default_value = getattr( + self, f"current_{attribute}" + ) new_values = np.repeat([default_value], adding, axis=0) values = np.concatenate( - (getattr(self, f"_{attribute}"), new_values), axis=0 + (getattr(self, f"_{attribute}"), new_values), + axis=0, ) setattr(self, attribute, values) @@ -512,4 +521,3 @@ def _get_state(self) -> Dict[str, Any]: state.pop("properties", None) state.pop("property_choices", None) return state - \ No newline at end of file From bbace5bb4c30939a40790e4de3f4afebb33747b8 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Thu, 21 Dec 2023 09:45:31 +1100 Subject: [PATCH 077/105] Remove spurious delete --- napari/layers/graph/graph.py | 1 - 1 file changed, 1 deletion(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 3f96c489f40..df97a92308e 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -372,7 +372,6 @@ def _get_ndim(self) -> int: def _make_slice_request_internal( self, slice_input: _SliceInput, data_slice: _ThickNDSlice ) -> _GraphSliceRequest: - print(data_slice) return _GraphSliceRequest( slice_input=slice_input, data=self.data, From 4f158af19c1efdc5715bd0f9a31446adf21742b9 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Thu, 4 Jan 2024 12:38:28 +1100 Subject: [PATCH 078/105] Fix count of added nodes --- napari/layers/graph/graph.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 97a8ad6d317..e5f517197ea 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -397,25 +397,28 @@ def add( """Adds nodes at coordinates. Parameters ---------- - coords : sequence of indices to add point at + coords : sequence of coordinates for each new node. indices : optional indices of the newly inserted nodes. """ + if indices is None: + count_adding = len(np.atleast_2d(coords)) + indices = self.data.get_next_valid_indices(count_adding) self.events.data( value=self.data, action=ActionType.ADDING, - data_indices=(-1,), + data_indices=tuple(indices), vertex_indices=((),), ) prev_size = self.data.n_allocated_nodes - self.data.add_nodes(indices=indices, coords=coords) + added_indices = self.data.add_nodes(indices=indices, coords=coords) self._data_changed(prev_size) self.events.data( value=self.data, action=ActionType.ADDED, data_indices=tuple( - self.selected_data, + added_indices, ), vertex_indices=((),), ) From bcabf32841fd10a8850691d62dbf00b6e76231d2 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Thu, 4 Jan 2024 15:04:54 +1100 Subject: [PATCH 079/105] more testing --- napari/layers/graph/_tests/test_graph.py | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 7d10e558389..f0bea1c1067 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -1,4 +1,5 @@ from typing import Type +from unittest.mock import Mock import networkx as nx import numpy as np @@ -11,6 +12,7 @@ ) from napari.layers import Graph +from napari.layers.base._base_constants import ActionType def test_empty_graph() -> None: @@ -259,3 +261,73 @@ def test_add_nodes_buffer_resize(graph_class): assert len(layer.data) == coords.shape[0] + 1 assert graph.n_nodes == coords.shape[0] + 1 + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_add_data_event(graph_class): + coords = np.asarray([[0, 0], [1, 1]]) + + graph = graph_class(edges=[[0, 1]], coords=coords) + layer = Graph(graph) + layer.events.data = Mock() + + layer.add([5, 5]) + calls = layer.events.data.call_args_list + assert len(calls) == 2 + + first_call = calls[0] + assert first_call[1]['action'] == ActionType.ADDING + assert len(first_call[1]['data_indices']) == 1 + # 3rd node added at index 2 + assert first_call[1]['data_indices'] == (2,) + + second_call = calls[1] + assert second_call[1]['action'] == ActionType.ADDED + assert second_call[1]['data_indices'] == (2,) + + new_node = [3, 3] + layer.add(new_node, [7]) + calls = layer.events.data.call_args_list + last_call = calls[-1] + assert last_call[1]['data_indices'] == (7,) + + new_nodes = [[4, 4], [5, 5]] + layer.add(new_nodes) + calls = layer.events.data.call_args_list + last_call = calls[-1] + assert last_call[1]['data_indices'] == (8, 9) + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_remove_data_event(graph_class): + coords = np.asarray([[0, 0], [1, 1], [2, 2], [3, 3]]) + + graph = graph_class(edges=[[0, 1]], coords=coords) + layer = Graph(graph) + layer.events.data = Mock() + print(layer.data._edges_buffer) + + layer.remove([1]) + calls = layer.events.data.call_args_list + assert len(calls) == 2 + + first_call = calls[0] + assert first_call[1]['action'] == ActionType.REMOVING + assert len(first_call[1]['data_indices']) == 1 + # 3rd node added at index 2 + assert first_call[1]['data_indices'] == (1,) + + second_call = calls[1] + assert second_call[1]['action'] == ActionType.REMOVED + assert second_call[1]['data_indices'] == (1,) + + # new_node = [3,3] + # layer.add(new_node, [7]) + # calls = layer.events.data.call_args_list + # last_call = calls[-1] + # assert last_call[1]['data_indices'] == (7,) + + # new_nodes = [[4,4],[5,5]] + # layer.add(new_nodes) + # calls = layer.events.data.call_args_list + # last_call = calls[-1] + # assert last_call[1]['data_indices'] == (8,9) From 535cecadc0cac1431dcfe441fe6cb02ca3067dc8 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Thu, 4 Jan 2024 15:23:17 +1100 Subject: [PATCH 080/105] More test --- napari/layers/graph/_tests/test_graph.py | 5 ++- napari/layers/graph/graph.py | 46 ++++++++++++++++-------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index f0bea1c1067..3aa80130e4d 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -301,12 +301,11 @@ def test_add_data_event(graph_class): def test_remove_data_event(graph_class): coords = np.asarray([[0, 0], [1, 1], [2, 2], [3, 3]]) - graph = graph_class(edges=[[0, 1]], coords=coords) + graph = graph_class(edges=[[0, 1], [1, 2]], coords=coords) layer = Graph(graph) layer.events.data = Mock() - print(layer.data._edges_buffer) - layer.remove([1]) + layer.remove(1) calls = layer.events.data.call_args_list assert len(calls) == 2 diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index d25fbbf7927..cacb85d1df3 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -403,6 +403,15 @@ def add( if indices is None: count_adding = len(np.atleast_2d(coords)) indices = self.data.get_next_valid_indices(count_adding) + indices = np.atleast_1d(indices) + if indices.ndim > 1: + raise ValueError( + trans._( + "Indices for removal must be 1-dim. Found {ndim}", + ndim=indices.ndim, + ) + ) + self.events.data( value=self.data, action=ActionType.ADDING, @@ -446,6 +455,15 @@ def _remove_nodes( is_buffer_domain : bool Indicates if node indices are on world or buffer domain. """ + indices = np.atleast_1d(indices) + if indices.ndim > 1: + raise ValueError( + trans._( + "Indices for removal must be 1-dim. Found {ndim}", + ndim=indices.ndim, + ) + ) + self.events.data( value=self.data, action=ActionType.REMOVING, @@ -455,19 +473,10 @@ def _remove_nodes( vertex_indices=((),), ) - indices_1d = np.atleast_1d(indices) - if indices_1d.ndim > 1: - raise ValueError( - trans._( - "Indices for removal must be 1-dim. Found {ndim}", - ndim=indices_1d.ndim, - ) - ) - prev_size = self.data.n_allocated_nodes # it got error missing __iter__ attribute, but we guarantee by np.atleast_1d call - for idx in indices_1d: # type: ignore[union-attr] + for idx in indices: # type: ignore[union-attr] self.data.remove_node(idx, is_buffer_domain) self._data_changed(prev_size) @@ -526,17 +535,25 @@ def _update_props_and_style(self, data_size: int, prev_size: int) -> None: self._border._add(n_colors=adding) self._face._update_current_properties(current_properties) self._face._add(n_colors=adding) - + # ensure each attribute is updated before refreshing with self._block_refresh(): - for attribute in ("shown", "size", "symbol", "border_width"): + for attribute in ( + "shown", + "size", + "symbol", + "border_width", + ): if attribute == "shown": default_value = True else: - default_value = getattr(self, f"current_{attribute}") + default_value = getattr( + self, f"current_{attribute}" + ) new_values = np.repeat([default_value], adding, axis=0) values = np.concatenate( - (getattr(self, f"_{attribute}"), new_values), axis=0 + (getattr(self, f"_{attribute}"), new_values), + axis=0, ) setattr(self, attribute, values) @@ -550,4 +567,3 @@ def _get_state(self) -> Dict[str, Any]: state.pop("properties", None) state.pop("property_choices", None) return state - \ No newline at end of file From 458c338a35877add7df4a2a7ea2d022692951d5a Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Fri, 5 Jan 2024 12:15:18 +1100 Subject: [PATCH 081/105] fixt test --- napari/layers/graph/_tests/test_graph.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 3aa80130e4d..8eb381f7a08 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -319,6 +319,20 @@ def test_remove_data_event(graph_class): assert second_call[1]['action'] == ActionType.REMOVED assert second_call[1]['data_indices'] == (1,) + layer.remove([2, 3]) + calls = layer.events.data.call_args_list + assert len(calls) == 2 + + first_call = calls[0] + assert first_call[1]['action'] == ActionType.REMOVING + assert len(first_call[1]['data_indices']) == 1 + # 3rd node added at index 2 + assert first_call[1]['data_indices'] == (1,) + + second_call = calls[1] + assert second_call[1]['action'] == ActionType.REMOVED + assert second_call[1]['data_indices'] == (1,) + # new_node = [3,3] # layer.add(new_node, [7]) # calls = layer.events.data.call_args_list From ac460ee8aec4c5ae66702bef8fdf7d537d5687bb Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Fri, 5 Jan 2024 13:26:49 +1100 Subject: [PATCH 082/105] Fixing tests after merge - tests passing --- napari/layers/graph/graph.py | 2 + napari/layers/points/points.py | 125 ++++++++++++++++++++------------- 2 files changed, 79 insertions(+), 48 deletions(-) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index df97a92308e..9cb4af08ad1 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -265,6 +265,7 @@ def __init__( canvas_size_limits=(2, 10000), antialiasing=1, shown=True, + projection_mode='none', ) -> None: self._data = self._fix_data(data, ndim) self._edges_indices_view: ArrayLike = [] @@ -305,6 +306,7 @@ def __init__( canvas_size_limits=canvas_size_limits, antialiasing=antialiasing, shown=shown, + projection_mode=projection_mode, ) # TODO: diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index b769841fbe9..6aacc09f6b9 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -19,6 +19,7 @@ import pandas as pd from numpy.typing import ArrayLike from psygnal.containers import Selection +from scipy.stats import gmean from napari.layers.base import Layer, no_op from napari.layers.base._base_constants import ActionType @@ -61,6 +62,7 @@ from napari.utils.geometry import project_points_onto_plane, rotate_points from napari.utils.migrations import add_deprecated_property, rename_argument from napari.utils.status_messages import generate_layer_coords_status +from napari.utils.transforms import Affine from napari.utils.translations import trans DEFAULT_COLOR_CYCLE = np.array([[1, 0, 1, 1], [0, 1, 0, 1]]) @@ -2499,13 +2501,13 @@ def _paste_data(self): self.text._paste(**self._clipboard['text']) - self._edge_width = np.append( - self.edge_width, - deepcopy(self._clipboard['edge_width']), + self._border_width = np.append( + self.border_width, + deepcopy(self._clipboard['border_width']), axis=0, ) - self._edge._paste( - colors=self._clipboard['edge_color'], + self._border._paste( + colors=self._clipboard['border_color'], properties=_features_to_properties( self._clipboard['features'] ), @@ -2531,12 +2533,12 @@ def _copy_data(self): index = list(self.selected_data) self._clipboard = { 'data': deepcopy(self.data[index]), - 'edge_color': deepcopy(self.edge_color[index]), + 'border_color': deepcopy(self.border_color[index]), 'face_color': deepcopy(self.face_color[index]), 'shown': deepcopy(self.shown[index]), 'size': deepcopy(self.size[index]), 'symbol': deepcopy(self.symbol[index]), - 'edge_width': deepcopy(self.edge_width[index]), + 'border_width': deepcopy(self.border_width[index]), 'features': deepcopy(self.features.iloc[index]), 'indices': self._data_slice, 'text': self.text._copy(index), @@ -2546,59 +2548,86 @@ def _copy_data(self): def to_mask( self, - position: Optional[Tuple] = None, *, - view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, - world: bool = False, - ) -> dict: - """Status message information of the data at a coordinate position. + shape: tuple, + data_to_world: Optional[Affine] = None, + isotropic_output: bool = True, + ): + """Return a binary mask array of all the points as balls. Parameters ---------- - position : tuple - Position in either data or world coordinates. - view_direction : Optional[np.ndarray] - A unit vector giving the direction of the ray in nD world coordinates. - The default value is None. - dims_displayed : Optional[List[int]] - A list of the dimensions currently being displayed in the viewer. - The default value is None. - world : bool - If True the position is taken to be in world coordinates - and converted into data coordinates. False by default. + shape : tuple + The shape of the mask to be generated. + data_to_world : Optional[Affine] + The data-to-world transform of the output mask image. This likely comes from a reference image. + If None, then this is the same as this layer's data-to-world transform. + isotropic_output : bool + If True, then force the output mask to always contain isotropic balls in data/pixel coordinates. + Otherwise, allow the anisotropy in the data-to-world transform to squash the balls in certain dimensions. + By default this is True, but you should set it to False if you are going to create a napari image + layer from the result with the same data-to-world transform and want the visualized balls to be + roughly isotropic. Returns ------- - source_info : dict - Dict containing information that can be used in a status update. + np.ndarray + The output binary mask array of the given shape containing this layer's points as balls. """ - if position is not None: - value = self.get_value( - position, - view_direction=view_direction, - dims_displayed=dims_displayed, - world=world, - ) - else: - value = None - - source_info = self._get_source_info() - source_info['coordinates'] = generate_layer_coords_status( - position[-self.ndim :], value + if data_to_world is None: + data_to_world = self._data_to_world + mask = np.zeros(shape, dtype=bool) + mask_world_to_data = data_to_world.inverse + points_data_to_mask_data = self._data_to_world.compose( + mask_world_to_data + ) + points_in_mask_data_coords = np.atleast_2d( + points_data_to_mask_data(self.data) ) - # if this points layer has properties - properties = self._get_properties( - position, - view_direction=view_direction, - dims_displayed=dims_displayed, - world=world, + # Calculating the radii of the output points in the mask is complex. + radii = self.size / 2 + + # Scale each radius by the geometric mean scale of the Points layer to + # keep the balls isotropic when visualized in world coordinates. + # The geometric means are used instead of the arithmetic mean + # to maintain the volume scaling factor of the transforms. + point_data_to_world_scale = gmean(np.abs(self._data_to_world.scale)) + mask_world_to_data_scale = ( + gmean(np.abs(mask_world_to_data.scale)) + if isotropic_output + else np.abs(mask_world_to_data.scale) ) - if properties: - source_info['coordinates'] += "; " + ", ".join(properties) + radii_scale = point_data_to_world_scale * mask_world_to_data_scale - return source_info + output_data_radii = radii[:, np.newaxis] * np.atleast_2d(radii_scale) + + for coords, radii in zip( + points_in_mask_data_coords, output_data_radii + ): + # Define a minimal set of coordinates where the mask could be present + # by defining an inclusive lower and exclusive upper bound for each dimension. + lower_coords = np.maximum(np.floor(coords - radii), 0).astype(int) + upper_coords = np.minimum( + np.ceil(coords + radii) + 1, shape + ).astype(int) + # Generate every possible coordinate within the bounds defined above + # in a grid of size D1 x D2 x ... x Dd x D (e.g. for D=2, this might be 4x5x2). + submask_coords = [ + range(lower_coords[i], upper_coords[i]) + for i in range(self.ndim) + ] + submask_grids = np.stack( + np.meshgrid(*submask_coords, copy=False, indexing='ij'), + axis=-1, + ) + # Update the mask coordinates based on the normalized square distance + # using a logical or to maintain any existing positive mask locations. + normalized_square_distances = np.sum( + ((submask_grids - coords) / radii) ** 2, axis=-1 + ) + mask[np.ix_(*submask_coords)] |= normalized_square_distances <= 1 + return mask Points._add_deprecated_properties() From 42350199bec6bb6239ce4aed3ff2af7867d747d3 Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Fri, 5 Jan 2024 13:33:45 +1100 Subject: [PATCH 083/105] add test for removing nodes --- napari/layers/graph/_tests/test_graph.py | 32 ++++-------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 8eb381f7a08..075da18e046 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -284,15 +284,15 @@ def test_add_data_event(graph_class): assert second_call[1]['action'] == ActionType.ADDED assert second_call[1]['data_indices'] == (2,) + # specifying index new_node = [3, 3] layer.add(new_node, [7]) - calls = layer.events.data.call_args_list last_call = calls[-1] assert last_call[1]['data_indices'] == (7,) + # adding multiple at once new_nodes = [[4, 4], [5, 5]] layer.add(new_nodes) - calls = layer.events.data.call_args_list last_call = calls[-1] assert last_call[1]['data_indices'] == (8, 9) @@ -312,35 +312,13 @@ def test_remove_data_event(graph_class): first_call = calls[0] assert first_call[1]['action'] == ActionType.REMOVING assert len(first_call[1]['data_indices']) == 1 - # 3rd node added at index 2 assert first_call[1]['data_indices'] == (1,) second_call = calls[1] assert second_call[1]['action'] == ActionType.REMOVED assert second_call[1]['data_indices'] == (1,) + # make sure data indices are old values layer.remove([2, 3]) - calls = layer.events.data.call_args_list - assert len(calls) == 2 - - first_call = calls[0] - assert first_call[1]['action'] == ActionType.REMOVING - assert len(first_call[1]['data_indices']) == 1 - # 3rd node added at index 2 - assert first_call[1]['data_indices'] == (1,) - - second_call = calls[1] - assert second_call[1]['action'] == ActionType.REMOVED - assert second_call[1]['data_indices'] == (1,) - - # new_node = [3,3] - # layer.add(new_node, [7]) - # calls = layer.events.data.call_args_list - # last_call = calls[-1] - # assert last_call[1]['data_indices'] == (7,) - - # new_nodes = [[4,4],[5,5]] - # layer.add(new_nodes) - # calls = layer.events.data.call_args_list - # last_call = calls[-1] - # assert last_call[1]['data_indices'] == (8,9) + last_call = calls[-1] + assert last_call[1]['data_indices'] == (2, 3) From 6b8026803adbd733d1ea89aa3c1072096435b6fc Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Fri, 5 Jan 2024 14:11:30 +1100 Subject: [PATCH 084/105] add remove_selected test --- napari/layers/graph/_tests/test_graph.py | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 075da18e046..097a7448349 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -322,3 +322,32 @@ def test_remove_data_event(graph_class): layer.remove([2, 3]) last_call = calls[-1] assert last_call[1]['data_indices'] == (2, 3) + + +@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +def test_remove_selected_data_event(graph_class): + coords = np.asarray([[0, 0], [1, 1], [2, 2], [3, 3]]) + + graph = graph_class(edges=[[0, 1], [1, 2]], coords=coords) + layer = Graph(graph) + layer.events.data = Mock() + + layer.selected_data = {0} + layer.remove_selected() + + calls = layer.events.data.call_args_list + assert len(calls) == 2 + + first_call = calls[0] + assert first_call[1]['action'] == ActionType.REMOVING + assert len(first_call[1]['data_indices']) == 1 + assert first_call[1]['data_indices'] == (0,) + + second_call = calls[1] + assert second_call[1]['action'] == ActionType.REMOVED + assert second_call[1]['data_indices'] == (0,) + + layer.selected_data = {1, 2} + layer.remove_selected() + last_call = calls[-1] + assert last_call[1]['data_indices'] == (1, 2) From b4ea21082b39b8b3a48332bc8c27e9475aa4093f Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Fri, 5 Jan 2024 14:23:33 +1100 Subject: [PATCH 085/105] clean up tests --- napari/layers/graph/_tests/test_graph.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 097a7448349..f718fbf48b8 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -351,3 +351,18 @@ def test_remove_selected_data_event(graph_class): layer.remove_selected() last_call = calls[-1] assert last_call[1]['data_indices'] == (1, 2) + + # refresh layer to make index reasoning more straightforward + graph = graph_class(edges=[[0, 1], [1, 2]], coords=coords) + layer = Graph(graph) + layer.events.data = Mock() + + layer.add([1, 1], 7) + + # buffer index, not node id + layer.selected_data = {4} + layer.remove_selected() + calls = layer.events.data.call_args_list + + last_call = calls[-1] + assert last_call[1]['data_indices'] == (4,) From 43a3bbdf8cffaca1119a50de398f00795872006c Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Fri, 5 Jan 2024 14:37:06 +1100 Subject: [PATCH 086/105] Add selected_data setting --- napari/layers/graph/_tests/test_graph.py | 4 ++++ napari/layers/graph/graph.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index f718fbf48b8..0230c17cea5 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -130,16 +130,20 @@ def test_add_nodes(graph_class: Type[BaseGraph]) -> None: layer.add([2, 2]) assert len(layer.data) == coords.shape[0] + 1 assert graph.n_nodes == coords.shape[0] + 1 + assert set(layer.selected_data) == {2} # adding with index layer.add([3, 3], 13) assert len(layer.data) == coords.shape[0] + 2 assert graph.n_nodes == coords.shape[0] + 2 + # buffer index not node index + assert set(layer.selected_data) == {3} # adding multiple with indices layer.add([[4, 4], [5, 5]], [24, 25]) assert len(layer.data) == coords.shape[0] + 4 assert graph.n_nodes == coords.shape[0] + 4 + assert set(layer.selected_data) == {4, 5} @pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index cacb85d1df3..fad80840e90 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -431,6 +431,10 @@ def add( ), vertex_indices=((),), ) + buffer_indices = { + self.data._world2buffer[idx] for idx in added_indices + } + self.selected_data = buffer_indices def remove_selected(self) -> None: """Removes selected points if any.""" From 924638446a215faa1541c9ccff9aa116876c628b Mon Sep 17 00:00:00 2001 From: jamesyan-git Date: Wed, 10 Jan 2024 10:52:01 +1100 Subject: [PATCH 087/105] address comments --- napari/layers/graph/_tests/test_graph.py | 3 ++- napari/layers/graph/graph.py | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index 0230c17cea5..ddca8458ee3 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -368,5 +368,6 @@ def test_remove_selected_data_event(graph_class): layer.remove_selected() calls = layer.events.data.call_args_list + # selected_data uses buffer id, events will always emit world id last_call = calls[-1] - assert last_call[1]['data_indices'] == (4,) + assert last_call[1]['data_indices'] == (7,) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index fad80840e90..fd5f647139a 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -431,10 +431,7 @@ def add( ), vertex_indices=((),), ) - buffer_indices = { - self.data._world2buffer[idx] for idx in added_indices - } - self.selected_data = buffer_indices + self.selected_data = self.data._map_world2buffer(added_indices) def remove_selected(self) -> None: """Removes selected points if any.""" @@ -467,12 +464,15 @@ def _remove_nodes( ndim=indices.ndim, ) ) - + # TODO: should know nothing about buffer + world_indices = ( + self.data._buffer2world[indices] if is_buffer_domain else indices + ) self.events.data( value=self.data, action=ActionType.REMOVING, data_indices=tuple( - indices, + world_indices, ), vertex_indices=((),), ) @@ -489,7 +489,7 @@ def _remove_nodes( value=self.data, action=ActionType.REMOVED, data_indices=tuple( - indices, + world_indices, ), vertex_indices=((),), ) From ef6e1a6bc15d6fe95ddc3291414686082585143a Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Wed, 31 Jan 2024 18:41:18 +1100 Subject: [PATCH 088/105] Actually working thick slices --- examples/add_graph.py | 15 ++++----- napari/layers/graph/_slice.py | 58 ++++++++++++++++++++++------------- napari/layers/graph/graph.py | 1 + 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/examples/add_graph.py b/examples/add_graph.py index c1860de2a8e..9d3abfd6268 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -7,7 +7,6 @@ .. tags:: visualization-basic """ -import numpy as np import pandas as pd from napari_graph import UndirectedGraph @@ -15,22 +14,24 @@ def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: - neighbors = np.random.randint(n_nodes, size=(n_nodes * n_neighbors)) - edges = np.stack([np.repeat(np.arange(n_nodes), n_neighbors), neighbors], axis=1) + edges = [[0, 2], [3, 4]] nodes_df = pd.DataFrame( - 400 * np.random.uniform(size=(n_nodes, 4)), - columns=["t", "z", "y", "x"], + { + 't': [0, 1, 2, 3, 4], + 'y': [0, 20, 45, 70, 90], + 'x': [0, 20, 45, 70, 90] + } ) graph = UndirectedGraph(edges=edges, coords=nodes_df) return graph -graph = build_graph(n_nodes=1_000_000, n_neighbors=5) +graph = build_graph(n_nodes=100, n_neighbors=5) viewer = napari.Viewer() -layer = viewer.add_graph(graph, out_of_slice_display=True) +layer = viewer.add_graph(graph, out_of_slice_display=True, size=5, projection_mode='all') if __name__ == "__main__": diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 8d4d9551cf1..8c21684e30f 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -6,6 +6,7 @@ from numpy.typing import ArrayLike from napari.layers.base._slice import _next_request_id +from napari.layers.points._points_constants import PointsProjectionMode from napari.layers.points._slice import _PointSliceResponse from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice @@ -60,6 +61,7 @@ class _GraphSliceRequest: slice_input: _SliceInput data: BaseGraph = field(repr=False) data_slice: _ThickNDSlice = field(repr=False) + projection_mode: PointsProjectionMode size: Any = field(repr=False) out_of_slice_display: bool = field(repr=False) id: int = field(default_factory=_next_request_id) @@ -90,24 +92,32 @@ def __call__(self) -> _GraphSliceResponse: request_id=self.id, ) - # We want a numpy array so we can use fancy indexing with the non-displayed - # indices, but as self.dims_indices can (and often/always does) contain slice - # objects, the array has dtype=object which is then very slow for the - # arithmetic below. As Points._round_index is always False, we can safely - # convert to float to get a major performance improvement. - not_disp_indices, m_left, m_right = self.data_slice[ - not_disp - ].as_array() + point, m_left, m_right = self.data_slice[not_disp].as_array() + + if self.projection_mode == 'none': + low = point.copy() + high = point.copy() + else: + low = point - m_left + high = point + m_right + + # assume slice thickness of 1 in data pixels + # (same as before thick slices were implemented) + too_thin_slice = np.isclose(high, low) + low[too_thin_slice] -= 0.5 + high[too_thin_slice] += 0.5 + + in_slice, node_indices, edges_indices, scale = self._get_slice_data( + not_disp, low, high + ) if self.out_of_slice_display and self.slice_input.ndim > 2: ( node_indices, edges_indices, scale, - ) = self._get_out_of_display_slice_data(not_disp, not_disp_indices) - else: - node_indices, edges_indices, scale = self._get_slice_data( - not_disp, not_disp_indices + ) = self._get_out_of_display_slice_data( + not_disp, low, high, in_slice ) return _GraphSliceResponse( @@ -121,7 +131,9 @@ def __call__(self) -> _GraphSliceResponse: def _get_out_of_display_slice_data( self, not_disp: Sequence[int], - not_disp_indices: np.ndarray, + low: np.ndarray, + high: np.ndarray, + in_slice: np.ndarray, ) -> Tuple[np.ndarray, np.ndarray, ArrayLike]: """ Slices data according to non displayed indices @@ -132,7 +144,11 @@ def _get_out_of_display_slice_data( ixgrid = np.ix_(valid_nodes, not_disp) data = self.data.coords_buffer[ixgrid] sizes = self.size[valid_nodes, np.newaxis] / 2 - distances = np.abs(data - not_disp_indices) + dist_from_low = np.abs(data - low) + dist_from_high = np.abs(data - high) + # keep distance of the closest margin + distances = np.minimum(dist_from_low, dist_from_high) + distances[in_slice] = 0 matches = np.all(distances <= sizes, axis=1) if not np.any(matches): return np.empty(0, dtype=int), np.empty(0, dtype=int), 1 @@ -148,20 +164,20 @@ def _get_out_of_display_slice_data( def _get_slice_data( self, - not_disp: Sequence[int], - not_disp_indices: np.ndarray, - ) -> Tuple[np.ndarray, np.ndarray, int]: + not_disp: np.ndarray, + low: np.ndarray, + high: np.ndarray, + ) -> np.ndarray: """ - Slices data according to non displayed indices + Slices data according to displayed indices while ignoring not initialized nodes from graph. """ valid_nodes = self.data.initialized_buffer_mask() data = self.data.coords_buffer[np.ix_(valid_nodes, not_disp)] - distances = np.abs(data - not_disp_indices) - matches = np.all(distances <= 0.5, axis=1) + matches = np.all((data >= low) & (data <= high), axis=1) valid_nodes[valid_nodes] = matches slice_indices = np.where(valid_nodes)[0].astype(int) edge_indices = self.data.subgraph_edges( slice_indices, is_buffer_domain=True ) - return slice_indices, edge_indices, 1 + return matches, slice_indices, edge_indices, 1 diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index de938a7450f..449d78fb5e1 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -379,6 +379,7 @@ def _make_slice_request_internal( slice_input=slice_input, data=self.data, data_slice=data_slice, + projection_mode=self.projection_mode, out_of_slice_display=self.out_of_slice_display, size=self.size, ) From e03887a0a30f0bfe9aa013defa0fd2eefd14f7d2 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Thu, 1 Feb 2024 14:37:20 +1100 Subject: [PATCH 089/105] Fix labels --- napari/layers/labels/_tests/test_labels.py | 24 ---------------------- napari/layers/labels/labels.py | 3 --- 2 files changed, 27 deletions(-) diff --git a/napari/layers/labels/_tests/test_labels.py b/napari/layers/labels/_tests/test_labels.py index df26fec389b..e72bd971ab1 100644 --- a/napari/layers/labels/_tests/test_labels.py +++ b/napari/layers/labels/_tests/test_labels.py @@ -310,16 +310,6 @@ def test_num_colors(): with pytest.warns(FutureWarning, match='num_colors is deprecated'): Labels(data, num_colors=2**17) - with pytest.raises( - ValueError, match=r".*Only up to 2\*\*16=65535 colors are supported" - ): - layer.num_colors = 2**17 - - with pytest.raises( - ValueError, match=r".*Only up to 2\*\*16=65535 colors are supported" - ): - Labels(data, num_colors=2**17) - def test_properties(): """Test adding labels with properties.""" @@ -1572,20 +1562,6 @@ def test_invalidate_cache_when_change_color_mode( ) -@pytest.mark.parametrize("dtype", np.sctypes['int'] + np.sctypes['uint']) -@pytest.mark.parametrize("mode", ["auto", "direct"]) -def test_cache_for_dtypes(dtype, mode): - data = np.zeros((10, 10), dtype=dtype) - labels = Labels(data) - labels.color_mode = mode - assert labels._cached_labels is None - labels._raw_to_displayed( - labels._slice.image.raw, (slice(None), slice(None)) - ) - assert labels._cached_labels is not None - assert labels._cached_mapped_labels.dtype == labels._slice.image.view.dtype - - def test_color_mapping_when_color_is_changed(): """Checks if the color mapping is computed correctly when the color palette is changed.""" diff --git a/napari/layers/labels/labels.py b/napari/layers/labels/labels.py index bee567031e6..0d6c37f1d2d 100644 --- a/napari/layers/labels/labels.py +++ b/napari/layers/labels/labels.py @@ -1030,9 +1030,6 @@ def _raw_to_displayed( if data_slice is None: data_slice = tuple(slice(0, size) for size in raw.shape) - self._cached_labels = None - else: - self._setup_cache(raw) labels = raw # for readability From 16b3a37e8af55984609b9537e163726e7383851c Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Fri, 9 Feb 2024 14:25:38 +1100 Subject: [PATCH 090/105] Fix tests and typing --- napari/layers/graph/_slice.py | 2 +- napari/layers/points/_slice.py | 3 ++- pyproject.toml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 19543273caf..5438685f5cb 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -70,7 +70,7 @@ def __call__(self) -> _GraphSliceResponse: # Return early if no data if self.data.n_nodes == 0: return _GraphSliceResponse( - indices=np.asarray([]), + indices=[], edges_indices=[], scale=np.empty(0), slice_input=self.slice_input, diff --git a/napari/layers/points/_slice.py b/napari/layers/points/_slice.py index 39dcfe4c757..8e7d8e23443 100644 --- a/napari/layers/points/_slice.py +++ b/napari/layers/points/_slice.py @@ -6,6 +6,7 @@ from napari.layers.base._slice import _next_request_id from napari.layers.points._points_constants import PointsProjectionMode from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice +from napari.types import ArrayLike @dataclass(frozen=True) @@ -25,7 +26,7 @@ class _PointSliceResponse: The identifier of the request from which this was generated. """ - indices: np.ndarray = field(repr=False) + indices: ArrayLike = field(repr=False) scale: Any = field(repr=False) slice_input: _SliceInput request_id: int diff --git a/pyproject.toml b/pyproject.toml index f123f2640c4..a1795c09d61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -316,6 +316,7 @@ module = [ 'napari.layers._layer_actions', 'napari.layers._multiscale_data', 'napari.layers.intensity_mixin', + 'napari.layers.graph.graph', 'napari.layers.points._points_key_bindings', 'napari.layers.points._points_utils', 'napari.layers.points._slice', From d8d8d21f834d27a29244d393fa78c16172cbb7d1 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Fri, 9 Feb 2024 14:30:43 +1100 Subject: [PATCH 091/105] More typing ignore --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a1795c09d61..90a6969bf25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -496,6 +496,7 @@ warn_unused_ignores = false [[tool.mypy.overrides]] module = [ "napari._qt.menus.plugins_menu", + "napari._vispy.layers.graph", "napari._vispy.layers.labels", "napari._vispy.layers.points", "napari._vispy.layers.shapes", From e28e0c00186e1a26583ff59ae1636b90cbf506c6 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Fri, 9 Feb 2024 14:34:53 +1100 Subject: [PATCH 092/105] More typing fix --- napari/_vispy/layers/graph.py | 4 ++-- pyproject.toml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/napari/_vispy/layers/graph.py b/napari/_vispy/layers/graph.py index 3e49cc1c524..378e3382315 100644 --- a/napari/_vispy/layers/graph.py +++ b/napari/_vispy/layers/graph.py @@ -7,11 +7,11 @@ class VispyGraphLayer(VispyPointsLayer): _visual = GraphVisual - def _on_data_change(self): + def _on_data_change(self) -> None: self._set_graph_edges_data() super()._on_data_change() - def _set_graph_edges_data(self): + def _set_graph_edges_data(self) -> None: """Sets the LineVisual (subvisual[4]) with the graph edges data""" subvisual = self.node._subvisuals[4] edges = self.layer._view_edges_coordinates diff --git a/pyproject.toml b/pyproject.toml index 90a6969bf25..a1795c09d61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -496,7 +496,6 @@ warn_unused_ignores = false [[tool.mypy.overrides]] module = [ "napari._qt.menus.plugins_menu", - "napari._vispy.layers.graph", "napari._vispy.layers.labels", "napari._vispy.layers.points", "napari._vispy.layers.shapes", From 9791e34db353265042bbe24033c00e53a21badb6 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Fri, 9 Feb 2024 14:37:23 +1100 Subject: [PATCH 093/105] More typing fix --- napari/layers/graph/_slice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index 5438685f5cb..b2ab2f34389 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -164,7 +164,7 @@ def _get_out_of_display_slice_data( def _get_slice_data( self, - not_disp: np.ndarray, + not_disp: ArrayLike, low: np.ndarray, high: np.ndarray, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, ArrayLike]: From 758b2f5d4c7dc660537849b6e1a1a6bebee49c20 Mon Sep 17 00:00:00 2001 From: Draga Doncila Date: Tue, 26 Mar 2024 13:45:09 +1100 Subject: [PATCH 094/105] Merge main... --- .github/workflows/benchmarks.yml | 4 +- .github/workflows/make_release.yml | 2 +- .github/workflows/reusable_build_wheel.yml | 2 +- .../workflows/reusable_coverage_upload.yml | 2 +- .github/workflows/reusable_run_tox_test.yml | 6 +- .github/workflows/test_comprehensive.yml | 17 +- .github/workflows/test_prereleases.yml | 2 +- .github/workflows/test_pull_requests.yml | 69 +- .github/workflows/test_translations.yml | 2 +- .github/workflows/test_typing.yml | 2 +- .github/workflows/test_vendored.yml | 2 +- .../workflows/upgrade_test_constraints.yml | 32 +- .pre-commit-config.yaml | 6 +- examples/3d_kymograph_.py | 46 +- examples/add_labels.py | 2 +- examples/add_labels_with_features.py | 2 +- examples/annotate-2d.py | 4 +- examples/clipboard_.py | 8 +- examples/clipping_planes_interactive_.py | 10 +- examples/dev/demo_shape_creation.py | 40 +- examples/dev/gui_notifications.py | 2 +- examples/dev/gui_notifications_threaded.py | 2 +- examples/dev/leaking_check.py | 20 +- examples/dev/q_list_view.py | 6 +- examples/dev/q_node_tree.py | 12 +- examples/dev/slicing/random_points.py | 2 +- examples/fourier_transform_playground.py | 2 +- examples/image_custom_kernel.py | 6 +- examples/inherit_viewer_style.py | 4 +- examples/magic_image_arithmetic.py | 4 +- examples/magic_parameter_sweep.py | 10 +- examples/magic_viewer.py | 2 +- examples/mgui_dask_delayed_.py | 2 +- examples/mgui_with_threadpoolexec_.py | 25 +- examples/mgui_with_threadworker_.py | 23 +- examples/minimum_blending.py | 6 +- examples/multiple_viewer_widget.py | 42 +- examples/multithreading_simple_.py | 6 +- examples/multithreading_two_way_.py | 26 +- examples/new_theme.py | 4 +- examples/progress_bar_minimal_.py | 24 +- examples/progress_bar_segmentation_.py | 20 +- examples/progress_bar_threading_.py | 14 +- examples/scale_bar.py | 2 +- ...i_texture.py => surface_multi_texture_.py} | 32 +- examples/surface_texture_and_colors.py | 14 +- examples/surface_timeseries_.py | 8 +- examples/viewer_fps_label.py | 2 +- examples/without_gui_qt.py | 4 +- napari/__init__.py | 2 +- napari/__main__.py | 86 +-- napari/_app_model/__init__.py | 2 +- napari/_app_model/_app.py | 3 +- .../_app_model/_tests/test_help_menu_urls.py | 2 +- .../_app_model/_tests/test_viewer_toggler.py | 8 +- napari/_app_model/actions/_help_actions.py | 23 +- napari/_app_model/actions/_layer_actions.py | 4 +- napari/_app_model/actions/_view_actions.py | 8 +- napari/_app_model/constants/_commands.py | 14 +- napari/_app_model/constants/_menus.py | 9 +- napari/_app_model/context/_context.py | 6 +- napari/_app_model/context/_context_keys.py | 2 +- .../_app_model/context/_layerlist_context.py | 88 +-- napari/_app_model/injection/_processors.py | 32 +- napari/_app_model/injection/_providers.py | 40 +- .../injection/_tests/test_processors.py | 2 +- napari/_qt/__init__.py | 12 +- .../_qt/_qapp_model/_tests/test_file_menu.py | 15 +- .../_qt/_qapp_model/_tests/test_qproviders.py | 36 + .../_qapp_model/injection}/__init__.py | 0 .../_qt/_qapp_model/injection/_qprocessors.py | 35 + .../_qt/_qapp_model/injection/_qproviders.py | 66 ++ napari/_qt/_qapp_model/qactions/__init__.py | 34 +- napari/_qt/_qapp_model/qactions/_file.py | 7 +- napari/_qt/_qapp_model/qactions/_help.py | 23 +- napari/_qt/_qapp_model/qactions/_plugins.py | 72 ++ napari/_qt/_qapp_model/qactions/_view.py | 3 +- napari/_qt/_qplugins/__init__.py | 11 + napari/_qt/_qplugins/_qnpe2.py | 448 ++++++++++++ napari/_qt/_tests/test_app.py | 16 +- napari/_qt/_tests/test_async_slicing.py | 18 +- napari/_qt/_tests/test_plugin_widgets.py | 230 +++--- napari/_qt/_tests/test_qt_event_filters.py | 16 +- napari/_qt/_tests/test_qt_notifications.py | 28 +- napari/_qt/_tests/test_qt_provide_theme.py | 24 +- napari/_qt/_tests/test_qt_utils.py | 20 +- napari/_qt/_tests/test_qt_viewer.py | 108 +-- napari/_qt/_tests/test_qt_viewer_2.py | 4 +- napari/_qt/_tests/test_qt_window.py | 26 +- napari/_qt/_tests/test_sigint_interupt.py | 4 +- napari/_qt/code_syntax_highlight.py | 6 +- napari/_qt/containers/_base_item_model.py | 12 +- napari/_qt/containers/_base_item_view.py | 2 +- napari/_qt/containers/_factory.py | 4 +- napari/_qt/containers/_layer_delegate.py | 7 +- .../containers/_tests/test_qt_layer_list.py | 40 +- napari/_qt/containers/_tests/test_qt_tree.py | 20 +- napari/_qt/containers/qt_layer_list.py | 9 +- napari/_qt/containers/qt_list_model.py | 21 +- napari/_qt/containers/qt_list_view.py | 2 +- napari/_qt/containers/qt_tree_model.py | 36 +- napari/_qt/containers/qt_tree_view.py | 2 +- .../dialogs/_tests/test_activity_dialog.py | 6 +- napari/_qt/dialogs/_tests/test_qt_modal.py | 8 +- .../dialogs/_tests/test_qt_plugin_report.py | 4 +- .../_qt/dialogs/_tests/test_reader_dialog.py | 2 +- .../dialogs/_tests/test_screenshot_dialog.py | 28 +- napari/_qt/dialogs/confirm_close_dialog.py | 16 +- napari/_qt/dialogs/preferences_dialog.py | 72 +- napari/_qt/dialogs/qt_about.py | 6 +- napari/_qt/dialogs/qt_activity_dialog.py | 8 +- napari/_qt/dialogs/qt_modal.py | 8 +- napari/_qt/dialogs/qt_notification.py | 25 +- napari/_qt/dialogs/qt_plugin_report.py | 16 +- napari/_qt/dialogs/qt_reader_dialog.py | 26 +- napari/_qt/dialogs/screenshot_dialog.py | 12 +- napari/_qt/layer_controls/__init__.py | 2 +- .../_tests/test_qt_image_base_layer_.py | 6 +- .../_tests/test_qt_labels_layer.py | 6 +- .../_tests/test_qt_layer_controls.py | 58 +- .../_tests/test_qt_tracks_layer.py | 4 +- .../_qt/layer_controls/qt_image_controls.py | 8 +- .../layer_controls/qt_image_controls_base.py | 26 +- .../_qt/layer_controls/qt_labels_controls.py | 4 +- .../qt_layer_controls_container.py | 4 +- .../_qt/layer_controls/qt_points_controls.py | 6 +- .../_qt/layer_controls/qt_shapes_controls.py | 24 +- napari/_qt/menus/__init__.py | 2 - napari/_qt/menus/_tests/test_plugins_menu.py | 239 +++++- napari/_qt/menus/_tests/test_util.py | 8 +- napari/_qt/menus/_util.py | 18 +- napari/_qt/menus/debug_menu.py | 8 +- napari/_qt/menus/plugins_menu.py | 139 ---- napari/_qt/menus/window_menu.py | 4 +- napari/_qt/perf/_tests/test_perf.py | 48 +- napari/_qt/perf/qt_event_tracing.py | 6 +- napari/_qt/perf/qt_performance.py | 38 +- napari/_qt/qt_event_loop.py | 22 +- napari/_qt/qt_main_window.py | 73 +- napari/_qt/qt_resources/__init__.py | 8 +- napari/_qt/qt_viewer.py | 156 ++-- napari/_qt/qthreading.py | 37 +- napari/_qt/utils.py | 11 +- napari/_qt/widgets/_slider_compat.py | 4 +- napari/_qt/widgets/_tests/test_qt_buttons.py | 4 +- napari/_qt/widgets/_tests/test_qt_dims.py | 4 +- .../_tests/test_qt_extension2reader.py | 8 +- .../_tests/test_qt_highlight_preview.py | 176 +++-- napari/_qt/widgets/_tests/test_qt_play.py | 2 +- .../widgets/_tests/test_qt_plugin_sorter.py | 4 +- .../widgets/_tests/test_qt_progress_bar.py | 16 +- .../widgets/_tests/test_qt_size_preview.py | 24 +- napari/_qt/widgets/_tests/test_qt_tooltip.py | 10 +- .../_tests/test_shortcut_editor_widget.py | 201 +++++- napari/_qt/widgets/qt_color_swatch.py | 4 +- napari/_qt/widgets/qt_dict_table.py | 14 +- napari/_qt/widgets/qt_dims.py | 12 +- napari/_qt/widgets/qt_dims_slider.py | 20 +- napari/_qt/widgets/qt_dims_sorter.py | 6 +- napari/_qt/widgets/qt_extension2reader.py | 6 +- napari/_qt/widgets/qt_font_size.py | 4 +- napari/_qt/widgets/qt_highlight_preview.py | 133 +++- napari/_qt/widgets/qt_keyboard_settings.py | 60 +- napari/_qt/widgets/qt_message_popup.py | 4 +- napari/_qt/widgets/qt_plugin_sorter.py | 24 +- napari/_qt/widgets/qt_progress_bar.py | 20 +- napari/_qt/widgets/qt_scrollbar.py | 2 +- napari/_qt/widgets/qt_size_preview.py | 18 +- napari/_qt/widgets/qt_theme_sample.py | 34 +- napari/_qt/widgets/qt_viewer_buttons.py | 18 +- napari/_qt/widgets/qt_viewer_dock_widget.py | 33 +- napari/_qt/widgets/qt_viewer_status_bar.py | 8 +- napari/_qt/widgets/qt_welcome.py | 24 +- napari/_tests/test_advanced.py | 10 +- napari/_tests/test_cli.py | 6 +- napari/_tests/test_conftest_fixtures.py | 8 +- napari/_tests/test_examples.py | 14 +- napari/_tests/test_import_time.py | 4 +- napari/_tests/test_magicgui.py | 8 +- napari/_tests/test_mouse_bindings.py | 6 +- napari/_tests/test_notebook_display.py | 34 +- napari/_tests/test_providers.py | 65 ++ napari/_tests/test_pytest_plugin.py | 6 +- napari/_tests/test_view_layers.py | 5 +- napari/_tests/test_viewer.py | 14 +- napari/_tests/test_windowsettings.py | 2 +- napari/_tests/test_with_screenshot.py | 2 +- napari/_tests/utils.py | 19 +- .../experimental/cachetools/CHANGELOG.rst | 319 -------- .../_vendor/experimental/cachetools/LICENSE | 20 - .../experimental/cachetools/__init__.py | 1 - .../cachetools/cachetools/__init__.py | 20 - .../experimental/cachetools/cachetools/abc.py | 46 -- .../cachetools/cachetools/cache.py | 89 --- .../cachetools/cachetools/decorators.py | 88 --- .../cachetools/cachetools/func.py | 147 ---- .../cachetools/cachetools/keys.py | 52 -- .../experimental/cachetools/cachetools/lfu.py | 34 - .../experimental/cachetools/cachetools/lru.py | 40 - .../experimental/cachetools/cachetools/rr.py | 35 - .../experimental/cachetools/cachetools/ttl.py | 209 ------ .../experimental/cachetools/docs/.gitignore | 1 - .../experimental/cachetools/docs/Makefile | 153 ---- .../experimental/cachetools/docs/conf.py | 24 - .../experimental/cachetools/docs/index.rst | 487 ------------- napari/_vendor/experimental/humanize/LICENCE | 20 - .../_vendor/experimental/humanize/README.md | 210 ------ .../humanize/src/humanize/__init__.py | 32 - .../humanize/src/humanize/filesize.py | 53 -- .../humanize/src/humanize/i18n.py | 127 ---- .../locale/de_DE/LC_MESSAGES/humanize.po | 282 -------- .../locale/es_ES/LC_MESSAGES/humanize.po | 281 -------- .../locale/fa_IR/LC_MESSAGES/humanize.po | 264 ------- .../locale/fi_FI/LC_MESSAGES/humanize.po | 281 -------- .../locale/fr_FR/LC_MESSAGES/humanize.po | 302 -------- .../locale/id_ID/LC_MESSAGES/humanize.po | 255 ------- .../locale/it_IT/LC_MESSAGES/humanize.po | 281 -------- .../locale/ja_JP/LC_MESSAGES/humanize.po | 267 ------- .../locale/ko_KR/LC_MESSAGES/humanize.po | 283 -------- .../locale/nl_NL/LC_MESSAGES/humanize.po | 282 -------- .../locale/pl_PL/LC_MESSAGES/humanize.po | 291 -------- .../locale/pt_BR/LC_MESSAGES/humanize.po | 281 -------- .../locale/pt_PT/LC_MESSAGES/humanize.po | 281 -------- .../locale/ru_RU/LC_MESSAGES/humanize.po | 280 ------- .../locale/sk_SK/LC_MESSAGES/humanize.po | 291 -------- .../locale/tr_TR/LC_MESSAGES/humanize.po | 264 ------- .../locale/uk_UA/LC_MESSAGES/humanize.po | 288 -------- .../locale/vi_VI/LC_MESSAGES/humanize.po | 271 ------- .../locale/zh_CN/LC_MESSAGES/humanize.po | 263 ------- .../humanize/src/humanize/number.py | 273 ------- .../humanize/src/humanize/time.py | 504 ------------- napari/_vendor/experimental/vendor.txt | 2 - .../qt_jsonschema_form/form.py | 2 +- .../qt_jsonschema_form/widgets.py | 10 +- napari/_vispy/__init__.py | 22 +- napari/_vispy/_tests/test_utils.py | 2 +- napari/_vispy/_tests/test_vispy_big_images.py | 2 +- .../_vispy/_tests/test_vispy_image_layer.py | 12 + napari/_vispy/_tests/test_vispy_labels.py | 2 +- .../_vispy/_tests/test_vispy_labels_layer.py | 4 +- .../_vispy/_tests/test_vispy_points_layer.py | 2 +- .../_vispy/_tests/test_vispy_surface_layer.py | 18 +- .../_vispy/_tests/test_vispy_vectors_layer.py | 6 +- napari/_vispy/camera.py | 6 +- napari/_vispy/canvas.py | 14 +- napari/_vispy/filters/tracks.py | 4 +- napari/_vispy/layers/base.py | 24 +- napari/_vispy/layers/image.py | 251 +------ napari/_vispy/layers/labels.py | 38 +- napari/_vispy/layers/points.py | 32 +- napari/_vispy/layers/scalar_field.py | 252 +++++++ napari/_vispy/layers/shapes.py | 9 +- napari/_vispy/overlays/base.py | 2 +- napari/_vispy/overlays/bounding_box.py | 5 - napari/_vispy/overlays/scale_bar.py | 3 +- napari/_vispy/overlays/text.py | 14 +- napari/_vispy/utils/gl.py | 57 +- napari/_vispy/utils/quaternion.py | 6 +- napari/_vispy/utils/visual.py | 6 +- .../_vispy/visuals/clipping_planes_mixin.py | 4 +- napari/_vispy/visuals/labels.py | 4 +- napari/_vispy/visuals/markers.py | 4 +- napari/_vispy/visuals/points.py | 59 +- napari/_vispy/visuals/scale_bar.py | 4 +- napari/_vispy/visuals/volume.py | 12 +- napari/benchmarks/benchmark_image_layer.py | 10 +- napari/benchmarks/benchmark_labels_layer.py | 48 +- napari/benchmarks/benchmark_points_layer.py | 19 +- napari/benchmarks/benchmark_python_layer.py | 6 +- napari/benchmarks/benchmark_qt_slicing.py | 53 +- .../benchmarks/benchmark_qt_viewer_image.py | 73 +- .../benchmarks/benchmark_qt_viewer_labels.py | 42 +- .../benchmarks/benchmark_qt_viewer_vectors.py | 16 +- napari/benchmarks/benchmark_shapes_layer.py | 4 +- napari/benchmarks/benchmark_text_manager.py | 6 +- napari/benchmarks/benchmark_tracks_layer.py | 24 +- napari/benchmarks/utils.py | 35 +- napari/components/_layer_slicer.py | 36 +- napari/components/_tests/test_add_layers.py | 18 +- napari/components/_tests/test_dims.py | 4 +- napari/components/_tests/test_layer_slicer.py | 6 +- napari/components/_tests/test_layers_list.py | 6 +- napari/components/_tests/test_multichannel.py | 4 +- napari/components/_tests/test_prune_kwargs.py | 4 +- .../_tests/test_viewer_keybindings.py | 80 ++ napari/components/_tests/test_viewer_model.py | 29 +- .../_tests/test_viewer_mouse_bindings.py | 14 +- napari/components/_viewer_constants.py | 4 +- napari/components/_viewer_key_bindings.py | 64 +- napari/components/camera.py | 20 +- napari/components/cursor.py | 6 +- napari/components/dims.py | 36 +- napari/components/experimental/commands.py | 4 +- .../experimental/monitor/__init__.py | 2 +- .../components/experimental/monitor/_api.py | 12 +- .../experimental/monitor/_monitor.py | 22 +- .../experimental/monitor/_service.py | 20 +- .../experimental/remote/__init__.py | 2 +- .../experimental/remote/_commands.py | 8 +- .../experimental/remote/_manager.py | 2 +- .../experimental/remote/_messages.py | 8 +- napari/components/grid.py | 8 +- napari/components/layerlist.py | 11 +- napari/components/overlays/__init__.py | 22 +- napari/components/overlays/base.py | 4 +- napari/components/overlays/brush_circle.py | 4 +- napari/components/overlays/interaction_box.py | 4 +- napari/components/overlays/text.py | 2 +- napari/components/tooltip.py | 2 +- napari/components/viewer_model.py | 310 ++++---- napari/conftest.py | 184 ++--- napari/errors/__init__.py | 6 +- napari/errors/reader_errors.py | 10 +- napari/layers/__init__.py | 3 +- napari/layers/_data_protocols.py | 9 +- napari/layers/_layer_actions.py | 12 +- napari/layers/_multiscale_data.py | 13 +- .../_scalar_field}/__init__.py | 0 napari/layers/_scalar_field/scalar_field.py | 602 ++++++++++++++++ napari/layers/_source.py | 15 +- napari/layers/_tests/test_dask_layers.py | 12 +- napari/layers/_tests/test_data_protocol.py | 2 +- napari/layers/_tests/test_layer_actions.py | 10 +- napari/layers/_tests/test_utils.py | 10 +- napari/layers/base/_base_constants.py | 25 +- napari/layers/base/_base_mouse_bindings.py | 64 +- napari/layers/base/base.py | 247 ++++--- napari/layers/image/_image_constants.py | 60 +- napari/layers/image/_image_key_bindings.py | 3 +- napari/layers/image/_image_mouse_bindings.py | 3 +- napari/layers/image/_image_utils.py | 13 +- napari/layers/image/_slice.py | 20 +- .../image/_tests/test_big_image_timing.py | 8 +- napari/layers/image/_tests/test_image.py | 8 +- .../layers/image/_tests/test_image_utils.py | 2 +- napari/layers/image/image.py | 682 ++---------------- napari/layers/intensity_mixin.py | 10 +- napari/layers/labels/_labels_constants.py | 4 +- napari/layers/labels/_labels_key_bindings.py | 36 +- napari/layers/labels/_labels_utils.py | 5 +- napari/layers/labels/_tests/test_labels.py | 117 +-- napari/layers/labels/labels.py | 214 +----- napari/layers/points/_points_constants.py | 4 +- napari/layers/points/_points_key_bindings.py | 12 +- .../layers/points/_points_mouse_bindings.py | 6 +- napari/layers/points/_points_utils.py | 11 +- napari/layers/points/_tests/test_points.py | 185 +++-- .../_tests/test_points_mouse_bindings.py | 34 +- napari/layers/points/points.py | 335 +++++---- napari/layers/shapes/_mesh.py | 8 +- napari/layers/shapes/_shape_list.py | 44 +- napari/layers/shapes/_shapes_constants.py | 8 +- napari/layers/shapes/_shapes_key_bindings.py | 51 +- .../layers/shapes/_shapes_models/__init__.py | 2 +- .../shapes/_shapes_models/_polgyon_base.py | 4 +- .../_tests/test_shapes_models.py | 6 +- .../layers/shapes/_shapes_models/ellipse.py | 4 +- napari/layers/shapes/_shapes_models/line.py | 4 +- .../layers/shapes/_shapes_models/rectangle.py | 4 +- napari/layers/shapes/_shapes_models/shape.py | 38 +- .../layers/shapes/_shapes_mouse_bindings.py | 17 +- napari/layers/shapes/_shapes_utils.py | 73 +- .../layers/shapes/_tests/test_shape_list.py | 2 +- napari/layers/shapes/_tests/test_shapes.py | 148 ++-- .../_tests/test_shapes_mouse_bindings.py | 60 +- .../layers/shapes/_tests/test_shapes_utils.py | 26 +- napari/layers/shapes/shapes.py | 128 ++-- napari/layers/surface/_surface_constants.py | 6 +- napari/layers/surface/_tests/test_surface.py | 8 +- .../surface/_tests/test_surface_utils.py | 2 +- napari/layers/surface/surface.py | 32 +- napari/layers/tracks/_tests/test_tracks.py | 62 +- napari/layers/tracks/_track_utils.py | 71 +- napari/layers/tracks/tracks.py | 20 +- napari/layers/utils/_link_layers.py | 61 +- napari/layers/utils/_slice_input.py | 14 +- .../layers/utils/_tests/test_color_manager.py | 2 +- .../utils/_tests/test_interactivity_utils.py | 2 +- .../layers/utils/_tests/test_layer_utils.py | 38 +- .../layers/utils/_tests/test_link_layers.py | 2 +- .../utils/_tests/test_string_encoding.py | 13 + napari/layers/utils/_tests/test_text_utils.py | 12 +- napari/layers/utils/_text_utils.py | 16 +- napari/layers/utils/color_encoding.py | 7 +- napari/layers/utils/color_manager.py | 32 +- napari/layers/utils/color_manager_utils.py | 14 +- napari/layers/utils/color_transformations.py | 9 +- napari/layers/utils/interaction_box.py | 10 +- napari/layers/utils/interactivity_utils.py | 26 +- napari/layers/utils/layer_utils.py | 98 +-- napari/layers/utils/plane.py | 4 +- napari/layers/utils/stack_utils.py | 26 +- napari/layers/utils/string_encoding.py | 11 +- napari/layers/utils/style_encoding.py | 3 +- napari/layers/utils/text_manager.py | 11 +- napari/layers/vectors/_slice.py | 19 +- napari/layers/vectors/_tests/test_vectors.py | 4 +- napari/layers/vectors/_vector_utils.py | 11 +- napari/layers/vectors/_vectors_constants.py | 6 +- .../layers/vectors/_vectors_key_bindings.py | 16 +- napari/layers/vectors/vectors.py | 24 +- napari/plugins/__init__.py | 4 +- napari/plugins/_npe2.py | 241 ++----- napari/plugins/_plugin_manager.py | 86 +-- napari/plugins/_tests/test_exceptions.py | 8 +- .../_tests/test_hook_specifications.py | 12 +- napari/plugins/_tests/test_npe2.py | 20 +- napari/plugins/_tests/test_plugin_widgets.py | 4 +- napari/plugins/_tests/test_provide_theme.py | 62 +- napari/plugins/_tests/test_sample_data.py | 4 +- napari/plugins/exceptions.py | 4 +- napari/plugins/hook_specifications.py | 14 +- napari/plugins/io.py | 33 +- napari/plugins/npe2api.py | 31 +- napari/plugins/utils.py | 18 +- napari/resources/_icons.py | 21 +- napari/settings/__init__.py | 2 +- napari/settings/_appearance.py | 63 +- napari/settings/_application.py | 150 ++-- napari/settings/_base.py | 43 +- napari/settings/_constants.py | 13 + napari/settings/_experimental.py | 20 +- napari/settings/_fields.py | 16 +- napari/settings/_migrations.py | 10 +- napari/settings/_napari_settings.py | 22 +- napari/settings/_plugins.py | 20 +- napari/settings/_shortcuts.py | 12 +- napari/settings/_tests/test_migrations.py | 2 +- napari/settings/_tests/test_settings.py | 48 +- napari/settings/_utils.py | 9 +- napari/settings/_yaml.py | 12 +- napari/types.py | 77 +- napari/utils/_appdirs.py | 2 +- napari/utils/_base.py | 4 +- napari/utils/_dask_utils.py | 11 +- napari/utils/_dtype.py | 4 +- napari/utils/_indexing.py | 12 +- napari/utils/_magicgui.py | 20 +- napari/utils/_proxies.py | 20 +- napari/utils/_register.py | 6 +- napari/utils/_test_utils.py | 14 +- napari/utils/_tests/test_action_manager.py | 6 +- napari/utils/_tests/test_compat.py | 12 +- napari/utils/_tests/test_dtype.py | 2 +- napari/utils/_tests/test_geometry.py | 12 +- napari/utils/_tests/test_history.py | 4 +- napari/utils/_tests/test_info.py | 32 +- napari/utils/_tests/test_io.py | 2 +- napari/utils/_tests/test_key_bindings.py | 20 +- napari/utils/_tests/test_migrations.py | 8 +- napari/utils/_tests/test_misc.py | 10 +- napari/utils/_tests/test_naming.py | 2 +- .../utils/_tests/test_notification_manager.py | 17 +- napari/utils/_tests/test_progress.py | 8 +- napari/utils/_tests/test_proxies.py | 8 +- napari/utils/_tests/test_register.py | 2 +- napari/utils/_tests/test_status.py | 8 +- napari/utils/_tests/test_theme.py | 120 +-- napari/utils/_tests/test_translations.py | 164 ++--- napari/utils/_testsupport.py | 50 +- napari/utils/_tracebacks.py | 79 +- napari/utils/action_manager.py | 18 +- napari/utils/color.py | 3 +- .../colormaps/_tests/test_color_to_array.py | 8 +- .../utils/colormaps/_tests/test_colormap.py | 34 +- .../colormaps/_tests/test_colormap_utils.py | 8 +- .../utils/colormaps/_tests/test_colormaps.py | 14 +- napari/utils/colormaps/bop_colors.py | 6 +- .../utils/colormaps/categorical_colormap.py | 6 +- .../colormaps/categorical_colormap_utils.py | 10 +- napari/utils/colormaps/colorbars.py | 4 +- napari/utils/colormaps/colormap.py | 54 +- napari/utils/colormaps/colormap_utils.py | 49 +- napari/utils/colormaps/inverse_colormaps.py | 10 +- napari/utils/colormaps/standardize_color.py | 27 +- napari/utils/config.py | 4 +- .../utils/events/_tests/test_event_emitter.py | 42 +- .../events/_tests/test_event_migrations.py | 4 +- .../utils/events/_tests/test_event_utils.py | 20 +- .../utils/events/_tests/test_evented_dict.py | 28 +- .../utils/events/_tests/test_evented_list.py | 10 +- .../utils/events/_tests/test_evented_model.py | 17 +- .../utils/events/_tests/test_evented_set.py | 14 +- .../events/_tests/test_selectable_list.py | 3 +- napari/utils/events/_tests/test_selection.py | 4 +- napari/utils/events/_tests/test_typed_dict.py | 16 +- napari/utils/events/_tests/test_typed_list.py | 26 +- napari/utils/events/containers/_dict.py | 27 +- .../utils/events/containers/_evented_dict.py | 35 +- .../utils/events/containers/_evented_list.py | 47 +- .../utils/events/containers/_nested_list.py | 52 +- .../events/containers/_selectable_list.py | 24 +- napari/utils/events/containers/_selection.py | 48 +- napari/utils/events/containers/_set.py | 36 +- napari/utils/events/containers/_typed.py | 46 +- napari/utils/events/custom_types.py | 25 +- napari/utils/events/debugging.py | 24 +- napari/utils/events/event.py | 90 ++- napari/utils/events/evented_model.py | 34 +- napari/utils/events/migrations.py | 6 +- napari/utils/geometry.py | 34 +- napari/utils/history.py | 5 +- napari/utils/indexing.py | 2 +- napari/utils/info.py | 100 +-- napari/utils/interactions.py | 9 +- napari/utils/io.py | 42 +- napari/utils/key_bindings.py | 15 +- napari/utils/migrations.py | 22 +- napari/utils/misc.py | 56 +- napari/utils/mouse_bindings.py | 11 +- napari/utils/naming.py | 9 +- napari/utils/notebook_display.py | 4 +- napari/utils/notifications.py | 49 +- napari/utils/perf/__init__.py | 18 +- napari/utils/perf/_config.py | 42 +- napari/utils/perf/_event.py | 12 +- napari/utils/perf/_patcher.py | 28 +- napari/utils/perf/_stat.py | 2 +- napari/utils/perf/_timers.py | 106 ++- napari/utils/perf/_trace_file.py | 38 +- napari/utils/progress.py | 11 +- napari/utils/shortcuts.py | 11 +- napari/utils/status_messages.py | 4 +- napari/utils/stubgen.py | 39 +- napari/utils/theme.py | 48 +- napari/utils/transforms/__init__.py | 12 +- napari/utils/transforms/transform_utils.py | 6 +- napari/utils/transforms/transforms.py | 93 ++- napari/utils/translations.py | 52 +- napari/utils/tree/__init__.py | 2 +- napari/utils/tree/_tests/test_tree_model.py | 24 +- napari/utils/tree/group.py | 13 +- napari/utils/tree/node.py | 15 +- napari/utils/validators.py | 9 +- napari/view_layers.py | 230 +++--- napari/viewer.py | 6 +- napari/window.py | 2 +- napari_builtins/__init__.py | 2 +- napari_builtins/_ndims_balls.py | 12 +- napari_builtins/_tests/conftest.py | 3 +- napari_builtins/_tests/test_io.py | 8 +- napari_builtins/_tests/test_ndims_balls.py | 24 +- napari_builtins/_tests/test_reader.py | 16 +- napari_builtins/_tests/test_writer.py | 4 +- napari_builtins/io/_read.py | 55 +- napari_builtins/io/_write.py | 18 +- pyproject.toml | 386 ++++++++-- resources/constraints/constraints_py3.10.txt | 255 ++++--- .../constraints/constraints_py3.10_docs.txt | 263 +++---- .../constraints_py3.10_pydantic_1.txt | 251 ++++--- resources/constraints/constraints_py3.11.txt | 255 ++++--- .../constraints_py3.11_pydantic_1.txt | 251 ++++--- ...aints_py3.8.txt => constraints_py3.12.txt} | 359 +++++---- ....txt => constraints_py3.12_pydantic_1.txt} | 356 +++++---- .../constraints/constraints_py3.8_min_req.txt | 0 resources/constraints/constraints_py3.9.txt | 255 ++++--- .../constraints_py3.9_examples.txt | 259 +++---- .../constraints/constraints_py3.9_min_req.txt | 0 .../constraints_py3.9_pydantic_1.txt | 251 ++++--- resources/constraints/version_denylist.txt | 2 +- setup.cfg | 235 ------ tools/check_updated_packages.py | 82 ++- tools/create_pr_or_update_existing_one.py | 192 ++--- tools/perfmon/compare_callable.py | 4 +- tools/perfmon/plot_callable.py | 4 +- tools/perfmon/run.py | 4 +- tools/remove_html_comments_from_pr.py | 32 +- tools/split_qt_backend.py | 8 +- tools/string_list.json | 19 +- tools/validate_strings.py | 126 ++-- tox.ini | 11 +- 571 files changed, 10185 insertions(+), 16902 deletions(-) rename examples/{surface_multi_texture.py => surface_multi_texture_.py} (87%) create mode 100644 napari/_qt/_qapp_model/_tests/test_qproviders.py rename napari/{_vendor/experimental => _qt/_qapp_model/injection}/__init__.py (100%) create mode 100644 napari/_qt/_qapp_model/injection/_qprocessors.py create mode 100644 napari/_qt/_qapp_model/injection/_qproviders.py create mode 100644 napari/_qt/_qapp_model/qactions/_plugins.py create mode 100644 napari/_qt/_qplugins/__init__.py create mode 100644 napari/_qt/_qplugins/_qnpe2.py delete mode 100644 napari/_qt/menus/plugins_menu.py create mode 100644 napari/_tests/test_providers.py delete mode 100644 napari/_vendor/experimental/cachetools/CHANGELOG.rst delete mode 100644 napari/_vendor/experimental/cachetools/LICENSE delete mode 100644 napari/_vendor/experimental/cachetools/__init__.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/__init__.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/abc.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/cache.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/decorators.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/func.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/keys.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/lfu.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/lru.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/rr.py delete mode 100644 napari/_vendor/experimental/cachetools/cachetools/ttl.py delete mode 100644 napari/_vendor/experimental/cachetools/docs/.gitignore delete mode 100644 napari/_vendor/experimental/cachetools/docs/Makefile delete mode 100644 napari/_vendor/experimental/cachetools/docs/conf.py delete mode 100644 napari/_vendor/experimental/cachetools/docs/index.rst delete mode 100644 napari/_vendor/experimental/humanize/LICENCE delete mode 100644 napari/_vendor/experimental/humanize/README.md delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/__init__.py delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/filesize.py delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/i18n.py delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/es_ES/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/fa_IR/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/fi_FI/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/id_ID/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/it_IT/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/ja_JP/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/ko_KR/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/nl_NL/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/pt_BR/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/pt_PT/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/sk_SK/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/tr_TR/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/uk_UA/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/vi_VI/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/locale/zh_CN/LC_MESSAGES/humanize.po delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/number.py delete mode 100644 napari/_vendor/experimental/humanize/src/humanize/time.py delete mode 100644 napari/_vendor/experimental/vendor.txt create mode 100644 napari/_vispy/layers/scalar_field.py rename napari/{_vendor/experimental/humanize => layers/_scalar_field}/__init__.py (100%) create mode 100644 napari/layers/_scalar_field/scalar_field.py rename resources/constraints/{constraints_py3.8.txt => constraints_py3.12.txt} (57%) rename resources/constraints/{constraints_py3.8_pydantic_1.txt => constraints_py3.12_pydantic_1.txt} (56%) delete mode 100644 resources/constraints/constraints_py3.8_min_req.txt rename napari/_vendor/experimental/humanize/src/__init__.py => resources/constraints/constraints_py3.9_min_req.txt (100%) delete mode 100644 setup.cfg diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 064732d3dcc..0c6cd90b53b 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -67,7 +67,7 @@ jobs: name: Install Python with: python-version: "3.11" - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 @@ -168,5 +168,5 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: asv-benchmark-results-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + name: asv-benchmark-results-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}-${{ matrix.benchmark-name }} path: .asv/results diff --git a/.github/workflows/make_release.yml b/.github/workflows/make_release.yml index cecb5bd77d7..188ac956376 100644 --- a/.github/workflows/make_release.yml +++ b/.github/workflows/make_release.yml @@ -21,7 +21,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.11 - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - name: Install Dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/reusable_build_wheel.yml b/.github/workflows/reusable_build_wheel.yml index 5ce5259f5a5..5f24aa53dd1 100644 --- a/.github/workflows/reusable_build_wheel.yml +++ b/.github/workflows/reusable_build_wheel.yml @@ -15,7 +15,7 @@ jobs: with: python-version: 3.11 cache: "pip" - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - name: Install Dependencies run: | diff --git a/.github/workflows/reusable_coverage_upload.yml b/.github/workflows/reusable_coverage_upload.yml index 3bbc3159aa3..e57380c9671 100644 --- a/.github/workflows/reusable_coverage_upload.yml +++ b/.github/workflows/reusable_coverage_upload.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.x" - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml cache: 'pip' - name: Install Dependencies diff --git a/.github/workflows/reusable_run_tox_test.yml b/.github/workflows/reusable_run_tox_test.yml index 8267679f7a4..042046cc8e6 100644 --- a/.github/workflows/reusable_run_tox_test.yml +++ b/.github/workflows/reusable_run_tox_test.yml @@ -64,7 +64,7 @@ jobs: with: python-version: ${{ inputs.python_version }} cache: "pip" - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 @@ -87,6 +87,8 @@ jobs: run: | pip install --upgrade pip pip install setuptools tox tox-gh-actions tox-min-req + env: + PIP_CONSTRAINT: "" - name: create _version.py file # workaround for not using src layout @@ -181,7 +183,7 @@ jobs: - name: Upload pytest timing reports as json ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} uses: actions/upload-artifact@v4 with: - name: upload pytest timing json ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} + name: upload pytest timing json ${{ inputs.platform }} py ${{ inputs.python_version }} ${{ inputs.toxenv || inputs.qt_backend }} ${{ inputs.tox_extras }} path: | ./report-*.json diff --git a/.github/workflows/test_comprehensive.yml b/.github/workflows/test_comprehensive.yml index bcd3e2424ca..a57277618e1 100644 --- a/.github/workflows/test_comprehensive.yml +++ b/.github/workflows/test_comprehensive.yml @@ -9,6 +9,9 @@ on: - "v*x" tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 + pull_request: + paths: + - '.github/workflows/test_comprehensive.yml' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -47,22 +50,22 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.9", "3.10", "3.11", "3.12"] backend: [pyqt5, pyside2] include: - - python: "3.9" + - python: "3.10" platform: macos-latest backend: pyqt5 # test with minimum specified requirements - - python: "3.8" + - python: "3.9" platform: ubuntu-20.04 backend: pyqt5 MIN_REQ: 1 # test without any Qt backends - - python: "3.8" + - python: "3.9" platform: ubuntu-20.04 backend: headless - - python: "3.11" + - python: "3.12" platform: ubuntu-latest backend: pyqt6 tox_extras: "testing_extra" @@ -73,6 +76,10 @@ jobs: exclude: - python: "3.11" backend: pyside2 + - python: "3.12" + backend: pyside2 + - platform: windows-latest + backend: pyside2 with: python_version: ${{ matrix.python }} platform: ${{ matrix.platform }} diff --git a/.github/workflows/test_prereleases.yml b/.github/workflows/test_prereleases.yml index 361632d08cf..8b767547334 100644 --- a/.github/workflows/test_prereleases.yml +++ b/.github/workflows/test_prereleases.yml @@ -35,7 +35,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 diff --git a/.github/workflows/test_pull_requests.yml b/.github/workflows/test_pull_requests.yml index 9fec2b5d330..86f8399abdb 100644 --- a/.github/workflows/test_pull_requests.yml +++ b/.github/workflows/test_pull_requests.yml @@ -42,7 +42,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml cache: 'pip' - name: Install Dependencies run: pip install --upgrade pip @@ -84,11 +84,11 @@ jobs: fail-fast: false matrix: include: - - python: 3.8 + - python: 3.9 platform: ubuntu-latest backend: pyqt5 pydantic: "_pydantic_1" - - python: 3.11 + - python: 3.12 platform: ubuntu-latest backend: pyqt6 pydantic: "" @@ -107,46 +107,51 @@ jobs: fail-fast: false matrix: platform: [ ubuntu-latest ] - python: [ "3.9", "3.10" ] - backend: [ "pyqt5,pyside2" ] + python: [ "3.9", "3.11" ] + backend: [ "pyqt5,pyside6" ] coverage: [ cov ] pydantic: ["_pydantic_1"] include: - # Windows py39 - - python: 3.9 + # Windows py310 + - python: "3.10" platform: windows-latest - backend: pyqt5,pyside2 + backend: pyqt5 #,pyside2 coverage: no_cov - - python: 3.11 + - python: "3.12" platform: windows-latest backend: pyqt6 coverage: no_cov - - python: 3.11 + - python: "3.12" platform: macos-latest backend: pyqt5 coverage: no_cov # minimum specified requirements - - python: 3.8 + - python: "3.9" platform: ubuntu-20.04 backend: pyqt5 MIN_REQ: 1 coverage: cov - - python: "3.10" + - python: "3.11" platform: ubuntu-22.04 backend: pyqt5 coverage: cov pydantic: "" tox_extras: "optional" # test without any Qt backends - - python: 3.9 + - python: "3.10" platform: ubuntu-20.04 backend: headless coverage: no_cov - - python: 3.11 + - python: "3.12" platform: ubuntu-latest - backend: pyqt6,pyside6 + backend: pyqt6 coverage: cov tox_extras: "testing_extra" + # pyside2 test + - python: "3.10" + platform: ubuntu-latest + backend: pyside2 + coverage: no_cov with: python_version: ${{ matrix.python }} @@ -187,25 +192,51 @@ jobs: name: test benchmarks runs-on: ubuntu-latest timeout-minutes: 60 + env: + GIT_LFS_SKIP_SMUDGE: 1 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 with: python-version: 3.11 - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - uses: tlambert03/setup-qt-libs@v1 + - uses: octokit/request-action@v2.x + # here we get hash of the latest release commit to compare with PR + id: latest_release + with: + route: GET /repos/{owner}/{repo}/releases/latest + owner: napari + repo: napari + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: install dependencies run: | pip install --upgrade pip pip install asv[virtualenv] - - name: Run benchmarks + - name: asv machine + run: asv machine --yes + + - name: Run benchmarks PR uses: aganders3/headless-gui@v2 with: run: | - asv machine --yes asv run --show-stderr --quick --attribute timeout=300 HEAD^! env: - PR: 1 + PR: 1 # prevents asv from running very compute-intensive benchmarks + + - name: Run benchmarks latest release + # here we check if the benchmark on the latest release is not broken + uses: aganders3/headless-gui@v2 + with: + run: | + asv run --show-stderr --quick --attribute timeout=300 ${{ fromJSON(steps.latest_release.outputs.data).target_commitish }}^! + env: + PR: 1 # prevents asv from running very compute-intensive benchmarks diff --git a/.github/workflows/test_translations.yml b/.github/workflows/test_translations.yml index e623c9a0997..2a6f2955537 100644 --- a/.github/workflows/test_translations.yml +++ b/.github/workflows/test_translations.yml @@ -19,7 +19,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.11" - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - name: Install napari run: | pip install -e .[all] diff --git a/.github/workflows/test_typing.yml b/.github/workflows/test_typing.yml index 04cfb356c50..28f3dbd8bbc 100644 --- a/.github/workflows/test_typing.yml +++ b/.github/workflows/test_typing.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - name: Install tox run: | pip install tox diff --git a/.github/workflows/test_vendored.yml b/.github/workflows/test_vendored.yml index 013fda8cc19..bc91321a5f0 100644 --- a/.github/workflows/test_vendored.yml +++ b/.github/workflows/test_vendored.yml @@ -20,7 +20,7 @@ jobs: run: python tools/check_vendored_modules.py --ci - name: Create PR updating vendored modules - uses: peter-evans/create-pull-request@v5 + uses: peter-evans/create-pull-request@v6 with: commit-message: Update vendored modules. branch: update-vendored-examples diff --git a/.github/workflows/upgrade_test_constraints.yml b/.github/workflows/upgrade_test_constraints.yml index 83cc54c2bb6..98a31893285 100644 --- a/.github/workflows/upgrade_test_constraints.yml +++ b/.github/workflows/upgrade_test_constraints.yml @@ -145,26 +145,26 @@ jobs: git remote -v # START PYTHON DEPENDENCIES - - uses: actions/setup-python@v5 - with: - python-version: "3.8" - cache: pip - cache-dependency-path: 'setup.cfg' - uses: actions/setup-python@v5 with: python-version: "3.9" cache: pip - cache-dependency-path: 'setup.cfg' + cache-dependency-path: 'pyproject.toml' - uses: actions/setup-python@v5 with: python-version: "3.10" cache: pip - cache-dependency-path: 'setup.cfg' + cache-dependency-path: 'pyproject.toml' - uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - cache-dependency-path: 'setup.cfg' + cache-dependency-path: 'pyproject.toml' + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: 'pyproject.toml' - name: Upgrade Python dependencies # ADD YOUR CUSTOM DEPENDENCY UPGRADE COMMANDS BELOW @@ -174,9 +174,9 @@ jobs: # python3.8 -m piptools compile - call pip-compile but ensure proper interpreter # --upgrade upgrade to the latest possible version. Without this pip-compile will take a look to output files and reuse versions (so will ad something on when adding dependency. # -o resources/constraints/constraints_py3.8.txt - output file - # setup.cfg resources/constraints/version_denylist.txt - source files. the resources/constraints/version_denylist.txt - contains our test specific constraints like pytes-cov` + # pyproject.toml resources/constraints/version_denylist.txt - source files. the resources/constraints/version_denylist.txt - contains our test specific constraints like pytes-cov` # - # --extra pyqt5 etc - names of extra sections from setup.cfg that should be checked for the dependencies list (maybe we could create a super extra section to collect them all in) + # --extra pyqt5 etc - names of extra sections from pyproject.toml that should be checked for the dependencies list (maybe we could create a super extra section to collect them all in) flags+=" --extra pyqt5" flags+=" --extra pyqt6_experimental" flags+=" --extra pyside2" @@ -185,7 +185,7 @@ jobs: flags+=" --extra testing_extra" flags+=" --extra optional" prefix="napari_repo" - setup_cfg="${prefix}/setup.cfg" + pyproject_toml="${prefix}/pyproject.toml" constraints="${prefix}/resources/constraints" @@ -198,14 +198,14 @@ jobs: # future default resolver. It is faster. Lower probability of long CI run. flags+=" --resolver=backtracking" - for pyv in 3.8 3.9 3.10 3.11; do + for pyv in 3.9 3.10 3.11 3.12; do python${pyv} -m pip install -U pip pip-tools - python${pyv} -m piptools compile --upgrade -o $constraints/constraints_py${pyv}.txt $setup_cfg $constraints/version_denylist.txt ${flags} - python${pyv} -m piptools compile --upgrade -o $constraints/constraints_py${pyv}_pydantic_1.txt $setup_cfg $constraints/version_denylist.txt $constraints/pydantic_le_2.txt ${flags} + python${pyv} -m piptools compile --upgrade -o $constraints/constraints_py${pyv}.txt $pyproject_toml $constraints/version_denylist.txt ${flags} + python${pyv} -m piptools compile --upgrade -o $constraints/constraints_py${pyv}_pydantic_1.txt $pyproject_toml $constraints/version_denylist.txt $constraints/pydantic_le_2.txt ${flags} done - python3.9 -m piptools compile --upgrade -o $constraints/constraints_py3.9_examples.txt $setup_cfg $constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt ${flags} - python3.10 -m piptools compile --upgrade -o $constraints/constraints_py3.10_docs.txt $setup_cfg $constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt docs/requirements.txt $constraints/pydantic_le_2.txt ${flags} + python3.9 -m piptools compile --upgrade -o $constraints/constraints_py3.9_examples.txt $pyproject_toml $constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt ${flags} + python3.10 -m piptools compile --upgrade -o $constraints/constraints_py3.10_docs.txt $pyproject_toml $constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt docs/requirements.txt $constraints/pydantic_le_2.txt ${flags} python3.11 -m piptools compile --upgrade -o resources/requirements_mypy.txt resources/requirements_mypy.in --resolver=backtracking # END PYTHON DEPENDENCIES diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d49d68d762..ede2a5fd7d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ exclude: _vendor|vendored repos: - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.1.1 + rev: 24.2.0 hooks: - id: black pass_filenames: true exclude: examples - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.14 + rev: v0.3.2 hooks: - id: ruff - repo: https://github.com/seddonym/import-linter @@ -16,7 +16,7 @@ repos: - id: import-linter stages: [manual] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.27.3 + rev: 0.28.0 hooks: - id: check-github-workflows - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/examples/3d_kymograph_.py b/examples/3d_kymograph_.py index 0eaa6418b77..2cf41ae0a4d 100644 --- a/examples/3d_kymograph_.py +++ b/examples/3d_kymograph_.py @@ -14,18 +14,18 @@ try: from omero.gateway import BlitzGateway except ModuleNotFoundError: - print("Could not import BlitzGateway which is") - print("required to download the sample datasets.") - print("Please install omero-py:") - print("https://pypi.org/project/omero-py/") + print('Could not import BlitzGateway which is') + print('required to download the sample datasets.') + print('Please install omero-py:') + print('https://pypi.org/project/omero-py/') exit(-1) try: from tqdm import tqdm except ModuleNotFoundError: - print("Could not import tqdm which is") - print("required to show progress when downloading the sample datasets.") - print("Please install tqdm:") - print("https://pypi.org/project/tqdm/") + print('Could not import tqdm which is') + print('required to show progress when downloading the sample datasets.') + print('Please install tqdm:') + print('https://pypi.org/project/tqdm/') exit(-1) import napari @@ -44,15 +44,15 @@ def IDR_fetch_image(image_id: int, progressbar: bool = True) -> np.ndarray: """ conn = BlitzGateway( - host="ws://idr.openmicroscopy.org/omero-ws", - username="public", - passwd="public", + host='ws://idr.openmicroscopy.org/omero-ws', + username='public', + passwd='public', secure=True, ) conn.connect() conn.c.enableKeepAlive(60) - idr_img = conn.getObject("Image", image_id) + idr_img = conn.getObject('Image', image_id) idr_pixels = idr_img.getPrimaryPixels() _ = idr_img @@ -73,7 +73,7 @@ def IDR_fetch_image(image_id: int, progressbar: bool = True) -> np.ndarray: _tmp = np.asarray(list(idr_plane_iterator)) _tmp = _tmp.reshape((nz, nc, nt, ny, nx)) # the following line reorders the axes (no summing, despite the name) - return np.einsum("jmikl", _tmp) + return np.einsum('jmikl', _tmp) description = """ @@ -133,29 +133,29 @@ def IDR_fetch_image(image_id: int, progressbar: bool = True) -> np.ndarray: print(description) samples = ( - {"IDRid": 2864587, "description": "AURKB knockdown", "vol": None}, - {"IDRid": 2862565, "description": "KIF11 knockdown", "vol": None}, - {"IDRid": 2867896, "description": "INCENP knockdown", "vol": None}, - {"IDRid": 1486532, "description": "TMPRSS11A knockdown", "vol": None}, + {'IDRid': 2864587, 'description': 'AURKB knockdown', 'vol': None}, + {'IDRid': 2862565, 'description': 'KIF11 knockdown', 'vol': None}, + {'IDRid': 2867896, 'description': 'INCENP knockdown', 'vol': None}, + {'IDRid': 1486532, 'description': 'TMPRSS11A knockdown', 'vol': None}, ) -print("-------------------------------------------------------") -print("Sample datasets will require ~490 MB download from IDR.") +print('-------------------------------------------------------') +print('Sample datasets will require ~490 MB download from IDR.') answer = input("Press Enter to proceed, 'n' to cancel: ") if answer.lower().startswith('n'): - print("User cancelled download. Exiting.") + print('User cancelled download. Exiting.') exit(0) -print("-------------------------------------------------------") +print('-------------------------------------------------------') for s in samples: print(f"Downloading sample {s['IDRid']}.") print(f"Description: {s['description']}") - s["vol"] = np.squeeze(IDR_fetch_image(s["IDRid"])) + s['vol'] = np.squeeze(IDR_fetch_image(s['IDRid'])) v = napari.Viewer(ndisplay=3) scale = (5, 1, 1) # "stretch" time domain for s in samples: v.add_image( - s["vol"], name=s['description'], scale=scale, blending="opaque" + s['vol'], name=s['description'], scale=scale, blending='opaque' ) v.grid.enabled = True # show the volumes in grid mode diff --git a/examples/add_labels.py b/examples/add_labels.py index 25bc395de00..60963b1c128 100644 --- a/examples/add_labels.py +++ b/examples/add_labels.py @@ -25,7 +25,7 @@ cleared = remove_small_objects(clear_border(bw), 20) # label image regions -label_image = label(cleared).astype("uint8") +label_image = label(cleared).astype('uint8') # initialise viewer with coins image viewer = napari.view_image(image, name='coins', rgb=False) diff --git a/examples/add_labels_with_features.py b/examples/add_labels_with_features.py index e0dbbe09e3d..05314be4e0b 100644 --- a/examples/add_labels_with_features.py +++ b/examples/add_labels_with_features.py @@ -45,7 +45,7 @@ 'row': ['none'] + ['top'] * 4 + ['bottom'] * 4, # background is row: none - 'size': ["none", *coin_sizes], # background is size: none + 'size': ['none', *coin_sizes], # background is size: none } colors = {1: 'white', 2: 'blue', 3: 'green', 4: 'red', 5: 'yellow', diff --git a/examples/annotate-2d.py b/examples/annotate-2d.py index 770c4cf980e..602e575ccd5 100644 --- a/examples/annotate-2d.py +++ b/examples/annotate-2d.py @@ -13,7 +13,7 @@ import napari -print("click to add points; close the window when finished.") +print('click to add points; close the window when finished.') viewer = napari.view_image(data.astronaut(), rgb=True) points = viewer.add_points(np.zeros((0, 2))) @@ -22,5 +22,5 @@ if __name__ == '__main__': napari.run() - print("you clicked on:") + print('you clicked on:') print(points.data) diff --git a/examples/clipboard_.py b/examples/clipboard_.py index 4b933ad89c8..f1da715cc9d 100644 --- a/examples/clipboard_.py +++ b/examples/clipboard_.py @@ -19,10 +19,10 @@ class Grabber(QWidget): def __init__(self) -> None: super().__init__() - self.copy_canvas_btn = QPushButton("Copy Canvas to Clipboard", self) - self.copy_canvas_btn.setToolTip("Copy screenshot of the canvas to clipboard.") - self.copy_viewer_btn = QPushButton("Copy Viewer to Clipboard", self) - self.copy_viewer_btn.setToolTip("Copy screenshot of the entire viewer to clipboard.") + self.copy_canvas_btn = QPushButton('Copy Canvas to Clipboard', self) + self.copy_canvas_btn.setToolTip('Copy screenshot of the canvas to clipboard.') + self.copy_viewer_btn = QPushButton('Copy Viewer to Clipboard', self) + self.copy_viewer_btn.setToolTip('Copy screenshot of the entire viewer to clipboard.') layout = QVBoxLayout(self) layout.addWidget(self.copy_canvas_btn) diff --git a/examples/clipping_planes_interactive_.py b/examples/clipping_planes_interactive_.py index 5687da5d95c..88fcaa6c3be 100644 --- a/examples/clipping_planes_interactive_.py +++ b/examples/clipping_planes_interactive_.py @@ -121,7 +121,7 @@ def shift_plane_along_normal(viewer, event): # Get transform which maps from data (vispy) to canvas # note that we're using a private attribute here, which may not be present in future napari versions visual2canvas = viewer.window._qt_viewer.canvas.layer_to_visual[volume_layer].node.get_transform( - map_from="visual", map_to="canvas" + map_from='visual', map_to='canvas' ) # Find start and end positions of plane normal in canvas coordinates @@ -148,7 +148,7 @@ def shift_plane_along_normal(viewer, event): start_position_canv = event.pos yield - while event.type == "mouse_move": + while event.type == 'mouse_move': # Get end position in canvas coordinates end_position_canv = event.pos @@ -190,9 +190,9 @@ def shift_plane_along_normal(viewer, event): viewer.camera.angles = (45, 45, 45) viewer.camera.zoom = 5 viewer.text_overlay.update({ - "text": 'Drag the clipping plane surface to move it along its normal.', - "font_size": 20, - "visible": True, + 'text': 'Drag the clipping plane surface to move it along its normal.', + 'font_size': 20, + 'visible': True, }) if __name__ == '__main__': diff --git a/examples/dev/demo_shape_creation.py b/examples/dev/demo_shape_creation.py index 404b7d53a51..8893555649e 100644 --- a/examples/dev/demo_shape_creation.py +++ b/examples/dev/demo_shape_creation.py @@ -30,39 +30,39 @@ def time_me(label, func): t = default_timer() res = func() t = default_timer() - t - print(f"{label}: {t:.4f} s") + print(f'{label}: {t:.4f} s') return res -if __name__ == "__main__": +if __name__ == '__main__': - parser = argparse.ArgumentParser(description="") + parser = argparse.ArgumentParser(description='') parser.add_argument( - "-n", - "--n_polys", + '-n', + '--n_polys', type=int, default=5000, help='number of polygons to show', ) parser.add_argument( - "-t", - "--type", + '-t', + '--type', type=str, - default="path", + default='path', choices=['path', 'path_concat', 'polygon', 'rectangle', 'ellipse'], ) parser.add_argument( - "-c", - "--concat", - action="store_true", + '-c', + '--concat', + action='store_true', help='concatenate all coordinates to a single mesh', ) parser.add_argument( - "-v", "--view", action="store_true", help='show napari viewer' + '-v', '--view', action='store_true', help='show napari viewer' ) parser.add_argument( - "--properties", action="store_true", help='add dummy shape properties' + '--properties', action='store_true', help='add dummy shape properties' ) args = parser.parse_args() @@ -90,16 +90,16 @@ def time_me(label, func): color_cycle = ['blue', 'magenta', 'green'] kwargs = { - "shape_type": args.type, - "properties": properties if args.properties else None, - "face_color": 'class' if args.properties else [1,1,1,1], - "face_color_cycle": color_cycle, - "edge_color": 'class' if args.properties else [1,1,1,1], - "edge_color_cycle": color_cycle, + 'shape_type': args.type, + 'properties': properties if args.properties else None, + 'face_color': 'class' if args.properties else [1,1,1,1], + 'face_color_cycle': color_cycle, + 'edge_color': 'class' if args.properties else [1,1,1,1], + 'edge_color_cycle': color_cycle, } layer = time_me( - "time to create layer", + 'time to create layer', lambda: napari.layers.Shapes(coords, **kwargs), ) diff --git a/examples/dev/gui_notifications.py b/examples/dev/gui_notifications.py index e6f3f6c2465..5893b4812d4 100644 --- a/examples/dev/gui_notifications.py +++ b/examples/dev/gui_notifications.py @@ -12,7 +12,7 @@ def raise_(): def warn_(): - warnings.warn("warning!") + warnings.warn('warning!') viewer = napari.Viewer() diff --git a/examples/dev/gui_notifications_threaded.py b/examples/dev/gui_notifications_threaded.py index ae89ee1aa54..3af4f3af6fd 100644 --- a/examples/dev/gui_notifications_threaded.py +++ b/examples/dev/gui_notifications_threaded.py @@ -15,7 +15,7 @@ def make_warning(*_): @thread_worker(start_thread=True) def make_error(*_): time.sleep(0.05) - raise ValueError("Error in another thread") + raise ValueError('Error in another thread') viewer = napari.Viewer() diff --git a/examples/dev/leaking_check.py b/examples/dev/leaking_check.py index 36f86f0adae..da0c6c0145f 100644 --- a/examples/dev/leaking_check.py +++ b/examples/dev/leaking_check.py @@ -12,33 +12,33 @@ process = psutil.Process(os.getpid()) viewer = napari.Viewer() -print("mem", process.memory_info().rss) +print('mem', process.memory_info().rss) for _ in range(0): print(viewer.add_image(np.random.random((60, 1000, 1000))).name) for _ in range(2): print(viewer.add_labels((np.random.random((2, 1000, 1000)) * 10).astype(np.uint8)).name) -print("mem", process.memory_info().rss) +print('mem', process.memory_info().rss) # napari.run() -print("controls", viewer.window.qt_viewer.controls.widgets) +print('controls', viewer.window.qt_viewer.controls.widgets) li = weakref.ref(viewer.layers[0]) data_li = weakref.ref(li()._data) controls = weakref.ref(viewer.window.qt_viewer.controls.widgets[li()]) -objgraph.show_backrefs(li(), filename="base.png") +objgraph.show_backrefs(li(), filename='base.png') del viewer.layers[0] qtpy.QtGui.QGuiApplication.processEvents() gc.collect() gc.collect() print(li()) -objgraph.show_backrefs(li(), max_depth=10, filename="test.png", refcounts=True) -objgraph.show_backrefs(controls(), max_depth=10, filename="controls.png", refcounts=True) -objgraph.show_backrefs(data_li(), max_depth=10, filename="test_data.png") -print("controls", viewer.window.qt_viewer.controls.widgets) -print("controls", gc.get_referrers(controls())) -print("controls", controls().parent()) +objgraph.show_backrefs(li(), max_depth=10, filename='test.png', refcounts=True) +objgraph.show_backrefs(controls(), max_depth=10, filename='controls.png', refcounts=True) +objgraph.show_backrefs(data_li(), max_depth=10, filename='test_data.png') +print('controls', viewer.window.qt_viewer.controls.widgets) +print('controls', gc.get_referrers(controls())) +print('controls', controls().parent()) #print("controls", controls().parent().indexOf(controls())) print(gc.get_referrers(li())) print(gc.get_referrers(li())[1]) diff --git a/examples/dev/q_list_view.py b/examples/dev/q_list_view.py index fb27cce24d7..629b4080b30 100644 --- a/examples/dev/q_list_view.py +++ b/examples/dev/q_list_view.py @@ -37,14 +37,14 @@ def __str__(self): # spy on events -root.events.reordered.connect(lambda e: print("reordered to: ", e.value)) +root.events.reordered.connect(lambda e: print('reordered to: ', e.value)) root.selection.events.changed.connect( lambda e: print( - f"selection changed. added: {e.added}, removed: {e.removed}" + f'selection changed. added: {e.added}, removed: {e.removed}' ) ) root.selection.events._current.connect( - lambda e: print(f"current item changed to: {e.value}") + lambda e: print(f'current item changed to: {e.value}') ) diff --git a/examples/dev/q_node_tree.py b/examples/dev/q_node_tree.py index d92a31148be..e17cf1fcafb 100644 --- a/examples/dev/q_node_tree.py +++ b/examples/dev/q_node_tree.py @@ -28,18 +28,18 @@ Group( [ Node(name='1'), - Group([Node(name='2'), Node(name='3')], name="g2"), + Group([Node(name='2'), Node(name='3')], name='g2'), Node(name='4'), Node(name='5'), Node(name='tip'), ], - name="g1", + name='g1', ), Node(name='7'), Node(name='8'), Node(name='9'), ], - name="root", + name='root', ) # create Qt view onto the Group view = QtNodeTreeView(root) @@ -65,14 +65,14 @@ # spy on events -root.events.reordered.connect(lambda e: print("reordered to: ", e.value)) +root.events.reordered.connect(lambda e: print('reordered to: ', e.value)) root.selection.events.changed.connect( lambda e: print( - f"selection changed. added: {e.added}, removed: {e.removed}" + f'selection changed. added: {e.added}, removed: {e.removed}' ) ) root.selection.events._current.connect( - lambda e: print(f"current item changed to: {e.value}") + lambda e: print(f'current item changed to: {e.value}') ) napari.run() diff --git a/examples/dev/slicing/random_points.py b/examples/dev/slicing/random_points.py index 55ad0d4489a..9bed4777e19 100644 --- a/examples/dev/slicing/random_points.py +++ b/examples/dev/slicing/random_points.py @@ -10,7 +10,7 @@ parser = argparse.ArgumentParser() parser.add_argument( - "n", type=int, nargs='?', default=10_000_000, help="(default: %(default)s)" + 'n', type=int, nargs='?', default=10_000_000, help='(default: %(default)s)' ) args = parser.parse_args() diff --git a/examples/fourier_transform_playground.py b/examples/fourier_transform_playground.py index daed3d785ef..2addea5ab65 100644 --- a/examples/fourier_transform_playground.py +++ b/examples/fourier_transform_playground.py @@ -94,7 +94,7 @@ def combine_and_set_data(wave_args): update_layer(f'wave {name}', data) -@thread_worker(connect={"yielded": combine_and_set_data}) +@thread_worker(connect={'yielded': combine_and_set_data}) def update_viewer(): # keep track of each wave in a dictionary by id, this way we can modify/remove # existing waves or add new ones diff --git a/examples/image_custom_kernel.py b/examples/image_custom_kernel.py index 670b7d09d90..aacf05088c7 100644 --- a/examples/image_custom_kernel.py +++ b/examples/image_custom_kernel.py @@ -49,9 +49,9 @@ def ridge_detection_kernel(): @magicgui( auto_call=True, - kernel_size={"widget_type": 'Slider', "min": 1, "max": 20}, - sigma={"widget_type": 'FloatSlider', "min": 0.1, "max": 5, "step": 0.1}, - kernel_type={"choices": ['none', 'gaussian', 'sharpen', 'ridge_detection']}, + kernel_size={'widget_type': 'Slider', 'min': 1, 'max': 20}, + sigma={'widget_type': 'FloatSlider', 'min': 0.1, 'max': 5, 'step': 0.1}, + kernel_type={'choices': ['none', 'gaussian', 'sharpen', 'ridge_detection']}, ) def gpu_kernel(image: napari.layers.Image, kernel_type: str = 'gaussian', kernel_size: int = 5, sigma: float = 1): if kernel_type == 'none': diff --git a/examples/inherit_viewer_style.py b/examples/inherit_viewer_style.py index 030727b908f..fb281c6a9d1 100644 --- a/examples/inherit_viewer_style.py +++ b/examples/inherit_viewer_style.py @@ -44,9 +44,9 @@ def __init__(self, parent=None) -> None: self.second_input = QSpinBox() self.btn = QPushButton('Add') layout = QGridLayout() - layout.addWidget(QLabel("first input"), 0, 0) + layout.addWidget(QLabel('first input'), 0, 0) layout.addWidget(self.first_input, 0, 1) - layout.addWidget(QLabel("second input"), 1, 0) + layout.addWidget(QLabel('second input'), 1, 0) layout.addWidget(self.second_input, 1, 1) layout.addWidget(self.btn, 2, 0, 1, 2) self.setLayout(layout) diff --git a/examples/magic_image_arithmetic.py b/examples/magic_image_arithmetic.py index afe107da5e9..bcc78cc6f47 100644 --- a/examples/magic_image_arithmetic.py +++ b/examples/magic_image_arithmetic.py @@ -47,8 +47,8 @@ def image_arithmetic( # create a new viewer with a couple image layers viewer = napari.Viewer() -viewer.add_image(np.random.rand(20, 20), name="Layer 1") -viewer.add_image(np.random.rand(20, 20), name="Layer 2") +viewer.add_image(np.random.rand(20, 20), name='Layer 1') +viewer.add_image(np.random.rand(20, 20), name='Layer 2') # Add our magic function to napari viewer.window.add_function_widget(image_arithmetic) diff --git a/examples/magic_parameter_sweep.py b/examples/magic_parameter_sweep.py index f3d938d7bac..2c8bfe47471 100644 --- a/examples/magic_parameter_sweep.py +++ b/examples/magic_parameter_sweep.py @@ -11,10 +11,10 @@ .. tags:: gui """ import typing +from typing import Annotated import skimage.data import skimage.filters -from typing_extensions import Annotated import napari @@ -32,8 +32,8 @@ # https://napari.org/magicgui/api/widgets.html. def gaussian_blur( layer: 'napari.layers.Image', - sigma: Annotated[float, {"widget_type": "FloatSlider", "max": 6}] = 1.0, - mode: Annotated[str, {"choices": ["reflect", "constant", "nearest", "mirror", "wrap"]}]="nearest", + sigma: Annotated[float, {'widget_type': 'FloatSlider', 'max': 6}] = 1.0, + mode: Annotated[str, {'choices': ['reflect', 'constant', 'nearest', 'mirror', 'wrap']}]='nearest', ) -> 'typing.Optional[napari.types.ImageData]': """Apply a gaussian blur to ``layer``.""" if layer: @@ -43,8 +43,8 @@ def gaussian_blur( # create a viewer and add some images viewer = napari.Viewer() -viewer.add_image(skimage.data.astronaut().mean(-1), name="astronaut") -viewer.add_image(skimage.data.grass().astype("float"), name="grass") +viewer.add_image(skimage.data.astronaut().mean(-1), name='astronaut') +viewer.add_image(skimage.data.grass().astype('float'), name='grass') # Add our magic function to napari viewer.window.add_function_widget(gaussian_blur) diff --git a/examples/magic_viewer.py b/examples/magic_viewer.py index f9d18457852..09390e6528c 100644 --- a/examples/magic_viewer.py +++ b/examples/magic_viewer.py @@ -14,7 +14,7 @@ # the viewer that the function is embedded in, when the function is added to # the viewer with add_function_widget. def my_function(viewer: napari.Viewer): - print(viewer, f"with {len(viewer.layers)} layers") + print(viewer, f'with {len(viewer.layers)} layers') viewer = napari.Viewer() diff --git a/examples/mgui_dask_delayed_.py b/examples/mgui_dask_delayed_.py index c10bf2b7dc1..d5949d7fb47 100644 --- a/examples/mgui_dask_delayed_.py +++ b/examples/mgui_dask_delayed_.py @@ -32,7 +32,7 @@ def widget(client, nz: int = 1000) -> Future[ImageData]: return client.submit(_slow_function, nz) viewer = napari.Viewer() - viewer.window.add_dock_widget(widget, area="right") + viewer.window.add_dock_widget(widget, area='right') if __name__ == '__main__': napari.run() diff --git a/examples/mgui_with_threadpoolexec_.py b/examples/mgui_with_threadpoolexec_.py index d3c2be40afc..6134b7e9a69 100644 --- a/examples/mgui_with_threadpoolexec_.py +++ b/examples/mgui_with_threadpoolexec_.py @@ -9,7 +9,6 @@ .. tags:: gui """ -import sys from concurrent.futures import Future, ThreadPoolExecutor from magicgui import magic_factory @@ -19,18 +18,14 @@ import napari from napari.types import ImageData, LayerDataTuple -if sys.version_info < (3, 9): - print('This example requires python >= 3.9') - sys.exit(0) - pool = ThreadPoolExecutor() @magic_factory( - min_sigma={"min": 0.5, "max": 15, "step": 0.5}, - max_sigma={"min": 1, "max": 200, "step": 0.5}, - num_sigma={"min": 1, "max": 20}, - threshold={"min": 0, "max": 1000, "step": 0.1}, + min_sigma={'min': 0.5, 'max': 15, 'step': 0.5}, + max_sigma={'min': 1, 'max': 200, 'step': 0.5}, + num_sigma={'min': 1, 'max': 20}, + threshold={'min': 0, 'max': 1000, 'step': 0.1}, ) def make_widget( image: ImageData, @@ -52,11 +47,11 @@ def _make_blob(): ) data = blobs[:, : image.ndim] kwargs = { - "size": blobs[:, -1], - "border_color": "red", - "border_width": 2, - "border_width_is_relative": False, - "face_color": "transparent", + 'size': blobs[:, -1], + 'border_color': 'red', + 'border_width': 2, + 'border_width_is_relative': False, + 'face_color': 'transparent', } return (data, kwargs, 'points') @@ -64,7 +59,7 @@ def _make_blob(): viewer = napari.Viewer() -viewer.window.add_dock_widget(make_widget(), area="right") +viewer.window.add_dock_widget(make_widget(), area='right') viewer.add_image(data.hubble_deep_field().mean(-1)) napari.run() diff --git a/examples/mgui_with_threadworker_.py b/examples/mgui_with_threadworker_.py index 0694a8a6c32..6a349cd9a24 100644 --- a/examples/mgui_with_threadworker_.py +++ b/examples/mgui_with_threadworker_.py @@ -7,10 +7,11 @@ .. tags:: gui """ +from typing import Annotated + from magicgui import magic_factory, widgets from skimage import data from skimage.feature import blob_log -from typing_extensions import Annotated import napari from napari.qt.threading import FunctionWorker, thread_worker @@ -21,10 +22,10 @@ def make_widget( pbar: widgets.ProgressBar, image: ImageData, - min_sigma: Annotated[float, {"min": 0.5, "max": 15, "step": 0.5}] = 5, - max_sigma: Annotated[float, {"min": 1, "max": 200, "step": 0.5}] = 30, - num_sigma: Annotated[int, {"min": 1, "max": 20}] = 10, - threshold: Annotated[float, {"min": 0, "max": 1000, "step": 0.1}] = 6, + min_sigma: Annotated[float, {'min': 0.5, 'max': 15, 'step': 0.5}] = 5, + max_sigma: Annotated[float, {'min': 1, 'max': 200, 'step': 0.5}] = 30, + num_sigma: Annotated[int, {'min': 1, 'max': 20}] = 10, + threshold: Annotated[float, {'min': 0, 'max': 1000, 'step': 0.1}] = 6, ) -> FunctionWorker[LayerDataTuple]: # @thread_worker creates a worker that runs a function in another thread @@ -35,11 +36,11 @@ def detect_blobs() -> LayerDataTuple: blobs = blob_log(image, min_sigma, max_sigma, num_sigma, threshold) points = blobs[:, : image.ndim] meta = { - "size": blobs[:, -1], - "border_color": "red", - "border_width": 2, - "border_width_is_relative": False, - "face_color": "transparent", + 'size': blobs[:, -1], + 'border_color': 'red', + 'border_width': 2, + 'border_width_is_relative': False, + 'face_color': 'transparent', } # return a "LayerDataTuple" return (points, meta, 'points') @@ -50,7 +51,7 @@ def detect_blobs() -> LayerDataTuple: viewer = napari.Viewer() -viewer.window.add_dock_widget(make_widget(), area="right") +viewer.window.add_dock_widget(make_widget(), area='right') viewer.add_image(data.hubble_deep_field().mean(-1)) napari.run() diff --git a/examples/minimum_blending.py b/examples/minimum_blending.py index b616604cabc..1aa7e95007e 100644 --- a/examples/minimum_blending.py +++ b/examples/minimum_blending.py @@ -25,11 +25,11 @@ # and minimum blending mode. Note that the bottom-most layer # must be translucent or opaque to prevent blending with the canvas. viewer.add_image(data.cells3d(), - name=["membrane", "nuclei"], + name=['membrane', 'nuclei'], channel_axis=1, contrast_limits = [[1110, 23855], [1600, 50000]], - colormap = ["I Purple", "I Orange"], - blending= ["translucent_no_depth", "minimum"] + colormap = ['I Purple', 'I Orange'], + blending= ['translucent_no_depth', 'minimum'] ) if __name__ == '__main__': diff --git a/examples/multiple_viewer_widget.py b/examples/multiple_viewer_widget.py index 409e70d4677..e6f72c170fb 100644 --- a/examples/multiple_viewer_widget.py +++ b/examples/multiple_viewer_widget.py @@ -38,16 +38,16 @@ from napari.utils.events.event import WarningEmitter from napari.utils.notifications import show_info -NAPARI_GE_4_16 = parse_version(napari.__version__) > parse_version("0.4.16") +NAPARI_GE_4_16 = parse_version(napari.__version__) > parse_version('0.4.16') -def copy_layer_le_4_16(layer: Layer, name: str = ""): +def copy_layer_le_4_16(layer: Layer, name: str = ''): res_layer = deepcopy(layer) # this deepcopy is not optimal for labels and images layers if isinstance(layer, (Image, Labels)): res_layer.data = layer.data - res_layer.metadata["viewer_name"] = name + res_layer.metadata['viewer_name'] = name res_layer.events.disconnect() res_layer.events.source = res_layer @@ -57,12 +57,12 @@ def copy_layer_le_4_16(layer: Layer, name: str = ""): return res_layer -def copy_layer(layer: Layer, name: str = ""): +def copy_layer(layer: Layer, name: str = ''): if not NAPARI_GE_4_16: return copy_layer_le_4_16(layer, name) res_layer = Layer.create(*layer.as_layer_data_tuple()) - res_layer.metadata["viewer_name"] = name + res_layer.metadata['viewer_name'] = name return res_layer @@ -72,7 +72,7 @@ def get_property_names(layer: Layer): for event_name, event_emitter in layer.events.emitters.items(): if isinstance(event_emitter, WarningEmitter): continue - if event_name in ("thumbnail", "name"): + if event_name in ('thumbnail', 'name'): continue if ( isinstance(getattr(klass, event_name, None), property) @@ -87,10 +87,10 @@ def center_cross_on_mouse( ): """move the cross to the mouse position""" - if not getattr(viewer_model, "mouse_over_canvas", True): + if not getattr(viewer_model, 'mouse_over_canvas', True): # There is no way for napari 0.4.15 to check if mouse is over sending canvas. show_info( - "Mouse is not over the canvas. You may need to click on the canvas." + 'Mouse is not over the canvas. You may need to click on the canvas.' ) return @@ -166,7 +166,7 @@ class CrossWidget(QCheckBox): """ def __init__(self, viewer: napari.Viewer) -> None: - super().__init__("Add cross layer") + super().__init__('Add cross layer') self.viewer = viewer self.setChecked(False) self.stateChanged.connect(self._update_cross_visibility) @@ -209,7 +209,7 @@ def _update_extent(self): def _update_ndim(self, event): if self.layer in self.viewer.layers: self.viewer.layers.remove(self.layer) - self.layer = Vectors(name=".cross", ndim=event.value) + self.layer = Vectors(name='.cross', ndim=event.value) self.layer.edge_width = 1.5 self.update_cross() @@ -249,7 +249,7 @@ class ExampleWidget(QWidget): def __init__(self) -> None: super().__init__() - self.btn = QPushButton("Perform action") + self.btn = QPushButton('Perform action') self.spin = QDoubleSpinBox() layout = QVBoxLayout() layout.addWidget(self.spin) @@ -264,16 +264,16 @@ class MultipleViewerWidget(QSplitter): def __init__(self, viewer: napari.Viewer) -> None: super().__init__() self.viewer = viewer - self.viewer_model1 = ViewerModel(title="model1") - self.viewer_model2 = ViewerModel(title="model2") + self.viewer_model1 = ViewerModel(title='model1') + self.viewer_model2 = ViewerModel(title='model2') self._block = False self.qt_viewer1 = QtViewerWrap(viewer, self.viewer_model1) self.qt_viewer2 = QtViewerWrap(viewer, self.viewer_model2) self.tab_widget = QTabWidget() w1 = ExampleWidget() w2 = ExampleWidget() - self.tab_widget.addTab(w1, "Sample 1") - self.tab_widget.addTab(w2, "Sample 2") + self.tab_widget.addTab(w1, 'Sample 1') + self.tab_widget.addTab(w2, 'Sample 2') viewer_splitter = QSplitter() viewer_splitter.setOrientation(Qt.Vertical) viewer_splitter.addWidget(self.qt_viewer1) @@ -347,10 +347,10 @@ def _order_update(self): def _layer_added(self, event): """add layer to additional viewers and connect all required events""" self.viewer_model1.layers.insert( - event.index, copy_layer(event.value, "model1") + event.index, copy_layer(event.value, 'model1') ) self.viewer_model2.layers.insert( - event.index, copy_layer(event.value, "model2") + event.index, copy_layer(event.value, 'model2') ) for name in get_property_names(event.value): getattr(event.value.events, name).connect( @@ -365,7 +365,7 @@ def _layer_added(self, event): self.viewer_model2.layers[ event.value.name ].events.set_data.connect(self._set_data_refresh) - if event.value.name != ".cross": + if event.value.name != '.cross': self.viewer_model1.layers[event.value.name].events.data.connect( self._sync_data ) @@ -448,7 +448,7 @@ def _property_sync(self, name, event): self._block = False -if __name__ == "__main__": +if __name__ == '__main__': from qtpy import QtCore, QtWidgets QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) # above two lines are needed to allow to undock the widget with @@ -457,8 +457,8 @@ def _property_sync(self, name, event): dock_widget = MultipleViewerWidget(view) cross = CrossWidget(view) - view.window.add_dock_widget(dock_widget, name="Sample") - view.window.add_dock_widget(cross, name="Cross", area="left") + view.window.add_dock_widget(dock_widget, name='Sample') + view.window.add_dock_widget(cross, name='Cross', area='left') view.open_sample('napari', 'cells3d') diff --git a/examples/multithreading_simple_.py b/examples/multithreading_simple_.py index 727f7cfab59..148abae19db 100644 --- a/examples/multithreading_simple_.py +++ b/examples/multithreading_simple_.py @@ -29,7 +29,7 @@ def create_widget(): return widget -if __name__ == "__main__": +if __name__ == '__main__': app = QApplication([]) wdg = create_widget() @@ -37,8 +37,8 @@ def create_widget(): # By default, @thread_worker-decorated functions do not immediately start worker = long_running_function() # Signals are best connected *before* starting the worker. - worker.started.connect(lambda: wdg.status.setText("worker is running...")) - worker.returned.connect(lambda x: wdg.status.setText(f"returned {x}")) + worker.started.connect(lambda: wdg.status.setText('worker is running...')) + worker.returned.connect(lambda x: wdg.status.setText(f'returned {x}')) # # Connections may also be passed directly to the decorated function. # # The above syntax is equivalent to: diff --git a/examples/multithreading_two_way_.py b/examples/multithreading_two_way_.py index d422dc77704..0bfdcd5695e 100644 --- a/examples/multithreading_two_way_.py +++ b/examples/multithreading_two_way_.py @@ -39,7 +39,7 @@ def two_way_communication_with_args(start, end): i = incoming if incoming is not None else i # do optional teardown here - return "done" + return 'done' class Controller(QWidget): @@ -49,9 +49,9 @@ def __init__(self) -> None: layout = QGridLayout() self.setLayout(layout) self.status = QLabel('Click "Start"', self) - self.play_btn = QPushButton("Start", self) - self.abort_btn = QPushButton("Abort!", self) - self.reset_btn = QPushButton("Reset", self) + self.play_btn = QPushButton('Start', self) + self.abort_btn = QPushButton('Abort!', self) + self.reset_btn = QPushButton('Reset', self) self.progress_bar = QProgressBar() layout.addWidget(self.play_btn, 0, 0) @@ -75,10 +75,10 @@ def create_connected_widget(): w.play_btn.clicked.connect(worker.start) # it provides signals like {started, yielded, returned, errored, finished} - worker.returned.connect(lambda x: w.status.setText(f"worker returned {x}")) - worker.errored.connect(lambda x: w.status.setText(f"worker errored {x}")) - worker.started.connect(lambda: w.status.setText("worker started...")) - worker.aborted.connect(lambda: w.status.setText("worker aborted")) + worker.returned.connect(lambda x: w.status.setText(f'worker returned {x}')) + worker.errored.connect(lambda x: w.status.setText(f'worker errored {x}')) + worker.started.connect(lambda: w.status.setText('worker started...')) + worker.aborted.connect(lambda: w.status.setText('worker aborted')) # send values into the function (like generator.send) using worker.send # abort thread with worker.abort() @@ -92,22 +92,22 @@ def on_reset_button_pressed(): def on_yield(x): # Receive events and update widget progress w.progress_bar.setValue(100 * x // steps) - w.status.setText(f"worker yielded {x}") + w.status.setText(f'worker yielded {x}') def on_start(): def handle_pause(): worker.toggle_pause() - w.play_btn.setText("Pause" if worker.is_paused else "Continue") + w.play_btn.setText('Pause' if worker.is_paused else 'Continue') w.play_btn.clicked.disconnect(worker.start) - w.play_btn.setText("Pause") + w.play_btn.setText('Pause') w.play_btn.clicked.connect(handle_pause) def on_finish(): w.play_btn.setDisabled(True) w.reset_btn.setDisabled(True) w.abort_btn.setDisabled(True) - w.play_btn.setText("Done") + w.play_btn.setText('Done') w.reset_btn.clicked.connect(on_reset_button_pressed) worker.yielded.connect(on_yield) @@ -116,7 +116,7 @@ def on_finish(): return w -if __name__ == "__main__": +if __name__ == '__main__': viewer = napari.view_image(np.random.rand(512, 512)) w = create_connected_widget() diff --git a/examples/new_theme.py b/examples/new_theme.py index 6ee14921414..4edb1503830 100644 --- a/examples/new_theme.py +++ b/examples/new_theme.py @@ -19,7 +19,7 @@ print('Originally themes', available_themes()) blue_theme = get_theme('dark') -blue_theme.id = "blue" +blue_theme.id = 'blue' blue_theme.icon = ( 'rgb(0, 255, 255)' # you can provide colors as rgb(XXX, YYY, ZZZ) ) @@ -29,7 +29,7 @@ blue_theme.current = 'orange' # or as color name blue_theme.font_size = '10pt' # you can provide a font size in points (pt) for the application -register_theme('blue', blue_theme, "custom") +register_theme('blue', blue_theme, 'custom') # List themes print('New themes', available_themes()) diff --git a/examples/progress_bar_minimal_.py b/examples/progress_bar_minimal_.py index a491d310b41..8d9b4e409f0 100644 --- a/examples/progress_bar_minimal_.py +++ b/examples/progress_bar_minimal_.py @@ -42,7 +42,7 @@ def iterable_w_context(): for i, im_slice in enumerate(pbr): # using a context manager also allows us to manipulate # the progress object e.g. by setting a description - pbr.set_description(f"Slice {i}") + pbr.set_description(f'Slice {i}') # we can group progress bars together in the viewer # by passing a parent progress bar to new progress @@ -58,7 +58,7 @@ def indeterminate(): with progress(total=0) as pbr: x = 0 while x != 42: - pbr.set_description(f"Processing {x}") + pbr.set_description(f'Processing {x}') x = choice(range(100)) sleep(0.05) @@ -67,16 +67,16 @@ def arbitrary_steps(): """ with progress(total=4) as pbr: sleep(3) - pbr.set_description("Step 1 Complete") + pbr.set_description('Step 1 Complete') # manually updating the progress bar by 1 pbr.update(1) sleep(1) - pbr.set_description("Step 2 Complete") + pbr.set_description('Step 2 Complete') pbr.update(1) sleep(2) - pbr.set_description("Processing Complete!") + pbr.set_description('Processing Complete!') # we can manually update by any number of steps pbr.update(2) @@ -91,36 +91,36 @@ def cancelable_iterable(): # You can use cancel_callback to close files, clean up state, etc # if the user cancels the operation. def cancel_callback(): - print("Operation canceled - cleaning up!") + print('Operation canceled - cleaning up!') for _ in cancelable_progress(range(100), cancel_callback=cancel_callback): np.random.rand(128, 128, 128).mean(0) viewer = napari.Viewer() button_layout = QVBoxLayout() -iterable_btn = QPushButton("Iterable") +iterable_btn = QPushButton('Iterable') iterable_btn.clicked.connect(iterable) button_layout.addWidget(iterable_btn) -iterable_context_btn = QPushButton("Iterable With Context") +iterable_context_btn = QPushButton('Iterable With Context') iterable_context_btn.clicked.connect(iterable_w_context) button_layout.addWidget(iterable_context_btn) -indeterminate_btn = QPushButton("Indeterminate") +indeterminate_btn = QPushButton('Indeterminate') indeterminate_btn.clicked.connect(indeterminate) button_layout.addWidget(indeterminate_btn) -steps_btn = QPushButton("Arbitrary Steps") +steps_btn = QPushButton('Arbitrary Steps') steps_btn.clicked.connect(arbitrary_steps) button_layout.addWidget(steps_btn) -cancel_iter_btn = QPushButton("Cancelable Iterable") +cancel_iter_btn = QPushButton('Cancelable Iterable') cancel_iter_btn.clicked.connect(cancelable_iterable) button_layout.addWidget(cancel_iter_btn) pbar_widget = QWidget() pbar_widget.setLayout(button_layout) -pbar_widget.setObjectName("Progress Examples") +pbar_widget.setObjectName('Progress Examples') viewer.window.add_dock_widget(pbar_widget) # showing the activity dock so we can see the progress bars diff --git a/examples/progress_bar_segmentation_.py b/examples/progress_bar_segmentation_.py index dbe652502f8..65d36846ac8 100644 --- a/examples/progress_bar_segmentation_.py +++ b/examples/progress_bar_segmentation_.py @@ -63,7 +63,7 @@ def try_thresholds(): binarised_nuclei, color={1: 'lightgreen'}, opacity=0.7, - name="Binarised", + name='Binarised', blending='translucent', ) @@ -79,7 +79,7 @@ def segment_binarised_ims(): the progress bar within the loop """ if 'Binarised' not in viewer.layers: - raise TypeError("Cannot segment before thresholding") + raise TypeError('Cannot segment before thresholding') if 'Segmented' in viewer.layers: del viewer.layers['Segmented'] binarised_data = viewer.layers['Binarised'].data @@ -93,7 +93,7 @@ def segment_binarised_ims(): for i, binarised_cells in enumerate(pbar): # this allows us to manipulate the pbar object within the loop # e.g. setting the description. - pbar.set_description(all_thresholds[i].__name__.split("_")[1]) + pbar.set_description(all_thresholds[i].__name__.split('_')[1]) labelled_im = label(binarised_cells) segmented_nuclei.append(labelled_im) @@ -106,7 +106,7 @@ def segment_binarised_ims(): segmented_nuclei = np.stack(segmented_nuclei) viewer.add_labels( segmented_nuclei, - name="Segmented", + name='Segmented', blending='translucent', ) viewer.layers['Binarised'].visible = False @@ -128,13 +128,13 @@ def process_ims(): # we instantiate a manually controlled `progress` object # by just passing a total with no iterable with progress(total=2) as pbar: - pbar.set_description("Thresholding") + pbar.set_description('Thresholding') try_thresholds() # once one processing step is complete, we increment # the value of our progress bar pbar.update(1) - pbar.set_description("Segmenting") + pbar.set_description('Segmenting') segment_binarised_ims() pbar.update(1) @@ -143,21 +143,21 @@ def process_ims(): # sleep(0.5) button_layout = QVBoxLayout() -process_btn = QPushButton("Full Process") +process_btn = QPushButton('Full Process') process_btn.clicked.connect(process_ims) button_layout.addWidget(process_btn) -thresh_btn = QPushButton("1.Threshold") +thresh_btn = QPushButton('1.Threshold') thresh_btn.clicked.connect(try_thresholds) button_layout.addWidget(thresh_btn) -segment_btn = QPushButton("2.Segment") +segment_btn = QPushButton('2.Segment') segment_btn.clicked.connect(segment_binarised_ims) button_layout.addWidget(segment_btn) action_widget = QWidget() action_widget.setLayout(button_layout) -action_widget.setObjectName("Segmentation") +action_widget.setObjectName('Segmentation') viewer.window.add_dock_widget(action_widget) # showing the activity dock so we can see the progress bars diff --git a/examples/progress_bar_threading_.py b/examples/progress_bar_threading_.py index 516ff826900..c95971a9b75 100644 --- a/examples/progress_bar_threading_.py +++ b/examples/progress_bar_threading_.py @@ -18,7 +18,7 @@ def handle_yields(yielded_val): - print(f"Just yielded: {yielded_val}") + print(f'Just yielded: {yielded_val}') # generator thread workers can provide progress updates on each yield @@ -55,7 +55,7 @@ def my_indeterminate_thread(*_): def return_func(return_val): - print(f"Returned: {return_val}") + print(f'Returned: {return_val}') # finally, a FunctionWorker can still provide an indeterminate @@ -74,22 +74,22 @@ def my_function(*_): button_layout = QVBoxLayout() -start_btn = QPushButton("Start") +start_btn = QPushButton('Start') start_btn.clicked.connect(my_long_running_thread) button_layout.addWidget(start_btn) -start_btn2 = QPushButton("Start Indeterminate") +start_btn2 = QPushButton('Start Indeterminate') start_btn2.clicked.connect(my_indeterminate_thread) button_layout.addWidget(start_btn2) -start_btn3 = QPushButton("Start FunctionWorker") +start_btn3 = QPushButton('Start FunctionWorker') start_btn3.clicked.connect(my_function) button_layout.addWidget(start_btn3) pbar_widget = QWidget() pbar_widget.setLayout(button_layout) -pbar_widget.setObjectName("Threading Examples") -viewer.window.add_dock_widget(pbar_widget, allowed_areas=["right"]) +pbar_widget.setObjectName('Threading Examples') +viewer.window.add_dock_widget(pbar_widget, allowed_areas=['right']) # showing the activity dock so we can see the progress bars viewer.window._status_bar._toggle_activity_dock(True) diff --git a/examples/scale_bar.py b/examples/scale_bar.py index 8449b45d313..e8cb53bbf4d 100644 --- a/examples/scale_bar.py +++ b/examples/scale_bar.py @@ -21,7 +21,7 @@ scale=(0.29, 0.26, 0.26), ) viewer.scale_bar.visible = True -viewer.scale_bar.unit = "um" +viewer.scale_bar.unit = 'um' if __name__ == '__main__': napari.run() diff --git a/examples/surface_multi_texture.py b/examples/surface_multi_texture_.py similarity index 87% rename from examples/surface_multi_texture.py rename to examples/surface_multi_texture_.py index 41cf10287b2..43213cd92ac 100644 --- a/examples/surface_multi_texture.py +++ b/examples/surface_multi_texture_.py @@ -55,26 +55,26 @@ # Download the model # ------------------ download = pooch.DOIDownloader(progressbar=True) -doi = "10.6084/m9.figshare.22348645.v1" -tmp_dir = pooch.os_cache("napari-surface-texture-example") +doi = '10.6084/m9.figshare.22348645.v1' +tmp_dir = pooch.os_cache('napari-surface-texture-example') os.makedirs(tmp_dir, exist_ok=True) data_files = { - "mesh": "PocilloporaDamicornisSkin.obj", + 'mesh': 'PocilloporaDamicornisSkin.obj', # "materials": "PocilloporaVerrugosaSkinCrop.mtl", # not yet supported - "Texture_0": "PocilloporaDamicornisSkin_Texture_0.jpg", - "GeneratedMat2": "PocilloporaDamicornisSkin_GeneratedMat2.png", + 'Texture_0': 'PocilloporaDamicornisSkin_Texture_0.jpg', + 'GeneratedMat2': 'PocilloporaDamicornisSkin_GeneratedMat2.png', } -print(f"downloading data into {tmp_dir}") +print(f'downloading data into {tmp_dir}') for file_name in data_files.values(): if not (tmp_dir / file_name).exists(): - print(f"downloading {file_name}") + print(f'downloading {file_name}') download( - f"doi:{doi}/{file_name}", + f'doi:{doi}/{file_name}', output_file=tmp_dir / file_name, pooch=None, ) else: - print(f"using cached {tmp_dir / file_name}") + print(f'using cached {tmp_dir / file_name}') ############################################################################### # Load the model @@ -83,7 +83,7 @@ # support reading material properties (.mtl files) nor separate texture and # vertex indices (i.e. repeated vertices). Normal vectors read from the file # are also ignored and re-calculated from the faces. -vertices, faces, _normals, texcoords = read_mesh(tmp_dir / data_files["mesh"]) +vertices, faces, _normals, texcoords = read_mesh(tmp_dir / data_files['mesh']) ############################################################################### # Load the textures @@ -91,17 +91,17 @@ # This model comes with two textures: `Texture_0` is generated from # photogrammetry of the actual object, and `GeneratedMat2` is a generated # material to fill in parts of the model lacking photographic texture. -photo_texture = imread(tmp_dir / data_files["Texture_0"]) -generated_texture = imread(tmp_dir / data_files["GeneratedMat2"]) +photo_texture = imread(tmp_dir / data_files['Texture_0']) +generated_texture = imread(tmp_dir / data_files['GeneratedMat2']) ############################################################################### # This is what the texture images look like in 2D: fig, axs = plt.subplots(1, 2) -axs[0].set_title(f"Texture_0 {photo_texture.shape}") +axs[0].set_title(f'Texture_0 {photo_texture.shape}') axs[0].imshow(photo_texture) axs[0].set_xticks((0, photo_texture.shape[1]), labels=(0.0, 1.0)) axs[0].set_yticks((0, photo_texture.shape[0]), labels=(0.0, 1.0)) -axs[1].set_title(f"GeneratedMat2 {generated_texture.shape}") +axs[1].set_title(f'GeneratedMat2 {generated_texture.shape}') axs[1].imshow(generated_texture) axs[1].set_xticks((0, generated_texture.shape[1]), labels=(0.0, 1.0)) axs[1].set_yticks((0, generated_texture.shape[0]), labels=(0.0, 1.0)) @@ -117,13 +117,13 @@ (vertices, faces), texture=photo_texture, texcoords=texcoords, - name="Texture_0", + name='Texture_0', ) generated_texture_layer = napari.layers.Surface( (vertices, faces), texture=generated_texture, texcoords=texcoords, - name="GeneratedMat2", + name='GeneratedMat2', ) ############################################################################### diff --git a/examples/surface_texture_and_colors.py b/examples/surface_texture_and_colors.py index d133f0045a4..69332010715 100644 --- a/examples/surface_texture_and_colors.py +++ b/examples/surface_texture_and_colors.py @@ -35,8 +35,8 @@ translate=(1, 0, 0), texture=texture, texcoords=texcoords, - shading="flat", - name="texture only", + shading='flat', + name='texture only', ) np.random.seed(0) @@ -44,9 +44,9 @@ (vertices, faces, np.random.random((3, 3, n))), texture=texture, texcoords=texcoords, - colormap="plasma", - shading="smooth", - name="vertex_values and texture", + colormap='plasma', + shading='smooth', + name='vertex_values and texture', ) rainbow_spot = napari.layers.Surface( @@ -57,8 +57,8 @@ # the vertices are _roughly_ in [-1, 1] for this model and RGB values just # get clipped to [0, 1], adding 0.5 brightens it up a little :) vertex_colors=vertices + 0.5, - shading="none", - name="vertex_colors and texture", + shading='none', + name='vertex_colors and texture', ) # create the viewer and window diff --git a/examples/surface_timeseries_.py b/examples/surface_timeseries_.py index 538b671e28d..571ce25295a 100644 --- a/examples/surface_timeseries_.py +++ b/examples/surface_timeseries_.py @@ -26,11 +26,11 @@ import numpy as np -if parse(np.__version__) >= parse("1.24"): +if parse(np.__version__) >= parse('1.24'): raise RuntimeError( - "Incompatible numpy version. " - "You must have numpy less than 1.24 for nilearn 0.10.1 and below to " - "work and download the example data" + 'Incompatible numpy version. ' + 'You must have numpy less than 1.24 for nilearn 0.10.1 and below to ' + 'work and download the example data' ) import napari diff --git a/examples/viewer_fps_label.py b/examples/viewer_fps_label.py index 41e50c1958c..84f089c94f6 100644 --- a/examples/viewer_fps_label.py +++ b/examples/viewer_fps_label.py @@ -13,7 +13,7 @@ def update_fps(fps): """Update fps.""" - viewer.text_overlay.text = f"{fps:1.1f} FPS" + viewer.text_overlay.text = f'{fps:1.1f} FPS' viewer = napari.Viewer() diff --git a/examples/without_gui_qt.py b/examples/without_gui_qt.py index c91c3e34482..ce37e61c993 100644 --- a/examples/without_gui_qt.py +++ b/examples/without_gui_qt.py @@ -35,6 +35,6 @@ # the viewer object. For example, add click the buttons to add various layer # types when the window is open and see the result below: - print("Your viewer has the following layers:") + print('Your viewer has the following layers:') for name, n in Counter(type(x).__name__ for x in viewer.layers).most_common(): - print(f" {name:<7}: {n}") + print(f' {name:<7}: {n}') diff --git a/napari/__init__.py b/napari/__init__.py index 75254c516c9..44ee5431860 100644 --- a/napari/__init__.py +++ b/napari/__init__.py @@ -5,7 +5,7 @@ try: from napari._version import version as __version__ except ImportError: - __version__ = "not-installed" + __version__ = 'not-installed' # Allows us to use pydata/sparse arrays as layer data os.environ.setdefault('SPARSE_AUTO_DENSIFY', '1') diff --git a/napari/__main__.py b/napari/__main__.py index 94d4b576674..149d20cd278 100644 --- a/napari/__main__.py +++ b/napari/__main__.py @@ -13,7 +13,7 @@ from itertools import chain, repeat from pathlib import Path from textwrap import wrap -from typing import Any, Dict, List +from typing import Any from napari.utils.translations import trans @@ -27,8 +27,8 @@ def __call__(self, *args, **kwargs): logging.basicConfig(level=logging.WARNING) print(sys_info()) - print("Plugins:") - cli.list(fields="", sort="0", format="compact") + print('Plugins:') + cli.list(fields='', sort='0', format='compact') sys.exit() @@ -39,9 +39,9 @@ def __call__(self, *args, **kwargs): from npe2 import cli cli.list( - fields="name,version,npe2,contributions", - sort="name", - format="table", + fields='name,version,npe2,contributions', + sort='name', + format='table', ) sys.exit() @@ -56,7 +56,7 @@ def __call__(self, *args, **kwargs): sys.exit() -def validate_unknown_args(unknown: List[str]) -> Dict[str, Any]: +def validate_unknown_args(unknown: list[str]) -> dict[str, Any]: """Convert a list of strings into a dict of valid kwargs for add_* methods. Will exit program if any of the arguments are unrecognized, or are @@ -76,23 +76,23 @@ def validate_unknown_args(unknown: List[str]) -> Dict[str, Any]: from napari.components.viewer_model import valid_add_kwargs - out: Dict[str, Any] = {} + out: dict[str, Any] = {} valid = set.union(*valid_add_kwargs().values()) for i, raw_arg in enumerate(unknown): - if not raw_arg.startswith("--"): + if not raw_arg.startswith('--'): continue arg = raw_arg.lstrip('-') - key, *values = arg.split("=", maxsplit=1) + key, *values = arg.split('=', maxsplit=1) key = key.replace('-', '_') if key not in valid: - sys.exit(f"error: unrecognized argument: {raw_arg}") + sys.exit(f'error: unrecognized argument: {raw_arg}') if values: value = values[0] else: - if len(unknown) <= i + 1 or unknown[i + 1].startswith("--"): - sys.exit(f"error: argument {raw_arg} expected one argument") + if len(unknown) <= i + 1 or unknown[i + 1].startswith('--'): + sys.exit(f'error: argument {raw_arg} expected one argument') value = unknown[i + 1] with contextlib.suppress(Exception): value = literal_eval(value) @@ -109,16 +109,16 @@ def parse_sys_argv(): kwarg_options = [] for layer_type, keys in valid_add_kwargs().items(): - kwarg_options.append(f" {layer_type.title()}:") + kwarg_options.append(f' {layer_type.title()}:') keys = {k.replace('_', '-') for k in keys} - lines = wrap(", ".join(sorted(keys)), break_on_hyphens=False) - kwarg_options.extend([f" {line}" for line in lines]) + lines = wrap(', '.join(sorted(keys)), break_on_hyphens=False) + kwarg_options.extend([f' {line}' for line in lines]) parser = argparse.ArgumentParser( usage=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, epilog="optional layer-type-specific arguments (precede with '--'):\n" - + "\n".join(kwarg_options), + + '\n'.join(kwarg_options), ) parser.add_argument('paths', nargs='*', help='path(s) to view.') parser.add_argument( @@ -126,7 +126,7 @@ def parse_sys_argv(): '--verbose', action='count', default=0, - help="increase output verbosity", + help='increase output verbosity', ) parser.add_argument( '-w', @@ -137,10 +137,10 @@ def parse_sys_argv(): default=[], metavar=('PLUGIN_NAME', 'WIDGET_NAME'), help=( - "open napari with dock widget from specified plugin name." - "(If plugin provides multiple dock widgets, widget name must also " - "be provided). Use __all__ to open all dock widgets of a " - "specified plugin. Multiple widgets are opened in tabs." + 'open napari with dock widget from specified plugin name.' + '(If plugin provides multiple dock widgets, widget name must also ' + 'be provided). Use __all__ to open all dock widgets of a ' + 'specified plugin. Multiple widgets are opened in tabs.' ), ) parser.add_argument( @@ -181,7 +181,7 @@ def parse_sys_argv(): ) parser.add_argument( '--layer-type', - metavar="TYPE", + metavar='TYPE', choices=set(layers.NAMES), help=( 'force file to be interpreted as a specific layer type. ' @@ -203,7 +203,7 @@ def parse_sys_argv(): # this is a hack to allow using "=" as a key=value separator while also # allowing nargs='*' on the "paths" argument... for idx, item in enumerate(reversed(args.paths)): - if item.startswith("--"): + if item.startswith('--'): unknown.append(args.paths.pop(len(args.paths) - idx - 1)) kwargs = validate_unknown_args(unknown) if unknown else {} @@ -222,7 +222,7 @@ def _run() -> None: level = levels[min(2, args.verbose)] # prevent index error logging.basicConfig( level=level, - format="%(asctime)s : %(levelname)s : %(threadName)s : %(message)s", + format='%(asctime)s : %(levelname)s : %(threadName)s : %(message)s', datefmt='%H:%M:%S', ) @@ -233,7 +233,7 @@ def _run() -> None: settings = get_settings() settings.reset() settings.save() - sys.exit("Resetting settings to default values.\n") + sys.exit('Resetting settings to default values.\n') if args.plugin: # make sure plugin is only used when files are specified @@ -444,17 +444,17 @@ def _maybe_rerun_with_macos_fixes(): # This import mus be here to raise exception about PySide6 problem if ( - sys.platform != "darwin" - or "pdb" in sys.modules - or "pydevd" in sys.modules + sys.platform != 'darwin' + or 'pdb' in sys.modules + or 'pydevd' in sys.modules ): return - if "_NAPARI_RERUN_WITH_FIXES" in os.environ: + if '_NAPARI_RERUN_WITH_FIXES' in os.environ: # This function already ran, do not recurse! # We also restore sys.executable to its initial value, # if we used a symlink - if exe := os.environ.pop("_NAPARI_SYMLINKED_EXECUTABLE", ""): + if exe := os.environ.pop('_NAPARI_SYMLINKED_EXECUTABLE', ''): sys.executable = exe return @@ -468,8 +468,8 @@ def _maybe_rerun_with_macos_fixes(): _MACOS_AT_LEAST_CATALINA = int(platform.release().split('.')[0]) >= 19 _MACOS_AT_LEAST_BIG_SUR = int(platform.release().split('.')[0]) >= 20 - _RUNNING_CONDA = "CONDA_PREFIX" in os.environ - _RUNNING_PYTHONW = "PYTHONEXECUTABLE" in os.environ + _RUNNING_CONDA = 'CONDA_PREFIX' in os.environ + _RUNNING_PYTHONW = 'PYTHONEXECUTABLE' in os.environ # 1) quick fix for Big Sur py3.9 and qt 5 # https://github.com/napari/napari/pull/1894 @@ -515,35 +515,35 @@ def _maybe_rerun_with_macos_fixes(): # When napari is launched from the conda bundle shortcut # it already has the right 'napari' name in the app title # and __CFBundleIdentifier is set to 'com.napari._()' - "napari" not in os.environ.get("__CFBUNDLEIDENTIFIER", "") + 'napari' not in os.environ.get('__CFBUNDLEIDENTIFIER', '') # with a sys.executable named napari, # macOS should have picked the right name already - or os.path.basename(executable) != "napari" + or os.path.basename(executable) != 'napari' ) if _NEEDS_SYMLINK: - tempdir = mkdtemp(prefix="symlink-to-fix-macos-menu-name-") + tempdir = mkdtemp(prefix='symlink-to-fix-macos-menu-name-') # By using a symlink with basename napari # we make macOS take 'napari' as the program name - napari_link = os.path.join(tempdir, "napari") + napari_link = os.path.join(tempdir, 'napari') os.symlink(executable, napari_link) # Pass original executable to the subprocess so it can restore it later - env["_NAPARI_SYMLINKED_EXECUTABLE"] = executable + env['_NAPARI_SYMLINKED_EXECUTABLE'] = executable executable = napari_link # if at this point 'executable' is different from 'sys.executable', we # need to launch the subprocess to apply the fixes if sys.executable != executable: - env["_NAPARI_RERUN_WITH_FIXES"] = "1" - if Path(sys.argv[0]).name == "napari": + env['_NAPARI_RERUN_WITH_FIXES'] = '1' + if Path(sys.argv[0]).name == 'napari': # launched through entry point, we do that again to avoid # issues with working directory getting into sys.path (#5007) cmd = [executable, sys.argv[0]] else: # we assume it must have been launched via '-m' syntax - cmd = [executable, "-m", "napari"] + cmd = [executable, '-m', 'napari'] # this fixes issues running from a venv/virtualenv based virtual # environment with certain python distributions (e.g. pyenv, asdf) - env["PYTHONEXECUTABLE"] = sys.executable + env['PYTHONEXECUTABLE'] = sys.executable # Append original command line arguments. if len(sys.argv) > 1: @@ -566,7 +566,7 @@ def main(): # Prevent https://github.com/napari/napari/issues/3415 # This one fix is needed _after_ a potential relaunch, # that's why it's here and not in _maybe_rerun_with_macos_fixes() - if sys.platform == "darwin": + if sys.platform == 'darwin': import multiprocessing multiprocessing.set_start_method('fork') diff --git a/napari/_app_model/__init__.py b/napari/_app_model/__init__.py index f6a2e72f241..a9a1ba48014 100644 --- a/napari/_app_model/__init__.py +++ b/napari/_app_model/__init__.py @@ -1,3 +1,3 @@ from napari._app_model._app import get_app -__all__ = ["get_app"] +__all__ = ['get_app'] diff --git a/napari/_app_model/_app.py b/napari/_app_model/_app.py index d6c29242fbf..7910e913c4a 100644 --- a/napari/_app_model/_app.py +++ b/napari/_app_model/_app.py @@ -2,7 +2,6 @@ from functools import lru_cache from itertools import chain -from typing import Dict from app_model import Application @@ -43,7 +42,7 @@ def get_app(cls, app_name: str = APP_NAME) -> NapariApplication: @lru_cache(maxsize=1) -def _napari_names() -> Dict[str, object]: +def _napari_names() -> dict[str, object]: """Napari names to inject into local namespace when evaluating type hints.""" import napari from napari import components, layers, viewer diff --git a/napari/_app_model/_tests/test_help_menu_urls.py b/napari/_app_model/_tests/test_help_menu_urls.py index 1eaf61ccf74..982f787b4cd 100644 --- a/napari/_app_model/_tests/test_help_menu_urls.py +++ b/napari/_app_model/_tests/test_help_menu_urls.py @@ -9,7 +9,7 @@ @pytest.mark.parametrize('url', HELP_URLS.keys()) def test_help_urls(url): if url == 'release_notes': - pytest.skip("No release notes for dev version") + pytest.skip('No release notes for dev version') r = requests.head(HELP_URLS[url]) r.raise_for_status() diff --git a/napari/_app_model/_tests/test_viewer_toggler.py b/napari/_app_model/_tests/test_viewer_toggler.py index 9fe2043d2d2..c93c20eefa1 100644 --- a/napari/_app_model/_tests/test_viewer_toggler.py +++ b/napari/_app_model/_tests/test_viewer_toggler.py @@ -15,7 +15,13 @@ def test_viewer_toggler(): app = get_app() app.register_action(action) - with app.injection_store.register(providers={Viewer: lambda: viewer}): + # Injection required as there is no current viewer, use a high weight (100) + # so this provider is used over `_provide_viewer`, which would raise an error + with app.injection_store.register( + providers=[ + (lambda: viewer, Viewer, 100), + ] + ): assert viewer.axes.visible is False app.commands.execute_command('some.command.id') assert viewer.axes.visible is True diff --git a/napari/_app_model/actions/_help_actions.py b/napari/_app_model/actions/_help_actions.py index 7c8d2cbe514..4b0dbdb2373 100644 --- a/napari/_app_model/actions/_help_actions.py +++ b/napari/_app_model/actions/_help_actions.py @@ -5,7 +5,6 @@ """ import webbrowser -from typing import List from app_model.types import Action from packaging.version import parse @@ -14,19 +13,19 @@ from napari._app_model.constants import CommandId, MenuGroup, MenuId v = parse(__version__) -VERSION = "dev" if v.is_devrelease else str(v) +VERSION = 'dev' if v.is_devrelease else str(v) HELP_URLS = { - "getting_started": f'https://napari.org/{VERSION}/tutorials/start_index.html', - "tutorials": f'https://napari.org/{VERSION}/tutorials/index.html', - "layers_guide": f'https://napari.org/{VERSION}/howtos/layers/index.html', - "examples_gallery": f'https://napari.org/{VERSION}/gallery.html', - "release_notes": f'https://napari.org/{VERSION}/release/release_{VERSION.replace(".", "_")}.html', - "github_issue": 'https://github.com/napari/napari/issues', - "homepage": 'https://napari.org', + 'getting_started': f'https://napari.org/{VERSION}/tutorials/start_index.html', + 'tutorials': f'https://napari.org/{VERSION}/tutorials/index.html', + 'layers_guide': f'https://napari.org/{VERSION}/howtos/layers/index.html', + 'examples_gallery': f'https://napari.org/{VERSION}/gallery.html', + 'release_notes': f'https://napari.org/{VERSION}/release/release_{VERSION.replace(".", "_")}.html', + 'github_issue': 'https://github.com/napari/napari/issues', + 'homepage': 'https://napari.org', } -HELP_ACTIONS: List[Action] = [ +HELP_ACTIONS: list[Action] = [ Action( id=CommandId.NAPARI_GETTING_STARTED, title=CommandId.NAPARI_GETTING_STARTED.command_title, @@ -60,7 +59,7 @@ menus=[ { 'id': MenuId.MENUBAR_HELP, - 'when': VERSION != "dev", + 'when': VERSION != 'dev', 'group': MenuGroup.NAVIGATION, } ], @@ -74,7 +73,7 @@ menus=[ { 'id': MenuId.MENUBAR_HELP, - 'when': VERSION == "dev", + 'when': VERSION == 'dev', 'group': MenuGroup.NAVIGATION, } ], diff --git a/napari/_app_model/actions/_layer_actions.py b/napari/_app_model/actions/_layer_actions.py index 43f3edb74dd..ae511d36db5 100644 --- a/napari/_app_model/actions/_layer_actions.py +++ b/napari/_app_model/actions/_layer_actions.py @@ -11,7 +11,7 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from app_model.types import Action @@ -39,7 +39,7 @@ # Statically defined Layer actions. # modifying this list at runtime has no effect. -LAYER_ACTIONS: List[Action] = [ +LAYER_ACTIONS: list[Action] = [ Action( id=CommandId.LAYER_DUPLICATE, title=CommandId.LAYER_DUPLICATE.command_title, diff --git a/napari/_app_model/actions/_view_actions.py b/napari/_app_model/actions/_view_actions.py index f58c3f0f937..2153a92acd3 100644 --- a/napari/_app_model/actions/_view_actions.py +++ b/napari/_app_model/actions/_view_actions.py @@ -4,15 +4,13 @@ `napari/_qt/_qapp_model/qactions/_view.py`. """ -from typing import List - from app_model.types import Action, ToggleRule from napari._app_model.actions._toggle_action import ViewerToggleAction from napari._app_model.constants import CommandId, MenuGroup, MenuId from napari.settings import get_settings -VIEW_ACTIONS: List[Action] = [] +VIEW_ACTIONS: list[Action] = [] MENUID_DICT = {'axes': MenuId.VIEW_AXES, 'scale_bar': MenuId.VIEW_SCALEBAR} for cmd, viewer_attr, sub_attr in ( @@ -36,12 +34,12 @@ ) -def _tooltip_visibility_toggle(): +def _tooltip_visibility_toggle() -> None: settings = get_settings().appearance settings.layer_tooltip_visibility = not settings.layer_tooltip_visibility -def _get_current_tooltip_visibility(): +def _get_current_tooltip_visibility() -> bool: return get_settings().appearance.layer_tooltip_visibility diff --git a/napari/_app_model/constants/_commands.py b/napari/_app_model/constants/_commands.py index fb5e1128fff..99a3502c9b8 100644 --- a/napari/_app_model/constants/_commands.py +++ b/napari/_app_model/constants/_commands.py @@ -55,6 +55,10 @@ class CommandId(StrEnum): TOGGLE_VIEWER_SCALE_BAR_COLORED = 'napari:window:view:toggle_viewer_scale_bar_colored' TOGGLE_VIEWER_SCALE_BAR_TICKS = 'napari:window:view:toggle_viewer_scale_bar_ticks' + # Plugins menubar + DLG_PLUGIN_INSTALL = 'napari:window:plugins:plugin_install_dialog' + DLG_PLUGIN_ERR = 'napari:window:plugins:plugin_err_reporter' + # Help menubar NAPARI_GETTING_STARTED = 'napari:window:help:getting_started' NAPARI_TUTORIALS = 'napari:window:help:tutorials' @@ -63,6 +67,7 @@ class CommandId(StrEnum): NAPARI_RELEASE_NOTES = 'napari:window:help:release_notes' NAPARI_HOMEPAGE = 'napari:window:help:homepage' NAPARI_INFO = 'napari:window:help:info' + NAPARI_ABOUT_MACOS = 'napari:window:help:about_macos' NAPARI_GITHUB_ISSUE = 'napari:window:help:github_issue' TOGGLE_BUG_REPORT_OPT_IN = 'napari:window:help:bug_report_opt_in' @@ -134,7 +139,7 @@ class _i(NamedTuple): CommandId.DLG_CLOSE: _i(trans._('Close Window')), CommandId.DLG_QUIT: _i(trans._('Exit')), CommandId.RESTART: _i(trans._('Restart')), - CommandId.IMAGE_FROM_CLIPBOARD: _i(trans._("New Image from Clipboard")), + CommandId.IMAGE_FROM_CLIPBOARD: _i(trans._('New Image from Clipboard')), # View menubar CommandId.TOGGLE_FULLSCREEN: _i(trans._('Toggle Full Screen')), @@ -151,6 +156,10 @@ class _i(NamedTuple): CommandId.TOGGLE_VIEWER_SCALE_BAR_COLORED: _i(trans._('Scale Bar Colored')), CommandId.TOGGLE_VIEWER_SCALE_BAR_TICKS: _i(trans._('Scale Bar Ticks')), + # Plugins menubar + CommandId.DLG_PLUGIN_INSTALL: _i(trans._('Install/Uninstall Plugins...')), + CommandId.DLG_PLUGIN_ERR: _i(trans._('Plugin Errors...')), + # Help menubar CommandId.NAPARI_GETTING_STARTED: _i(trans._('Getting started')), CommandId.NAPARI_TUTORIALS: _i(trans._('Tutorials')), @@ -158,7 +167,8 @@ class _i(NamedTuple): CommandId.NAPARI_EXAMPLES: _i(trans._('Examples Gallery')), CommandId.NAPARI_RELEASE_NOTES: _i(trans._('Release Notes')), CommandId.NAPARI_HOMEPAGE: _i(trans._('napari homepage')), - CommandId.NAPARI_INFO: _i(trans._('napari Info')), + CommandId.NAPARI_INFO: _i(trans._('‎napari Info')), + CommandId.NAPARI_ABOUT_MACOS: _i(trans._('About napari')), CommandId.NAPARI_GITHUB_ISSUE: _i(trans._('Report an issue on GitHub')), CommandId.TOGGLE_BUG_REPORT_OPT_IN: _i(trans._('Bug Reporting Opt In/Out...')), diff --git a/napari/_app_model/constants/_menus.py b/napari/_app_model/constants/_menus.py index dbb524a59a6..a0080246959 100644 --- a/napari/_app_model/constants/_menus.py +++ b/napari/_app_model/constants/_menus.py @@ -25,6 +25,8 @@ class MenuId(StrEnum): VIEW_AXES = 'napari/view/axes' VIEW_SCALEBAR = 'napari/view/scalebar' + MENUBAR_PLUGINS = 'napari/plugins' + MENUBAR_HELP = 'napari/help' LAYERLIST_CONTEXT = 'napari/layers/context' @@ -39,6 +41,11 @@ def __str__(self) -> str: class MenuGroup: NAVIGATION = 'navigation' # always the first group in any menu RENDER = '1_render' + # Plugins menubar + PLUGINS = '1_plugins' + PLUGIN_MULTI_SUBMENU = '2_plugin_multi_submenu' + PLUGIN_SINGLE_CONTRIBUTIONS = '3_plugin_contributions' + # File menubar PREFERENCES = '2_preferences' SAVE = '3_save' CLOSE = '4_close' @@ -57,5 +64,5 @@ class LAYERLIST_CONTEXT: def is_menu_contributable(menu_id: str) -> bool: """Return True if the given menu_id is a menu that plugins can contribute to.""" return ( - menu_id in _CONTRIBUTABLES if menu_id.startswith("napari/") else True + menu_id in _CONTRIBUTABLES if menu_id.startswith('napari/') else True ) diff --git a/napari/_app_model/context/_context.py b/napari/_app_model/context/_context.py index 02c0522b445..a4a9f0438f8 100644 --- a/napari/_app_model/context/_context.py +++ b/napari/_app_model/context/_context.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from napari.utils.events import Event -__all__ = ["create_context", "get_context", "Context", "SettingsAwareContext"] +__all__ = ['create_context', 'get_context', 'Context', 'SettingsAwareContext'] class SettingsAwareContext(Context): @@ -39,7 +39,7 @@ def __del__(self): def __missing__(self, key: str) -> Any: if key.startswith(self._PREFIX): - splits = [k for k in key.split(".")[1:] if k] + splits = [k for k in key.split('.')[1:] if k] val: Any = self._settings if splits: while splits: @@ -61,7 +61,7 @@ def __setitem__(self, k: str, v: Any) -> None: if k.startswith(self._PREFIX): raise ValueError( trans._( - "Cannot set key starting with {prefix!r}", + 'Cannot set key starting with {prefix!r}', deferred=True, prefix=self._PREFIX, ) diff --git a/napari/_app_model/context/_context_keys.py b/napari/_app_model/context/_context_keys.py index 546c7947057..2caa6b3d364 100644 --- a/napari/_app_model/context/_context_keys.py +++ b/napari/_app_model/context/_context_keys.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from napari.utils.events import Event -A = TypeVar("A") +A = TypeVar('A') class ContextNamespace(_ContextNamespace, Generic[A]): diff --git a/napari/_app_model/context/_layerlist_context.py b/napari/_app_model/context/_layerlist_context.py index 6809bd10915..86e958bcba9 100644 --- a/napari/_app_model/context/_layerlist_context.py +++ b/napari/_app_model/context/_layerlist_context.py @@ -1,7 +1,7 @@ from __future__ import annotations import contextlib -from typing import TYPE_CHECKING, Optional, Tuple, Union +from typing import TYPE_CHECKING, Optional, Union from app_model.expressions import ContextKey @@ -32,7 +32,7 @@ class LayerListContextKeys(ContextNamespace['Layer']): num_layers = ContextKey( 0, - trans._("Number of layers."), + trans._('Number of layers.'), _len, ) @@ -50,63 +50,63 @@ def _n_unselected_links(s: LayerSel) -> int: def _is_rgb(s: LayerSel) -> bool: - return getattr(s.active, "rgb", False) + return getattr(s.active, 'rgb', False) def _only_img(s: LayerSel) -> bool: - return bool(s and all(x._type_string == "image" for x in s)) + return bool(s and all(x._type_string == 'image' for x in s)) def _n_selected_imgs(s: LayerSel) -> int: - return sum(x._type_string == "image" for x in s) + return sum(x._type_string == 'image' for x in s) def _only_labels(s: LayerSel) -> bool: - return bool(s and all(x._type_string == "labels" for x in s)) + return bool(s and all(x._type_string == 'labels' for x in s)) def _n_selected_labels(s: LayerSel) -> int: - return sum(x._type_string == "labels" for x in s) + return sum(x._type_string == 'labels' for x in s) def _only_points(s: LayerSel) -> bool: - return bool(s and all(x._type_string == "points" for x in s)) + return bool(s and all(x._type_string == 'points' for x in s)) def _n_selected_points(s: LayerSel) -> int: - return sum(x._type_string == "points" for x in s) + return sum(x._type_string == 'points' for x in s) def _only_shapes(s: LayerSel) -> bool: - return bool(s and all(x._type_string == "shapes" for x in s)) + return bool(s and all(x._type_string == 'shapes' for x in s)) def _n_selected_shapes(s: LayerSel) -> int: - return sum(x._type_string == "shapes" for x in s) + return sum(x._type_string == 'shapes' for x in s) def _only_surface(s: LayerSel) -> bool: - return bool(s and all(x._type_string == "surface" for x in s)) + return bool(s and all(x._type_string == 'surface' for x in s)) def _n_selected_surfaces(s: LayerSel) -> int: - return sum(x._type_string == "surface" for x in s) + return sum(x._type_string == 'surface' for x in s) def _only_vectors(s: LayerSel) -> bool: - return bool(s and all(x._type_string == "vectors" for x in s)) + return bool(s and all(x._type_string == 'vectors' for x in s)) def _n_selected_vectors(s: LayerSel) -> int: - return sum(x._type_string == "vectors" for x in s) + return sum(x._type_string == 'vectors' for x in s) def _only_tracks(s: LayerSel) -> bool: - return bool(s and all(x._type_string == "tracks" for x in s)) + return bool(s and all(x._type_string == 'tracks' for x in s)) def _n_selected_tracks(s: LayerSel) -> int: - return sum(x._type_string == "tracks" for x in s) + return sum(x._type_string == 'tracks' for x in s) def _active_type(s: LayerSel) -> Optional[str]: @@ -114,11 +114,11 @@ def _active_type(s: LayerSel) -> Optional[str]: def _active_ndim(s: LayerSel) -> Optional[int]: - return getattr(s.active.data, "ndim", None) if s.active else None + return getattr(s.active.data, 'ndim', None) if s.active else None -def _active_shape(s: LayerSel) -> Optional[Tuple[int, ...]]: - return getattr(s.active.data, "shape", None) if s.active else None +def _active_shape(s: LayerSel) -> Optional[tuple[int, ...]]: + return getattr(s.active.data, 'shape', None) if s.active else None def _same_shape(s: LayerSel) -> bool: @@ -139,7 +139,7 @@ def _same_shape(s: LayerSel) -> bool: .. [1] https://github.com/ml-explore/mlx .. [2] https://data-apis.org/array-api/latest/API_specification/generated/array_api.array.shape.html """ - return len({tuple(getattr(x.data, "shape", ())) for x in s}) == 1 + return len({tuple(getattr(x.data, 'shape', ())) for x in s}) == 1 def _active_dtype(s: LayerSel) -> DTypeLike: @@ -157,14 +157,14 @@ def _same_type(s: LayerSel) -> bool: def _active_is_image_3d(s: LayerSel) -> bool: _activ_ndim = _active_ndim(s) return ( - _active_type(s) == "image" + _active_type(s) == 'image' and _activ_ndim is not None and (_activ_ndim > 3 or (_activ_ndim) > 2 and not _is_rgb(s)) ) def _empty_shapes_layer_selected(s: LayerSel) -> bool: - return any(x._type_string == "shapes" and not len(x.data) for x in s) + return any(x._type_string == 'shapes' and not len(x.data) for x in s) class LayerListSelectionContextKeys(ContextNamespace['LayerSel']): @@ -176,28 +176,28 @@ class LayerListSelectionContextKeys(ContextNamespace['LayerSel']): num_selected_layers = ContextKey( 0, - trans._("Number of currently selected layers."), + trans._('Number of currently selected layers.'), _len, ) num_selected_layers_linked = ContextKey( False, - trans._("True when all selected layers are linked."), + trans._('True when all selected layers are linked.'), _all_linked, ) num_unselected_linked_layers = ContextKey( 0, - trans._("Number of unselected layers linked to selected layer(s)."), + trans._('Number of unselected layers linked to selected layer(s).'), _n_unselected_links, ) active_layer_is_rgb = ContextKey( False, - trans._("True when the active layer is RGB."), + trans._('True when the active layer is RGB.'), _is_rgb, ) active_layer_type = ContextKey['LayerSel', Optional[str]]( None, trans._( - "Lowercase name of active layer type, or None of none active." + 'Lowercase name of active layer type, or None of none active.' ), _active_type, ) @@ -206,78 +206,78 @@ class LayerListSelectionContextKeys(ContextNamespace['LayerSel']): # support Sets, tuples, lists, etc... which they currently do not. num_selected_image_layers = ContextKey( 0, - trans._("Number of selected image layers."), + trans._('Number of selected image layers.'), _n_selected_imgs, ) num_selected_labels_layers = ContextKey( 0, - trans._("Number of selected labels layers."), + trans._('Number of selected labels layers.'), _n_selected_labels, ) num_selected_points_layers = ContextKey( 0, - trans._("Number of selected points layers."), + trans._('Number of selected points layers.'), _n_selected_points, ) num_selected_shapes_layers = ContextKey( 0, - trans._("Number of selected shapes layers."), + trans._('Number of selected shapes layers.'), _n_selected_shapes, ) num_selected_surface_layers = ContextKey( 0, - trans._("Number of selected surface layers."), + trans._('Number of selected surface layers.'), _n_selected_surfaces, ) num_selected_vectors_layers = ContextKey( 0, - trans._("Number of selected vectors layers."), + trans._('Number of selected vectors layers.'), _n_selected_vectors, ) num_selected_tracks_layers = ContextKey( 0, - trans._("Number of selected tracks layers."), + trans._('Number of selected tracks layers.'), _n_selected_tracks, ) active_layer_ndim = ContextKey['LayerSel', Optional[int]]( None, trans._( - "Number of dimensions in the active layer, or `None` if nothing is active." + 'Number of dimensions in the active layer, or `None` if nothing is active.' ), _active_ndim, ) - active_layer_shape = ContextKey['LayerSel', Optional[Tuple[int, ...]]]( + active_layer_shape = ContextKey['LayerSel', Optional[tuple[int, ...]]]( (), - trans._("Shape of the active layer, or `None` if nothing is active."), + trans._('Shape of the active layer, or `None` if nothing is active.'), _active_shape, ) active_layer_is_image_3d = ContextKey( False, - trans._("True when the active layer is a 3D image."), + trans._('True when the active layer is a 3D image.'), _active_is_image_3d, ) active_layer_dtype = ContextKey( None, - trans._("Dtype of the active layer, or `None` if nothing is active."), + trans._('Dtype of the active layer, or `None` if nothing is active.'), _active_dtype, ) all_selected_layers_same_shape = ContextKey( False, - trans._("True when all selected layers have the same shape."), + trans._('True when all selected layers have the same shape.'), _same_shape, ) all_selected_layers_same_type = ContextKey( False, - trans._("True when all selected layers are of the same type."), + trans._('True when all selected layers are of the same type.'), _same_type, ) all_selected_layers_labels = ContextKey( False, - trans._("True when all selected layers are labels."), + trans._('True when all selected layers are labels.'), _only_labels, ) selected_empty_shapes_layer = ContextKey( False, - trans._("True when there is a shapes layer without data selected."), + trans._('True when there is a shapes layer without data selected.'), _empty_shapes_layer_selected, ) diff --git a/napari/_app_model/injection/_processors.py b/napari/_app_model/injection/_processors.py index dcb7b7a6bed..9d68eba9b03 100644 --- a/napari/_app_model/injection/_processors.py +++ b/napari/_app_model/injection/_processors.py @@ -1,14 +1,15 @@ -import sys +"""Non-Qt processors. + +Qt processors can be found in `napari/_qt/_qapp_model/injection/_qprocessors.py`. +""" + from concurrent.futures import Future from contextlib import nullcontext, suppress from functools import partial from typing import ( Any, Callable, - Dict, - List, Optional, - Set, Union, get_origin, ) @@ -19,7 +20,7 @@ def _add_layer_data_tuples_to_viewer( - data: Union[types.LayerDataTuple, List[types.LayerDataTuple]], + data: Union[types.LayerDataTuple, list[types.LayerDataTuple]], return_type=None, viewer=None, source: Optional[dict] = None, @@ -32,7 +33,7 @@ def _add_layer_data_tuples_to_viewer( data = data if isinstance(data, list) else [data] for datum in ensure_list_of_layer_data_tuple(data): # then try to update a viewer layer with the same name. - if len(datum) > 1 and (name := datum[1].get("name")): + if len(datum) > 1 and (name := datum[1].get('name')): with suppress(KeyError): layer = viewer.layers[name] layer.data = datum[0] @@ -88,10 +89,10 @@ def _add_layer_data_to_viewer( ] is not type(None): # this case should be impossible, but we'll check anyway. raise TypeError( - f"napari supports only Optional[], not {return_type}" + f'napari supports only Optional[], not {return_type}' ) return_type = return_type.__args__[0] - layer_type = return_type.__name__.replace("Data", "").lower() + layer_type = return_type.__name__.replace('Data', '').lower() with layer_source(**source) if source else nullcontext(): getattr(viewer, f'add_{layer_type}')(data=data, name=layer_name) @@ -107,7 +108,7 @@ def _add_layer_to_viewer( # here to prevent garbace collection of the future object while processing. -_FUTURES: Set[Future] = set() +_FUTURES: set[Future] = set() def _add_future_data( @@ -167,16 +168,15 @@ def _on_future_ready(f: Future): _FUTURES.add(future) -# Add future and LayerData processors for each layer type. -PROCESSORS: Dict[object, Callable] = { +PROCESSORS: dict[object, Callable] = { types.LayerDataTuple: _add_layer_data_tuples_to_viewer, - List[types.LayerDataTuple]: _add_layer_data_tuples_to_viewer, + list[types.LayerDataTuple]: _add_layer_data_tuples_to_viewer, layers.Layer: _add_layer_to_viewer, } +# Add future and LayerData processors for each layer type. for t in types._LayerData.__args__: # type: ignore [attr-defined] PROCESSORS[t] = partial(_add_layer_data_to_viewer, return_type=t) - if sys.version_info >= (3, 9): - PROCESSORS[Future[t]] = partial( # type: ignore [valid-type] - _add_future_data, return_type=t, _from_tuple=False - ) + PROCESSORS[Future[t]] = partial( # type: ignore [valid-type] + _add_future_data, return_type=t, _from_tuple=False + ) diff --git a/napari/_app_model/injection/_providers.py b/napari/_app_model/injection/_providers.py index a061898b44a..c1252d22fbe 100644 --- a/napari/_app_model/injection/_providers.py +++ b/napari/_app_model/injection/_providers.py @@ -1,10 +1,42 @@ -from typing import Optional +"""Non-Qt providers. -from napari import components, layers, viewer +Qt providers can be found in `napari/_qt/_qapp_model/injection/_qproviders.py`. +Because `_provide_viewer` needs `_QtMainWindow` (otherwise returns `None`) +tests are in `napari/_tests/test_providers.py`, which are not run in headless mode. +""" -def _provide_viewer() -> Optional[viewer.Viewer]: - return viewer.current_viewer() +from typing import Optional + +from napari import components, layers, viewer +from napari.utils._proxies import PublicOnlyProxy +from napari.utils.translations import trans + + +def _provide_viewer(public_proxy: bool = True) -> Optional[viewer.Viewer]: + """Provide `PublicOnlyProxy` (allows internal napari access) of current viewer.""" + if current_viewer := viewer.current_viewer(): + if public_proxy: + return PublicOnlyProxy(current_viewer) + return current_viewer + return None + + +def _provide_viewer_or_raise( + msg: str = '', public_proxy: bool = False +) -> viewer.Viewer: + viewer = _provide_viewer(public_proxy) + if viewer: + return viewer + if msg: + msg = ' ' + msg + raise RuntimeError( + trans._( + 'No current `Viewer` found.{msg}', + deferred=True, + msg=msg, + ) + ) def _provide_active_layer() -> Optional[layers.Layer]: diff --git a/napari/_app_model/injection/_tests/test_processors.py b/napari/_app_model/injection/_tests/test_processors.py index 1fd0d2855a4..272d591f944 100644 --- a/napari/_app_model/injection/_tests/test_processors.py +++ b/napari/_app_model/injection/_tests/test_processors.py @@ -10,7 +10,7 @@ def test_add_layer_data_to_viewer(): v = MagicMock() - with pytest.raises(TypeError, match="napari supports only Optional"): + with pytest.raises(TypeError, match='napari supports only Optional'): _add_layer_data_to_viewer( data=np.zeros((10, 10)), return_type=Union[ImageData, LabelsData], diff --git a/napari/_qt/__init__.py b/napari/_qt/__init__.py index fa6207b44f9..69667652bc2 100644 --- a/napari/_qt/__init__.py +++ b/napari/_qt/__init__.py @@ -12,7 +12,7 @@ from inspect import cleandoc installed_with_conda = list( - Path(sys.prefix, "conda-meta").glob("napari-*.json") + Path(sys.prefix, 'conda-meta').glob('napari-*.json') ) raise ImportError( @@ -36,7 +36,7 @@ """ ), deferred=True, - tool="conda" if installed_with_conda else "pip", + tool='conda' if installed_with_conda else 'pip', ) ) from e raise @@ -56,10 +56,10 @@ assert isinstance(QT_VERSION, str) - if version.parse(QT_VERSION) > version.parse("6.3.1"): + if version.parse(QT_VERSION) > version.parse('6.3.1'): raise RuntimeError( trans._( - "Napari is not expected to work with PySide6 >= 6.3.2 on Python < 3.10", + 'Napari is not expected to work with PySide6 >= 6.3.2 on Python < 3.10', deferred=True, ) ) @@ -80,7 +80,7 @@ ) except ModuleNotFoundError: warn_message = trans._( - "\n\nnapari was tested with QT library `>=5.12.3`.\nThe version installed is {version}. Please report any issues with\nthis specific QT version at https://github.com/Napari/napari/issues.", + '\n\nnapari was tested with QT library `>=5.12.3`.\nThe version installed is {version}. Please report any issues with\nthis specific QT version at https://github.com/Napari/napari/issues.', deferred=True, version=QtCore.__version__, ) @@ -90,4 +90,4 @@ from napari._qt.qt_event_loop import get_app, gui_qt, quit_app, run from napari._qt.qt_main_window import Window -__all__ = ["get_app", "gui_qt", "quit_app", "run", "Window"] +__all__ = ['get_app', 'gui_qt', 'quit_app', 'run', 'Window'] diff --git a/napari/_qt/_qapp_model/_tests/test_file_menu.py b/napari/_qt/_qapp_model/_tests/test_file_menu.py index 1f51e858fb5..e5f98216a74 100644 --- a/napari/_qt/_qapp_model/_tests/test_file_menu.py +++ b/napari/_qt/_qapp_model/_tests/test_file_menu.py @@ -142,9 +142,9 @@ def test_sample_menu_single_data( def test_show_shortcuts_actions(make_napari_viewer): viewer = make_napari_viewer() assert viewer.window._pref_dialog is None - action_manager.trigger("napari:show_shortcuts") + action_manager.trigger('napari:show_shortcuts') assert viewer.window._pref_dialog is not None - assert viewer.window._pref_dialog._list.currentItem().text() == "Shortcuts" + assert viewer.window._pref_dialog._list.currentItem().text() == 'Shortcuts' viewer.window._pref_dialog.close() @@ -172,7 +172,7 @@ def _get_menu(act): @pytest.mark.parametrize( - "menu_str,dialog_method,dialog_return,filename_call,stack", + 'menu_str,dialog_method,dialog_return,filename_call,stack', [ ( 'Open File(s)...', @@ -207,11 +207,10 @@ def test_open_with_plugin( ): viewer = make_napari_viewer() action, _a = get_open_with_plugin_action(viewer, menu_str) - with mock.patch( - 'napari._qt.qt_viewer.QFileDialog' - ) as mock_file, mock.patch( - 'napari._qt.qt_viewer.QtViewer._qt_open' - ) as mock_read: + with ( + mock.patch('napari._qt.qt_viewer.QFileDialog') as mock_file, + mock.patch('napari._qt.qt_viewer.QtViewer._qt_open') as mock_read, + ): mock_file_instance = mock_file.return_value getattr(mock_file_instance, dialog_method).return_value = dialog_return action.trigger() diff --git a/napari/_qt/_qapp_model/_tests/test_qproviders.py b/napari/_qt/_qapp_model/_tests/test_qproviders.py new file mode 100644 index 00000000000..3e3b43ad84a --- /dev/null +++ b/napari/_qt/_qapp_model/_tests/test_qproviders.py @@ -0,0 +1,36 @@ +"""Test app-model Qt-related providers.""" + +import pytest + +from napari._qt._qapp_model.injection._qproviders import ( + _provide_qt_viewer_or_raise, + _provide_window_or_raise, +) +from napari._qt.qt_main_window import Window +from napari._qt.qt_viewer import QtViewer + + +def test_provide_qt_viewer_or_raise(make_napari_viewer): + """Check `_provide_qt_viewer_or_raise` raises or returns `QtViewer`.""" + # raises when no QtViewer + with pytest.raises( + RuntimeError, match='No current `QtViewer` found. test' + ): + _provide_qt_viewer_or_raise(msg='test') + + # create QtViewer + make_napari_viewer() + viewer = _provide_qt_viewer_or_raise() + assert isinstance(viewer, QtViewer) + + +def test_provide_window_or_raise(make_napari_viewer): + """Check `_provide_window_or_raise` raises or returns `Window`.""" + # raises when no Window + with pytest.raises(RuntimeError, match='No current `Window` found. test'): + _provide_window_or_raise(msg='test') + + # create viewer (and Window) + make_napari_viewer() + viewer = _provide_window_or_raise() + assert isinstance(viewer, Window) diff --git a/napari/_vendor/experimental/__init__.py b/napari/_qt/_qapp_model/injection/__init__.py similarity index 100% rename from napari/_vendor/experimental/__init__.py rename to napari/_qt/_qapp_model/injection/__init__.py diff --git a/napari/_qt/_qapp_model/injection/_qprocessors.py b/napari/_qt/_qapp_model/injection/_qprocessors.py new file mode 100644 index 00000000000..215dfb7c437 --- /dev/null +++ b/napari/_qt/_qapp_model/injection/_qprocessors.py @@ -0,0 +1,35 @@ +"""Qt processors. + +Non-Qt processors can be found in `napari/_app_model/injection/_processors.py`. +""" + +from typing import ( + Callable, + Optional, + Union, +) + +from magicgui.widgets import FunctionGui, Widget +from qtpy.QtWidgets import QWidget + +from napari import viewer +from napari._app_model.injection._providers import _provide_viewer_or_raise + + +def _add_plugin_dock_widget( + widget_name_tuple: tuple[Union[FunctionGui, QWidget, Widget], str], + viewer: Optional[viewer.Viewer] = None, +) -> None: + if viewer is None: + viewer = _provide_viewer_or_raise( + msg='Widgets cannot be opened in headless mode.', + ) + widget, full_name = widget_name_tuple + viewer.window.add_dock_widget(widget, name=full_name) + + +QPROCESSORS: dict[object, Callable] = { + Optional[ + tuple[Union[FunctionGui, QWidget, Widget], str] + ]: _add_plugin_dock_widget, +} diff --git a/napari/_qt/_qapp_model/injection/_qproviders.py b/napari/_qt/_qapp_model/injection/_qproviders.py new file mode 100644 index 00000000000..a17880fc000 --- /dev/null +++ b/napari/_qt/_qapp_model/injection/_qproviders.py @@ -0,0 +1,66 @@ +"""Qt providers. + +Non-Qt providers can be found in `napari/_app_model/injection/_providers.py`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from napari.utils.translations import trans + +if TYPE_CHECKING: + from napari._qt.qt_main_window import Window + from napari._qt.qt_viewer import QtViewer + + +def _provide_qt_viewer() -> Optional[QtViewer]: + from napari._qt.qt_main_window import _QtMainWindow + + if _qmainwin := _QtMainWindow.current(): + return _qmainwin._qt_viewer + return None + + +def _provide_qt_viewer_or_raise(msg: str = '') -> QtViewer: + qt_viewer = _provide_qt_viewer() + if qt_viewer: + return qt_viewer + if msg: + msg = ' ' + msg + raise RuntimeError( + trans._( + 'No current `QtViewer` found.{msg}', + deferred=True, + msg=msg, + ) + ) + + +def _provide_window() -> Optional[Window]: + from napari._qt.qt_main_window import _QtMainWindow + + if _qmainwin := _QtMainWindow.current(): + return _qmainwin._window + return None + + +def _provide_window_or_raise(msg: str = '') -> Window: + window = _provide_window() + if window: + return window + if msg: + msg = ' ' + msg + raise RuntimeError( + trans._( + 'No current `Window` found.{msg}', + deferred=True, + msg=msg, + ) + ) + + +QPROVIDERS = [ + (_provide_qt_viewer,), + (_provide_window,), +] diff --git a/napari/_qt/_qapp_model/qactions/__init__.py b/napari/_qt/_qapp_model/qactions/__init__.py index dd0833c0acd..203f9e7f875 100644 --- a/napari/_qt/_qapp_model/qactions/__init__.py +++ b/napari/_qt/_qapp_model/qactions/__init__.py @@ -1,6 +1,10 @@ +from __future__ import annotations + from functools import lru_cache from itertools import chain -from typing import Optional + +from napari._qt._qapp_model.injection._qprocessors import QPROCESSORS +from napari._qt._qapp_model.injection._qproviders import QPROVIDERS # Submodules should be able to import from most modules, so to # avoid circular imports, don't import submodules at the top level here, @@ -21,12 +25,12 @@ def init_qactions() -> None: - registering provider functions for the names added to the namespace - registering Qt-dependent actions with app-model (i.e. Q_*_ACTIONS actions). """ - from napari._app_model import get_app from napari._qt._qapp_model.qactions._file import Q_FILE_ACTIONS from napari._qt._qapp_model.qactions._help import Q_HELP_ACTIONS + from napari._qt._qapp_model.qactions._plugins import Q_PLUGINS_ACTIONS from napari._qt._qapp_model.qactions._view import Q_VIEW_ACTIONS - from napari._qt.qt_main_window import Window, _QtMainWindow + from napari._qt.qt_main_window import Window from napari._qt.qt_viewer import QtViewer # update the namespace with the Qt-specific types/providers/processors @@ -39,17 +43,17 @@ def init_qactions() -> None: } # Qt-specific providers/processors - @store.register_provider - def _provide_window() -> Optional[Window]: - if _qmainwin := _QtMainWindow.current(): - return _qmainwin._window - return None - - @store.register_provider - def _provide_qt_viewer() -> Optional[QtViewer]: - if _qmainwin := _QtMainWindow.current(): - return _qmainwin._qt_viewer - return None + app.injection_store.register( + processors=QPROCESSORS, + providers=QPROVIDERS, + ) # register actions - app.register_actions(chain(Q_FILE_ACTIONS, Q_HELP_ACTIONS, Q_VIEW_ACTIONS)) + app.register_actions( + chain( + Q_FILE_ACTIONS, + Q_HELP_ACTIONS, + Q_PLUGINS_ACTIONS, + Q_VIEW_ACTIONS, + ) + ) diff --git a/napari/_qt/_qapp_model/qactions/_file.py b/napari/_qt/_qapp_model/qactions/_file.py index 1ffffac9beb..1e1d7d29f47 100644 --- a/napari/_qt/_qapp_model/qactions/_file.py +++ b/napari/_qt/_qapp_model/qactions/_file.py @@ -1,6 +1,5 @@ import sys from pathlib import Path -from typing import List from app_model.types import Action, KeyCode, KeyMod, StandardKeyBinding @@ -42,13 +41,13 @@ def _close_app(window: Window): window._qt_window.close(quit_app=True, confirm_need=True) -Q_FILE_ACTIONS: List[Action] = [ +Q_FILE_ACTIONS: list[Action] = [ Action( id=CommandId.IMAGE_FROM_CLIPBOARD, title=CommandId.IMAGE_FROM_CLIPBOARD.command_title, callback=QtViewer._image_from_clipboard, menus=[{'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.NAVIGATION}], - keybindings=[{"primary": KeyMod.CtrlCmd | KeyCode.KeyN}], + keybindings=[{'primary': KeyMod.CtrlCmd | KeyCode.KeyN}], ), Action( id=CommandId.DLG_OPEN_FILES, @@ -178,7 +177,7 @@ def _close_app(window: Window): 'id': MenuId.MENUBAR_FILE, 'group': MenuGroup.CLOSE, 'when': ( - Path(sys.executable).parent / ".napari_is_bundled" + Path(sys.executable).parent / '.napari_is_bundled' ).exists(), } ], diff --git a/napari/_qt/_qapp_model/qactions/_help.py b/napari/_qt/_qapp_model/qactions/_help.py index b03e86d86f4..b7bd8065320 100644 --- a/napari/_qt/_qapp_model/qactions/_help.py +++ b/napari/_qt/_qapp_model/qactions/_help.py @@ -4,7 +4,7 @@ file within `napari/_app_model/actions/`. """ -from typing import List +import sys from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod @@ -23,15 +23,28 @@ def _show_about(window: Window): QtAbout.showAbout(window._qt_window) -Q_HELP_ACTIONS: List[Action] = [ +Q_HELP_ACTIONS: list[Action] = [ Action( id=CommandId.NAPARI_INFO, title=CommandId.NAPARI_INFO.command_title, callback=_show_about, - menus=[{"id": MenuId.MENUBAR_HELP, 'group': MenuGroup.RENDER}], + menus=[{'id': MenuId.MENUBAR_HELP, 'group': MenuGroup.RENDER}], status_tip=trans._('About napari'), keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.Slash)], - ) + ), + Action( + id=CommandId.NAPARI_ABOUT_MACOS, + title=CommandId.NAPARI_ABOUT_MACOS.command_title, + callback=_show_about, + menus=[ + { + 'id': MenuId.MENUBAR_HELP, + 'group': MenuGroup.RENDER, + 'when': sys.platform == 'darwin', + } + ], + status_tip=trans._('About napari'), + ), ] if ask_opt_in is not None: @@ -40,6 +53,6 @@ def _show_about(window: Window): id=CommandId.TOGGLE_BUG_REPORT_OPT_IN, title=CommandId.TOGGLE_BUG_REPORT_OPT_IN.command_title, callback=lambda: ask_opt_in(force=True), - menus=[{"id": MenuId.MENUBAR_HELP}], + menus=[{'id': MenuId.MENUBAR_HELP}], ) ) diff --git a/napari/_qt/_qapp_model/qactions/_plugins.py b/napari/_qt/_qapp_model/qactions/_plugins.py new file mode 100644 index 00000000000..57fb6f30be5 --- /dev/null +++ b/napari/_qt/_qapp_model/qactions/_plugins.py @@ -0,0 +1,72 @@ +"""Defines plugins menu q actions.""" + +from importlib.util import find_spec +from logging import getLogger + +from app_model.types import Action + +from napari._app_model.constants import CommandId, MenuGroup, MenuId +from napari._qt.dialogs.qt_plugin_report import QtPluginErrReporter +from napari._qt.qt_main_window import Window +from napari.utils.translations import trans + +logger = getLogger(__name__) + + +def _plugin_manager_dialog_avail() -> bool: + """Returns whether the plugin manager class is available.""" + + plugin_dlg = find_spec('napari_plugin_manager') + if plugin_dlg: + return True + # not available + logger.debug('QtPluginDialog not available') + return False + + +def _show_plugin_install_dialog(window: Window) -> None: + """Show dialog that allows users to install and enable/disable plugins.""" + + # TODO: Once menu contributions supported, `napari_plugin_manager` should be + # amended to be a napari plugin and simply add this menu item itself. + # This callback is only used when this package is available, thus we do not check + from napari_plugin_manager.qt_plugin_dialog import QtPluginDialog + + QtPluginDialog(window._qt_window).exec_() + + +def _show_plugin_err_reporter(window: Window) -> None: + """Show dialog that allows users to review and report plugin errors.""" + QtPluginErrReporter(parent=window._qt_window).exec_() # type: ignore [attr-defined] + + +Q_PLUGINS_ACTIONS: list[Action] = [ + Action( + id=CommandId.DLG_PLUGIN_INSTALL, + title=CommandId.DLG_PLUGIN_INSTALL.command_title, + menus=[ + { + 'id': MenuId.MENUBAR_PLUGINS, + 'group': MenuGroup.PLUGINS, + 'order': 1, + 'when': _plugin_manager_dialog_avail(), + } + ], + callback=_show_plugin_install_dialog, + ), + Action( + id=CommandId.DLG_PLUGIN_ERR, + title=CommandId.DLG_PLUGIN_ERR.command_title, + menus=[ + { + 'id': MenuId.MENUBAR_PLUGINS, + 'group': MenuGroup.PLUGINS, + 'order': 2, + } + ], + callback=_show_plugin_err_reporter, + status_tip=trans._( + 'Review stack traces for plugin exceptions and notify developers' + ), + ), +] diff --git a/napari/_qt/_qapp_model/qactions/_view.py b/napari/_qt/_qapp_model/qactions/_view.py index 8c012913a8e..785d91a9122 100644 --- a/napari/_qt/_qapp_model/qactions/_view.py +++ b/napari/_qt/_qapp_model/qactions/_view.py @@ -5,7 +5,6 @@ """ import sys -from typing import List from app_model.types import ( Action, @@ -41,7 +40,7 @@ def _get_current_activity_dock_status(window: Window): return window._qt_window._activity_dialog.isVisible() -Q_VIEW_ACTIONS: List[Action] = [ +Q_VIEW_ACTIONS: list[Action] = [ Action( id=CommandId.TOGGLE_FULLSCREEN, title=CommandId.TOGGLE_FULLSCREEN.command_title, diff --git a/napari/_qt/_qplugins/__init__.py b/napari/_qt/_qplugins/__init__.py new file mode 100644 index 00000000000..db3e5442843 --- /dev/null +++ b/napari/_qt/_qplugins/__init__.py @@ -0,0 +1,11 @@ +from napari._qt._qplugins._qnpe2 import ( + _rebuild_npe1_plugins_menu, + _rebuild_npe1_samples_menu, + _register_qt_actions, +) + +__all__ = [ + '_rebuild_npe1_samples_menu', + '_rebuild_npe1_plugins_menu', + '_register_qt_actions', +] diff --git a/napari/_qt/_qplugins/_qnpe2.py b/napari/_qt/_qplugins/_qnpe2.py new file mode 100644 index 00000000000..a0f167716cb --- /dev/null +++ b/napari/_qt/_qplugins/_qnpe2.py @@ -0,0 +1,448 @@ +"""Plugin related functions that require Qt. + +Non-Qt plugin functions can be found in: `napari/plugins/_npe2.py` +""" + +from __future__ import annotations + +import inspect +from functools import partial +from itertools import chain +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, + cast, +) + +from app_model import Action +from app_model.types import SubmenuItem, ToggleRule +from magicgui.type_map._magicgui import MagicFactory +from magicgui.widgets import FunctionGui, Widget +from npe2 import plugin_manager as pm +from qtpy.QtWidgets import QWidget + +from napari._app_model import get_app +from napari._app_model.constants import MenuGroup, MenuId +from napari._app_model.injection._providers import _provide_viewer_or_raise +from napari._qt._qapp_model.injection._qproviders import ( + _provide_window, + _provide_window_or_raise, +) +from napari.errors.reader_errors import MultipleReaderError +from napari.plugins import menu_item_template, plugin_manager +from napari.plugins._npe2 import get_widget_contribution +from napari.utils.events import Event +from napari.utils.translations import trans +from napari.viewer import Viewer + +if TYPE_CHECKING: + from npe2.manifest import PluginManifest + from npe2.plugin_manager import PluginName + from npe2.types import WidgetCreator + + from napari.qt import QtViewer + + +# TODO: This is a separate function from `_build_samples_submenu_actions` so it +# can be easily deleted once npe1 is no longer supported. +def _rebuild_npe1_samples_menu() -> None: # pragma: no cover + """Register submenu and actions for all npe1 plugins, clearing all first.""" + app = get_app() + # Unregister all existing npe1 sample menu actions and submenus + if unreg := plugin_manager._unreg_sample_submenus: + unreg() + if unreg := plugin_manager._unreg_sample_actions: + unreg() + + sample_actions: list[Action] = [] + sample_submenus: list[Any] = [] + for plugin_name, samples in plugin_manager._sample_data.items(): + multiprovider = len(samples) > 1 + if multiprovider: + submenu_id = f'napari/file/samples/{plugin_name}' + submenu = ( + MenuId.FILE_SAMPLES, + SubmenuItem(submenu=submenu_id, title=trans._(plugin_name)), + ) + sample_submenus.append(submenu) + else: + submenu_id = MenuId.FILE_SAMPLES + + for sample_name, sample_dict in samples.items(): + + _add_sample_partial = partial( + _add_sample, + plugin=plugin_name, + sample=sample_name, + ) + + display_name = sample_dict['display_name'].replace('&', '&&') + if multiprovider: + title = display_name + else: + title = menu_item_template.format(plugin_name, display_name) + + action: Action = Action( + id=f'{plugin_name}:{display_name}', + title=title, + menus=[{'id': submenu_id, 'group': MenuGroup.NAVIGATION}], + callback=_add_sample_partial, + ) + sample_actions.append(action) + + if sample_submenus: + unreg_sample_submenus = app.menus.append_menu_items(sample_submenus) + plugin_manager._unreg_sample_submenus = unreg_sample_submenus + if sample_actions: + unreg_sample_actions = app.register_actions(sample_actions) + plugin_manager._unreg_sample_actions = unreg_sample_actions + + +# TODO: This should be deleted once npe1 is no longer supported. +def _toggle_or_get_widget_npe1( + plugin: str, + widget_name: str, + name: str, + hook_type: str, +) -> None: # pragma: no cover + """Toggle if widget already built otherwise return widget for npe1.""" + window = _provide_window_or_raise( + msg='Note that widgets cannot be opened in headless mode.' + ) + + if window and (dock_widget := window._dock_widgets.get(name)): + dock_widget.setVisible(not dock_widget.isVisible()) + return + + if hook_type == 'dock': + window.add_plugin_dock_widget(plugin, widget_name) + else: + window._add_plugin_function_widget(plugin, widget_name) + + +def _rebuild_npe1_plugins_menu() -> None: + """Register widget submenu and actions for all npe1 plugins, clearing all first.""" + app = get_app() + + # Unregister all existing npe1 plugin menu actions and submenus + if unreg := plugin_manager._unreg_plugin_submenus: + unreg() + if unreg := plugin_manager._unreg_plugin_actions: + unreg() + + widget_actions: list[Action] = [] + widget_submenus: list[Any] = [] + for hook_type, (plugin_name, widgets) in chain( + plugin_manager.iter_widgets() + ): + multiprovider = len(widgets) > 1 + if multiprovider: + submenu_id = f'napari/plugins/{plugin_name}' + submenu = ( + MenuId.MENUBAR_PLUGINS, + SubmenuItem( + submenu=submenu_id, + title=trans._(plugin_name), + group=MenuGroup.PLUGIN_MULTI_SUBMENU, + ), + ) + widget_submenus.append(submenu) + else: + submenu_id = MenuId.MENUBAR_PLUGINS + + for widget_name in widgets: + full_name = menu_item_template.format(plugin_name, widget_name) + title = widget_name if multiprovider else full_name + + _widget_callback = partial( + _toggle_or_get_widget_npe1, + plugin=plugin_name, + widget_name=widget_name, + name=full_name, + hook_type=hook_type, + ) + _get_current_dock_status_partial = partial( + _get_current_dock_status, + full_name=full_name, + ) + action: Action = Action( + id=f'{plugin_name}:{widget_name.replace("&", "&&")}', + title=title.replace('&', '&&'), + menus=[ + { + 'id': submenu_id, + 'group': MenuGroup.PLUGIN_SINGLE_CONTRIBUTIONS, + } + ], + callback=_widget_callback, + toggled=ToggleRule( + get_current=_get_current_dock_status_partial + ), + ) + widget_actions.append(action) + + if widget_submenus: + unreg_plugin_submenus = app.menus.append_menu_items(widget_submenus) + plugin_manager._unreg_plugin_submenus = unreg_plugin_submenus + if widget_actions: + unreg_plugin_actions = app.register_actions(widget_actions) + plugin_manager._unreg_plugin_actions = unreg_plugin_actions + + +def _get_contrib_parent_menu( + multiprovider: bool, + parent_menu: MenuId, + mf: PluginManifest, + group: Optional[str] = None, +) -> tuple[str, list[tuple[str, SubmenuItem]]]: + """Get parent menu of plugin contribution (samples/widgets). + + If plugin provides multiple contributions, create a new submenu item. + """ + submenu: list[tuple[str, SubmenuItem]] = [] + if multiprovider: + submenu_id = f'{parent_menu}/{mf.name}' + submenu = [ + ( + parent_menu, + SubmenuItem( + submenu=submenu_id, + title=trans._(mf.display_name), + group=group, + ), + ), + ] + else: + submenu_id = parent_menu + return submenu_id, submenu + + +# Note `QtViewer` gets added to `injection_store.namespace` during +# `init_qactions` so does not need to be imported for type annotation resolution +def _add_sample(qt_viewer: QtViewer, plugin: str, sample: str) -> None: + from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading + + try: + qt_viewer.viewer.open_sample(plugin, sample) + except MultipleReaderError as e: + handle_gui_reading( + [str(p) for p in e.paths], + qt_viewer, + stack=False, + ) + + +def _build_samples_submenu_actions( + mf: PluginManifest, +) -> tuple[list[tuple[str, SubmenuItem]], list[Action]]: + """Build sample data submenu and actions for a single npe2 plugin manifest.""" + from napari._app_model.constants import MenuGroup, MenuId + from napari.plugins import menu_item_template + + # If no sample data, return + if not mf.contributions.sample_data: + return [], [] + + sample_data = mf.contributions.sample_data + multiprovider = len(sample_data) > 1 + submenu_id, submenu = _get_contrib_parent_menu( + multiprovider, + MenuId.FILE_SAMPLES, + mf, + ) + + sample_actions: list[Action] = [] + for sample in sample_data: + _add_sample_partial = partial( + _add_sample, + plugin=mf.name, + sample=sample.key, + ) + + if multiprovider: + title = sample.display_name + else: + title = menu_item_template.format( + mf.display_name, sample.display_name + ) + # To display '&' instead of creating a shortcut + title = title.replace('&', '&&') + + action: Action = Action( + id=f'{mf.name}:{sample.key}', + title=title, + menus=[{'id': submenu_id, 'group': MenuGroup.NAVIGATION}], + callback=_add_sample_partial, + ) + sample_actions.append(action) + return submenu, sample_actions + + +def _get_widget_viewer_param( + widget_callable: WidgetCreator, widget_name: str +) -> str: + """Get widget parameter name for `viewer` (if any) and check type.""" + if inspect.isclass(widget_callable) and issubclass( + widget_callable, + (QWidget, Widget), + ): + widget_param = '' + try: + sig = inspect.signature(widget_callable.__init__) + except ValueError: # pragma: no cover + # Inspection can fail when adding to bundled viewer as it thinks widget is + # a builtin + pass + else: + for param in sig.parameters.values(): + if param.name == 'napari_viewer' or param.annotation in ( + 'napari.viewer.Viewer', + Viewer, + ): + widget_param = param.name + break + + # For magicgui type widget contributions, `Viewer` injection is done by + # `magicgui.register_type`. + elif isinstance(widget_callable, MagicFactory) or inspect.isfunction( + widget_callable + ): + widget_param = '' + else: + raise TypeError( + trans._( + "'{widget}' must be `QtWidgets.QWidget` or `magicgui.widgets.Widget` subclass, `MagicFactory` instance or function. Please raise an issue in napari GitHub with the plugin and widget you were trying to use.", + deferred=True, + widget=widget_name, + ) + ) + return widget_param + + +def _toggle_or_get_widget( + plugin: str, + widget_name: str, + full_name: str, +) -> Optional[tuple[Union[FunctionGui, QWidget, Widget], str]]: + """Toggle if widget already built otherwise return widget. + + Returned widget will be added to main window by a processor. + Note for magicgui type widget contributions, `Viewer` injection is done by + `magicgui.register_type` instead of a provider via annnotation. + """ + viewer = _provide_viewer_or_raise( + msg='Note that widgets cannot be opened in headless mode.', + ) + + window = viewer.window + if window and (dock_widget := window._dock_widgets.get(full_name)): + dock_widget.setVisible(not dock_widget.isVisible()) + return None + + # Get widget param name (if any) and check type + widget_callable, _ = get_widget_contribution(plugin, widget_name) # type: ignore [misc] + widget_param = _get_widget_viewer_param(widget_callable, widget_name) + + kwargs = {} + if widget_param: + kwargs[widget_param] = viewer + return widget_callable(**kwargs), full_name + + +def _get_current_dock_status(full_name: str) -> bool: + window = _provide_window_or_raise( + msg='Note that widgets cannot be opened in headless mode.', + ) + if full_name in window._dock_widgets: + return window._dock_widgets[full_name].isVisible() + return False + + +def _build_widgets_submenu_actions( + mf: PluginManifest, +) -> tuple[list[tuple[str, SubmenuItem]], list[Action]]: + """Build widget submenu and actions for a single npe2 plugin manifest.""" + # If no widgets, return + if not mf.contributions.widgets: + return [], [] + + widgets = mf.contributions.widgets + multiprovider = len(widgets) > 1 + submenu_id, submenu = _get_contrib_parent_menu( + multiprovider, + MenuId.MENUBAR_PLUGINS, + mf, + MenuGroup.PLUGIN_MULTI_SUBMENU, + ) + + widget_actions: list[Action] = [] + for widget in widgets: + full_name = menu_item_template.format( + mf.display_name, + widget.display_name, + ) + + _widget_callback = partial( + _toggle_or_get_widget, + plugin=mf.name, + widget_name=widget.display_name, + full_name=full_name, + ) + _get_current_dock_status_partial = partial( + _get_current_dock_status, + full_name=full_name, + ) + + title = widget.display_name if multiprovider else full_name + # To display '&' instead of creating a shortcut + title = title.replace('&', '&&') + + widget_actions.append( + Action( + id=f'{mf.name}:{widget.display_name}', + title=title, + callback=_widget_callback, + menus=[ + { + 'id': submenu_id, + 'group': MenuGroup.PLUGIN_SINGLE_CONTRIBUTIONS, + } + ], + toggled=ToggleRule( + get_current=_get_current_dock_status_partial + ), + ) + ) + return submenu, widget_actions + + +def _register_qt_actions(mf: PluginManifest) -> None: + """Register samples and widget actions and submenus from a manifest. + + This is called when a plugin is registered or enabled and it adds the + plugin's sample and widget actions and submenus to the app model registry. + """ + app = get_app() + samples_submenu, sample_actions = _build_samples_submenu_actions(mf) + widgets_submenu, widget_actions = _build_widgets_submenu_actions(mf) + + context = pm.get_context(cast('PluginName', mf.name)) + actions = sample_actions + widget_actions + if actions: + context.register_disposable(app.register_actions(actions)) + submenus = samples_submenu + widgets_submenu + if submenus: + context.register_disposable(app.menus.append_menu_items(submenus)) + + # Register dispose functions to remove plugin widgets from widget dictionary + # `window._dock_widgets` + if window := _provide_window(): + for widget in mf.contributions.widgets or (): + widget_event = Event(type_name='', value=widget.display_name) + + def _remove_widget(event: Event = widget_event) -> None: + window._remove_dock_widget(event) + + context.register_disposable(_remove_widget) diff --git a/napari/_qt/_tests/test_app.py b/napari/_qt/_tests/test_app.py index d7d807e5de0..7dd9904af9c 100644 --- a/napari/_qt/_tests/test_app.py +++ b/napari/_qt/_tests/test_app.py @@ -9,7 +9,7 @@ from napari._qt.qt_event_loop import _ipython_has_eventloop, run, set_app_id -@pytest.mark.skipif(os.name != "Windows", reason="Windows specific") +@pytest.mark.skipif(os.name != 'Windows', reason='Windows specific') def test_windows_grouping_overwrite(qapp): import ctypes @@ -22,13 +22,13 @@ def get_app_id(): ctypes.windll.Ole32.CoTaskMemFree(mem) return res - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("test_text") + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('test_text') - assert get_app_id() == "test_text" - set_app_id("custom_string") - assert get_app_id() == "custom_string" - set_app_id("") - assert get_app_id() == "" + assert get_app_id() == 'test_text' + set_app_id('custom_string') + assert get_app_id() == 'custom_string' + set_app_id('') + assert get_app_id() == '' def test_run_outside_ipython(qapp, monkeypatch): @@ -56,7 +56,7 @@ def test_shortcut_collision(qtbot, make_napari_viewer): shortcuts = viewer.window._qt_window.findChildren(QShortcut) for shortcut in shortcuts: key = shortcut.key().toString() - if key == "Ctrl+M": + if key == 'Ctrl+M': # menubar toggle support # https://github.com/napari/napari/pull/3204 continue diff --git a/napari/_qt/_tests/test_async_slicing.py b/napari/_qt/_tests/test_async_slicing.py index ee5c76ae672..313b83e9677 100644 --- a/napari/_qt/_tests/test_async_slicing.py +++ b/napari/_qt/_tests/test_async_slicing.py @@ -34,7 +34,7 @@ def enable_async(fresh_settings, make_napari_viewer): settings.get_settings().experimental.async_ = True -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_image_on_current_step_change( make_napari_viewer, qtbot, rng ): @@ -49,7 +49,7 @@ def test_async_slice_image_on_current_step_change( wait_until_vispy_image_data_equal(qtbot, vispy_image, data[2, :, :]) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_image_on_order_change(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 5, 7)) @@ -62,7 +62,7 @@ def test_async_slice_image_on_order_change(make_napari_viewer, qtbot, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data[:, 2, :]) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_image_on_ndisplay_change(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 4, 5)) @@ -75,7 +75,7 @@ def test_async_slice_image_on_ndisplay_change(make_napari_viewer, qtbot, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_multiscale_image_on_pan(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = [rng.random((4, 8, 10)), rng.random((2, 4, 5))] @@ -97,7 +97,7 @@ def test_async_slice_multiscale_image_on_pan(make_napari_viewer, qtbot, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data[1][0, 0:4, 0:3]) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_multiscale_image_on_zoom(qtbot, make_napari_viewer, rng): viewer = make_napari_viewer() data = [rng.random((4, 8, 10)), rng.random((2, 4, 5))] @@ -119,7 +119,7 @@ def test_async_slice_multiscale_image_on_zoom(qtbot, make_napari_viewer, rng): wait_until_vispy_image_data_equal(qtbot, vispy_image, data[0][1, 2:6, 3:7]) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_points_on_current_step_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() data = np.array( @@ -140,7 +140,7 @@ def test_async_slice_points_on_current_step_change(make_napari_viewer, qtbot): wait_until_vispy_points_data_equal(qtbot, vispy_points, np.array([[5, 6]])) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_points_on_point_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() # Define data so that slicing at 1.6 in the first dimension should match the @@ -164,7 +164,7 @@ def test_async_slice_points_on_point_change(make_napari_viewer, qtbot): wait_until_vispy_points_data_equal(qtbot, vispy_points, np.array([[3, 4]])) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_image_loaded(make_napari_viewer, qtbot, rng): viewer = make_napari_viewer() data = rng.random((3, 4, 5)) @@ -184,7 +184,7 @@ def test_async_slice_image_loaded(make_napari_viewer, qtbot, rng): np.testing.assert_allclose(vispy_layer.node._data, data[2, :, :]) -@pytest.mark.usefixtures("enable_async") +@pytest.mark.usefixtures('enable_async') def test_async_slice_vectors_on_current_step_change(make_napari_viewer, qtbot): viewer = make_napari_viewer() data = np.array( diff --git a/napari/_qt/_tests/test_plugin_widgets.py b/napari/_qt/_tests/test_plugin_widgets.py index 5f68ec1c858..f65e6939c4a 100644 --- a/napari/_qt/_tests/test_plugin_widgets.py +++ b/napari/_qt/_tests/test_plugin_widgets.py @@ -1,21 +1,52 @@ -from itertools import dropwhile from unittest.mock import Mock, patch import pytest +from magicgui import magic_factory, magicgui +from magicgui.widgets import Container from napari_plugin_engine import napari_hook_implementation +from npe2 import DynamicPlugin from qtpy.QtWidgets import QWidget import napari -from napari import Viewer -from napari._qt.menus import PluginsMenu +from napari._app_model import get_app +from napari._qt._qplugins._qnpe2 import _get_widget_viewer_param from napari._qt.qt_main_window import _instantiate_dock_widget from napari.utils._proxies import PublicOnlyProxy +from napari.viewer import Viewer -class Widg1(QWidget): +class ErrorWidget: pass +class QWidget_example(QWidget): + def __init__(self, napari_viewer): + super().__init__() + + +class QWidget_string_annnot(QWidget): + def __init__(self, test: 'napari.viewer.Viewer'): + super().__init__() # pragma: no cover + + +class Container_example(Container): + def __init__(self, test: Viewer): + super().__init__() + + +@magic_factory +def magic_widget_example(): + """Example magic factory widget.""" + + +def callable_example(): + @magicgui + def magic_widget_example(): + """Example magic factory widget.""" + + return magic_widget_example + + class Widg2(QWidget): def __init__(self, napari_viewer) -> None: self.viewer = napari_viewer @@ -37,13 +68,13 @@ def magicfunc(viewer: 'napari.Viewer'): dwidget_args = { - 'single_class': Widg1, - 'class_tuple': (Widg1, {'area': 'right'}), - 'tuple_list': [(Widg1, {'area': 'right'}), (Widg2, {})], - 'tuple_list2': [(Widg1, {'area': 'right'}), Widg2], + 'single_class': QWidget_example, + 'class_tuple': (QWidget_example, {'area': 'right'}), + 'tuple_list': [(QWidget_example, {'area': 'right'}), (Widg2, {})], + 'tuple_list2': [(QWidget_example, {'area': 'right'}), Widg2], 'bad_class': 1, - 'bad_tuple1': (Widg1, 1), - 'bad_double_tuple': ((Widg1, {}), (Widg2, {})), + 'bad_tuple1': (QWidget_example, 1), + 'bad_double_tuple': ((QWidget_example, {}), (Widg2, {})), } @@ -69,7 +100,7 @@ def napari_experimental_provide_dock_widget(): assert not widgets else: assert len(recwarn) == 0 - assert widgets['Plugin']['Widg1'][0] == Widg1 + assert widgets['Plugin']['Q Widget_example'][0] == QWidget_example if 'tuple_list' in request.node.name: assert widgets['Plugin']['Widg2'][0] == Widg2 @@ -79,108 +110,19 @@ def test_plugin_widgets(monkeypatch, napari_plugin_manager): """A smattering of example registered dock widgets and function widgets.""" tnpm = napari_plugin_manager dock_widgets = { - "TestP1": {"Widg1": (Widg1, {}), "Widg2": (Widg2, {})}, - "TestP2": {"Widg3": (Widg3, {})}, + 'TestP1': { + 'QWidget_example': (QWidget_example, {}), + 'Widg2': (Widg2, {}), + }, + 'TestP2': {'Widg3': (Widg3, {})}, } - monkeypatch.setattr(tnpm, "_dock_widgets", dock_widgets) + monkeypatch.setattr(tnpm, '_dock_widgets', dock_widgets) function_widgets = {'TestP3': {'magic': magicfunc}} - monkeypatch.setattr(tnpm, "_function_widgets", function_widgets) + monkeypatch.setattr(tnpm, '_function_widgets', function_widgets) yield -def test_plugin_widgets_menus(test_plugin_widgets, qtbot): - """Test the plugin widgets get added to the window menu correctly.""" - # only take the plugin actions - window = Mock() - qtwin = QWidget() - qtbot.addWidget(qtwin) - with patch.object(window, '_qt_window', qtwin): - actions = PluginsMenu(window=window).actions() - actions = list(dropwhile(lambda a: a.text() != '', actions)) - texts = [a.text() for a in actions][1:] - for t in ['TestP1', 'Widg3 (TestP2)', 'magic (TestP3)']: - assert t in texts - - # Expect a submenu ("Test plugin1") with particular entries. - tp1 = next(m for m in actions if m.text() == 'TestP1') - assert tp1.parent() - assert [a.text() for a in tp1.parent().actions()] == ['Widg1', 'Widg2'] - - -def test_making_plugin_dock_widgets( - test_plugin_widgets, make_napari_viewer, qtbot -): - """Test that we can create dock widgets, and they get the viewer.""" - viewer = make_napari_viewer() - # only take the plugin actions - actions = viewer.window.plugins_menu.actions() - actions = list(dropwhile(lambda a: a.text() != '', actions)) - - # trigger the 'TestP2: Widg3' action - tp2 = next(m for m in actions if m.text().endswith('(TestP2)')) - tp2.trigger() - # make sure that a dock widget was created - assert 'Widg3 (TestP2)' in viewer.window._dock_widgets - dw = viewer.window._dock_widgets['Widg3 (TestP2)'] - assert isinstance(dw.widget(), Widg3) - # This widget uses the parameter annotation method to receive a viewer - assert isinstance(dw.widget().viewer, napari.Viewer) - # Add twice is ok, only does a show - tp2.trigger() - - # trigger the 'TestP1 > Widg2' action (it's in a submenu) - tp2 = next(m for m in actions if m.text().endswith('TestP1')) - action = tp2.parent().actions()[1] - assert action.text() == 'Widg2' - action.trigger() - # make sure that a dock widget was created - assert 'Widg2 (TestP1)' in viewer.window._dock_widgets - dw = viewer.window._dock_widgets['Widg2 (TestP1)'] - assert isinstance(dw.widget(), Widg2) - # This widget uses parameter *name* "napari_viewer" to get a viewer - assert isinstance(dw.widget().viewer, napari.Viewer) - # Add twice is ok, only does a show - action.trigger() - # Check that widget is still there when closed. - widg = dw.widget() - dw.title.hide_button.click() - assert widg - # Check that widget is destroyed when closed. - dw.destroyOnClose() - assert action not in viewer.window.plugins_menu.actions() - assert widg.parent() is None - widg.deleteLater() - widg.close() - qtbot.wait(50) - - -def test_making_function_dock_widgets(test_plugin_widgets, make_napari_viewer): - """Test that we can create magicgui widgets, and they get the viewer.""" - import magicgui - - viewer = make_napari_viewer() - # only take the plugin actions - actions = viewer.window.plugins_menu.actions() - actions = dropwhile(lambda a: a.text() != '', actions) - - # trigger the 'TestP3: magic' action - tp3 = next(m for m in actions if m.text().endswith('(TestP3)')) - tp3.trigger() - # make sure that a dock widget was created - assert 'magic (TestP3)' in viewer.window._dock_widgets - dw = viewer.window._dock_widgets['magic (TestP3)'] - # make sure that it contains a magicgui widget - magic_widget = dw.widget()._magic_widget - assert isinstance(magic_widget, magicgui.widgets.FunctionGui) - # This magicgui widget uses the parameter annotation to receive a viewer - assert isinstance(magic_widget.viewer.value, napari.Viewer) - # The function just returns the viewer... make sure we can call it - assert isinstance(magic_widget(), napari.Viewer) - # Add twice is ok, only does a show - tp3.trigger() - - def test_inject_viewer_proxy(make_napari_viewer): """Test that the injected viewer is a public-only proxy""" viewer = make_napari_viewer() @@ -191,3 +133,77 @@ def test_inject_viewer_proxy(make_napari_viewer): with patch('napari.utils.misc.ROOT_DIR', new='/some/other/package'): with pytest.warns(FutureWarning): wdg.fail() + + +@pytest.mark.parametrize( + 'widget_callable, param', + [ + (QWidget_example, 'napari_viewer'), + (QWidget_string_annnot, 'test'), + (Container_example, 'test'), + ], +) +def test_get_widget_viewer_param(widget_callable, param): + """Test `_get_widget_viewer_param` returns correct parameter name.""" + out = _get_widget_viewer_param(widget_callable, 'widget_name') + assert out == param + + +def test_get_widget_viewer_param_error(): + """Test incorrect subclass raises error in `_get_widget_viewer_param`.""" + with pytest.raises(TypeError) as e: + _get_widget_viewer_param(ErrorWidget, 'widget_name') + assert "'widget_name' must be `QtWidgets.QWidget`" in str(e) + + +def test_widget_hide_destroy(make_napari_viewer): + """Test that widget hide and destroy works.""" + viewer = make_napari_viewer() + viewer.window.add_dock_widget(QWidget_example(viewer), name='test') + widget = viewer.window._dock_widgets['test'] + + # Check widget persists after hide + ww = widget.widget() + widget.title.hide_button.click() + assert ww + # Check that widget removed from `_dock_widgets` dict and parent + # `QtViewerDockWidget` is `None` when closed + widget.destroyOnClose() + assert 'test' not in viewer.window._dock_widgets + assert ww.parent() is None + + +@pytest.mark.parametrize( + 'Widget', + [ + QWidget_example, + Container_example, + magic_widget_example, + callable_example, + ], +) +def test_widget_types_supported( + make_napari_viewer, + tmp_plugin: DynamicPlugin, + Widget, +): + """Test all supported widget types correctly instantiated and call processor. + + The 4 parametrized `Widget`s represent the varing widget constructors and + signatures that we want to support. + """ + # Using the decorator as a function on the parametrized `Widget` + # This allows `Widget` to be callable object that, when called, returns an + # instance of a widget + tmp_plugin.contribute.widget(display_name='Widget')(Widget) + + app = get_app() + viewer = make_napari_viewer() + + # `side_effect` required so widget is added to window and then + # cleaned up, preventing widget leaks + viewer.window.add_dock_widget = Mock( + side_effect=viewer.window.add_dock_widget + ) + app.commands.execute_command('tmp_plugin:Widget') + viewer.window.add_dock_widget.assert_called_once() diff --git a/napari/_qt/_tests/test_qt_event_filters.py b/napari/_qt/_tests/test_qt_event_filters.py index ea59b9fb858..e1a82181f67 100644 --- a/napari/_qt/_tests/test_qt_event_filters.py +++ b/napari/_qt/_tests/test_qt_event_filters.py @@ -6,18 +6,18 @@ @pytest.mark.parametrize( - "tooltip,is_qt_tag_present", + 'tooltip,is_qt_tag_present', [ ( - "" - "

A widget to test that a rich text tooltip might be detected " - "and therefore not changed to include a qt tag

" - "", + '' + '

A widget to test that a rich text tooltip might be detected ' + 'and therefore not changed to include a qt tag

' + '', False, ), ( - "A widget to test that a non-rich text tooltip might " - "be detected and therefore changed", + 'A widget to test that a non-rich text tooltip might ' + 'be detected and therefore changed', True, ), ], @@ -38,4 +38,4 @@ def test_qt_tooltip_event_filter(qtbot, tooltip, is_qt_tag_present): qtbot.addWidget(widget) widget.setToolTip(tooltip) event_filter_handler.eventFilter(widget, qevent) - assert ("" in widget.toolTip()) == is_qt_tag_present + assert ('' in widget.toolTip()) == is_qt_tag_present diff --git a/napari/_qt/_tests/test_qt_notifications.py b/napari/_qt/_tests/test_qt_notifications.py index 224d5c60ab1..10f2fbf7f77 100644 --- a/napari/_qt/_tests/test_qt_notifications.py +++ b/napari/_qt/_tests/test_qt_notifications.py @@ -39,7 +39,7 @@ def _threading_raise(): def _raise(): - raise ValueError("error!") + raise ValueError('error!') @pytest.fixture @@ -64,8 +64,8 @@ def mock_current_main_window(*_, **__): def store_widget(self, *args, **kwargs): base_show(self, *args, **kwargs) - monkeypatch.setattr(NapariQtNotification, "show", store_widget) - monkeypatch.setattr(_QtMainWindow, "current", mock_current_main_window) + monkeypatch.setattr(NapariQtNotification, 'show', store_widget) + monkeypatch.setattr(_QtMainWindow, 'current', mock_current_main_window) @dataclass @@ -83,15 +83,15 @@ def _raise_on_call(self, *args, **kwargs): return _raise_on_call monkeypatch.setattr( - NapariQtNotification, 'show', raise_prepare("notification show") + NapariQtNotification, 'show', raise_prepare('notification show') ) monkeypatch.setattr( - TracebackDialog, 'show', raise_prepare("traceback show") + TracebackDialog, 'show', raise_prepare('traceback show') ) monkeypatch.setattr( NapariQtNotification, 'close_with_fade', - raise_prepare("close_with_fade"), + raise_prepare('close_with_fade'), ) @@ -105,8 +105,8 @@ def mock_show_notif(_): def mock_show_traceback(_): stat.show_traceback_count += 1 - monkeypatch.setattr(NapariQtNotification, "show", mock_show_notif) - monkeypatch.setattr(TracebackDialog, "show", mock_show_traceback) + monkeypatch.setattr(NapariQtNotification, 'show', mock_show_notif) + monkeypatch.setattr(TracebackDialog, 'show', mock_show_traceback) return stat @@ -124,8 +124,8 @@ def mock_traceback_init(self, *args, **kwargs): old_traceback_init(self, *args, **kwargs) qtbot.add_widget(self) - monkeypatch.setattr(NapariQtNotification, "__init__", mock_notif_init) - monkeypatch.setattr(TracebackDialog, "__init__", mock_traceback_init) + monkeypatch.setattr(NapariQtNotification, '__init__', mock_notif_init) + monkeypatch.setattr(TracebackDialog, '__init__', mock_traceback_init) def test_clean_current_path_exist(make_napari_viewer): @@ -136,7 +136,7 @@ def test_clean_current_path_exist(make_napari_viewer): @pytest.mark.parametrize( - "raise_func,warn_func", + 'raise_func,warn_func', [(_raise, _warn), (_threading_raise, _threading_warn)], ) def test_notification_manager_via_gui( @@ -153,7 +153,7 @@ def test_notification_manager_via_gui( qtbot.addWidget(errButton) qtbot.addWidget(warnButton) monkeypatch.setattr( - NapariQtNotification, "show_notification", lambda x: None + NapariQtNotification, 'show_notification', lambda x: None ) with notification_manager: for btt, expected_message in [ @@ -245,7 +245,7 @@ def test_notification_error(count_show, monkeypatch): monkeypatch.delenv('NAPARI_CATCH_ERRORS', raising=False) monkeypatch.setattr( - NapariQtNotification, "close_with_fade", lambda x, y: None + NapariQtNotification, 'close_with_fade', lambda x, y: None ) monkeypatch.setattr( settings.application, @@ -273,7 +273,7 @@ def test_notifications_error_with_threading( """Test notifications of `threading` threads, using a dask example.""" random_image = da.random.random((10, 10)) monkeypatch.setattr( - NapariQtNotification, "show_notification", lambda x: None + NapariQtNotification, 'show_notification', lambda x: None ) with notification_manager: viewer = make_napari_viewer(strict_qt=False) diff --git a/napari/_qt/_tests/test_qt_provide_theme.py b/napari/_qt/_tests/test_qt_provide_theme.py index c3824832398..7fc70b564ce 100644 --- a/napari/_qt/_tests/test_qt_provide_theme.py +++ b/napari/_qt/_tests/test_qt_provide_theme.py @@ -12,8 +12,8 @@ @skip_on_win_ci -@patch.object(Window, "_remove_theme") -@patch.object(Window, "_add_theme") +@patch.object(Window, '_remove_theme') +@patch.object(Window, '_add_theme') def test_provide_theme_hook_registered_correctly( mock_add_theme, mock_remove_theme, @@ -29,7 +29,7 @@ def test_provide_theme_hook_registered_correctly( ) # set the viewer theme to the plugin theme - viewer.theme = "dark-test-2" + viewer.theme = 'dark-test-2' # triggered when theme was added mock_add_theme.assert_called() @@ -38,13 +38,13 @@ def test_provide_theme_hook_registered_correctly( # now, lets unregister the theme # We didn't set the setting, so ensure that no warning with warnings.catch_warnings(): - warnings.simplefilter("error") - napari_plugin_manager.unregister("TestPlugin") + warnings.simplefilter('error') + napari_plugin_manager.unregister('TestPlugin') mock_remove_theme.assert_called() -@patch.object(Window, "_remove_theme") -@patch.object(Window, "_add_theme") +@patch.object(Window, '_remove_theme') +@patch.object(Window, '_add_theme') def test_plugin_provide_theme_hook_set_settings_correctly( mock_add_theme, mock_remove_theme, @@ -59,7 +59,7 @@ def test_plugin_provide_theme_hook_set_settings_correctly( name='dark-test-2', ) # set the plugin theme as a setting - get_settings().appearance.theme = "dark-test-2" + get_settings().appearance.theme = 'dark-test-2' # triggered when theme was added mock_add_theme.assert_called() @@ -67,8 +67,8 @@ def test_plugin_provide_theme_hook_set_settings_correctly( # now, lets unregister the theme # We *did* set the setting, so there should be a warning - with pytest.warns(UserWarning, match="The current theme "): - napari_plugin_manager.unregister("TestPlugin") + with pytest.warns(UserWarning, match='The current theme '): + napari_plugin_manager.unregister('TestPlugin') mock_remove_theme.assert_called() @@ -76,7 +76,7 @@ def make_napari_viewer_with_plugin_theme( make_napari_viewer, napari_plugin_manager, *, theme_type: str, name: str ) -> Viewer: theme = get_theme(theme_type).to_rgb_dict() - theme["name"] = name + theme['name'] = name class TestPlugin: @napari_hook_implementation @@ -89,7 +89,7 @@ def napari_experimental_provide_theme(): # register theme napari_plugin_manager.register(TestPlugin) - reg = napari_plugin_manager._theme_data["TestPlugin"] + reg = napari_plugin_manager._theme_data['TestPlugin'] assert isinstance(reg[name], Theme) return viewer diff --git a/napari/_qt/_tests/test_qt_utils.py b/napari/_qt/_tests/test_qt_utils.py index 1c09d79c60e..055409399f5 100644 --- a/napari/_qt/_tests/test_qt_utils.py +++ b/napari/_qt/_tests/test_qt_utils.py @@ -41,30 +41,30 @@ def test_signal_blocker(qtbot): def test_is_qbyte_valid(): is_qbyte(QBYTE_FLAG) is_qbyte( - "!QBYTE_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=" + '!QBYTE_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=' ) def test_str_to_qbytearray_valid(): with pytest.raises(ValueError): - str_to_qbytearray("") + str_to_qbytearray('') with pytest.raises(ValueError): - str_to_qbytearray("FOOBAR") + str_to_qbytearray('FOOBAR') with pytest.raises(ValueError): str_to_qbytearray( - "_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=" + '_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=' ) def test_str_to_qbytearray_invalid(): with pytest.raises(ValueError): - str_to_qbytearray("") + str_to_qbytearray('') with pytest.raises(ValueError): str_to_qbytearray( - "_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=" + '_AAAA/wAAAAD9AAAAAgAAAAAAAAECAAACePwCAAAAAvsAAAAcAGwAYQB5AGUAcgAgAGMAbwBuAHQAcgBvAGwAcwEAAAAAAAABFwAAARcAAAEX+wAAABQAbABhAHkAZQByACAAbABpAHMAdAEAAAEXAAABYQAAALcA////AAAAAwAAAAAAAAAA/AEAAAAB+wAAAA4AYwBvAG4AcwBvAGwAZQAAAAAA/////wAAADIA////AAADPAAAAngAAAAEAAAABAAAAAgAAAAI/AAAAAA=' ) @@ -91,17 +91,17 @@ def test_add_flash_animation(qtbot): assert widget.graphicsEffect() is None add_flash_animation(widget) assert widget.graphicsEffect() is not None - assert hasattr(widget, "_flash_animation") + assert hasattr(widget, '_flash_animation') qtbot.wait(350) assert widget.graphicsEffect() is None - assert not hasattr(widget, "_flash_animation") + assert not hasattr(widget, '_flash_animation') def test_qt_might_be_rich_text(qtbot): widget = QMainWindow() qtbot.addWidget(widget) - assert qt_might_be_rich_text("rich text") - assert not qt_might_be_rich_text("plain text") + assert qt_might_be_rich_text('rich text') + assert not qt_might_be_rich_text('plain text') def test_thread_proxy_guard(monkeypatch, qapp, single_threaded_executor): diff --git a/napari/_qt/_tests/test_qt_viewer.py b/napari/_qt/_tests/test_qt_viewer.py index 3b99962d28a..931e74d1fe2 100644 --- a/napari/_qt/_tests/test_qt_viewer.py +++ b/napari/_qt/_tests/test_qt_viewer.py @@ -3,7 +3,6 @@ import weakref from dataclasses import dataclass from itertools import product, takewhile -from typing import List, Tuple from unittest import mock import numpy as np @@ -72,19 +71,19 @@ def test_qt_viewer_toggle_console(make_napari_viewer): @skip_local_popups -@pytest.mark.skipif(os.environ.get("MIN_REQ", "0") == "1", reason="min req") +@pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_qt_viewer_console_focus(qtbot, make_napari_viewer): """Test console has focus when instantiating from viewer.""" viewer = make_napari_viewer(show=True) view = viewer.window._qt_viewer - assert not view.console.hasFocus(), "console has focus before being shown" + assert not view.console.hasFocus(), 'console has focus before being shown' view.toggle_console_visibility(None) def console_has_focus(): assert ( view.console.hasFocus() - ), "console does not have focus when shown" + ), 'console does not have focus when shown' qtbot.waitUntil(console_has_focus) @@ -260,7 +259,7 @@ def test_screenshot(make_napari_viewer): assert screenshot.ndim == 3 -@pytest.mark.skip("new approach") +@pytest.mark.skip('new approach') def test_screenshot_dialog(make_napari_viewer, tmpdir): """Test save screenshot functionality.""" viewer = make_napari_viewer() @@ -289,9 +288,10 @@ def test_screenshot_dialog(make_napari_viewer, tmpdir): # Save screenshot input_filepath = os.path.join(tmpdir, 'test-save-screenshot') mock_return = (input_filepath, '') - with mock.patch('napari._qt._qt_viewer.QFileDialog') as mocker, mock.patch( - 'napari._qt._qt_viewer.QMessageBox' - ) as mocker2: + with ( + mock.patch('napari._qt._qt_viewer.QFileDialog') as mocker, + mock.patch('napari._qt._qt_viewer.QMessageBox') as mocker2, + ): mocker.getSaveFileName.return_value = mock_return mocker2.warning.return_value = QMessageBox.Yes viewer.window._qt_viewer._screenshot_dialog() @@ -338,12 +338,12 @@ def test_qt_viewer_clipboard_with_flash(make_napari_viewer, qtbot): viewer.window._qt_viewer._welcome_widget.graphicsEffect() is not None ) assert hasattr( - viewer.window._qt_viewer._welcome_widget, "_flash_animation" + viewer.window._qt_viewer._welcome_widget, '_flash_animation' ) qtbot.wait(500) # wait for the animation to finish assert viewer.window._qt_viewer._welcome_widget.graphicsEffect() is None assert not hasattr( - viewer.window._qt_viewer._welcome_widget, "_flash_animation" + viewer.window._qt_viewer._welcome_widget, '_flash_animation' ) # clear clipboard and grab image from application view @@ -358,10 +358,10 @@ def test_qt_viewer_clipboard_with_flash(make_napari_viewer, qtbot): # ensure the flash effect is applied assert viewer.window._qt_window.graphicsEffect() is not None - assert hasattr(viewer.window._qt_window, "_flash_animation") + assert hasattr(viewer.window._qt_window, '_flash_animation') qtbot.wait(500) # wait for the animation to finish assert viewer.window._qt_window.graphicsEffect() is None - assert not hasattr(viewer.window._qt_window, "_flash_animation") + assert not hasattr(viewer.window._qt_window, '_flash_animation') @skip_on_win_ci @@ -384,7 +384,7 @@ def test_qt_viewer_clipboard_without_flash(make_napari_viewer): # ensure the flash effect is not applied assert viewer.window._qt_viewer._welcome_widget.graphicsEffect() is None assert not hasattr( - viewer.window._qt_viewer._welcome_widget, "_flash_animation" + viewer.window._qt_viewer._welcome_widget, '_flash_animation' ) # clear clipboard and grab image from application view @@ -399,7 +399,7 @@ def test_qt_viewer_clipboard_without_flash(make_napari_viewer): # ensure the flash effect is not applied assert viewer.window._qt_window.graphicsEffect() is None - assert not hasattr(viewer.window._qt_window, "_flash_animation") + assert not hasattr(viewer.window._qt_window, '_flash_animation') def test_active_keybindings(make_napari_viewer): @@ -434,7 +434,7 @@ def test_active_keybindings(make_napari_viewer): @dataclass class MouseEvent: # mock mouse event class - pos: List[int] + pos: list[int] def test_process_mouse_event(make_napari_viewer): @@ -513,7 +513,7 @@ def test_leaks_labels(qtbot, make_napari_viewer): assert not dr() -@pytest.mark.parametrize("theme", available_themes()) +@pytest.mark.parametrize('theme', available_themes()) def test_canvas_color(make_napari_viewer, theme): """Test instantiating viewer with different themes. @@ -676,7 +676,7 @@ def _update_data( qtbot: QtBot, qt_viewer: QtViewer, dtype: np.dtype = np.uint64, -) -> Tuple[np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray]: """Change layer data and return color of label and middle pixel of screenshot.""" layer.data = np.full((2, 2), label, dtype=dtype) layer.selected_label = label @@ -708,9 +708,9 @@ def qt_viewer_with_controls(qtbot): @skip_local_popups @skip_on_win_ci @pytest.mark.parametrize( - "use_selection", [True, False], ids=["selected", "all"] + 'use_selection', [True, False], ids=['selected', 'all'] ) -@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int64]) +@pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int64]) def test_label_colors_matching_widget_auto( qtbot, qt_viewer_with_controls, use_selection, dtype ): @@ -743,7 +743,7 @@ def test_label_colors_matching_widget_auto( ) npt.assert_allclose( - color_box_color, middle_pixel, atol=1, err_msg=f"label {label}" + color_box_color, middle_pixel, atol=1, err_msg=f'label {label}' ) # there is a difference of rounding between the QtColorBox and the screenshot @@ -751,9 +751,9 @@ def test_label_colors_matching_widget_auto( @skip_local_popups @skip_on_win_ci @pytest.mark.parametrize( - "use_selection", [True, False], ids=["selected", "all"] + 'use_selection', [True, False], ids=['selected', 'all'] ) -@pytest.mark.parametrize("dtype", [np.uint64, np.uint16, np.uint8, np.int16]) +@pytest.mark.parametrize('dtype', [np.uint64, np.uint16, np.uint8, np.int16]) def test_label_colors_matching_widget_direct( qtbot, qt_viewer_with_controls, use_selection, dtype ): @@ -762,16 +762,16 @@ def test_label_colors_matching_widget_direct( test_colors = (1, 2, 3, 8, 150, 50) color = { - 0: "transparent", - 1: "yellow", - 3: "blue", - 8: "red", - 150: "green", - None: "white", + 0: 'transparent', + 1: 'yellow', + 3: 'blue', + 8: 'red', + 150: 'green', + None: 'white', } if np.iinfo(dtype).min < 0: - color[-1] = "pink" - color[-2] = "orange" + color[-1] = 'pink' + color[-2] = 'orange' test_colors = test_colors + (-1, -2, -10) colormap = DirectLabelColormap(color_dict=color) @@ -791,12 +791,12 @@ def test_label_colors_matching_widget_direct( layer, label, qtbot, qt_viewer_with_controls, dtype ) npt.assert_almost_equal( - color_box_color, middle_pixel, err_msg=f"{label=}" + color_box_color, middle_pixel, err_msg=f'{label=}' ) npt.assert_almost_equal( color_box_color, colormap.color_dict.get(label, colormap.color_dict[None]) * 255, - err_msg=f"{label=}", + err_msg=f'{label=}', ) @@ -826,7 +826,7 @@ def qt_viewer(qtbot): del qt_viewer -def _find_margin(data: np.ndarray, additional_margin: int) -> Tuple[int, int]: +def _find_margin(data: np.ndarray, additional_margin: int) -> tuple[int, int]: """ helper function to determine margins in test_thumbnail_labels """ @@ -843,7 +843,7 @@ def _find_margin(data: np.ndarray, additional_margin: int) -> Tuple[int, int]: # @pytest.mark.xfail(reason="Fails on CI, but not locally") @skip_local_popups -@pytest.mark.parametrize('direct', [True, False], ids=["direct", "auto"]) +@pytest.mark.parametrize('direct', [True, False], ids=['direct', 'auto']) def test_thumbnail_labels(qtbot, direct, qt_viewer: QtViewer, tmp_path): # Add labels to empty viewer layer = qt_viewer.viewer.add_labels( @@ -870,29 +870,29 @@ def test_thumbnail_labels(qtbot, direct, qt_viewer: QtViewer, tmp_path): import imageio - imageio.imwrite(tmp_path / "canvas_screenshot_.png", canvas_screenshot_) - np.savez(tmp_path / "canvas_screenshot_.npz", canvas_screenshot_) + imageio.imwrite(tmp_path / 'canvas_screenshot_.png', canvas_screenshot_) + np.savez(tmp_path / 'canvas_screenshot_.npz', canvas_screenshot_) # cut off black border margin1, margin2 = _find_margin(canvas_screenshot_, 10) canvas_screenshot = canvas_screenshot_[margin1:-margin1, margin2:-margin2] assert ( canvas_screenshot.size > 0 - ), f"{canvas_screenshot_.shape}, {margin1=}, {margin2=}" + ), f'{canvas_screenshot_.shape}, {margin1=}, {margin2=}' thumbnail = layer.thumbnail scaled_thumbnail = ndi.zoom( thumbnail, np.array(canvas_screenshot.shape) / np.array(thumbnail.shape), order=0, - mode="nearest", + mode='nearest', ) close = np.isclose(canvas_screenshot, scaled_thumbnail) problematic_pixels_count = np.sum(~close) assert problematic_pixels_count < 0.01 * canvas_screenshot.size -@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32]) +@pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32]) def test_background_color(qtbot, qt_viewer: QtViewer, dtype): data = np.zeros((10, 10), dtype=dtype) data[5:] = 10 @@ -913,10 +913,10 @@ def test_background_color(qtbot, qt_viewer: QtViewer, dtype): npt.assert_array_equal( background_pixel, [0, 0, 0, 255], - err_msg=f"background {background}", + err_msg=f'background {background}', ) npt.assert_array_equal( - color_pixel, color, err_msg=f"background {background}" + color_pixel, color, err_msg=f'background {background}' ) @@ -924,7 +924,7 @@ def test_rendering_interpolation(qtbot, qt_viewer): data = np.zeros((20, 20, 20), dtype=np.uint8) data[1:-1, 1:-1, 1:-1] = 5 layer = qt_viewer.viewer.add_labels( - data, opacity=1, rendering="translucent" + data, opacity=1, rendering='translucent' ) layer.selected_label = 5 qt_viewer.viewer.dims.ndisplay = 3 @@ -941,7 +941,7 @@ def test_shortcut_passing(make_napari_viewer): layer = viewer.add_labels( np.zeros((2, 2, 2), dtype=np.uint8), scale=(1, 2, 4) ) - layer.mode = "fill" + layer.mode = 'fill' qt_window = viewer.window._qt_window @@ -955,19 +955,19 @@ def test_shortcut_passing(make_napari_viewer): QEvent.Type.KeyPress, Qt.Key.Key_1, Qt.KeyboardModifier.NoModifier ) ) - assert layer.mode == "erase" + assert layer.mode == 'erase' -@pytest.mark.parametrize("mode", ["direct", "random"]) +@pytest.mark.parametrize('mode', ['direct', 'random']) def test_selection_collision(qt_viewer: QtViewer, mode): data = np.zeros((10, 10), dtype=np.uint8) data[:5] = 10 data[5:] = 10 + 49 layer = qt_viewer.viewer.add_labels(data, opacity=1) layer.selected_label = 10 - if mode == "direct": + if mode == 'direct': layer.colormap = DirectLabelColormap( - color_dict={10: "red", 10 + 49: "red", None: "black"} + color_dict={10: 'red', 10 + 49: 'red', None: 'black'} ) for dtype in np.sctypes['int'] + np.sctypes['uint']: @@ -978,7 +978,7 @@ def test_selection_collision(qt_viewer: QtViewer, mode): shape = np.array(canvas_screenshot.shape[:2]) pixel_10 = canvas_screenshot[tuple((shape * 0.25).astype(int))] pixel_59 = canvas_screenshot[tuple((shape * 0.75).astype(int))] - npt.assert_array_equal(pixel_10, pixel_59, err_msg=f"{dtype}") + npt.assert_array_equal(pixel_10, pixel_59, err_msg=f'{dtype}') assert not np.all(pixel_10 == [0, 0, 0, 255]), dtype layer.show_selected_label = True @@ -988,8 +988,8 @@ def test_selection_collision(qt_viewer: QtViewer, mode): pixel_10_2 = canvas_screenshot[tuple((shape * 0.25).astype(int))] pixel_59_2 = canvas_screenshot[tuple((shape * 0.75).astype(int))] - npt.assert_array_equal(pixel_59_2, [0, 0, 0, 255], err_msg=f"{dtype}") - npt.assert_array_equal(pixel_10_2, pixel_10, err_msg=f"{dtype}") + npt.assert_array_equal(pixel_59_2, [0, 0, 0, 255], err_msg=f'{dtype}') + npt.assert_array_equal(pixel_10_2, pixel_10, err_msg=f'{dtype}') def test_all_supported_dtypes(qt_viewer): @@ -1005,7 +1005,7 @@ def test_all_supported_dtypes(qt_viewer): tuple(np.array(canvas_screenshot.shape[:2]) // 2) ] npt.assert_equal( - midd_pixel, layer.colormap.map(i) * 255, err_msg=f"{dtype} {i}" + midd_pixel, layer.colormap.map(i) * 255, err_msg=f'{dtype} {i}' ) layer.colormap = DirectLabelColormap( @@ -1035,12 +1035,12 @@ def test_all_supported_dtypes(qt_viewer): tuple(np.array(canvas_screenshot.shape[:2]) // 2) ] npt.assert_equal( - midd_pixel, layer.colormap.map(i) * 255, err_msg=f"{dtype} {i}" + midd_pixel, layer.colormap.map(i) * 255, err_msg=f'{dtype} {i}' ) def test_more_than_uint16_colors(qt_viewer): - pytest.importorskip("numba") + pytest.importorskip('numba') # this test is slow (10s locally) data = np.zeros((10, 10), dtype=np.uint32) colors = { @@ -1064,5 +1064,5 @@ def test_more_than_uint16_colors(qt_viewer): tuple(np.array(canvas_screenshot.shape[:2]) // 2) ] npt.assert_equal( - midd_pixel, layer.colormap.map(i) * 255, err_msg=f"{i}" + midd_pixel, layer.colormap.map(i) * 255, err_msg=f'{i}' ) diff --git a/napari/_qt/_tests/test_qt_viewer_2.py b/napari/_qt/_tests/test_qt_viewer_2.py index faa166e37b3..118987a1cc3 100644 --- a/napari/_qt/_tests/test_qt_viewer_2.py +++ b/napari/_qt/_tests/test_qt_viewer_2.py @@ -11,7 +11,7 @@ # That test (number 26) was split off to make debugging easier # See https://github.com/napari/napari/pull/5676 @pytest.mark.parametrize( - "dtype", + 'dtype', [ 'int8', 'uint8', @@ -50,7 +50,7 @@ def test_qt_viewer_data_integrity(make_napari_viewer, dtype): @pytest.mark.parametrize( - "dtype,expected", + 'dtype,expected', [ (np.bool_, np.uint8), (np.int8, np.float32), diff --git a/napari/_qt/_tests/test_qt_window.py b/napari/_qt/_tests/test_qt_window.py index 979774d9790..9494fb0702e 100644 --- a/napari/_qt/_tests/test_qt_window.py +++ b/napari/_qt/_tests/test_qt_window.py @@ -59,9 +59,9 @@ def test_set_geometry(make_napari_viewer): assert viewer.window.geometry() == values -@patch.object(Window, "_update_theme_no_event") -@patch.object(Window, "_remove_theme") -@patch.object(Window, "_add_theme") +@patch.object(Window, '_update_theme_no_event') +@patch.object(Window, '_remove_theme') +@patch.object(Window, '_add_theme') def test_update_theme( mock_add_theme, mock_remove_theme, @@ -70,34 +70,34 @@ def test_update_theme( ): viewer = make_napari_viewer() - blue = get_theme("dark") - blue.id = "blue" - register_theme("blue", blue, "test") + blue = get_theme('dark') + blue.id = 'blue' + register_theme('blue', blue, 'test') # triggered when theme was added mock_add_theme.assert_called() mock_remove_theme.assert_not_called() - unregister_theme("blue") + unregister_theme('blue') # triggered when theme was removed mock_remove_theme.assert_called() mock_update_theme_no_event.assert_not_called() - viewer.theme = "light" - theme = _themes["light"] - theme.icon = "#FF0000" + viewer.theme = 'light' + theme = _themes['light'] + theme.icon = '#FF0000' mock_update_theme_no_event.assert_called() def test_lazy_console(make_napari_viewer): v = make_napari_viewer() assert v.window._qt_viewer._console is None - v.update_console({"test": "test"}) + v.update_console({'test': 'test'}) assert v.window._qt_viewer._console is None @pytest.mark.skipif( - platform.system() == "Darwin", reason="Cannot control menu bar on MacOS" + platform.system() == 'Darwin', reason='Cannot control menu bar on MacOS' ) def test_menubar_shortcut(make_napari_viewer): v = make_napari_viewer() @@ -115,7 +115,7 @@ def test_screenshot_to_file(make_napari_viewer, tmp_path): Test taking a screenshot using the Window instance and saving it to a file. """ viewer = make_napari_viewer() - screenshot_file_path = str(tmp_path / "screenshot.png") + screenshot_file_path = str(tmp_path / 'screenshot.png') np.random.seed(0) # Add image diff --git a/napari/_qt/_tests/test_sigint_interupt.py b/napari/_qt/_tests/test_sigint_interupt.py index 507d211aec2..aa798b4ea3b 100644 --- a/napari/_qt/_tests/test_sigint_interupt.py +++ b/napari/_qt/_tests/test_sigint_interupt.py @@ -11,7 +11,7 @@ def platform_simulate_ctrl_c(): import signal from functools import partial - if hasattr(signal, "CTRL_C_EVENT"): + if hasattr(signal, 'CTRL_C_EVENT'): win32api = pytest.importorskip('win32api') return partial(win32api.GenerateConsoleCtrlEvent, 0, 0) @@ -19,7 +19,7 @@ def platform_simulate_ctrl_c(): return partial(os.kill, os.getpid(), signal.SIGINT) -@pytest.mark.skipif(os.name != "Windows", reason="Windows specific") +@pytest.mark.skipif(os.name != 'Windows', reason='Windows specific') def test_sigint(qapp, platform_simulate_ctrl_c, make_napari_viewer): def fire_signal(): platform_simulate_ctrl_c() diff --git a/napari/_qt/code_syntax_highlight.py b/napari/_qt/code_syntax_highlight.py index e3f3b49cbeb..470dff8bc5c 100644 --- a/napari/_qt/code_syntax_highlight.py +++ b/napari/_qt/code_syntax_highlight.py @@ -18,10 +18,10 @@ def get_text_char_format(style): text_char_format = QtGui.QTextCharFormat() try: - text_char_format.setFontFamilies(["monospace"]) + text_char_format.setFontFamilies(['monospace']) except AttributeError: text_char_format.setFontFamily( - "monospace" + 'monospace' ) # backward compatibility for pyqt5 5.12.3 if style.get('color'): text_char_format.setForeground(QtGui.QColor(f"#{style['color']}")) @@ -33,7 +33,7 @@ def get_text_char_format(style): text_char_format.setFontWeight(QtGui.QFont.Bold) if style.get('italic'): text_char_format.setFontItalic(True) - if style.get("underline"): + if style.get('underline'): text_char_format.setFontUnderline(True) # TODO find if it is possible to support border style. diff --git a/napari/_qt/containers/_base_item_model.py b/napari/_qt/containers/_base_item_model.py index b71b30ad8c2..5143031bc52 100644 --- a/napari/_qt/containers/_base_item_model.py +++ b/napari/_qt/containers/_base_item_model.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import MutableSequence -from typing import TYPE_CHECKING, Any, Generic, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt @@ -15,7 +15,7 @@ from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] -ItemType = TypeVar("ItemType") +ItemType = TypeVar('ItemType') ItemRole = Qt.UserRole SortRole = Qt.UserRole + 1 @@ -201,12 +201,12 @@ def setRoot(self, root: SelectableEventedList[ItemType]): if not isinstance(root, SelectableEventedList): raise TypeError( trans._( - "root must be an instance of {class_name}", + 'root must be an instance of {class_name}', deferred=True, class_name=SelectableEventedList, ) ) - current_root = getattr(self, "_root", None) + current_root = getattr(self, '_root', None) if root is current_root: return @@ -224,8 +224,8 @@ def setRoot(self, root: SelectableEventedList[ItemType]): self._root.events.connect(self._process_event) def _split_nested_index( - self, nested_index: Union[int, Tuple[int, ...]] - ) -> Tuple[QModelIndex, int]: + self, nested_index: Union[int, tuple[int, ...]] + ) -> tuple[QModelIndex, int]: """Return (parent_index, row) for a given index.""" if isinstance(nested_index, int): return QModelIndex(), nested_index diff --git a/napari/_qt/containers/_base_item_view.py b/napari/_qt/containers/_base_item_view.py index 4d04c4761ef..15066dd6e0a 100644 --- a/napari/_qt/containers/_base_item_view.py +++ b/napari/_qt/containers/_base_item_view.py @@ -9,7 +9,7 @@ from napari._qt.containers._base_item_model import ItemRole from napari._qt.containers._factory import create_model -ItemType = TypeVar("ItemType") +ItemType = TypeVar('ItemType') if TYPE_CHECKING: from qtpy.QtCore import QAbstractItemModel diff --git a/napari/_qt/containers/_factory.py b/napari/_qt/containers/_factory.py index cd0efc53f3c..a075b965fc9 100644 --- a/napari/_qt/containers/_factory.py +++ b/napari/_qt/containers/_factory.py @@ -40,7 +40,7 @@ def create_view( return QtListView(obj, parent=parent) raise TypeError( trans._( - "Cannot create Qt view for obj: {obj}", + 'Cannot create Qt view for obj: {obj}', deferred=True, obj=obj, ) @@ -78,7 +78,7 @@ def create_model( return QtListModel(obj, parent=parent) raise TypeError( trans._( - "Cannot create Qt model for obj: {obj}", + 'Cannot create Qt model for obj: {obj}', deferred=True, obj=obj, ) diff --git a/napari/_qt/containers/_layer_delegate.py b/napari/_qt/containers/_layer_delegate.py index a28f93c39e2..66fc4237bef 100644 --- a/napari/_qt/containers/_layer_delegate.py +++ b/napari/_qt/containers/_layer_delegate.py @@ -201,7 +201,7 @@ def editorEvent( ): pnt = ( event.globalPosition().toPoint() - if hasattr(event, "globalPosition") + if hasattr(event, 'globalPosition') else event.globalPos() ) @@ -314,5 +314,10 @@ def show_context_menu(self, index, model, pos: QPoint, parent): ) layer_list: LayerList = model.sourceModel()._root + # update context keys of selected layers + for key, get in layer_list._selection_ctx_keys._getters.items(): + setattr( + layer_list._selection_ctx_keys, key, get(layer_list.selection) + ) self._context_menu.update_from_context(get_context(layer_list)) self._context_menu.exec_(pos) diff --git a/napari/_qt/containers/_tests/test_qt_layer_list.py b/napari/_qt/containers/_tests/test_qt_layer_list.py index f7d12831619..8915583927b 100644 --- a/napari/_qt/containers/_tests/test_qt_layer_list.py +++ b/napari/_qt/containers/_tests/test_qt_layer_list.py @@ -1,13 +1,11 @@ -from typing import Tuple - import numpy as np -from qtpy.QtCore import QModelIndex, Qt +from qtpy.QtCore import QModelIndex, QPoint, Qt from qtpy.QtWidgets import QLineEdit, QStyleOptionViewItem from napari._qt.containers import QtLayerList from napari._qt.containers._layer_delegate import LayerDelegate from napari.components import LayerList -from napari.layers import Image +from napari.layers import Image, Shapes def test_set_layer_invisible_makes_item_unchecked(qtbot): @@ -145,6 +143,38 @@ def test_second_alt_click_to_restore_layer_state(qtbot): assert delegate._alt_click_layer() is None +def test_contextual_menu_updates_selection_ctx_keys(monkeypatch, qtbot): + shapes_layer = Shapes() + layer_list = LayerList() + layer_list._create_contexts() + layer_list.append(shapes_layer) + view = QtLayerList(layer_list) + qtbot.addWidget(view) + delegate = view.itemDelegate() + assert not layer_list[0].data + + layer_list.selection.add(shapes_layer) + index = layer_to_model_index(view, 0) + assert layer_list._selection_ctx_keys.num_selected_shapes_layers == 1 + assert layer_list._selection_ctx_keys.selected_empty_shapes_layer + + monkeypatch.setattr( + 'app_model.backends.qt.QModelMenu.exec_', lambda self, x: x + ) + + delegate.show_context_menu( + index, view.model(), QPoint(10, 10), parent=view + ) + assert layer_list._selection_ctx_keys.selected_empty_shapes_layer + + layer_list[0].add(np.array(([0, 0], [0, 10], [10, 10], [10, 0]))) + assert layer_list[0].data + delegate.show_context_menu( + index, view.model(), QPoint(10, 10), parent=view + ) + assert not layer_list._selection_ctx_keys.selected_empty_shapes_layer + + def make_qt_layer_list_with_delegate(qtbot): image1 = Image(np.zeros((4, 3))) image2 = Image(np.zeros((4, 3))) @@ -159,7 +189,7 @@ def make_qt_layer_list_with_delegate(qtbot): return image1, image2, image3, layers, view, delegate -def make_qt_layer_list_with_layer(qtbot) -> Tuple[QtLayerList, Image]: +def make_qt_layer_list_with_layer(qtbot) -> tuple[QtLayerList, Image]: image = Image(np.zeros((4, 3))) layers = LayerList([image]) view = QtLayerList(layers) diff --git a/napari/_qt/containers/_tests/test_qt_tree.py b/napari/_qt/containers/_tests/test_qt_tree.py index 722800d8f69..31b05459d69 100644 --- a/napari/_qt/containers/_tests/test_qt_tree.py +++ b/napari/_qt/containers/_tests/test_qt_tree.py @@ -11,21 +11,21 @@ def tree_model(qapp): root = Group( [ - Node(name="1"), + Node(name='1'), Group( [ - Node(name="2"), - Group([Node(name="3"), Node(name="4")], name="g2"), - Node(name="5"), - Node(name="6"), - Node(name="7"), + Node(name='2'), + Group([Node(name='3'), Node(name='4')], name='g2'), + Node(name='5'), + Node(name='6'), + Node(name='7'), ], - name="g1", + name='g1', ), - Node(name="8"), - Node(name="9"), + Node(name='8'), + Node(name='9'), ], - name="root", + name='root', ) return QtNodeTreeModel(root) diff --git a/napari/_qt/containers/qt_layer_list.py b/napari/_qt/containers/qt_layer_list.py index 4ea354ac33d..f94acf6875a 100644 --- a/napari/_qt/containers/qt_layer_list.py +++ b/napari/_qt/containers/qt_layer_list.py @@ -68,7 +68,14 @@ def keyPressEvent(self, e: Optional[QKeyEvent]) -> None: """Override Qt event to pass events to the viewer.""" if e is None: return - if e.key() != Qt.Key.Key_Space: + # capture arrows with modifiers so they are handled by Viewer keybindings + if (e.key() == Qt.Key.Key_Up or e.key() == Qt.Key.Key_Down) and ( + e.modifiers() & Qt.KeyboardModifier.AltModifier + or e.modifiers() & Qt.KeyboardModifier.ControlModifier + or e.modifiers() & Qt.KeyboardModifier.MetaModifier + ): + e.ignore() + elif e.key() != Qt.Key.Key_Space: super().keyPressEvent(e) if e.key() not in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): e.ignore() # pass key events up to viewer diff --git a/napari/_qt/containers/qt_list_model.py b/napari/_qt/containers/qt_list_model.py index 7d0c1bcc5f9..55d79333d23 100644 --- a/napari/_qt/containers/qt_list_model.py +++ b/napari/_qt/containers/qt_list_model.py @@ -1,14 +1,15 @@ import logging import pickle -from typing import List, Optional, Sequence, TypeVar +from collections.abc import Sequence +from typing import Optional, TypeVar from qtpy.QtCore import QMimeData, QModelIndex, Qt from napari._qt.containers._base_item_model import _BaseEventedItemModel logger = logging.getLogger(__name__) -ListIndexMIMEType = "application/x-list-index" -ItemType = TypeVar("ItemType") +ListIndexMIMEType = 'application/x-list-index' +ItemType = TypeVar('ItemType') class QtListModel(_BaseEventedItemModel[ItemType]): @@ -20,16 +21,16 @@ class QtListModel(_BaseEventedItemModel[ItemType]): :class:`~napari._qt.containers.QtListView` for additional background. """ - def mimeTypes(self) -> List[str]: + def mimeTypes(self) -> list[str]: """Returns the list of allowed MIME types. When implementing drag and drop support in a custom model, if you will return data in formats other than the default internal MIME type, reimplement this function to return your list of MIME types. """ - return [ListIndexMIMEType, "text/plain"] + return [ListIndexMIMEType, 'text/plain'] - def mimeData(self, indices: List[QModelIndex]) -> Optional['QMimeData']: + def mimeData(self, indices: list[QModelIndex]) -> Optional['QMimeData']: """Return an object containing serialized data from `indices`. If the list of indexes is empty, or there are no supported MIME types, @@ -68,7 +69,7 @@ def dropMimeData( moving_indices = data.indices logger.debug( - "dropMimeData: indices %s ➡ %s", + 'dropMimeData: indices %s ➡ %s', moving_indices, destRow, ) @@ -91,7 +92,7 @@ def __init__( self.indices = tuple(sorted(indices)) if items: self.setData(ListIndexMIMEType, pickle.dumps(self.indices)) - self.setText(" ".join(str(item) for item in items)) + self.setText(' '.join(str(item) for item in items)) - def formats(self) -> List[str]: - return [ListIndexMIMEType, "text/plain"] + def formats(self) -> list[str]: + return [ListIndexMIMEType, 'text/plain'] diff --git a/napari/_qt/containers/qt_list_view.py b/napari/_qt/containers/qt_list_view.py index d592ad2fa06..1e1faa34535 100644 --- a/napari/_qt/containers/qt_list_view.py +++ b/napari/_qt/containers/qt_list_view.py @@ -14,7 +14,7 @@ from napari.utils.events.containers import SelectableEventedList -ItemType = TypeVar("ItemType") +ItemType = TypeVar('ItemType') class QtListView(_BaseEventedItemView[ItemType], QListView): diff --git a/napari/_qt/containers/qt_tree_model.py b/napari/_qt/containers/qt_tree_model.py index 1c2173ee837..018eb663065 100644 --- a/napari/_qt/containers/qt_tree_model.py +++ b/napari/_qt/containers/qt_tree_model.py @@ -1,6 +1,6 @@ import logging import pickle -from typing import List, Optional, Tuple, TypeVar +from typing import Optional, TypeVar from qtpy.QtCore import QMimeData, QModelIndex, Qt @@ -9,8 +9,8 @@ from napari.utils.tree import Group, Node logger = logging.getLogger(__name__) -NodeType = TypeVar("NodeType", bound=Node) -NodeMIMEType = "application/x-tree-node" +NodeType = TypeVar('NodeType', bound=Node) +NodeMIMEType = 'application/x-tree-node' class QtNodeTreeModel(_BaseEventedItemModel[NodeType]): @@ -97,7 +97,7 @@ def parent(self, index: QModelIndex) -> QModelIndex: row = parentItem.index_in_parent() or 0 return self.createIndex(row, 0, parentItem) - def mimeTypes(self) -> List[str]: + def mimeTypes(self) -> list[str]: """Returns the list of allowed MIME types. By default, the built-in models and views use an internal MIME type: @@ -116,9 +116,9 @@ def mimeTypes(self) -> List[str]: list of str MIME types allowed for drag & drop support """ - return [NodeMIMEType, "text/plain"] + return [NodeMIMEType, 'text/plain'] - def mimeData(self, indices: List[QModelIndex]) -> Optional['NodeMimeData']: + def mimeData(self, indices: list[QModelIndex]) -> Optional['NodeMimeData']: """Return an object containing serialized data from `indices`. The format used to describe the encoded data is obtained from the @@ -163,8 +163,8 @@ def dropMimeData( moving_indices = data.node_indices() logger.debug( - "dropMimeData: indices {ind} ➡ {idx}", - extra={"ind": moving_indices, "idx": dest_idx}, + 'dropMimeData: indices {ind} ➡ {idx}', + extra={'ind': moving_indices, 'idx': dest_idx}, ) if len(moving_indices) == 1: @@ -180,14 +180,14 @@ def setRoot(self, root: Group[NodeType]): if not isinstance(root, Group): raise TypeError( trans._( - "root node must be an instance of {Group}", + 'root node must be an instance of {Group}', deferred=True, Group=Group, ) ) super().setRoot(root) - def nestedIndex(self, nested_index: Tuple[int, ...]) -> QModelIndex: + def nestedIndex(self, nested_index: tuple[int, ...]) -> QModelIndex: """Return a QModelIndex for a given ``nested_index``.""" parent = QModelIndex() if isinstance(nested_index, tuple): @@ -201,7 +201,7 @@ def nestedIndex(self, nested_index: Tuple[int, ...]) -> QModelIndex: else: raise TypeError( trans._( - "nested_index must be an int or tuple of int.", + 'nested_index must be an int or tuple of int.', deferred=True, ) ) @@ -211,18 +211,18 @@ def nestedIndex(self, nested_index: Tuple[int, ...]) -> QModelIndex: class NodeMimeData(QMimeData): """An object to store Node data during a drag operation.""" - def __init__(self, nodes: Optional[List[NodeType]] = None) -> None: + def __init__(self, nodes: Optional[list[NodeType]] = None) -> None: super().__init__() - self.nodes: List[NodeType] = nodes or [] + self.nodes: list[NodeType] = nodes or [] if nodes: self.setData(NodeMIMEType, pickle.dumps(self.node_indices())) - self.setText(" ".join(node._node_name() for node in nodes)) + self.setText(' '.join(node._node_name() for node in nodes)) - def formats(self) -> List[str]: - return [NodeMIMEType, "text/plain"] + def formats(self) -> list[str]: + return [NodeMIMEType, 'text/plain'] - def node_indices(self) -> List[Tuple[int, ...]]: + def node_indices(self) -> list[tuple[int, ...]]: return [node.index_from_root() for node in self.nodes] - def node_names(self) -> List[str]: + def node_names(self) -> list[str]: return [node._node_name() for node in self.nodes] diff --git a/napari/_qt/containers/qt_tree_view.py b/napari/_qt/containers/qt_tree_view.py index 698537f855e..b6841c8714d 100644 --- a/napari/_qt/containers/qt_tree_view.py +++ b/napari/_qt/containers/qt_tree_view.py @@ -16,7 +16,7 @@ from qtpy.QtWidgets import QWidget # type: ignore[attr-defined] -NodeType = TypeVar("NodeType", bound=Node) +NodeType = TypeVar('NodeType', bound=Node) class QtNodeTreeView(_BaseEventedItemView[NodeType], QTreeView): diff --git a/napari/_qt/dialogs/_tests/test_activity_dialog.py b/napari/_qt/dialogs/_tests/test_activity_dialog.py index 2aaeb5dfffb..2d4b09b5bc7 100644 --- a/napari/_qt/dialogs/_tests/test_activity_dialog.py +++ b/napari/_qt/dialogs/_tests/test_activity_dialog.py @@ -10,7 +10,7 @@ ) from napari.utils import progress -SHOW = bool(sys.platform == 'linux' or os.getenv("CI")) +SHOW = bool(sys.platform == 'linux' or os.getenv('CI')) def qt_viewer_has_pbar(qt_viewer): @@ -123,9 +123,9 @@ def test_progress_set_description(make_napari_viewer): viewer = make_napari_viewer(show=SHOW) prog = progress(total=5) - prog.set_description("Test") + prog.set_description('Test') pbar = get_qt_labeled_progress_bar(prog, viewer) - assert pbar.description_label.text() == "Test: " + assert pbar.description_label.text() == 'Test: ' prog.close() diff --git a/napari/_qt/dialogs/_tests/test_qt_modal.py b/napari/_qt/dialogs/_tests/test_qt_modal.py index a790cd5f7b9..fdb16430993 100644 --- a/napari/_qt/dialogs/_tests/test_qt_modal.py +++ b/napari/_qt/dialogs/_tests/test_qt_modal.py @@ -26,7 +26,7 @@ def test_move_to_error_no_parent(self, qtbot): with pytest.raises(ValueError): popup.move_to() - @pytest.mark.parametrize("pos", ["top", "bottom", "left", "right"]) + @pytest.mark.parametrize('pos', ['top', 'bottom', 'left', 'right']) def test_move_to(self, pos, qtbot): window = QMainWindow() qtbot.addWidget(window) @@ -42,12 +42,12 @@ def test_move_to_error_wrong_params(self, qtbot): window.setCentralWidget(widget) popup = QtPopup(widget) with pytest.raises(ValueError): - popup.move_to("dummy_text") + popup.move_to('dummy_text') with pytest.raises(TypeError): popup.move_to({}) - @pytest.mark.parametrize("pos", [[10, 10, 10, 10], (15, 10, 10, 10)]) + @pytest.mark.parametrize('pos', [[10, 10, 10, 10], (15, 10, 10, 10)]) def test_move_to_cords(self, pos, qtbot): window = QMainWindow() qtbot.addWidget(window) @@ -58,7 +58,7 @@ def test_move_to_cords(self, pos, qtbot): def test_click(self, qtbot, monkeypatch): popup = QtPopup(None) - monkeypatch.setattr(popup, "close", MagicMock()) + monkeypatch.setattr(popup, 'close', MagicMock()) qtbot.addWidget(popup) qtbot.keyClick(popup, Qt.Key_8) popup.close.assert_not_called() diff --git a/napari/_qt/dialogs/_tests/test_qt_plugin_report.py b/napari/_qt/dialogs/_tests/test_qt_plugin_report.py index 7a81c0bdf90..4add2944cac 100644 --- a/napari/_qt/dialogs/_tests/test_qt_plugin_report.py +++ b/napari/_qt/dialogs/_tests/test_qt_plugin_report.py @@ -24,7 +24,7 @@ def test_error_reporter(qtbot, monkeypatch): try: # we need to raise to make sure a __traceback__ is attached to the error. raise PluginError( - error_message, plugin_name='test_plugin', plugin="mock" + error_message, plugin_name='test_plugin', plugin='mock' ) except PluginError: pass @@ -42,7 +42,7 @@ def test_error_reporter(qtbot, monkeypatch): def mock_webbrowser_open(url, new=0): assert new == 2 assert "Errors for plugin 'test_plugin'" in url - assert "Traceback from napari" in url + assert 'Traceback from napari' in url monkeypatch.setattr(webbrowser, 'open', mock_webbrowser_open) diff --git a/napari/_qt/dialogs/_tests/test_reader_dialog.py b/napari/_qt/dialogs/_tests/test_reader_dialog.py index 9e54dadb425..e83d7b8c627 100644 --- a/napari/_qt/dialogs/_tests/test_reader_dialog.py +++ b/napari/_qt/dialogs/_tests/test_reader_dialog.py @@ -56,7 +56,7 @@ def test_reader_dir_with_extension(tmpdir, reader_dialog): assert hasattr(widg, 'persist_checkbox') assert ( widg.persist_checkbox.text() - == "Remember this choice for files with a .zarr extension" + == 'Remember this choice for files with a .zarr extension' ) diff --git a/napari/_qt/dialogs/_tests/test_screenshot_dialog.py b/napari/_qt/dialogs/_tests/test_screenshot_dialog.py index b4d94a69d67..e42f131a7a5 100644 --- a/napari/_qt/dialogs/_tests/test_screenshot_dialog.py +++ b/napari/_qt/dialogs/_tests/test_screenshot_dialog.py @@ -5,7 +5,7 @@ from napari._qt.dialogs.screenshot_dialog import ScreenshotDialog -@pytest.mark.parametrize("filename", ["test", "test.png", "test.tif"]) +@pytest.mark.parametrize('filename', ['test', 'test.png', 'test.tif']) def test_screenshot_save(qtbot, tmp_path, filename): """Check passing different extensions with the filename.""" @@ -13,12 +13,12 @@ def save_function(path): # check incoming path has extension event when a filename without one # was provided assert filename in path - assert "." in filename or ".png" in path + assert '.' in filename or '.png' in path # create a file with the given path to check for # non-native qt overwrite message - with open(path, "w") as mock_img: - mock_img.write("") + with open(path, 'w') as mock_img: + mock_img.write('') qt_overwrite_shown = False @@ -48,7 +48,7 @@ def qt_overwrite_qmessagebox_warning(): QTimer.singleShot(100, qt_overwrite_qmessagebox_warning) dialog.accept() qtbot.wait(120) - assert not qt_overwrite_shown, "Qt non-native overwrite message was shown!" + assert not qt_overwrite_shown, 'Qt non-native overwrite message was shown!' # check the file was created save_filename = filename if '.' in filename else f'{filename}.png' @@ -57,25 +57,25 @@ def qt_overwrite_qmessagebox_warning(): def test_screenshot_overwrite_save(qtbot, tmp_path, monkeypatch): """Check overwriting file validation.""" - (tmp_path / "test.png").write_text("") + (tmp_path / 'test.png').write_text('') def save_function(path): - assert "test.png" in path - (tmp_path / "test.png").write_text("overwritten") + assert 'test.png' in path + (tmp_path / 'test.png').write_text('overwritten') def overwrite_qmessagebox_warning(*args): box, parent, title, text, buttons, default = args assert parent == dialog - assert title == "Confirm overwrite" - assert "test.png" in text - assert "already exists. Do you want to replace it?" in text + assert title == 'Confirm overwrite' + assert 'test.png' in text + assert 'already exists. Do you want to replace it?' in text assert buttons == QMessageBox.Yes | QMessageBox.No assert default == QMessageBox.No return QMessageBox.Yes # monkeypath custom overwrite QMessageBox usage - monkeypatch.setattr(QMessageBox, "warning", overwrite_qmessagebox_warning) + monkeypatch.setattr(QMessageBox, 'warning', overwrite_qmessagebox_warning) dialog = ScreenshotDialog( save_function, directory=str(tmp_path), history=[] @@ -87,8 +87,8 @@ def overwrite_qmessagebox_warning(*args): # check dialog, set filename and trigger accept logic assert dialog.windowTitle() == 'Save screenshot' line_edit = dialog.findChild(QLineEdit) - line_edit.setText("test") + line_edit.setText('test') dialog.accept() # check the file was overwritten - assert (tmp_path / "test.png").read_text() == "overwritten" + assert (tmp_path / 'test.png').read_text() == 'overwritten' diff --git a/napari/_qt/dialogs/confirm_close_dialog.py b/napari/_qt/dialogs/confirm_close_dialog.py index 3074ab16c1f..dc9b643373b 100644 --- a/napari/_qt/dialogs/confirm_close_dialog.py +++ b/napari/_qt/dialogs/confirm_close_dialog.py @@ -16,12 +16,12 @@ class ConfirmCloseDialog(QDialog): def __init__(self, parent, close_app=False) -> None: super().__init__(parent) - cancel_btn = QPushButton(trans._("Cancel")) - close_btn = QPushButton(trans._("Close")) - close_btn.setObjectName("warning_icon_btn") + cancel_btn = QPushButton(trans._('Cancel')) + close_btn = QPushButton(trans._('Close')) + close_btn.setObjectName('warning_icon_btn') icon_label = QWidget() - self.do_not_ask = QCheckBox(trans._("Do not ask in future")) + self.do_not_ask = QCheckBox(trans._('Do not ask in future')) if close_app: self.setWindowTitle(trans._('Close Application?')) @@ -31,9 +31,9 @@ def __init__(self, parent, close_app=False) -> None: QKeySequence.NativeText ), ) - close_btn.setObjectName("error_icon_btn") + close_btn.setObjectName('error_icon_btn') close_btn.setShortcut(QKeySequence('Ctrl+Q')) - icon_label.setObjectName("error_icon_element") + icon_label.setObjectName('error_icon_element') else: self.setWindowTitle(trans._('Close Window?')) text = trans._( @@ -42,9 +42,9 @@ def __init__(self, parent, close_app=False) -> None: QKeySequence.NativeText ), ) - close_btn.setObjectName("warning_icon_btn") + close_btn.setObjectName('warning_icon_btn') close_btn.setShortcut(QKeySequence('Ctrl+W')) - icon_label.setObjectName("warning_icon_element") + icon_label.setObjectName('warning_icon_element') cancel_btn.clicked.connect(self.reject) close_btn.clicked.connect(self.accept) diff --git a/napari/_qt/dialogs/preferences_dialog.py b/napari/_qt/dialogs/preferences_dialog.py index d038782230d..c5d1b5fb816 100644 --- a/napari/_qt/dialogs/preferences_dialog.py +++ b/napari/_qt/dialogs/preferences_dialog.py @@ -1,6 +1,6 @@ import json from enum import EnumMeta -from typing import TYPE_CHECKING, ClassVar, Dict, Tuple, cast +from typing import TYPE_CHECKING, ClassVar, cast from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtWidgets import ( @@ -25,13 +25,13 @@ class PreferencesDialog(QDialog): """Preferences Dialog for Napari user settings.""" - ui_schema: ClassVar[Dict[str, Dict[str, str]]] = { - "call_order": {"ui:widget": "plugins"}, - "highlight_thickness": {"ui:widget": "highlight"}, - "shortcuts": {"ui:widget": "shortcuts"}, - "extension2reader": {"ui:widget": "extension2reader"}, - "dask": {"ui:widget": "horizontal_object"}, - "font_size": {"ui:widget": "font_size"}, + ui_schema: ClassVar[dict[str, dict[str, str]]] = { + 'call_order': {'ui:widget': 'plugins'}, + 'highlight': {'ui:widget': 'highlight'}, + 'shortcuts': {'ui:widget': 'shortcuts'}, + 'extension2reader': {'ui:widget': 'extension2reader'}, + 'dask': {'ui:widget': 'horizontal_object'}, + 'font_size': {'ui:widget': 'font_size'}, } resized = Signal(QSize) @@ -40,22 +40,22 @@ def __init__(self, parent=None) -> None: from napari.settings import get_settings super().__init__(parent) - self.setWindowTitle(trans._("Preferences")) + self.setWindowTitle(trans._('Preferences')) self.setMinimumSize(QSize(1065, 470)) self._settings = get_settings() self._stack = QStackedWidget(self) self._list = QListWidget(self) - self._list.setObjectName("Preferences") + self._list.setObjectName('Preferences') self._list.currentRowChanged.connect(self._stack.setCurrentIndex) # Set up buttons - self._button_cancel = QPushButton(trans._("Cancel")) + self._button_cancel = QPushButton(trans._('Cancel')) self._button_cancel.clicked.connect(self.reject) - self._button_ok = QPushButton(trans._("OK")) + self._button_ok = QPushButton(trans._('OK')) self._button_ok.clicked.connect(self.accept) self._button_ok.setDefault(True) - self._button_restore = QPushButton(trans._("Restore defaults")) + self._button_restore = QPushButton(trans._('Restore defaults')) self._button_restore.clicked.connect(self._restore_default_dialog) # Layout @@ -134,7 +134,7 @@ def _add_page(self, field: 'ModelField'): excluded = set( getattr( getattr(settings_category, 'NapariConfig', None), - "preferences_exclude", + 'preferences_exclude', {}, ) ) @@ -149,7 +149,7 @@ def _add_page(self, field: 'ModelField'): self._list.addItem(field.field_info.title or field.name) self._stack.addWidget(page_scrollarea) - def _get_page_dict(self, field: 'ModelField') -> Tuple[dict, dict]: + def _get_page_dict(self, field: 'ModelField') -> tuple[dict, dict]: """Provides the schema, set of values for each setting, and the properties for each setting.""" ftype = cast('BaseModel', field.type_) @@ -161,17 +161,17 @@ def _get_page_dict(self, field: 'ModelField') -> Tuple[dict, dict]: # hardcode workaround because pydantic's schema generation # does not allow you to specify custom JSON serialization schema = { - "title": "ShortcutsSettings", - "type": "object", - "properties": { - "shortcuts": { - "title": field.type_.__fields__[ - "shortcuts" + 'title': 'ShortcutsSettings', + 'type': 'object', + 'properties': { + 'shortcuts': { + 'title': field.type_.__fields__[ + 'shortcuts' ].field_info.title, - "description": field.type_.__fields__[ - "shortcuts" + 'description': field.type_.__fields__[ + 'shortcuts' ].field_info.description, - "type": "object", + 'type': 'object', } }, } @@ -179,28 +179,28 @@ def _get_page_dict(self, field: 'ModelField') -> Tuple[dict, dict]: schema = json.loads(ftype.schema_json()) if field.field_info.title: - schema["title"] = field.field_info.title + schema['title'] = field.field_info.title if field.field_info.description: - schema["description"] = field.field_info.description + schema['description'] = field.field_info.description # find enums: for name, subfield in ftype.__fields__.items(): if isinstance(subfield.type_, EnumMeta): enums = [s.value for s in subfield.type_] # type: ignore - schema["properties"][name]["enum"] = enums - schema["properties"][name]["type"] = "string" + schema['properties'][name]['enum'] = enums + schema['properties'][name]['type'] = 'string' if isinstance(subfield.type_, ModelMetaclass): local_schema = json.loads(subfield.type_.schema_json()) - schema["properties"][name]["type"] = "object" - schema["properties"][name]["properties"] = local_schema[ - "properties" + schema['properties'][name]['type'] = 'object' + schema['properties'][name]['properties'] = local_schema[ + 'properties' ] # Need to remove certain properties that will not be displayed on the GUI setting = getattr(self._settings, field.name) with setting.enums_as_values(): values = setting.dict() - napari_config = getattr(setting, "NapariConfig", None) + napari_config = getattr(setting, 'NapariConfig', None) if hasattr(napari_config, 'preferences_exclude'): for val in napari_config.preferences_exclude: schema['properties'].pop(val, None) @@ -219,8 +219,8 @@ def _restore_default_dialog(self): response = QMessageBox.question( self, - trans._("Restore Settings"), - trans._("Are you sure you want to restore default settings?"), + trans._('Restore Settings'), + trans._('Are you sure you want to restore default settings?'), QMessageBox.StandardButton.RestoreDefaults | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.RestoreDefaults, @@ -236,9 +236,9 @@ def _restart_required_dialog(self): """Displays the dialog informing user a restart is required.""" QMessageBox.information( self, - trans._("Restart required"), + trans._('Restart required'), trans._( - "A restart is required for some new settings to have an effect." + 'A restart is required for some new settings to have an effect.' ), ) diff --git a/napari/_qt/dialogs/qt_about.py b/napari/_qt/dialogs/qt_about.py index ea1b78a13cb..270d47caa31 100644 --- a/napari/_qt/dialogs/qt_about.py +++ b/napari/_qt/dialogs/qt_about.py @@ -48,7 +48,7 @@ def __init__(self, parent=None) -> None: # Description title_label = QLabel( trans._( - "napari: a multi-dimensional image viewer for python" + 'napari: a multi-dimensional image viewer for python' ) ) title_label.setTextInteractionFlags( @@ -124,9 +124,9 @@ class QtCopyToClipboardButton(QPushButton): def __init__(self, text_edit) -> None: super().__init__() - self.setObjectName("QtCopyToClipboardButton") + self.setObjectName('QtCopyToClipboardButton') self.text_edit = text_edit - self.setToolTip(trans._("Copy to clipboard")) + self.setToolTip(trans._('Copy to clipboard')) self.clicked.connect(self.copyToClipboard) def copyToClipboard(self): diff --git a/napari/_qt/dialogs/qt_activity_dialog.py b/napari/_qt/dialogs/qt_activity_dialog.py index e03f92b5c49..b5beb97e7ca 100644 --- a/napari/_qt/dialogs/qt_activity_dialog.py +++ b/napari/_qt/dialogs/qt_activity_dialog.py @@ -35,7 +35,7 @@ def __init__(self, parent=None) -> None: self.setLayout(QHBoxLayout()) self._activityBtn = QToolButton() - self._activityBtn.setObjectName("QtActivityButton") + self._activityBtn.setObjectName('QtActivityButton') self._activityBtn.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonTextBesideIcon ) @@ -44,7 +44,7 @@ def __init__(self, parent=None) -> None: self._activityBtn.setText(trans._('activity')) self._activityBtn.setCheckable(True) - self._inProgressIndicator = QLabel(trans._("in progress..."), self) + self._inProgressIndicator = QLabel(trans._('in progress...'), self) sp = self._inProgressIndicator.sizePolicy() sp.setRetainSizeWhenHidden(True) self._inProgressIndicator.setSizePolicy(sp) @@ -101,7 +101,7 @@ def __init__(self, parent=None, toggle_button=None) -> None: QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) ) line = QFrame(self) - line.setObjectName("QtCustomTitleBarLine") + line.setObjectName('QtCustomTitleBarLine') titleLayout = QHBoxLayout() titleLayout.setSpacing(4) titleLayout.setContentsMargins(8, 1, 8, 0) @@ -287,7 +287,7 @@ def remove_separators(current_pbars): parent and new progress bar to remove separators from """ for current_pbar in current_pbars: - if line_widg := current_pbar.findChild(QFrame, "QtCustomTitleBarLine"): + if line_widg := current_pbar.findChild(QFrame, 'QtCustomTitleBarLine'): current_pbar.layout().removeWidget(line_widg) line_widg.hide() line_widg.deleteLater() diff --git a/napari/_qt/dialogs/qt_modal.py b/napari/_qt/dialogs/qt_modal.py index 647399673c8..07939a82943 100644 --- a/napari/_qt/dialogs/qt_modal.py +++ b/napari/_qt/dialogs/qt_modal.py @@ -34,14 +34,14 @@ class QtPopup(QDialog): def __init__(self, parent) -> None: super().__init__(parent) - self.setObjectName("QtModalPopup") + self.setObjectName('QtModalPopup') self.setModal(False) # if False, then clicking anywhere else closes it flags = Qt.Popup | Qt.FramelessWindowHint self.setWindowFlags(flags) self.setLayout(QVBoxLayout()) self.frame = QFrame() - self.frame.setObjectName("QtPopupFrame") + self.frame.setObjectName('QtPopupFrame') self.layout().addWidget(self.frame) self.layout().setContentsMargins(0, 0, 0, 0) @@ -89,7 +89,7 @@ def move_to(self, position='top', *, win_ratio=0.9, min_length=0): if not window: raise ValueError( trans._( - "Specifying position as a string is only possible if the popup has a parent", + 'Specifying position as a string is only possible if the popup has a parent', deferred=True, ) ) @@ -127,7 +127,7 @@ def move_to(self, position='top', *, win_ratio=0.9, min_length=0): else: raise TypeError( trans._( - "Wrong type of position {position}", + 'Wrong type of position {position}', deferred=True, position=position, ) diff --git a/napari/_qt/dialogs/qt_notification.py b/napari/_qt/dialogs/qt_notification.py index 562696f1eba..217399c0e33 100644 --- a/napari/_qt/dialogs/qt_notification.py +++ b/napari/_qt/dialogs/qt_notification.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Callable, Optional, Sequence, Tuple, Union +from collections.abc import Sequence +from typing import Callable, Optional, Union from qtpy.QtCore import ( QEasingCurve, @@ -32,7 +33,7 @@ from napari.utils.theme import get_theme from napari.utils.translations import trans -ActionSequence = Sequence[Tuple[str, Callable[['NapariQtNotification'], None]]] +ActionSequence = Sequence[tuple[str, Callable[['NapariQtNotification'], None]]] class NapariQtNotification(QDialog): @@ -104,8 +105,8 @@ def __init__( self.timer = QTimer() self.opacity = QGraphicsOpacityEffect() self.setGraphicsEffect(self.opacity) - self.opacity_anim = QPropertyAnimation(self.opacity, b"opacity", self) - self.geom_anim = QPropertyAnimation(self, b"geometry", self) + self.opacity_anim = QPropertyAnimation(self.opacity, b'opacity', self) + self.geom_anim = QPropertyAnimation(self, b'geometry', self) self.move_to_bottom_right() def _update_icon(self, severity: str): @@ -120,8 +121,8 @@ def _update_icon(self, severity: str): # FIXME: Should these be defined at the theme level? # Currently there is a warning one colors = { - 'error': "#D85E38", - 'warning': "#E3B617", + 'error': '#D85E38', + 'warning': '#E3B617', 'info': default_color, 'debug': default_color, 'none': default_color, @@ -250,7 +251,7 @@ def setupUi(self): self.row1.setContentsMargins(12, 12, 12, 8) self.row1.setSpacing(4) self.severity_icon = QLabel(self.row1_widget) - self.severity_icon.setObjectName("severity_icon") + self.severity_icon.setObjectName('severity_icon') self.severity_icon.setMinimumWidth(30) self.severity_icon.setMaximumWidth(30) self.row1.addWidget( @@ -265,7 +266,7 @@ def setupUi(self): ) self.row1.addWidget(self.message, alignment=Qt.AlignmentFlag.AlignTop) self.expand_button = QPushButton(self.row1_widget) - self.expand_button.setObjectName("expand_button") + self.expand_button.setObjectName('expand_button') self.expand_button.setCursor(Qt.PointingHandCursor) self.expand_button.setMaximumWidth(20) self.expand_button.setFlat(True) @@ -274,7 +275,7 @@ def setupUi(self): self.expand_button, alignment=Qt.AlignmentFlag.AlignTop ) self.close_button = QPushButton(self.row1_widget) - self.close_button.setObjectName("close_button") + self.close_button.setObjectName('close_button') self.close_button.setCursor(Qt.PointingHandCursor) self.close_button.setMaximumWidth(20) self.close_button.setFlat(True) @@ -287,7 +288,7 @@ def setupUi(self): self.row2_widget.hide() self.row2 = QHBoxLayout(self.row2_widget) self.source_label = QLabel(self.row2_widget) - self.source_label.setObjectName("source_label") + self.source_label.setObjectName('source_label') self.row2.addWidget( self.source_label, alignment=Qt.AlignmentFlag.AlignBottom ) @@ -406,7 +407,7 @@ def _debug_tb(tb): with event_hook_removed(): print("Entering debugger. Type 'q' to return to napari.\n") pdb.post_mortem(tb) - print("\nDebugging finished. Napari active again.") + print('\nDebugging finished. Napari active again.') class TracebackDialog(QDialog): @@ -418,7 +419,7 @@ def __init__(self, exception, parent=None) -> None: self.resize(650, 270) text = QTextEdit() theme = get_theme(get_settings().appearance.theme) - _highlight = Pylighter(text.document(), "python", theme.syntax_style) + _highlight = Pylighter(text.document(), 'python', theme.syntax_style) text.setText(exception.as_text()) text.setReadOnly(True) self.btn = QPushButton(trans._('Enter Debugger')) diff --git a/napari/_qt/dialogs/qt_plugin_report.py b/napari/_qt/dialogs/qt_plugin_report.py index 3b350ebc982..94efe0f8e08 100644 --- a/napari/_qt/dialogs/qt_plugin_report.py +++ b/napari/_qt/dialogs/qt_plugin_report.py @@ -77,7 +77,7 @@ def __init__( self.text_area = QTextEdit() theme = get_theme(get_settings().appearance.theme) self._highlight = Pylighter( - self.text_area.document(), "python", theme.syntax_style + self.text_area.document(), 'python', theme.syntax_style ) self.text_area.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse @@ -104,15 +104,15 @@ def __init__( # create copy to clipboard button self.clipboard_button = QPushButton() self.clipboard_button.hide() - self.clipboard_button.setObjectName("QtCopyToClipboardButton") + self.clipboard_button.setObjectName('QtCopyToClipboardButton') self.clipboard_button.setToolTip( - trans._("Copy error log to clipboard") + trans._('Copy error log to clipboard') ) self.clipboard_button.clicked.connect(self.copyToClipboard) # plugin_meta contains a URL to the home page, (and/or other details) self.plugin_meta = QLabel('', parent=self) - self.plugin_meta.setObjectName("pluginInfo") + self.plugin_meta.setObjectName('pluginInfo') self.plugin_meta.setTextFormat(Qt.TextFormat.RichText) self.plugin_meta.setTextInteractionFlags( Qt.TextInteractionFlag.TextBrowserInteraction @@ -170,7 +170,7 @@ def set_plugin(self, plugin: str) -> None: self.plugin_combo.setCurrentText(plugin) - err_string = format_exceptions(plugin, as_html=False, color="NoColor") + err_string = format_exceptions(plugin, as_html=False, color='NoColor') self.text_area.setText(err_string) self.clipboard_button.show() @@ -195,9 +195,9 @@ def onclick(): err = format_exceptions(plugin, as_html=False) err = ( - "\n\n\n\n" - "
\nTraceback from napari" - f"\n\n```\n{err}\n```\n
" + '\n\n\n\n' + '
\nTraceback from napari' + f'\n\n```\n{err}\n```\n
' ) url = f'{meta.get("url")}/issues/new?&body={err}' webbrowser.open(url, new=2) diff --git a/napari/_qt/dialogs/qt_reader_dialog.py b/napari/_qt/dialogs/qt_reader_dialog.py index 56db9de1fea..61352c64abe 100644 --- a/napari/_qt/dialogs/qt_reader_dialog.py +++ b/napari/_qt/dialogs/qt_reader_dialog.py @@ -1,5 +1,5 @@ import os -from typing import Dict, List, Optional, Tuple, Union +from typing import Optional, Union from qtpy.QtWidgets import ( QButtonGroup, @@ -25,7 +25,7 @@ def __init__( self, pth: str = '', parent: QWidget = None, - readers: Optional[Dict[str, str]] = None, + readers: Optional[dict[str, str]] = None, error_message: str = '', persist_checked: bool = True, ) -> None: @@ -62,9 +62,9 @@ def setup_ui(self, error_message, readers, persist_checked): # add instruction label layout = QVBoxLayout() if error_message: - error_message += "\n" + error_message += '\n' label = QLabel( - f"{error_message}Choose reader for {self._current_file}:" + f'{error_message}Choose reader for {self._current_file}:' ) layout.addWidget(label) @@ -118,7 +118,7 @@ def setup_ui(self, error_message, readers, persist_checked): def add_reader_buttons(self, layout, readers): """Add radio button to layout for each reader in readers""" for display_name in sorted(readers.values()): - button = QRadioButton(f"{display_name}") + button = QRadioButton(f'{display_name}') self.reader_btn_group.addButton(button) layout.addWidget(button) @@ -136,7 +136,7 @@ def _get_persist_choice(self): and self.persist_checkbox.isChecked() ) - def get_user_choices(self) -> Tuple[str, bool]: + def get_user_choices(self) -> tuple[str, bool]: """Execute dialog and get user choices""" display_name = '' persist_choice = False @@ -152,9 +152,9 @@ def get_user_choices(self) -> Tuple[str, bool]: def handle_gui_reading( - paths: List[str], + paths: list[str], qt_viewer, - stack: Union[bool, List[List[str]]], + stack: Union[bool, list[list[str]]], plugin_name: Optional[str] = None, error: Optional[ReaderPluginError] = None, plugin_override: bool = False, @@ -211,7 +211,7 @@ def handle_gui_reading( def prepare_remaining_readers( - paths: List[str], + paths: list[str], plugin_name: Optional[str] = None, error: Optional[ReaderPluginError] = None, ): @@ -244,9 +244,9 @@ def prepare_remaining_readers( if not readers and error: raise ReaderPluginError( trans._( - "Tried to read {path_message} with plugin {plugin}, because it was associated with that file extension/because it is the only plugin capable of reading that path, but it gave an error. Try associating a different plugin or installing a different plugin for this kind of file.", + 'Tried to read {path_message} with plugin {plugin}, because it was associated with that file extension/because it is the only plugin capable of reading that path, but it gave an error. Try associating a different plugin or installing a different plugin for this kind of file.', path_message=( - f"[{paths[0]}, ...]" if len(paths) > 1 else paths[0] + f'[{paths[0]}, ...]' if len(paths) > 1 else paths[0] ), plugin=plugin_name, ), @@ -261,8 +261,8 @@ def open_with_dialog_choices( display_name: str, persist: bool, extension: str, - readers: Dict[str, str], - paths: List[str], + readers: dict[str, str], + paths: list[str], stack: bool, qt_viewer, **kwargs, diff --git a/napari/_qt/dialogs/screenshot_dialog.py b/napari/_qt/dialogs/screenshot_dialog.py index 55ed81e9787..f72b588e42c 100644 --- a/napari/_qt/dialogs/screenshot_dialog.py +++ b/napari/_qt/dialogs/screenshot_dialog.py @@ -32,11 +32,11 @@ def __init__( directory=HOME_DIRECTORY, history=None, ) -> None: - super().__init__(parent, trans._("Save screenshot")) + super().__init__(parent, trans._('Save screenshot')) self.setAcceptMode(QFileDialog.AcceptSave) self.setFileMode(QFileDialog.AnyFile) self.setNameFilter( - trans._("Image files (*.png *.bmp *.gif *.tif *.tiff)") + trans._('Image files (*.png *.bmp *.gif *.tif *.tiff)') ) self.setDirectory(directory) self.setHistory(history) @@ -48,14 +48,14 @@ def __init__( def accept(self): save_path = self.selectedFiles()[0] - if os.path.splitext(save_path)[1] == "": - save_path = save_path + ".png" + if os.path.splitext(save_path)[1] == '': + save_path = save_path + '.png' if os.path.exists(save_path): res = QMessageBox().warning( self, - trans._("Confirm overwrite"), + trans._('Confirm overwrite'), trans._( - "{save_path} already exists. Do you want to replace it?", + '{save_path} already exists. Do you want to replace it?', save_path=save_path, ), QMessageBox.Yes | QMessageBox.No, diff --git a/napari/_qt/layer_controls/__init__.py b/napari/_qt/layer_controls/__init__.py index 799bf735df1..a8d14856959 100644 --- a/napari/_qt/layer_controls/__init__.py +++ b/napari/_qt/layer_controls/__init__.py @@ -2,4 +2,4 @@ QtLayerControlsContainer, ) -__all__ = ["QtLayerControlsContainer"] +__all__ = ['QtLayerControlsContainer'] diff --git a/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py b/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py index 92c866e544a..2cd97d068a4 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py +++ b/napari/_qt/layer_controls/_tests/test_qt_image_base_layer_.py @@ -46,7 +46,7 @@ def test_clim_right_click_shows_popup(mock_show, qtbot, layer): assert hasattr(qtctrl, 'clim_popup') # this mock doesn't seem to be working on cirrus windows # but it works on local windows tests... - if not (os.name == 'nt' and os.getenv("CI")): + if not (os.name == 'nt' and os.getenv('CI')): mock_show.assert_called_once() @@ -76,14 +76,14 @@ def test_range_popup_clim_buttons(mock_show, qtbot, qapp, layer): # pressing the reset button returns the clims to the default values reset_button = qtctrl.clim_popup.findChild( - QPushButton, "reset_clims_button" + QPushButton, 'reset_clims_button' ) reset_button.click() qapp.processEvents() assert tuple(qtctrl.contrastLimitsSlider.value()) == original_clims rangebtn = qtctrl.clim_popup.findChild( - QPushButton, "full_clim_range_button" + QPushButton, 'full_clim_range_button' ) # data in this test is uint16 or int32 for Image, and float for Surface. # Surface will not have a "full range button" diff --git a/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py b/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py index 75fa0988e26..836aca5f9f6 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py +++ b/napari/_qt/layer_controls/_tests/test_qt_labels_layer.py @@ -14,7 +14,7 @@ 3: 'green', 4: 'red', 5: 'yellow', - None: "black", + None: 'black', } ) @@ -34,10 +34,10 @@ def test_changing_layer_color_mode_updates_combo_box(make_labels_controls): """Updating layer color mode changes the combo box selection""" layer, qtctrl = make_labels_controls(colormap=_COLOR) - assert qtctrl.colorModeComboBox.currentText() == "direct" + assert qtctrl.colorModeComboBox.currentText() == 'direct' layer.colormap = layer._random_colormap - assert qtctrl.colorModeComboBox.currentText() == "auto" + assert qtctrl.colorModeComboBox.currentText() == 'auto' def test_changing_layer_show_selected_label_updates_check_box( diff --git a/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py b/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py index a104405f3fc..dfaa495baa8 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py +++ b/napari/_qt/layer_controls/_tests/test_qt_layer_controls.py @@ -1,7 +1,7 @@ import os import random import sys -from typing import NamedTuple, Optional, Type +from typing import NamedTuple, Optional import numpy as np import pytest @@ -45,11 +45,11 @@ class LayerTypeWithData(NamedTuple): - type: Type[Layer] + type: type[Layer] data: np.ndarray colormap: Optional[DirectLabelColormap] properties: Optional[dict] - expected_isinstance: Type[QtLayerControlsContainer] + expected_isinstance: type[QtLayerControlsContainer] np.random.seed(0) @@ -72,7 +72,7 @@ class LayerTypeWithData(NamedTuple): 3: 'green', 4: 'red', 5: 'yellow', - None: "black", + None: 'black', } ), properties=None, @@ -168,18 +168,18 @@ def _create_layer_controls(layer_type_with_data): _VECTORS, ], ids=[ - "labels_with_direct_colormap", - "labels_with_auto_colormap", - "image", - "points", - "shapes", - "surface", - "tracks", - "vectors", + 'labels_with_direct_colormap', + 'labels_with_auto_colormap', + 'image', + 'points', + 'shapes', + 'surface', + 'tracks', + 'vectors', ], ) @pytest.mark.qt_no_exception_capture -@pytest.mark.skipif(os.environ.get("MIN_REQ", "0") == "1", reason="min req") +@pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls( qtbot, create_layer_controls, layer_type_with_data, capsys ): @@ -241,7 +241,7 @@ def test_create_layer_controls( ], ) @pytest.mark.qt_no_exception_capture -@pytest.mark.skipif(os.environ.get("MIN_REQ", "0") == "1", reason="min req") +@pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls_spin( qtbot, create_layer_controls, layer_type_with_data, capsys ): @@ -288,16 +288,16 @@ def test_create_layer_controls_spin( if captured.err: # since an error was found check if it is associated with a known issue still open expected_errors = [ - "MemoryError: Unable to allocate", # See https://github.com/napari/napari/issues/5798 - "ValueError: array is too big; `arr.size * arr.dtype.itemsize` is larger than the maximum possible size.", # See https://github.com/napari/napari/issues/5798 - "ValueError: Maximum allowed dimension exceeded", # See https://github.com/napari/napari/issues/5798 - "IndexError: index ", # See https://github.com/napari/napari/issues/4864 - "RuntimeWarning: overflow encountered", # See https://github.com/napari/napari/issues/4864 + 'MemoryError: Unable to allocate', # See https://github.com/napari/napari/issues/5798 + 'ValueError: array is too big; `arr.size * arr.dtype.itemsize` is larger than the maximum possible size.', # See https://github.com/napari/napari/issues/5798 + 'ValueError: Maximum allowed dimension exceeded', # See https://github.com/napari/napari/issues/5798 + 'IndexError: index ', # See https://github.com/napari/napari/issues/4864 + 'RuntimeWarning: overflow encountered', # See https://github.com/napari/napari/issues/4864 ] assert any( expected_error in captured.err for expected_error in expected_errors - ), f"value: {value}, range {value_range}\nerr: {captured.err}" + ), f'value: {value}, range {value_range}\nerr: {captured.err}' assert qspinbox.value() in [qspinbox_max, qspinbox_max - 1] qspinbox.setValue(qspinbox_initial_value) @@ -317,7 +317,7 @@ def test_create_layer_controls_spin( ], ) @pytest.mark.qt_no_exception_capture -@pytest.mark.skipif(os.environ.get("MIN_REQ", "0") == "1", reason="min req") +@pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls_qslider( qtbot, create_layer_controls, layer_type_with_data, capsys ): @@ -330,7 +330,7 @@ def test_create_layer_controls_qslider( # check QAbstractSlider by changing value with `setValue` from minimum value to maximum for qslider in ctrl.findChildren(QAbstractSlider): if isinstance(qslider.minimum(), float): - if getattr(qslider, "_valuesChanged", None): + if getattr(qslider, '_valuesChanged', None): # create a list of tuples in the case the slider is ranged # from (minimum, minimum) to (maximum, maximum) + # from (minimum, maximum) to (minimum, minimum) @@ -350,7 +350,7 @@ def test_create_layer_controls_qslider( else: value_range = np.linspace(qslider.minimum(), qslider.maximum()) else: - if getattr(qslider, "_valuesChanged", None): + if getattr(qslider, '_valuesChanged', None): # create a list of tuples in the case the slider is ranged # from (minimum, minimum) to (maximum, maximum) + # from (minimum, maximum) to (minimum, minimum) @@ -379,7 +379,7 @@ def test_create_layer_controls_qslider( captured = capsys.readouterr() assert not captured.out assert not captured.err - if getattr(qslider, "_valuesChanged", None): + if getattr(qslider, '_valuesChanged', None): assert qslider.value()[0] == qslider.minimum() else: assert qslider.value() == qslider.maximum() @@ -399,7 +399,7 @@ def test_create_layer_controls_qslider( ], ) @pytest.mark.qt_no_exception_capture -@pytest.mark.skipif(os.environ.get("MIN_REQ", "0") == "1", reason="min req") +@pytest.mark.skipif(os.environ.get('MIN_REQ', '0') == '1', reason='min req') def test_create_layer_controls_qcolorswatchedit( qtbot, create_layer_controls, layer_type_with_data, capsys ): @@ -414,12 +414,12 @@ def test_create_layer_controls_qcolorswatchedit( lineedit = qcolorswatchedit.line_edit colorswatch = qcolorswatchedit.color_swatch colors = [ - ("white", "white", np.array([1.0, 1.0, 1.0, 1.0])), - ("black", "black", np.array([0.0, 0.0, 0.0, 1.0])), + ('white', 'white', np.array([1.0, 1.0, 1.0, 1.0])), + ('black', 'black', np.array([0.0, 0.0, 0.0, 1.0])), # check autocompletion `bla` -> `black` - ("bla", "black", np.array([0.0, 0.0, 0.0, 1.0])), + ('bla', 'black', np.array([0.0, 0.0, 0.0, 1.0])), # check that setting an invalid color makes it fallback to the previous value - ("invalid_value", "black", np.array([0.0, 0.0, 0.0, 1.0])), + ('invalid_value', 'black', np.array([0.0, 0.0, 0.0, 1.0])), ] for color, expected_color, expected_array in colors: lineedit.clear() diff --git a/napari/_qt/layer_controls/_tests/test_qt_tracks_layer.py b/napari/_qt/layer_controls/_tests/test_qt_tracks_layer.py index c1aadfd727c..53389e9e07c 100644 --- a/napari/_qt/layer_controls/_tests/test_qt_tracks_layer.py +++ b/napari/_qt/layer_controls/_tests/test_qt_tracks_layer.py @@ -1,5 +1,3 @@ -from typing import Dict, List - import numpy as np import pytest from qtpy.QtCore import Qt @@ -14,7 +12,7 @@ def null_data() -> np.ndarray: @pytest.fixture -def properties() -> Dict[str, List]: +def properties() -> dict[str, list]: return { 'track_id': [0, 0], 'time': [0, 0], diff --git a/napari/_qt/layer_controls/qt_image_controls.py b/napari/_qt/layer_controls/qt_image_controls.py index eb5e8c0b5ef..53612703a24 100644 --- a/napari/_qt/layer_controls/qt_image_controls.py +++ b/napari/_qt/layer_controls/qt_image_controls.py @@ -160,7 +160,7 @@ def __init__(self, layer) -> None: colormap_layout = QHBoxLayout() if hasattr(self.layer, 'rgb') and self.layer.rgb: - colormap_layout.addWidget(QLabel("RGB")) + colormap_layout.addWidget(QLabel('RGB')) self.colormapComboBox.setVisible(False) self.colorbarLabel.setVisible(False) else: @@ -285,7 +285,11 @@ def _on_interpolation_change(self, event): """ interp_string = event.value.value - with self.layer.events.interpolation.blocker(), self.layer.events.interpolation2d.blocker(), self.layer.events.interpolation3d.blocker(): + with ( + self.layer.events.interpolation.blocker(), + self.layer.events.interpolation2d.blocker(), + self.layer.events.interpolation3d.blocker(), + ): if self.interpComboBox.findText(interp_string) == -1: self.interpComboBox.addItem(interp_string) self.interpComboBox.setCurrentText(interp_string) diff --git a/napari/_qt/layer_controls/qt_image_controls_base.py b/napari/_qt/layer_controls/qt_image_controls_base.py index 797c947c8f3..c6f7e6502bb 100644 --- a/napari/_qt/layer_controls/qt_image_controls_base.py +++ b/napari/_qt/layer_controls/qt_image_controls_base.py @@ -81,7 +81,7 @@ def __init__(self, layer: Image) -> None: ) comboBox = QtColormapComboBox(self) - comboBox.setObjectName("colormapComboBox") + comboBox.setObjectName('colormapComboBox') comboBox._allitems = set(self.layer.colormaps) for name, cm in AVAILABLE_COLORMAPS.items(): @@ -110,7 +110,7 @@ def __init__(self, layer: Image) -> None: connect_setattr( self.contrastLimitsSlider.valueChanged, self.layer, - "contrast_limits", + 'contrast_limits', ) connect_setattr( self.contrastLimitsSlider.rangeChanged, @@ -224,9 +224,9 @@ def __init__(self, layer: Image, parent=None) -> None: auto_btn.setCheckable(True) auto_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) once_btn.clicked.connect(lambda: auto_btn.setChecked(False)) - connect_no_arg(once_btn.clicked, layer, "reset_contrast_limits") - connect_setattr(auto_btn.toggled, layer, "_keep_auto_contrast") - connect_no_arg(auto_btn.clicked, layer, "reset_contrast_limits") + connect_no_arg(once_btn.clicked, layer, 'reset_contrast_limits') + connect_setattr(auto_btn.toggled, layer, '_keep_auto_contrast') + connect_no_arg(auto_btn.clicked, layer, 'reset_contrast_limits') self.layout().addWidget(once_btn) self.layout().addWidget(auto_btn) @@ -246,9 +246,9 @@ def __init__(self, layer: Image, parent=None) -> None: self.slider.setSingleStep(10**-decimals) self.slider.setValue(layer.contrast_limits) - connect_setattr(self.slider.valueChanged, layer, "contrast_limits") + connect_setattr(self.slider.valueChanged, layer, 'contrast_limits') connect_setattr( - self.slider.rangeChanged, layer, "contrast_limits_range" + self.slider.rangeChanged, layer, 'contrast_limits_range' ) def reset(): @@ -260,9 +260,9 @@ def reset(): self.slider.setDecimals(decimals_) self.slider.setSingleStep(10**-decimals_) - reset_btn = QPushButton("reset") - reset_btn.setObjectName("reset_clims_button") - reset_btn.setToolTip(trans._("autoscale contrast to data range")) + reset_btn = QPushButton('reset') + reset_btn.setObjectName('reset_clims_button') + reset_btn.setToolTip(trans._('autoscale contrast to data range')) reset_btn.setFixedWidth(45) reset_btn.clicked.connect(reset) self._layout.addWidget( @@ -273,10 +273,10 @@ def reset(): # unsigned integer type (it's unclear what range should be set) # so we don't show create it at all. if np.issubdtype(normalize_dtype(layer.dtype), np.integer): - range_btn = QPushButton("full range") - range_btn.setObjectName("full_clim_range_button") + range_btn = QPushButton('full range') + range_btn.setObjectName('full_clim_range_button') range_btn.setToolTip( - trans._("set contrast range to full bit-depth") + trans._('set contrast range to full bit-depth') ) range_btn.setFixedWidth(75) range_btn.clicked.connect(layer.reset_contrast_limits_range) diff --git a/napari/_qt/layer_controls/qt_labels_controls.py b/napari/_qt/layer_controls/qt_labels_controls.py index 22cc936a9b3..8a7711d5844 100644 --- a/napari/_qt/layer_controls/qt_labels_controls.py +++ b/napari/_qt/layer_controls/qt_labels_controls.py @@ -175,7 +175,7 @@ def __init__(self, layer) -> None: selectedColorCheckbox = QCheckBox() selectedColorCheckbox.setToolTip( - trans._("Display only selected label") + trans._('Display only selected label') ) selectedColorCheckbox.stateChanged.connect(self.toggle_selected_mode) self.selectedColorCheckbox = selectedColorCheckbox @@ -362,7 +362,7 @@ def _on_mode_change(self, event): elif mode == Mode.ERASE: self.erase_button.setChecked(True) elif mode != Mode.TRANSFORM: - raise ValueError(trans._("Mode not recognized")) + raise ValueError(trans._('Mode not recognized')) def changeRendering(self, text): """Change rendering mode for image display. diff --git a/napari/_qt/layer_controls/qt_layer_controls_container.py b/napari/_qt/layer_controls/qt_layer_controls_container.py index 674488fe99d..dde49cc0bbf 100644 --- a/napari/_qt/layer_controls/qt_layer_controls_container.py +++ b/napari/_qt/layer_controls/qt_layer_controls_container.py @@ -91,12 +91,12 @@ class QtLayerControlsContainer(QStackedWidget): def __init__(self, viewer) -> None: super().__init__() - self.setProperty("emphasized", True) + self.setProperty('emphasized', True) self.viewer = viewer self.setMouseTracking(True) self.empty_widget = QFrame() - self.empty_widget.setObjectName("empty_controls_widget") + self.empty_widget.setObjectName('empty_controls_widget') self.widgets = {} self.addWidget(self.empty_widget) self.setCurrentWidget(self.empty_widget) diff --git a/napari/_qt/layer_controls/qt_points_controls.py b/napari/_qt/layer_controls/qt_points_controls.py index 5839f09c0bb..293ede1a77e 100644 --- a/napari/_qt/layer_controls/qt_points_controls.py +++ b/napari/_qt/layer_controls/qt_points_controls.py @@ -102,7 +102,7 @@ def __init__(self, layer) -> None: sld = QSlider(Qt.Orientation.Horizontal) sld.setToolTip( trans._( - "Change the size of currently selected points and any added afterwards." + 'Change the size of currently selected points and any added afterwards.' ) ) sld.setFocusPolicy(Qt.FocusPolicy.NoFocus) @@ -134,7 +134,7 @@ def __init__(self, layer) -> None: sym_cb = QComboBox() sym_cb.setToolTip( trans._( - "Change the symbol of currently selected points and any added afterwards." + 'Change the symbol of currently selected points and any added afterwards.' ) ) current_index = 0 @@ -247,7 +247,7 @@ def _on_mode_change(self, event): elif mode == Mode.PAN_ZOOM: self.panzoom_button.setChecked(True) elif mode != Mode.TRANSFORM: - raise ValueError(trans._("Mode not recognized {mode}", mode=mode)) + raise ValueError(trans._('Mode not recognized {mode}', mode=mode)) def changeCurrentSymbol(self, text): """Change marker symbol of the points on the layer model. diff --git a/napari/_qt/layer_controls/qt_shapes_controls.py b/napari/_qt/layer_controls/qt_shapes_controls.py index a434bb7b22f..3ac5ddbdf09 100644 --- a/napari/_qt/layer_controls/qt_shapes_controls.py +++ b/napari/_qt/layer_controls/qt_shapes_controls.py @@ -164,18 +164,18 @@ def _radio_button( return btn self.select_button = _radio_button( - layer, 'select', Mode.SELECT, "activate_select_mode" + layer, 'select', Mode.SELECT, 'activate_select_mode' ) self.direct_button = _radio_button( - layer, 'direct', Mode.DIRECT, "activate_direct_mode" + layer, 'direct', Mode.DIRECT, 'activate_direct_mode' ) self.panzoom_button = _radio_button( layer, 'pan', Mode.PAN_ZOOM, - "activate_shapes_pan_zoom_mode", + 'activate_shapes_pan_zoom_mode', extra_tooltip_text=trans._('(or hold Space)'), checked=True, ) @@ -184,44 +184,44 @@ def _radio_button( layer, 'rectangle', Mode.ADD_RECTANGLE, - "activate_add_rectangle_mode", + 'activate_add_rectangle_mode', ) self.ellipse_button = _radio_button( layer, 'ellipse', Mode.ADD_ELLIPSE, - "activate_add_ellipse_mode", + 'activate_add_ellipse_mode', ) self.line_button = _radio_button( - layer, 'line', Mode.ADD_LINE, "activate_add_line_mode" + layer, 'line', Mode.ADD_LINE, 'activate_add_line_mode' ) self.path_button = _radio_button( - layer, 'path', Mode.ADD_PATH, "activate_add_path_mode" + layer, 'path', Mode.ADD_PATH, 'activate_add_path_mode' ) self.polygon_button = _radio_button( layer, 'polygon', Mode.ADD_POLYGON, - "activate_add_polygon_mode", + 'activate_add_polygon_mode', ) self.polygon_lasso_button = _radio_button( layer, 'polygon_lasso', Mode.ADD_POLYGON_LASSO, - "activate_add_polygon_lasso_mode", + 'activate_add_polygon_lasso_mode', ) self.vertex_insert_button = _radio_button( layer, 'vertex_insert', Mode.VERTEX_INSERT, - "activate_vertex_insert_mode", + 'activate_vertex_insert_mode', ) self.vertex_remove_button = _radio_button( layer, 'vertex_remove', Mode.VERTEX_REMOVE, - "activate_vertex_remove_mode", + 'activate_vertex_remove_mode', ) self.move_front_button = QtModePushButton( @@ -250,7 +250,7 @@ def _radio_button( 'delete_shape', slot=self.layer.remove_selected, tooltip=trans._( - "Delete selected shapes ({shortcut})", + 'Delete selected shapes ({shortcut})', shortcut=Shortcut('Backspace').platform, ), ) diff --git a/napari/_qt/menus/__init__.py b/napari/_qt/menus/__init__.py index 74c2108fe9a..fcf3d1d0760 100644 --- a/napari/_qt/menus/__init__.py +++ b/napari/_qt/menus/__init__.py @@ -1,9 +1,7 @@ from napari._qt.menus.debug_menu import DebugMenu -from napari._qt.menus.plugins_menu import PluginsMenu from napari._qt.menus.window_menu import WindowMenu __all__ = [ 'DebugMenu', - 'PluginsMenu', 'WindowMenu', ] diff --git a/napari/_qt/menus/_tests/test_plugins_menu.py b/napari/_qt/menus/_tests/test_plugins_menu.py index fc63e7b444b..9492beda0cf 100644 --- a/napari/_qt/menus/_tests/test_plugins_menu.py +++ b/napari/_qt/menus/_tests/test_plugins_menu.py @@ -1,17 +1,98 @@ -import sys +import importlib +import pytest +from app_model.types import MenuItem, SubmenuItem from npe2 import DynamicPlugin from qtpy.QtWidgets import QWidget +from napari._app_model import get_app +from napari._app_model.constants import CommandId, MenuId +from napari._qt._qapp_model.qactions import _plugins, init_qactions +from napari._qt._qplugins._qnpe2 import _toggle_or_get_widget +from napari._tests.utils import skip_local_popups + class DummyWidget(QWidget): pass -def test_plugin_display_name_use_for_multiple_widgets( +@skip_local_popups +def test_toggle_or_get_widget( + make_napari_viewer, + tmp_plugin: DynamicPlugin, + qtbot, +): + """Check `_toggle_or_get_widget` changes visibility correctly.""" + widget_name = 'Widget' + plugin_name = 'tmp_plugin' + full_name = 'Widget (Temp Plugin)' + + @tmp_plugin.contribute.widget(display_name=widget_name) + def widget1(): + return DummyWidget() + + app = get_app() + # Viewer needs to be visible + viewer = make_napari_viewer(show=True) + + # Trigger the action, opening the widget: `Widget 1` + app.commands.execute_command('tmp_plugin:Widget') + widget = viewer.window._dock_widgets[full_name] + # Widget takes some time to appear + qtbot.waitUntil(widget.isVisible) + assert widget.isVisible() + + # Define not visible callable + def widget_not_visible(): + return not widget.isVisible() + + # Hide widget + _toggle_or_get_widget( + plugin=plugin_name, + widget_name=widget_name, + full_name=full_name, + ) + qtbot.waitUntil(widget_not_visible) + assert not widget.isVisible() + + # Make widget appear again + _toggle_or_get_widget( + plugin=plugin_name, + widget_name=widget_name, + full_name=full_name, + ) + qtbot.waitUntil(widget.isVisible) + assert widget.isVisible() + + +def test_plugin_single_widget_menu( make_napari_viewer, tmp_plugin: DynamicPlugin ): - """For plugin with more than two widgets, should use plugin_display for building the menu""" + """Test single plugin widgets get added to the window menu correctly.""" + + @tmp_plugin.contribute.widget(display_name='Widget 1') + def widget1(): + return DummyWidget() + + app = get_app() + viewer = make_napari_viewer() + + assert tmp_plugin.display_name == 'Temp Plugin' + plugin_menu = app.menus.get_menu('napari/plugins') + assert plugin_menu[0].command.title == 'Widget 1 (Temp Plugin)' + assert len(viewer.window._dock_widgets) == 0 + assert 'tmp_plugin:Widget 1' in app.commands + # trigger the action, opening the widget: `Widget 1` + app.commands.execute_command('tmp_plugin:Widget 1') + assert len(viewer.window._dock_widgets) == 1 + assert 'Widget 1 (Temp Plugin)' in viewer.window._dock_widgets + + +def test_plugin_multiple_widget_menu( + make_napari_viewer, + tmp_plugin: DynamicPlugin, +): + """Check plugin with >1 widgets added with submenu and uses 'display_name'.""" @tmp_plugin.contribute.widget(display_name='Widget 1') def widget1(): @@ -21,19 +102,103 @@ def widget1(): def widget2(): return DummyWidget() - assert tmp_plugin.display_name == 'Temp Plugin' + app = get_app() viewer = make_napari_viewer() - # the submenu should use the `display_name` from manifest - plugin_action_menu = viewer.window.plugins_menu.actions()[3].menu() - assert plugin_action_menu.title() == tmp_plugin.display_name - # Now ensure that the actions are still correct - # trigger the action, opening the first widget: `Widget 1` + + assert tmp_plugin.display_name == 'Temp Plugin' + plugin_menu = app.menus.get_menu('napari/plugins') + assert plugin_menu[0].title == tmp_plugin.display_name + plugin_submenu = app.menus.get_menu('napari/plugins/tmp_plugin') + assert plugin_submenu[0].command.title == 'Widget 1' assert len(viewer.window._dock_widgets) == 0 - plugin_action_menu.actions()[0].trigger() + assert 'tmp_plugin:Widget 1' in app.commands + # Trigger the action, opening the first widget: `Widget 1` + app.commands.execute_command('tmp_plugin:Widget 1') assert len(viewer.window._dock_widgets) == 1 - assert ( - next(iter(viewer.window._dock_widgets.data)) == 'Widget 1 (tmp_plugin)' + assert 'Widget 1 (Temp Plugin)' in viewer.window._dock_widgets + + +def test_plugin_menu_plugin_state_change( + make_napari_viewer, + tmp_plugin: DynamicPlugin, +): + """Check plugin menu items correct after a plugin changes state.""" + app = get_app() + pm = tmp_plugin.plugin_manager + + # Register plugin q actions + init_qactions() + # Check only `Q_PLUGINS_ACTIONS` in plugin menu before any plugins registered + plugins_menu = app.menus.get_menu(MenuId.MENUBAR_PLUGINS) + assert len(plugins_menu) == len(_plugins.Q_PLUGINS_ACTIONS) + + @tmp_plugin.contribute.widget(display_name='Widget 1') + def widget1(): + """Dummy widget.""" + + @tmp_plugin.contribute.widget(display_name='Widget 2') + def widget2(): + """Dummy widget.""" + + # Configures `app`, registers actions and initializes plugins + make_napari_viewer() + + plugins_menu = app.menus.get_menu(MenuId.MENUBAR_PLUGINS) + assert len(plugins_menu) == len(_plugins.Q_PLUGINS_ACTIONS) + 1 + assert isinstance(plugins_menu[-1], SubmenuItem) + assert plugins_menu[-1].title == tmp_plugin.display_name + plugin_submenu = app.menus.get_menu(MenuId.MENUBAR_PLUGINS + '/tmp_plugin') + assert len(plugin_submenu) == 2 + assert isinstance(plugin_submenu[0], MenuItem) + assert plugin_submenu[0].command.title == 'Widget 1' + assert 'tmp_plugin:Widget 1' in app.commands + + # Disable plugin + pm.disable(tmp_plugin.name) + with pytest.raises(KeyError): + app.menus.get_menu(MenuId.MENUBAR_PLUGINS + '/tmp_plugin') + assert 'tmp_plugin:Widget 1' not in app.commands + + # Enable plugin + pm.enable(tmp_plugin.name) + samples_sub_menu = app.menus.get_menu( + MenuId.MENUBAR_PLUGINS + '/tmp_plugin' ) + assert len(samples_sub_menu) == 2 + assert 'tmp_plugin:Widget 1' in app.commands + + +def test_plugin_widget_checked( + make_napari_viewer, qtbot, tmp_plugin: DynamicPlugin +): + """Check widget toggling/hiding updates check mark correctly.""" + + @tmp_plugin.contribute.widget(display_name='Widget') + def widget_contrib(): + return DummyWidget() + + app = get_app() + viewer = make_napari_viewer() + + assert 'tmp_plugin:Widget' in app.commands + widget_action = viewer.window.plugins_menu.findAction('tmp_plugin:Widget') + assert not widget_action.isChecked() + # Trigger the action, opening the widget + widget_action.trigger() + assert widget_action.isChecked() + assert 'Widget (Temp Plugin)' in viewer.window._dock_widgets + widget = viewer.window._dock_widgets['Widget (Temp Plugin)'] + + # Hide widget + widget.title.hide_button.click() + # Run `_on_about_to_show`, which is called on `aboutToShow`` event, + # to update checked status + viewer.window.plugins_menu._on_about_to_show() + assert not widget_action.isChecked() + + # Trigger the action again to open widget and test item checked + widget_action.trigger() + assert widget_action.isChecked() def test_import_plugin_manager(): @@ -42,36 +207,32 @@ def test_import_plugin_manager(): assert QtPluginDialog is not None -def test_plugin_manager(make_napari_viewer, monkeypatch, qtbot): +def test_plugin_manager(make_napari_viewer): """Test that the plugin manager is accessible from the viewer""" viewer = make_napari_viewer() + assert _plugins._plugin_manager_dialog_avail() - plugins_menu = viewer.window.plugins_menu - assert plugins_menu._plugin_manager_dialog_cls() is not None - - actions = plugins_menu.actions() - for action in actions: - if action.text() == "Plugin Manager": - break - else: # pragma: no cover - found = [action.text() for action in actions] - raise AssertionError( - f'Plugin Manager menu item not found. Only found: {", ".join(found)}' - ) - - -def test_no_plugin_manager(make_napari_viewer, monkeypatch): - """Test that the plugin manager is not accessible from the viewer when not installed""" - monkeypatch.setitem(sys.modules, 'napari_plugin_manager', None) - monkeypatch.setitem( - sys.modules, 'napari_plugin_manager.qt_plugin_dialog', None + # Check plugin install action is visible + plugin_install_action = viewer.window.plugins_menu.findAction( + CommandId.DLG_PLUGIN_INSTALL, ) - viewer = make_napari_viewer() + assert plugin_install_action.isVisible() + + +def test_no_plugin_manager(monkeypatch, make_napari_viewer): + """Test that the plugin manager menu item is hidden when not installed.""" - plugins_menu = viewer.window.plugins_menu - assert plugins_menu._plugin_manager_dialog_cls() is None + def mockreturn(*args): + return None - actions = plugins_menu.actions() - for action in actions: - if action.text() == "Plugin Manager": - raise AssertionError(f"Plugin Manager was found: {action}") + monkeypatch.setattr('importlib.util.find_spec', mockreturn) + # We need to reload `_plugins` for the monkeypatching to work + importlib.reload(_plugins) + + assert not _plugins._plugin_manager_dialog_avail() + # Check plugin install action is not visible + viewer = make_napari_viewer() + plugin_install_action = viewer.window.plugins_menu.findAction( + CommandId.DLG_PLUGIN_INSTALL + ) + assert not plugin_install_action.isVisible() diff --git a/napari/_qt/menus/_tests/test_util.py b/napari/_qt/menus/_tests/test_util.py index 5441c1ebab6..41b3d8cf36a 100644 --- a/napari/_qt/menus/_tests/test_util.py +++ b/napari/_qt/menus/_tests/test_util.py @@ -10,9 +10,9 @@ def test_populate_menu_create(qtbot): mock = MagicMock() menu = QMenu() - populate_menu(menu, [{"text": "test", "slot": mock}]) + populate_menu(menu, [{'text': 'test', 'slot': mock}]) assert len(menu.actions()) == 1 - assert menu.actions()[0].text() == "test" + assert menu.actions()[0].text() == 'test' assert menu.actions()[0].isCheckable() is False with qtbot.waitSignal(menu.actions()[0].triggered): menu.actions()[0].trigger() @@ -24,9 +24,9 @@ def test_populate_menu_create_checkable(qtbot): mock = MagicMock() menu = QMenu() - populate_menu(menu, [{"text": "test", "slot": mock, "checkable": True}]) + populate_menu(menu, [{'text': 'test', 'slot': mock, 'checkable': True}]) assert len(menu.actions()) == 1 - assert menu.actions()[0].text() == "test" + assert menu.actions()[0].text() == 'test' assert menu.actions()[0].isCheckable() is True with qtbot.waitSignal(menu.actions()[0].triggered): menu.actions()[0].trigger() diff --git a/napari/_qt/menus/_util.py b/napari/_qt/menus/_util.py index 17e36182516..209aa15686b 100644 --- a/napari/_qt/menus/_util.py +++ b/napari/_qt/menus/_util.py @@ -1,5 +1,5 @@ import contextlib -from typing import TYPE_CHECKING, Callable, ClassVar, List, Union +from typing import TYPE_CHECKING, Callable, ClassVar, Union from qtpy.QtWidgets import QAction, QMenu @@ -27,7 +27,7 @@ class ActionDict(TypedDict): class MenuDict(TypedDict): menu: str # these are optional - items: List[ActionDict] + items: list[ActionDict] # note: TypedDict still doesn't have the concept of "optional keys" # so we add in generic `dict` for type checking. @@ -35,7 +35,7 @@ class MenuDict(TypedDict): MenuItem = Union[MenuDict, ActionDict, dict] -def populate_menu(menu: QMenu, actions: List['MenuItem']): +def populate_menu(menu: QMenu, actions: list['MenuItem']): """Populate a QMenu from a declarative list of QAction dicts. Parameters @@ -75,7 +75,7 @@ def populate_menu(menu: QMenu, actions: List['MenuItem']): if not ax: menu.addSeparator() continue - if not ax.get("when", True): + if not ax.get('when', True): continue if 'menu' in ax: sub = ax['menu'] @@ -84,11 +84,11 @@ def populate_menu(menu: QMenu, actions: List['MenuItem']): sub.setParent(menu) else: sub = menu.addMenu(sub) - populate_menu(sub, ax.get("items", [])) + populate_menu(sub, ax.get('items', [])) continue action: QAction = menu.addAction(ax['text']) if 'slot' in ax: - if ax.get("checkable"): + if ax.get('checkable'): action.toggled.connect(ax['slot']) else: action.triggered.connect(ax['slot']) @@ -96,9 +96,9 @@ def populate_menu(menu: QMenu, actions: List['MenuItem']): action.setStatusTip(ax.get('statusTip', '')) if 'menuRole' in ax: action.setMenuRole(ax['menuRole']) - if ax.get("checkable"): + if ax.get('checkable'): action.setCheckable(True) - action.setChecked(ax.get("checked", False)) + action.setChecked(ax.get('checked', False)) if 'check_on' in ax: emitter = ax['check_on'] @@ -115,7 +115,7 @@ class NapariMenu(QMenu): close. """ - _INSTANCES: ClassVar[List['NapariMenu']] = [] + _INSTANCES: ClassVar[list['NapariMenu']] = [] def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/napari/_qt/menus/debug_menu.py b/napari/_qt/menus/debug_menu.py index a9aa9c36447..4d545f1ea28 100644 --- a/napari/_qt/menus/debug_menu.py +++ b/napari/_qt/menus/debug_menu.py @@ -23,7 +23,7 @@ class DebugMenu(NapariMenu): def __init__(self, window: 'Window') -> None: self._win = window super().__init__(trans._('&Debug'), window._qt_window) - self._perf_menu = NapariMenu(trans._("Performance Trace"), self) + self._perf_menu = NapariMenu(trans._('Performance Trace'), self) ACTIONS = [ { @@ -66,11 +66,11 @@ def _start_trace_dialog(self): viewer, # parent trans._('Record performance trace file'), # caption hist[0], # directory in PyQt, dir in PySide - filter=trans._("Trace Files (*.json)"), + filter=trans._('Trace Files (*.json)'), ) if filename: - if not filename.endswith(".json"): - filename += ".json" + if not filename.endswith('.json'): + filename += '.json' # Schedule this to avoid bogus "MetaCall" event for the entire # time the file dialog was up. diff --git a/napari/_qt/menus/plugins_menu.py b/napari/_qt/menus/plugins_menu.py deleted file mode 100644 index 14cb2525516..00000000000 --- a/napari/_qt/menus/plugins_menu.py +++ /dev/null @@ -1,139 +0,0 @@ -from itertools import chain -from logging import getLogger -from typing import TYPE_CHECKING, Sequence, Union - -from qtpy.QtWidgets import QAction - -from napari._qt.dialogs.qt_plugin_report import QtPluginErrReporter -from napari._qt.menus._util import NapariMenu -from napari.plugins import _npe2 -from napari.utils.translations import trans - -if TYPE_CHECKING: - from napari._qt.qt_main_window import Window - - -logger = getLogger(__name__) - - -class PluginsMenu(NapariMenu): - def __init__(self, window: 'Window') -> None: - self._win = window - super().__init__(trans._('&Plugins'), window._qt_window) - - from napari.plugins import plugin_manager - - _npe2.index_npe1_adapters() - - plugin_manager.discover_widgets() - plugin_manager.events.disabled.connect( - self._remove_unregistered_widget - ) - plugin_manager.events.registered.connect(self._add_registered_widget) - plugin_manager.events.unregistered.connect( - self._remove_unregistered_widget - ) - self._build() - - def _build(self, event=None): - self.clear() - if self._plugin_manager_dialog_cls() is not None: - action = self.addAction(trans._("Plugin Manager")) - action.triggered.connect(self._show_plugin_install_dialog) - action = self.addAction(trans._("Plugin Errors...")) - action.setStatusTip( - trans._( - 'Review stack traces for plugin exceptions and notify developers' - ) - ) - action.triggered.connect(self._show_plugin_err_reporter) - self.addSeparator() - - # Add a menu item (QAction) for each available plugin widget - self._add_registered_widget(call_all=True) - - def _remove_unregistered_widget(self, event): - for action in self.actions(): - if event.value in action.text(): - self.removeAction(action) - self._win._remove_dock_widget(event=event) - - def _add_registered_widget(self, event=None, call_all=False): - from napari.plugins import plugin_manager - - # eg ('dock', ('my_plugin', {'My widget': MyWidget})) - for hook_type, (plugin_name, widgets) in chain( - _npe2.widget_iterator(), plugin_manager.iter_widgets() - ): - if call_all or event.value == plugin_name: - self._add_plugin_actions(hook_type, plugin_name, widgets) - - def _add_plugin_actions( - self, hook_type: str, plugin_name: str, widgets: Sequence[str] - ): - from napari.plugins import menu_item_template - - multiprovider = len(widgets) > 1 - if multiprovider: - # use display_name if npe2 plugin - from npe2 import plugin_manager as pm - - try: - plugin_display_name = pm.get_manifest(plugin_name).display_name - except KeyError: - plugin_display_name = plugin_name - menu = NapariMenu(plugin_display_name, self) - self.addMenu(menu) - else: - menu = self - - for wdg_name in widgets: - key = (plugin_name, wdg_name) - if multiprovider: - action = QAction(wdg_name.replace("&", "&&"), parent=self) - else: - full_name = menu_item_template.format(*key) - action = QAction(full_name.replace("&", "&&"), parent=self) - - def _add_toggle_widget(*, key=key, hook_type=hook_type): - full_name = menu_item_template.format(*key) - if full_name in self._win._dock_widgets: - dock_widget = self._win._dock_widgets[full_name] - if dock_widget.isVisible(): - dock_widget.hide() - else: - dock_widget.show() - return - - if hook_type == 'dock': - self._win.add_plugin_dock_widget(*key) - else: - self._win._add_plugin_function_widget(*key) - - action.setCheckable(True) - # check that this wasn't added to the menu already - actions = [a.text() for a in menu.actions()] - if action.text() not in actions: - menu.addAction(action) - action.triggered.connect(_add_toggle_widget) - - def _plugin_manager_dialog_cls(self) -> Union[type, None]: - """Return the plugin manager class, if available.""" - try: - # TODO: Register via plugin system? - from napari_plugin_manager.qt_plugin_dialog import QtPluginDialog - except ImportError as exc: - logger.debug("QtPluginDialog not available", exc_info=exc) - return None - else: - return QtPluginDialog - - def _show_plugin_install_dialog(self): - """Show dialog that allows users to sort the call order of plugins.""" - # We don't check whether the class is not None, because this - # function should only be connected in that case. - self._plugin_manager_dialog_cls()(self._win._qt_window).exec_() - - def _show_plugin_err_reporter(self): - """Show dialog that allows users to review and report plugin errors.""" - QtPluginErrReporter(parent=self._win._qt_window).exec_() diff --git a/napari/_qt/menus/window_menu.py b/napari/_qt/menus/window_menu.py index e9c13fd8922..3cc0808fda4 100644 --- a/napari/_qt/menus/window_menu.py +++ b/napari/_qt/menus/window_menu.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from napari._qt.menus._util import NapariMenu, populate_menu from napari.utils.translations import trans @@ -11,5 +11,5 @@ class WindowMenu(NapariMenu): def __init__(self, window: 'Window') -> None: super().__init__(trans._('&Window'), window._qt_window) - ACTIONS: List[MenuItem] = [] + ACTIONS: list[MenuItem] = [] populate_menu(self, ACTIONS) diff --git a/napari/_qt/perf/_tests/test_perf.py b/napari/_qt/perf/_tests/test_perf.py index 94d12fbdd0a..0248a358f20 100644 --- a/napari/_qt/perf/_tests/test_perf.py +++ b/napari/_qt/perf/_tests/test_perf.py @@ -25,13 +25,13 @@ """ CONFIG = { - "trace_qt_events": True, - "trace_file_on_start": '', - "trace_callables": ["chunk_loader"], - "callable_lists": { - "chunk_loader": [ - "napari.components.experimental.chunk._loader.ChunkLoader.load_request", - "napari.components.experimental.chunk._loader.ChunkLoader._on_done", + 'trace_qt_events': True, + 'trace_file_on_start': '', + 'trace_callables': ['chunk_loader'], + 'callable_lists': { + 'chunk_loader': [ + 'napari.components.experimental.chunk._loader.ChunkLoader.load_request', + 'napari.components.experimental.chunk._loader.ChunkLoader._on_done', ] }, } @@ -39,8 +39,8 @@ @pytest.fixture() def perf_config(tmp_path: Path): - trace_path = tmp_path / "trace.json" - config_path = tmp_path / "perfmon.json" + trace_path = tmp_path / 'trace.json' + config_path = tmp_path / 'perfmon.json' CONFIG['trace_file_on_start'] = str(trace_path) config_path.write_text(json.dumps(CONFIG)) @@ -50,17 +50,17 @@ def perf_config(tmp_path: Path): @pytest.fixture() def perfmon_script(tmp_path): script = PERFMON_SCRIPT - if "coverage" in sys.modules: - script_path = tmp_path / "script.py" - with script_path.open("w") as f: + if 'coverage' in sys.modules: + script_path = tmp_path / 'script.py' + with script_path.open('w') as f: f.write(script) - return "-m", "coverage", "run", str(script_path) - return "-c", script + return '-m', 'coverage', 'run', str(script_path) + return '-c', script @skip_on_win_ci @skip_local_popups -@pytest.mark.usefixtures("qapp") +@pytest.mark.usefixtures('qapp') def test_trace_on_start(tmp_path: Path, perf_config, perfmon_script): """Make sure napari can write a perfmon trace file.""" @@ -70,8 +70,8 @@ def test_trace_on_start(tmp_path: Path, perf_config, perfmon_script): subprocess.run([sys.executable, *perfmon_script], env=env, check=True) # Make sure file exists and is not empty. - assert perf_config.trace_path.exists(), "Trace file not written" - assert perf_config.trace_path.stat().st_size > 0, "Trace file is empty" + assert perf_config.trace_path.exists(), 'Trace file not written' + assert perf_config.trace_path.stat().st_size > 0, 'Trace file is empty' # Assert every event contains every important field. with perf_config.trace_path.open() as infile: @@ -88,18 +88,18 @@ def test_qt_performance(qtbot, monkeypatch): qtbot.addWidget(widget) mock = MagicMock() data = [ - ("test1", MockTimer(1, 1)), - ("test2", MockTimer(20, 120)), - ("test1", MockTimer(70, 90)), - ("test2", MockTimer(50, 220)), + ('test1', MockTimer(1, 1)), + ('test2', MockTimer(20, 120)), + ('test1', MockTimer(70, 90)), + ('test2', MockTimer(50, 220)), ] mock.timers.items = MagicMock(return_value=data) monkeypatch.setattr(qt_performance.perf, 'timers', mock) - assert widget.log.toPlainText() == "" + assert widget.log.toPlainText() == '' widget.update() assert widget.log.toPlainText() == ' 120ms test2\n 220ms test2\n' - widget._change_thresh("150") - assert widget.log.toPlainText() == "" + widget._change_thresh('150') + assert widget.log.toPlainText() == '' widget.update() assert widget.log.toPlainText() == ' 220ms test2\n' diff --git a/napari/_qt/perf/qt_event_tracing.py b/napari/_qt/perf/qt_event_tracing.py index 8bd75c4b352..c460834793b 100644 --- a/napari/_qt/perf/qt_event_tracing.py +++ b/napari/_qt/perf/qt_event_tracing.py @@ -35,7 +35,7 @@ def notify(self, receiver, event): timer_name = _get_event_label(receiver, event) # Time the event while we handle it. - with perf.perf_timer(timer_name, "qt_event"): + with perf.perf_timer(timer_name, 'qt_event'): return QApplication.notify(self, receiver, event) @@ -71,7 +71,7 @@ def as_string(self, event: QEvent.Type) -> str: try: return self.string_name[event] except KeyError: - return trans._("UnknownEvent:{event}", event=event) + return trans._('UnknownEvent:{event}', event=event) EVENT_TYPES = EventTypes() @@ -118,7 +118,7 @@ def _get_event_label(receiver: QWidget, event: QEvent) -> str: object_name = None if object_name: - return f"{event_str}:{object_name}" + return f'{event_str}:{object_name}' # There was no object (pretty common). return event_str diff --git a/napari/_qt/perf/qt_performance.py b/napari/_qt/perf/qt_performance.py index 0d507bbf3eb..cd270d08fa4 100644 --- a/napari/_qt/perf/qt_performance.py +++ b/napari/_qt/perf/qt_performance.py @@ -2,7 +2,7 @@ """ import time -from typing import ClassVar, List +from typing import ClassVar from qtpy.QtCore import Qt, QTimer from qtpy.QtGui import QTextCursor @@ -41,7 +41,7 @@ def append(self, name: str, time_ms: float) -> None: self.moveCursor(QTextCursor.MoveOperation.End) self.setTextColor(Qt.GlobalColor.red) self.insertPlainText( - trans._("{time_ms:5.0f}ms {name}\n", time_ms=time_ms, name=name) + trans._('{time_ms:5.0f}ms {name}\n', time_ms=time_ms, name=name) ) @@ -76,17 +76,17 @@ class QtPerformance(QWidget): # We log events slower than some threshold (in milliseconds). THRESH_DEFAULT = 100 - THRESH_OPTIONS: ClassVar[List[str]] = [ - "1", - "5", - "10", - "15", - "20", - "30", - "40", - "50", - "100", - "200", + THRESH_OPTIONS: ClassVar[list[str]] = [ + '1', + '5', + '10', + '15', + '20', + '30', + '40', + '50', + '100', + '200', ] # Update at 250ms / 4Hz for now. The more we update more alive our @@ -104,7 +104,7 @@ def __init__(self) -> None: self.start_time = time.time() # Label for our progress bar. - bar_label = QLabel(trans._("Draw Time:")) + bar_label = QLabel(trans._('Draw Time:')) layout.addWidget(bar_label) # Progress bar is not used for "progress", it's just a bar graph to show @@ -112,7 +112,7 @@ def __init__(self) -> None: bar = QProgressBar() bar.setRange(0, 100) bar.setValue(50) - bar.setFormat("%vms") + bar.setFormat('%vms') layout.addWidget(bar) self.bar = bar @@ -124,9 +124,9 @@ def __init__(self) -> None: self.thresh_combo.setCurrentText(str(self.thresh_ms)) combo_layout = QHBoxLayout() - combo_layout.addWidget(QLabel(trans._("Show Events Slower Than:"))) + combo_layout.addWidget(QLabel(trans._('Show Events Slower Than:'))) combo_layout.addWidget(self.thresh_combo) - combo_layout.addWidget(QLabel(trans._("milliseconds"))) + combo_layout.addWidget(QLabel(trans._('milliseconds'))) combo_layout.addItem( QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) ) @@ -163,7 +163,7 @@ def _get_timer_info(self): for name, timer in perf.timers.timers.items(): # The Qt Event "UpdateRequest" is the main "draw" event, so # that's what we use for our progress bar. - if name.startswith("UpdateRequest"): + if name.startswith('UpdateRequest'): average = timer.average # Log any "long" events to the text window. @@ -177,7 +177,7 @@ def update(self): # Update our timer label. elapsed = time.time() - self.start_time self.timer_label.setText( - trans._("Uptime: {elapsed:.2f}", elapsed=elapsed) + trans._('Uptime: {elapsed:.2f}', elapsed=elapsed) ) average, long_events = self._get_timer_info() diff --git a/napari/_qt/qt_event_loop.py b/napari/_qt/qt_event_loop.py index 6702d70dd26..9ee0bc28288 100644 --- a/napari/_qt/qt_event_loop.py +++ b/napari/_qt/qt_event_loop.py @@ -40,7 +40,7 @@ def set_app_id(app_id): - if os.name == "nt" and app_id and not getattr(sys, 'frozen', False): + if os.name == 'nt' and app_id and not getattr(sys, 'frozen', False): import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) @@ -58,7 +58,7 @@ def set_app_id(app_id): # store reference to QApplication to prevent garbage collection _app_ref = None -_IPYTHON_WAS_HERE_FIRST = "IPython" in sys.modules +_IPYTHON_WAS_HERE_FIRST = 'IPython' in sys.modules def get_app( @@ -118,7 +118,7 @@ def get_app( app = QApplication.instance() if app: - set_values.discard("ipy_interactive") + set_values.discard('ipy_interactive') if set_values: warn( trans._( @@ -131,7 +131,7 @@ def get_app( if perf_config and perf_config.trace_qt_events: warn( trans._( - "Using NAPARI_PERFMON with an already-running QtApp (--gui qt?) is not supported.", + 'Using NAPARI_PERFMON with an already-running QtApp (--gui qt?) is not supported.', deferred=True, ), stacklevel=2, @@ -153,11 +153,11 @@ def get_app( ) argv = sys.argv.copy() - if sys.platform == "darwin" and not argv[0].endswith("napari"): + if sys.platform == 'darwin' and not argv[0].endswith('napari'): # Make sure the app name in the Application menu is `napari` # which is taken from the basename of sys.argv[0]; we use # a copy so the original value is still available at sys.argv - argv[0] = "napari" + argv[0] = 'napari' if perf_config and perf_config.trace_qt_events: from napari._qt.perf.qt_event_tracing import ( @@ -313,7 +313,7 @@ def _ipython_has_eventloop() -> bool: at the prompt. So it will likely "appear" like there is no event loop running, but we still don't need to start one. """ - ipy_module = sys.modules.get("IPython") + ipy_module = sys.modules.get('IPython') if not ipy_module: return False @@ -338,7 +338,7 @@ def _pycharm_has_eventloop(app: QApplication) -> bool: def _try_enable_ipython_gui(gui='qt'): """Start %gui qt the eventloop.""" - ipy_module = sys.modules.get("IPython") + ipy_module = sys.modules.get('IPython') if not ipy_module: return @@ -399,7 +399,7 @@ def run( if not app.topLevelWidgets() and not force: warn( trans._( - "Refusing to run a QApplication with no topLevelWidgets. To run the app anyway, use `{_func_name}(force=True)`", + 'Refusing to run a QApplication with no topLevelWidgets. To run the app anyway, use `{_func_name}(force=True)`', deferred=True, _func_name=_func_name, ), @@ -411,8 +411,8 @@ def run( loops = app.thread().loopLevel() warn( trans._n( - "A QApplication is already running with 1 event loop. To enter *another* event loop, use `{_func_name}(max_loop_level={max_loop_level})`", - "A QApplication is already running with {n} event loops. To enter *another* event loop, use `{_func_name}(max_loop_level={max_loop_level})`", + 'A QApplication is already running with 1 event loop. To enter *another* event loop, use `{_func_name}(max_loop_level={max_loop_level})`', + 'A QApplication is already running with {n} event loops. To enter *another* event loop, use `{_func_name}(max_loop_level={max_loop_level})`', n=loops, deferred=True, _func_name=_func_name, diff --git a/napari/_qt/qt_main_window.py b/napari/_qt/qt_main_window.py index b748e3dfed6..a9b9e70ea8d 100644 --- a/napari/_qt/qt_main_window.py +++ b/napari/_qt/qt_main_window.py @@ -9,15 +9,12 @@ import sys import time import warnings +from collections.abc import MutableMapping, Sequence from typing import ( TYPE_CHECKING, Any, ClassVar, - List, - MutableMapping, Optional, - Sequence, - Tuple, Union, cast, ) @@ -52,6 +49,10 @@ from napari._qt import menus from napari._qt._qapp_model import build_qmodel_menu from napari._qt._qapp_model.qactions import init_qactions +from napari._qt._qplugins import ( + _rebuild_npe1_plugins_menu, + _rebuild_npe1_samples_menu, +) from napari._qt.dialogs.confirm_close_dialog import ConfirmCloseDialog from napari._qt.dialogs.preferences_dialog import PreferencesDialog from napari._qt.dialogs.qt_activity_dialog import QtActivityDialog @@ -73,7 +74,7 @@ menu_item_template as plugin_menu_item_template, plugin_manager, ) -from napari.plugins._npe2 import _rebuild_npe1_samples_menu +from napari.plugins._npe2 import index_npe1_adapters from napari.settings import get_settings from napari.utils import perf from napari.utils._proxies import PublicOnlyProxy @@ -107,7 +108,7 @@ class _QtMainWindow(QMainWindow): # We use this instead of QApplication.activeWindow for compatibility with # IPython usage. When you activate IPython, it will appear that there are # *no* active windows, so we want to track the most recently active windows - _instances: ClassVar[List['_QtMainWindow']] = [] + _instances: ClassVar[list['_QtMainWindow']] = [] # `window` is passed through on construction, so it's available to a window # provider for dependency injection @@ -215,7 +216,7 @@ def event(self, e: QEvent) -> bool: # https://doc-snapshots.qt.io/qt6-dev/qmouseevent-obsolete.html#globalPos pnt = ( e.globalPosition().toPoint() - if hasattr(e, "globalPosition") + if hasattr(e, 'globalPosition') else e.globalPos() ) QToolTip.showText(pnt, self._qt_viewer.viewer.tooltip.text, self) @@ -429,7 +430,7 @@ def _save_current_window_settings(self): def close(self, quit_app=False, confirm_need=False): """Override to handle closing app or just the window.""" - if hasattr(self.status_throttler, "_timer"): + if hasattr(self.status_throttler, '_timer'): self.status_throttler._timer.stop() if not quit_app and not self._qt_viewer.viewer.layers: return super().close() @@ -466,7 +467,7 @@ def close_window(self): try: parent = parent.parent() except AttributeError: - parent = getattr(parent, "_parent", None) + parent = getattr(parent, '_parent', None) def show(self, block=False): super().show() @@ -483,7 +484,7 @@ def changeEvent(self, event): # of times which makes it hard to track the original size before # maximization. condition = ( - self.isMaximized() if os.name == "nt" else self.isFullScreen() + self.isMaximized() if os.name == 'nt' else self.isFullScreen() ) if condition and self._old_size is not None: if self._positions and len(self._positions) > 1: @@ -656,6 +657,9 @@ def __init__(self, viewer: 'Viewer', *, show: bool = True) -> None: plugin_manager.discover_themes() self._setup_existing_themes() + # import and index all discovered shimmed npe1 plugins + index_npe1_adapters() + self._add_menus() self._update_theme() self._update_theme_font_size() @@ -806,6 +810,10 @@ def _update_menu_state(self, menu): menu_model = getattr(self, menu) menu_model.update_from_context(get_context(layerlist)) + def _update_plugin_menu_state(self): + self._update_menu_state('plugins_menu') + + # TODO: Remove once npe1 deprecated def _setup_npe1_samples_menu(self): """Register npe1 sample data, build menu and connect to events.""" plugin_manager.discover_sample_data() @@ -815,6 +823,15 @@ def _setup_npe1_samples_menu(self): plugin_manager.events.unregistered.connect(_rebuild_npe1_samples_menu) _rebuild_npe1_samples_menu() + # TODO: Remove once npe1 deprecated + def _setup_npe1_plugins_menu(self): + """Register npe1 widgets, build menu and connect to events""" + plugin_manager.discover_widgets() + plugin_manager.events.registered.connect(_rebuild_npe1_plugins_menu) + plugin_manager.events.disabled.connect(_rebuild_npe1_plugins_menu) + plugin_manager.events.unregistered.connect(_rebuild_npe1_plugins_menu) + _rebuild_npe1_plugins_menu() + def _add_menus(self): """Add menubar to napari app.""" # TODO: move this to _QMainWindow... but then all of the Menu() @@ -850,7 +867,13 @@ def _add_menus(self): ) self.main_menu.addMenu(self.view_menu) # plugin menu - self.plugins_menu = menus.PluginsMenu(self) + self.plugins_menu = build_qmodel_menu( + MenuId.MENUBAR_PLUGINS, + title=trans._('&Plugins'), + parent=self._qt_window, + ) + self._setup_npe1_plugins_menu() + self.plugins_menu.aboutToShow.connect(self._update_plugin_menu_state) self.main_menu.addMenu(self.plugins_menu) # window menu self.window_menu = menus.WindowMenu(self) @@ -898,7 +921,7 @@ def add_plugin_dock_widget( plugin_name: str, widget_name: Optional[str] = None, tabify: bool = False, - ) -> Tuple[QtViewerDockWidget, Any]: + ) -> tuple[QtViewerDockWidget, Any]: """Add plugin dock widget if not already added. Parameters @@ -1034,7 +1057,7 @@ def add_dock_widget( with contextlib.suppress(AttributeError): name = widget.objectName() name = name or trans._( - "Dock widget {number}", + 'Dock widget {number}', number=self._unnamed_dockwidget_count, ) @@ -1131,7 +1154,7 @@ def _add_viewer_dock_widget( import warnings with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) + warnings.simplefilter('ignore', FutureWarning) # deprecating with 0.4.8, but let's try to keep compatibility. shortcut = dock_widget.shortcut if shortcut is not None: @@ -1142,7 +1165,7 @@ def _add_viewer_dock_widget( # see #3663, to fix #3624 more generally dock_widget.setFloating(False) - def _remove_dock_widget(self, event): + def _remove_dock_widget(self, event) -> None: names = list(self._dock_widgets.keys()) for widget_name in names: if event.value in widget_name: @@ -1179,7 +1202,7 @@ def remove_dock_widget(self, widget: QWidget, menu=None): else: raise LookupError( trans._( - "Could not find a dock widget containing: {widget}", + 'Could not find a dock widget containing: {widget}', deferred=True, widget=widget, ) @@ -1245,7 +1268,7 @@ def add_function_widget( if magic_kwargs is None: magic_kwargs = { 'auto_call': False, - 'call_button': "run", + 'call_button': 'run', 'layout': 'vertical', } @@ -1299,7 +1322,7 @@ def set_geometry(self, left, top, width, height): """ self._qt_window.setGeometry(left, top, width, height) - def geometry(self) -> Tuple[int, int, int, int]: + def geometry(self) -> tuple[int, int, int, int]: """Get the geometry of the widget Returns @@ -1330,7 +1353,7 @@ def show(self, *, block=False): except (AttributeError, RuntimeError) as e: raise RuntimeError( trans._( - "This viewer has already been closed and deleted. Please create a new one.", + 'This viewer has already been closed and deleted. Please create a new one.', deferred=True, ) ) from e @@ -1342,7 +1365,7 @@ def show(self, *, block=False): except (AttributeError, RuntimeError) as e: raise RuntimeError( trans._( - "This viewer has already been closed and deleted. Please create a new one.", + 'This viewer has already been closed and deleted. Please create a new one.', deferred=True, ) ) from e @@ -1357,7 +1380,7 @@ def show(self, *, block=False): warnings.warn( trans._( - "The window geometry settings could not be loaded due to the following error: {err}", + 'The window geometry settings could not be loaded due to the following error: {err}', deferred=True, err=err, ), @@ -1393,7 +1416,7 @@ def _update_theme_no_event(self): def _update_theme_font_size(self, event=None): settings = get_settings() font_size = event.value if event else settings.appearance.font_size - extra_variables = {"font_size": f"{font_size}pt"} + extra_variables = {'font_size': f'{font_size}pt'} self._update_theme(extra_variables=extra_variables) def _update_theme(self, event=None, extra_variables=None): @@ -1405,13 +1428,13 @@ def _update_theme(self, event=None, extra_variables=None): value = event.value if event else settings.appearance.theme self._qt_viewer.viewer.theme = value actual_theme_name = value - if value == "system": + if value == 'system': # system isn't a theme, so get the name actual_theme_name = get_system_theme() # check `font_size` value is always passed when updating style - if "font_size" not in extra_variables: + if 'font_size' not in extra_variables: extra_variables.update( - {"font_size": f"{settings.appearance.font_size}pt"} + {'font_size': f'{settings.appearance.font_size}pt'} ) # set the style sheet with the theme name and extra_variables self._qt_window.setStyleSheet( diff --git a/napari/_qt/qt_resources/__init__.py b/napari/_qt/qt_resources/__init__.py index ae4998e5ab2..791e8213777 100644 --- a/napari/_qt/qt_resources/__init__.py +++ b/napari/_qt/qt_resources/__init__.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List, Optional +from typing import Optional from napari._qt.qt_resources._svg import QColoredSVGIcon from napari.settings import get_settings @@ -13,8 +13,8 @@ def get_stylesheet( theme_id: Optional[str] = None, - extra: Optional[List[str]] = None, - extra_variables: Optional[Dict[str, str]] = None, + extra: Optional[list[str]] = None, + extra_variables: Optional[dict[str, str]] = None, ) -> str: """Combine all qss files into single, possibly pre-themed, style string. @@ -58,7 +58,7 @@ def get_stylesheet( return stylesheet -def get_current_stylesheet(extra: Optional[List[str]] = None) -> str: +def get_current_stylesheet(extra: Optional[list[str]] = None) -> str: """ Return the current stylesheet base on settings. This is wrapper around :py:func:`get_stylesheet` that takes the current theme base on settings. diff --git a/napari/_qt/qt_viewer.py b/napari/_qt/qt_viewer.py index 3b89e43bd80..6e803737150 100644 --- a/napari/_qt/qt_viewer.py +++ b/napari/_qt/qt_viewer.py @@ -3,20 +3,15 @@ import logging import sys import traceback -import typing import warnings import weakref +from collections.abc import Sequence from pathlib import Path from types import FrameType from typing import ( TYPE_CHECKING, Any, - Dict, - List, Optional, - Sequence, - Tuple, - Type, Union, ) from weakref import WeakSet, ref @@ -85,7 +80,7 @@ def _npe2_decode_selected_filter( # `[]`. This function will return None. for entry, writer in zip( - ext_str.split(";;"), + ext_str.split(';;'), writers, ): if entry.startswith(selected_filter): @@ -95,7 +90,7 @@ def _npe2_decode_selected_filter( def _extension_string_for_layers( layers: Sequence[Layer], -) -> Tuple[str, List[WriterContribution]]: +) -> tuple[str, list[WriterContribution]]: """Return an extension string and the list of corresponding writers. The extension string is a ";;" delimeted string of entries. Each entry @@ -118,24 +113,24 @@ def _extension_string_for_layers( if selected_layer._type_string == 'image': ext = imsave_extensions() - ext_list = [f"*{val}" for val in ext] + ext_list = [f'*{val}' for val in ext] ext_str = ';;'.join(ext_list) ext_str = trans._( - "All Files (*);; Image file types:;;{ext_str}", + 'All Files (*);; Image file types:;;{ext_str}', ext_str=ext_str, ) elif selected_layer._type_string == 'points': - ext_str = trans._("All Files (*);; *.csv;;") + ext_str = trans._('All Files (*);; *.csv;;') else: # layer other than image or points - ext_str = trans._("All Files (*);;") + ext_str = trans._('All Files (*);;') else: # multiple layers. - ext_str = trans._("All Files (*);;") + ext_str = trans._('All Files (*);;') return ext_str, [] @@ -180,7 +175,7 @@ def __init__( self, viewer: ViewerModel, show_welcome_screen: bool = False, - canvas_class: Type[VispyCanvas] = VispyCanvas, + canvas_class: type[VispyCanvas] = VispyCanvas, ) -> None: super().__init__() self._instances.add(self) @@ -277,7 +272,7 @@ def view(self): """ warnings.warn( trans._( - "Access to QtViewer.view is deprecated since 0.5.0 and will be removed in the napari 0.6.0. Change to QtViewer.canvas.view instead." + 'Access to QtViewer.view is deprecated since 0.5.0 and will be removed in the napari 0.6.0. Change to QtViewer.canvas.view instead.' ), FutureWarning, stacklevel=2, @@ -292,7 +287,7 @@ def camera(self): """ warnings.warn( trans._( - "Access to QtViewer.camera will become deprecated in the 0.6.0. Change to QtViewer.canvas.camera instead." + 'Access to QtViewer.camera will become deprecated in the 0.6.0. Change to QtViewer.canvas.camera instead.' ), FutureWarning, stacklevel=2, @@ -423,12 +418,12 @@ def layer_to_visual(self): def _leave_canvas(self): """disable status on canvas leave""" - self.viewer.status = "" + self.viewer.status = '' self.viewer.mouse_over_canvas = False def _enter_canvas(self): """enable status on canvas enter""" - self.viewer.status = "Ready" + self.viewer.status = 'Ready' self.viewer.mouse_over_canvas = True def _ensure_connect(self): @@ -540,15 +535,16 @@ def console_backlog(self): return self._console_backlog def _get_console(self) -> Optional[QtConsole]: - """ - Function for setup console. + """Function to setup console. Returns ------- + console : QtConsole or None + The napari console. Notes _____ - extracted to separated function for simplify testing + _get_console extracted to separate function to simplify testing. """ try: @@ -565,14 +561,14 @@ def _get_console(self) -> Optional[QtConsole]: import napari with warnings.catch_warnings(): - warnings.filterwarnings("ignore") + warnings.filterwarnings('ignore') console = QtConsole(self.viewer) console.push( {'napari': napari, 'action_manager': action_manager} ) with CallerFrame(_in_napari) as c: - if c.frame.f_globals.get("__name__", "") == "__main__": - console.push({"np": np}) + if c.frame.f_globals.get('__name__', '') == '__main__': + console.push({'np': np}) for i in self.console_backlog: # recover weak refs console.push( @@ -624,7 +620,7 @@ def _on_slice_ready(self, event): Provides updates after slicing using the slice response data. This only gets triggered on the async slicing path. """ - responses: Dict[weakref.ReferenceType[Layer], Any] = event.value + responses: dict[weakref.ReferenceType[Layer], Any] = event.value logging.debug('QtViewer._on_slice_ready: %s', responses) for weak_layer, response in responses.items(): if layer := weak_layer(): @@ -684,6 +680,60 @@ def _add_layer(self, layer): self.canvas.add_layer_visual_mapping(layer, vispy_layer) + def _remove_invalid_chars(self, selected_layer_name): + """Removes invalid characters from selected layer name to suggest a filename. + + Parameters + ---------- + selected_layer_name : str + The selected napari layer name. + + Returns + ------- + suggested_name : str + Suggested name from input selected layer name, without invalid characters. + """ + unprintable_ascii_chars = ( + '\x00', + '\x01', + '\x02', + '\x03', + '\x04', + '\x05', + '\x06', + '\x07', + '\x08', + '\x0e', + '\x0f', + '\x10', + '\x11', + '\x12', + '\x13', + '\x14', + '\x15', + '\x16', + '\x17', + '\x18', + '\x19', + '\x1a', + '\x1b', + '\x1c', + '\x1d', + '\x1e', + '\x1f', + '\x7f', + ) + invalid_characters = ( + ''.join(unprintable_ascii_chars) + + '/' + + '\\' # invalid Windows filename character + + ':*?"<>|\t\n\r\x0b\x0c' # invalid Windows path characters + ) + translation_table = dict.fromkeys(map(ord, invalid_characters), None) + # Remove invalid characters + suggested_name = selected_layer_name.translate(translation_table) + return suggested_name + def _save_layers_dialog(self, selected=False): """Save layers (all or selected) to disk, using ``LayerList.save()``. @@ -695,14 +745,14 @@ def _save_layers_dialog(self, selected=False): """ msg = '' if not len(self.viewer.layers): - msg = trans._("There are no layers in the viewer to save") + msg = trans._('There are no layers in the viewer to save') elif selected and not len(self.viewer.layers.selection): msg = trans._( 'Please select one or more layers to save,' '\nor use "Save all layers..."' ) if msg: - raise OSError(trans._("Nothing to save")) + raise OSError(trans._('Nothing to save')) # prepare list of extensions for drop down menu. ext_str, writers = _extension_string_for_layers( @@ -711,16 +761,24 @@ def _save_layers_dialog(self, selected=False): else self.viewer.layers ) - msg = trans._("selected") if selected else trans._("all") + msg = trans._('selected') if selected else trans._('all') dlg = QFileDialog() hist = get_save_history() dlg.setHistory(hist) - + # get the layer's name to use for a default name if only one layer is selected + selected_layer_name = '' + if self.viewer.layers.selection.active is not None: + selected_layer_name = self.viewer.layers.selection.active.name + selected_layer_name = self._remove_invalid_chars( + selected_layer_name + ) filename, selected_filter = dlg.getSaveFileName( self, # parent trans._('Save {msg} layers', msg=msg), # caption - # home dir by default - hist[0], # directory in PyQt, dir in PySide + # home dir by default if selected all, home dir and file name if only 1 layer + str( + Path(hist[0]) / selected_layer_name + ), # directory in PyQt, dir in PySide filter=ext_str, options=( QFileDialog.DontUseNativeDialog @@ -746,12 +804,12 @@ def _save_layers_dialog(self, selected=False): filename, selected=selected, _writer=writer ) logging.debug('Saved %s', saved) - error_messages = "\n".join(str(x.message.args[0]) for x in wa) + error_messages = '\n'.join(str(x.message.args[0]) for x in wa) if not saved: raise OSError( trans._( - "File {filename} save failed.\n{error_messages}", + 'File {filename} save failed.\n{error_messages}', deferred=True, filename=filename, error_messages=error_messages, @@ -828,7 +886,7 @@ def _screenshot_dialog(self): if dial.exec_(): update_save_history(dial.selectedFiles()[0]) - def _open_file_dialog_uni(self, caption: str) -> typing.List[str]: + def _open_file_dialog_uni(self, caption: str) -> list[str]: """ Open dialog to get list of files from user """ @@ -837,17 +895,17 @@ def _open_file_dialog_uni(self, caption: str) -> typing.List[str]: dlg.setHistory(hist) open_kwargs = { - "parent": self, - "caption": caption, + 'parent': self, + 'caption': caption, } - if "pyside" in QFileDialog.__module__.lower(): + if 'pyside' in QFileDialog.__module__.lower(): # PySide6 - open_kwargs["dir"] = hist[0] + open_kwargs['dir'] = hist[0] else: - open_kwargs["directory"] = hist[0] + open_kwargs['directory'] = hist[0] if in_ipython(): - open_kwargs["options"] = QFileDialog.DontUseNativeDialog + open_kwargs['options'] = QFileDialog.DontUseNativeDialog return dlg.getOpenFileNames(**open_kwargs)[0] @@ -891,8 +949,8 @@ def _open_folder_dialog(self, choose_plugin=False): def _qt_open( self, - filenames: List[str], - stack: Union[bool, List[List[str]]], + filenames: list[str], + stack: Union[bool, list[list[str]]], choose_plugin: bool = False, plugin: Optional[str] = None, layer_type: Optional[str] = None, @@ -1041,17 +1099,17 @@ def _image_from_clipboard(self): self.viewer.add_image(arr) return if cb.mimeData().hasUrls(): - show_info("No image in clipboard, trying to open link instead.") + show_info('No image in clipboard, trying to open link instead.') self._open_from_list_of_urls_data( cb.mimeData().urls(), stack=False, choose_plugin=False ) return if cb.mimeData().hasText(): show_info( - "No image in clipboard, trying to parse text in clipboard as a link." + 'No image in clipboard, trying to parse text in clipboard as a link.' ) url_list = [] - for line in cb.mimeData().text().split("\n"): + for line in cb.mimeData().text().split('\n'): url = QUrl(line.strip()) if url.isEmpty(): continue @@ -1065,7 +1123,7 @@ def _image_from_clipboard(self): url_list, stack=False, choose_plugin=False ) return - show_info("No image or link in clipboard.") + show_info('No image or link in clipboard.') def dropEvent(self, event): """Add local files and web URLS with drag and drop. @@ -1096,7 +1154,7 @@ def dropEvent(self, event): ) def _open_from_list_of_urls_data( - self, urls_list: List[QUrl], stack: bool, choose_plugin: bool + self, urls_list: list[QUrl], stack: bool, choose_plugin: bool ): filenames = [] for url in urls_list: @@ -1218,7 +1276,7 @@ def _in_napari(n: int, frame: FrameType): if n < 2: return True # in-n-out is used in napari for dependency injection. - for pref in {"napari.", "in_n_out."}: - if frame.f_globals.get("__name__", "").startswith(pref): + for pref in {'napari.', 'in_n_out.'}: + if frame.f_globals.get('__name__', '').startswith(pref): return True return False diff --git a/napari/_qt/qthreading.py b/napari/_qt/qthreading.py index ad4c9469b60..99b91da394f 100644 --- a/napari/_qt/qthreading.py +++ b/napari/_qt/qthreading.py @@ -1,14 +1,11 @@ import inspect import warnings +from collections.abc import Sequence from functools import partial, wraps from types import FunctionType, GeneratorType from typing import ( Callable, - Dict, - List, Optional, - Sequence, - Type, TypeVar, Union, ) @@ -19,11 +16,11 @@ from napari.utils.translations import trans __all__ = [ - "FunctionWorker", - "GeneratorWorker", - "create_worker", - "thread_worker", - "register_threadworker_processors", + 'FunctionWorker', + 'GeneratorWorker', + 'create_worker', + 'thread_worker', + 'register_threadworker_processors', ] wait_for_workers_to_quit = _qthreading.WorkerBase.await_workers @@ -46,9 +43,9 @@ def _relay_warning(self, show_warn_args: tuple): notification_manager.receive_warning(*show_warn_args) -_Y = TypeVar("_Y") -_S = TypeVar("_S") -_R = TypeVar("_R") +_Y = TypeVar('_Y') +_S = TypeVar('_S') +_R = TypeVar('_R') class FunctionWorker(_qthreading.FunctionWorker[_R], _NotifyingMixin): ... @@ -66,10 +63,10 @@ def create_worker( func: Union[FunctionType, GeneratorType], *args, _start_thread: Optional[bool] = None, - _connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None, - _progress: Optional[Union[bool, Dict[str, Union[int, bool, str]]]] = None, + _connect: Optional[dict[str, Union[Callable, Sequence[Callable]]]] = None, + _progress: Optional[Union[bool, dict[str, Union[int, bool, str]]]] = None, _worker_class: Union[ - Type[GeneratorWorker], Type[FunctionWorker], None + type[GeneratorWorker], type[FunctionWorker], None ] = None, _ignore_errors: bool = False, **kwargs, @@ -165,7 +162,7 @@ def long_function(duration): if isinstance(worker, FunctionWorker) and total != 0: warnings.warn( trans._( - "_progress total != 0 but worker is FunctionWorker and will not yield. Returning indeterminate progress bar...", + '_progress total != 0 but worker is FunctionWorker and will not yield. Returning indeterminate progress bar...', deferred=True, ), RuntimeWarning, @@ -200,10 +197,10 @@ def long_function(duration): def thread_worker( function: Optional[Callable] = None, start_thread: Optional[bool] = None, - connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None, - progress: Optional[Union[bool, Dict[str, Union[int, bool, str]]]] = None, + connect: Optional[dict[str, Union[Callable, Sequence[Callable]]]] = None, + progress: Optional[Union[bool, dict[str, Union[int, bool, str]]]] = None, worker_class: Union[ - Type[FunctionWorker], Type[GeneratorWorker], None + type[FunctionWorker], type[GeneratorWorker], None ] = None, ignore_errors: bool = False, ): @@ -355,7 +352,7 @@ def register_threadworker_processors(): app = get_app() - for _type in (LayerDataTuple, List[LayerDataTuple]): + for _type in (LayerDataTuple, list[LayerDataTuple]): t = FunctionWorker[_type] magicgui.register_type(t, return_callback=_mgui.add_worker_data) app.injection_store.register( diff --git a/napari/_qt/utils.py b/napari/_qt/utils.py index ebecff490b1..c4628a38dbb 100644 --- a/napari/_qt/utils.py +++ b/napari/_qt/utils.py @@ -4,9 +4,10 @@ import signal import socket import weakref +from collections.abc import Iterable, Sequence from contextlib import contextmanager from functools import partial -from typing import Iterable, Sequence, Union +from typing import Union import numpy as np import qtpy @@ -33,8 +34,8 @@ from napari.utils.misc import is_sequence from napari.utils.translations import trans -QBYTE_FLAG = "!QBYTE_" -RICH_TEXT_PATTERN = re.compile("<[^\n]+>") +QBYTE_FLAG = '!QBYTE_' +RICH_TEXT_PATTERN = re.compile('<[^\n]+>') def is_qbyte(string: str) -> bool: @@ -261,12 +262,12 @@ def add_flash_animation( Color of the flash animation. By default, we use light gray. """ color = transform_color(color)[0] - color = (255 * color).astype("int") + color = (255 * color).astype('int') effect = QGraphicsColorizeEffect(widget) widget.setGraphicsEffect(effect) - widget._flash_animation = QPropertyAnimation(effect, b"color") + widget._flash_animation = QPropertyAnimation(effect, b'color') widget._flash_animation.setStartValue(QColor(0, 0, 0, 0)) widget._flash_animation.setEndValue(QColor(0, 0, 0, 0)) widget._flash_animation.setLoopCount(1) diff --git a/napari/_qt/widgets/_slider_compat.py b/napari/_qt/widgets/_slider_compat.py index 04d0be3f582..d6616908bfc 100644 --- a/napari/_qt/widgets/_slider_compat.py +++ b/napari/_qt/widgets/_slider_compat.py @@ -3,9 +3,9 @@ from superqt import QDoubleSlider # here until we can debug why labeled sliders render differently on 5.12 -if tuple(int(x) for x in QT_VERSION.split(".")) >= (5, 14): +if tuple(int(x) for x in QT_VERSION.split('.')) >= (5, 14): from superqt import QLabeledDoubleSlider as QDoubleSlider # noqa from superqt import QLabeledSlider as QSlider -__all__ = ["QSlider", "QDoubleSlider"] +__all__ = ['QSlider', 'QDoubleSlider'] diff --git a/napari/_qt/widgets/_tests/test_qt_buttons.py b/napari/_qt/widgets/_tests/test_qt_buttons.py index b8e9c4a75d8..69b558f4ece 100644 --- a/napari/_qt/widgets/_tests/test_qt_buttons.py +++ b/napari/_qt/widgets/_tests/test_qt_buttons.py @@ -40,7 +40,7 @@ def set_test_prop(): def test_layers_button_works(make_napari_viewer): v = make_napari_viewer() layer = v.add_layer(Points()) - assert layer.mode != "add" + assert layer.mode != 'add' controls = v.window._qt_viewer.controls.widgets[layer] controls.addition_button.click() - assert layer.mode == "add" + assert layer.mode == 'add' diff --git a/napari/_qt/widgets/_tests/test_qt_dims.py b/napari/_qt/widgets/_tests/test_qt_dims.py index 8fcf957c19f..debbdfee1f8 100644 --- a/napari/_qt/widgets/_tests/test_qt_dims.py +++ b/napari/_qt/widgets/_tests/test_qt_dims.py @@ -271,7 +271,7 @@ def on_axis_labels_changed(): # while being elided on the GUI first_label.setText('napari') assert first_label.text() == view.dims.axis_labels[0] - assert "…" in first_label._elidedText() + assert '…' in first_label._elidedText() assert observed_axis_labels_event # increase width to check the full text is shown @@ -329,7 +329,7 @@ def test_play_button(qtbot): # Check popup updates widget properties (fps, play mode and loop mode) button.fpsspin.clear() - qtbot.keyClicks(button.fpsspin, "11") + qtbot.keyClicks(button.fpsspin, '11') qtbot.keyClick(button.fpsspin, Qt.Key_Enter) assert slider.fps == button.fpsspin.value() == 11 button.reverse_check.setChecked(True) diff --git a/napari/_qt/widgets/_tests/test_qt_extension2reader.py b/napari/_qt/widgets/_tests/test_qt_extension2reader.py index 27b37c1b568..4c7adad4383 100644 --- a/napari/_qt/widgets/_tests/test_qt_extension2reader.py +++ b/napari/_qt/widgets/_tests/test_qt_extension2reader.py @@ -86,7 +86,7 @@ def test_extension2reader_removal(extension2reader_widget, qtbot): qtbot.mouseClick(btn_to_click, Qt.LeftButton) assert not get_settings().plugins.extension2reader assert widget._table.rowCount() == 1 - assert "No filename preferences found" in widget._table.item(0, 0).text() + assert 'No filename preferences found' in widget._table.item(0, 0).text() def test_all_readers_in_dropdown( @@ -147,7 +147,7 @@ def test_filtering_readers( ) -@pytest.mark.parametrize("pattern", [".", "", "/"]) +@pytest.mark.parametrize('pattern', ['.', '', '/']) def test_filtering_readers_problematic_patterns( extension2reader_widget, builtins, tif_reader, npy_reader, pattern ): @@ -156,7 +156,7 @@ def test_filtering_readers_problematic_patterns( ) widget._filter_compatible_readers(pattern) assert widget._new_reader_dropdown.count() == 1 - assert widget._new_reader_dropdown.itemText(0) == "None available" + assert widget._new_reader_dropdown.itemText(0) == 'None available' def test_filtering_readers_complex_pattern( @@ -219,7 +219,7 @@ def test_adding_new_preference_no_asterisk( def test_editing_preference(extension2reader_widget, tif_reader): tiff2 = tif_reader.spawn(register=True) - @tiff2.contribute.reader(filename_patterns=["*.tif"]) + @tiff2.contribute.reader(filename_patterns=['*.tif']) def ff(path): ... get_settings().plugins.extension2reader = {'*.tif': tif_reader.name} diff --git a/napari/_qt/widgets/_tests/test_qt_highlight_preview.py b/napari/_qt/widgets/_tests/test_qt_highlight_preview.py index 435194ae801..7afce3faae4 100644 --- a/napari/_qt/widgets/_tests/test_qt_highlight_preview.py +++ b/napari/_qt/widgets/_tests/test_qt_highlight_preview.py @@ -1,7 +1,8 @@ +import numpy as np import pytest from napari._qt.widgets.qt_highlight_preview import ( - QtHighlightSizePreviewWidget, + QtHighlightPreviewWidget, QtStar, QtTriangle, ) @@ -32,15 +33,15 @@ def _triangle_widget(**kwargs): @pytest.fixture -def highlight_size_preview_widget(qtbot): - def _highlight_size_preview_widget(**kwargs): - widget = QtHighlightSizePreviewWidget(**kwargs) +def highlight_preview_widget(qtbot): + def _highlight_preview_widget(**kwargs): + widget = QtHighlightPreviewWidget(**kwargs) widget.show() qtbot.addWidget(widget) return widget - return _highlight_size_preview_widget + return _highlight_preview_widget # QtStar @@ -110,130 +111,169 @@ def test_qt_triangle_signal(qtbot, triangle_widget): widget.setValue(-5) -# QtHighlightSizePreviewWidget +# QtHighlightPreviewWidget # ---------------------------------------------------------------------------- -def test_qt_highlight_size_preview_widget_defaults( - highlight_size_preview_widget, +def test_qt_highlight_preview_widget_defaults( + highlight_preview_widget, ): - highlight_size_preview_widget() + highlight_preview_widget() -def test_qt_highlight_size_preview_widget_description( - highlight_size_preview_widget, +def test_qt_highlight_preview_widget_description( + highlight_preview_widget, ): - description = "Some text" - widget = highlight_size_preview_widget(description=description) + description = 'Some text' + widget = highlight_preview_widget(description=description) assert widget.description() == description - widget = highlight_size_preview_widget() + widget = highlight_preview_widget() widget.setDescription(description) assert widget.description() == description -def test_qt_highlight_size_preview_widget_unit(highlight_size_preview_widget): - unit = "CM" - widget = highlight_size_preview_widget(unit=unit) +def test_qt_highlight_preview_widget_unit(highlight_preview_widget): + unit = 'CM' + widget = highlight_preview_widget(unit=unit) assert widget.unit() == unit - widget = highlight_size_preview_widget() + widget = highlight_preview_widget() widget.setUnit(unit) assert widget.unit() == unit -def test_qt_highlight_size_preview_widget_minimum( - highlight_size_preview_widget, +def test_qt_highlight_preview_widget_minimum( + highlight_preview_widget, ): minimum = 5 - widget = highlight_size_preview_widget(min_value=minimum) + widget = highlight_preview_widget(min_value=minimum) assert widget.minimum() == minimum - assert widget.value() >= minimum + assert widget._thickness_value >= minimum + assert widget.value()['highlight_thickness'] >= minimum - widget = highlight_size_preview_widget() + widget = highlight_preview_widget() widget.setMinimum(3) assert widget.minimum() == 3 - assert widget.value() == 3 + assert widget._thickness_value == 3 + assert widget.value()['highlight_thickness'] == 3 assert widget._slider.minimum() == 3 - assert widget._slider_min_label.text() == "3" + assert widget._slider_min_label.text() == '3' assert widget._triangle.minimum() == 3 - assert widget._lineedit.text() == "3" + assert widget._lineedit.text() == '3' -def test_qt_highlight_size_preview_widget_minimum_invalid( - highlight_size_preview_widget, +def test_qt_highlight_preview_widget_minimum_invalid( + highlight_preview_widget, ): - widget = highlight_size_preview_widget() + widget = highlight_preview_widget() with pytest.raises(ValueError): widget.setMinimum(60) -def test_qt_highlight_size_preview_widget_maximum( - highlight_size_preview_widget, +def test_qt_highlight_preview_widget_maximum( + highlight_preview_widget, ): maximum = 10 - widget = highlight_size_preview_widget(max_value=maximum) + widget = highlight_preview_widget(max_value=maximum) assert widget.maximum() == maximum - assert widget.value() <= maximum - - widget = highlight_size_preview_widget() + assert widget._thickness_value <= maximum + assert widget.value()['highlight_thickness'] <= maximum + + widget = highlight_preview_widget( + value={ + 'highlight_thickness': 6, + 'highlight_color': [0.0, 0.6, 1.0, 1.0], + } + ) widget.setMaximum(20) assert widget.maximum() == 20 assert widget._slider.maximum() == 20 assert widget._triangle.maximum() == 20 - assert widget._slider_max_label.text() == "20" + assert widget._slider_max_label.text() == '20' + assert widget._thickness_value == 6 + assert widget.value()['highlight_thickness'] == 6 widget.setMaximum(5) assert widget.maximum() == 5 - # assert widget.value() == 5 - # assert widget._slider.maximum() == 5 - # assert widget._triangle.maximum() == 20 - # assert widget._lineedit.text() == "5" - # assert widget._slider_max_label.text() == "5" + assert widget._thickness_value == 5 + assert widget.value()['highlight_thickness'] == 5 + assert widget._slider.maximum() == 5 + assert widget._triangle.maximum() == 5 + assert widget._lineedit.text() == '5' + assert widget._slider_max_label.text() == '5' -def test_qt_highlight_size_preview_widget_maximum_invalid( - highlight_size_preview_widget, +def test_qt_highlight_preview_widget_maximum_invalid( + highlight_preview_widget, ): - widget = highlight_size_preview_widget() + widget = highlight_preview_widget() with pytest.raises(ValueError): widget.setMaximum(-5) -def test_qt_highlight_size_preview_widget_value(highlight_size_preview_widget): - widget = highlight_size_preview_widget(value=5) - assert widget.value() <= 5 - - widget = highlight_size_preview_widget() - widget.setValue(5) - assert widget.value() == 5 - - -def test_qt_highlight_size_preview_widget_value_invalid( - qtbot, highlight_size_preview_widget +def test_qt_highlight_preview_widget_value(highlight_preview_widget): + widget = highlight_preview_widget( + value={ + 'highlight_thickness': 5, + 'highlight_color': [0.0, 0.6, 1.0, 1.0], + } + ) + assert widget._thickness_value <= 5 + assert widget.value()['highlight_thickness'] <= 5 + assert widget._color_value == [0.0, 0.6, 1.0, 1.0] + assert widget.value()['highlight_color'] == [0.0, 0.6, 1.0, 1.0] + + widget = highlight_preview_widget() + widget.setValue( + {'highlight_thickness': 5, 'highlight_color': [0.6, 0.6, 1.0, 1.0]} + ) + assert widget._thickness_value == 5 + assert widget.value()['highlight_thickness'] == 5 + assert np.array_equal( + np.array(widget._color_value, dtype=np.float32), + np.array([0.6, 0.6, 1.0, 1.0], dtype=np.float32), + ) + assert np.array_equal( + np.array(widget.value()['highlight_color'], dtype=np.float32), + np.array([0.6, 0.6, 1.0, 1.0], dtype=np.float32), + ) + + +def test_qt_highlight_preview_widget_value_invalid( + qtbot, highlight_preview_widget ): - widget = highlight_size_preview_widget() + widget = highlight_preview_widget() widget.setMaximum(50) - widget.setValue(51) - assert widget.value() == 50 - assert widget._lineedit.text() == "50" + widget.setValue( + {'highlight_thickness': 51, 'highlight_color': [0.0, 0.6, 1.0, 1.0]} + ) + assert widget.value()['highlight_thickness'] == 50 + assert widget._lineedit.text() == '50' widget.setMinimum(5) - widget.setValue(1) - assert widget.value() == 5 - assert widget._lineedit.text() == "5" + widget.setValue( + {'highlight_thickness': 1, 'highlight_color': [0.0, 0.6, 1.0, 1.0]} + ) + assert widget.value()['highlight_thickness'] == 5 + assert widget._lineedit.text() == '5' -def test_qt_highlight_size_preview_widget_signal( - qtbot, highlight_size_preview_widget -): - widget = highlight_size_preview_widget() +def test_qt_highlight_preview_widget_signal(qtbot, highlight_preview_widget): + widget = highlight_preview_widget() with qtbot.waitSignal(widget.valueChanged, timeout=500): - widget.setValue(7) + widget.setValue( + {'highlight_thickness': 7, 'highlight_color': [0.0, 0.6, 1.0, 1.0]} + ) with qtbot.waitSignal(widget.valueChanged, timeout=500): - widget.setValue(-5) + widget.setValue( + { + 'highlight_thickness': -5, + 'highlight_color': [0.0, 0.6, 1.0, 1.0], + } + ) diff --git a/napari/_qt/widgets/_tests/test_qt_play.py b/napari/_qt/widgets/_tests/test_qt_play.py index 321636abbef..68230527a7b 100644 --- a/napari/_qt/widgets/_tests/test_qt_play.py +++ b/napari/_qt/widgets/_tests/test_qt_play.py @@ -69,7 +69,7 @@ def go(): ] -@pytest.mark.parametrize("nframes,fps,mode,rng,result", CONDITIONS) +@pytest.mark.parametrize('nframes,fps,mode,rng,result', CONDITIONS) def test_animation_thread_variants(qtbot, nframes, fps, mode, rng, result): """This is mostly testing that AnimationWorker.advance works as expected""" with make_worker( diff --git a/napari/_qt/widgets/_tests/test_qt_plugin_sorter.py b/napari/_qt/widgets/_tests/test_qt_plugin_sorter.py index 34d4b3caa73..0804abce910 100644 --- a/napari/_qt/widgets/_tests/test_qt_plugin_sorter.py +++ b/napari/_qt/widgets/_tests/test_qt_plugin_sorter.py @@ -6,7 +6,7 @@ @pytest.mark.parametrize( 'text,expected_text', [ - ("", ""), + ('', ''), ( """Return a function capable of loading ``path`` into napari, or ``None``. @@ -102,7 +102,7 @@ def test_create_qt_plugin_sorter(qtbot): @pytest.mark.parametrize( - "hook_name,help_info", + 'hook_name,help_info', [ ('select hook... ', ''), ( diff --git a/napari/_qt/widgets/_tests/test_qt_progress_bar.py b/napari/_qt/widgets/_tests/test_qt_progress_bar.py index 557add0582c..3806c49fcba 100644 --- a/napari/_qt/widgets/_tests/test_qt_progress_bar.py +++ b/napari/_qt/widgets/_tests/test_qt_progress_bar.py @@ -19,8 +19,8 @@ def test_qt_labeled_progress_bar_base(qtbot): assert progress.qt_progress_bar.value() == -1 progress.setValue(5) assert progress.qt_progress_bar.value() == 5 - progress.setDescription("text") - assert progress.description_label.text() == "text: " + progress.setDescription('text') + assert progress.description_label.text() == 'text: ' def test_qt_labeled_progress_bar_event_handle(qtbot): @@ -33,12 +33,12 @@ def test_qt_labeled_progress_bar_event_handle(qtbot): assert progress._get_value() == -1 progress._set_value(Namespace(value=5)) assert progress._get_value() == 5 - assert progress.description_label.text() == "" - progress._set_description(Namespace(value="text")) - assert progress.description_label.text() == "text: " - assert progress.eta_label.text() == "" - progress._set_eta(Namespace(value="test")) - assert progress.eta_label.text() == "test" + assert progress.description_label.text() == '' + progress._set_description(Namespace(value='text')) + assert progress.description_label.text() == 'text: ' + assert progress.eta_label.text() == '' + progress._set_eta(Namespace(value='test')) + assert progress.eta_label.text() == 'test' progress._make_indeterminate(None) assert progress.qt_progress_bar.maximum() == 0 diff --git a/napari/_qt/widgets/_tests/test_qt_size_preview.py b/napari/_qt/widgets/_tests/test_qt_size_preview.py index 291bf0cd3cf..2a4f2cbb215 100644 --- a/napari/_qt/widgets/_tests/test_qt_size_preview.py +++ b/napari/_qt/widgets/_tests/test_qt_size_preview.py @@ -37,7 +37,7 @@ def test_qt_font_size_preview_defaults(preview_widget): def test_qt_font_size_preview_text(preview_widget): - text = "Some text" + text = 'Some text' widget = preview_widget(text=text) assert widget.text() == text @@ -53,7 +53,7 @@ def test_qt_size_slider_preview_widget_defaults(font_size_preview_widget): def test_qt_size_slider_preview_widget_description(font_size_preview_widget): - description = "Some text" + description = 'Some text' widget = font_size_preview_widget(description=description) assert widget.description() == description @@ -63,7 +63,7 @@ def test_qt_size_slider_preview_widget_description(font_size_preview_widget): def test_qt_size_slider_preview_widget_unit(font_size_preview_widget): - unit = "EM" + unit = 'EM' widget = font_size_preview_widget(unit=unit) assert widget.unit() == unit @@ -73,7 +73,7 @@ def test_qt_size_slider_preview_widget_unit(font_size_preview_widget): def test_qt_size_slider_preview_widget_preview(font_size_preview_widget): - preview = "Some preview" + preview = 'Some preview' widget = font_size_preview_widget(preview_text=preview) assert widget.previewText() == preview @@ -92,14 +92,14 @@ def test_qt_size_slider_preview_widget_minimum(font_size_preview_widget): widget.setMinimum(5) assert widget.minimum() == 5 assert widget._slider.minimum() == 5 - assert widget._slider_min_label.text() == "5" + assert widget._slider_min_label.text() == '5' widget.setMinimum(20) assert widget.minimum() == 20 assert widget.value() == 20 assert widget._slider.minimum() == 20 - assert widget._slider_min_label.text() == "20" - assert widget._lineedit.text() == "20" + assert widget._slider_min_label.text() == '20' + assert widget._lineedit.text() == '20' def test_qt_size_slider_preview_widget_minimum_invalid( @@ -122,14 +122,14 @@ def test_qt_size_slider_preview_widget_maximum(font_size_preview_widget): widget.setMaximum(20) assert widget.maximum() == 20 assert widget._slider.maximum() == 20 - assert widget._slider_max_label.text() == "20" + assert widget._slider_max_label.text() == '20' widget.setMaximum(5) assert widget.maximum() == 5 assert widget.value() == 5 assert widget._slider.maximum() == 5 - assert widget._lineedit.text() == "5" - assert widget._slider_max_label.text() == "5" + assert widget._lineedit.text() == '5' + assert widget._slider_max_label.text() == '5' def test_qt_size_slider_preview_widget_maximum_invalid( @@ -157,12 +157,12 @@ def test_qt_size_slider_preview_widget_value_invalid( widget.setMaximum(50) widget.setValue(51) assert widget.value() == 50 - assert widget._lineedit.text() == "50" + assert widget._lineedit.text() == '50' widget.setMinimum(5) widget.setValue(1) assert widget.value() == 5 - assert widget._lineedit.text() == "5" + assert widget._lineedit.text() == '5' def test_qt_size_slider_preview_signal(qtbot, font_size_preview_widget): diff --git a/napari/_qt/widgets/_tests/test_qt_tooltip.py b/napari/_qt/widgets/_tests/test_qt_tooltip.py index c833089ea7d..a7d1f77caee 100644 --- a/napari/_qt/widgets/_tests/test_qt_tooltip.py +++ b/napari/_qt/widgets/_tests/test_qt_tooltip.py @@ -10,17 +10,17 @@ @pytest.mark.skipif( - os.environ.get("CI", False) and sys.platform == "darwin", - reason="Timeouts when running on macOS CI", + os.environ.get('CI', False) and sys.platform == 'darwin', + reason='Timeouts when running on macOS CI', ) def test_qt_tooltip_label(qtbot): - tooltip_text = "Test QtToolTipLabel showing a tooltip" - widget = QtToolTipLabel("Label with a tooltip") + tooltip_text = 'Test QtToolTipLabel showing a tooltip' + widget = QtToolTipLabel('Label with a tooltip') widget.setToolTip(tooltip_text) qtbot.addWidget(widget) widget.show() - assert QToolTip.text() == "" + assert QToolTip.text() == '' # simulate movement mouse from outside the widget to the center pos = QPointF(widget.rect().center()) event = QEnterEvent(pos, pos, QPointF(widget.pos()) + pos) diff --git a/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py b/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py index 6cde1202f08..6af700dab94 100644 --- a/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py +++ b/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py @@ -1,15 +1,21 @@ import sys from unittest.mock import patch +import pyautogui import pytest from qtpy.QtCore import QPoint, Qt from qtpy.QtWidgets import QApplication, QMessageBox from napari._qt.widgets.qt_keyboard_settings import ShortcutEditor, WarnPopup +from napari._tests.utils import skip_local_focus, skip_on_mac_ci from napari.settings import get_settings from napari.utils.action_manager import action_manager from napari.utils.interactions import KEY_SYMBOLS +META_CONTROL_KEY = Qt.KeyboardModifier.ControlModifier +if sys.platform == 'darwin': + META_CONTROL_KEY = Qt.KeyboardModifier.MetaModifier + @pytest.fixture def shortcut_editor_widget(qtbot): @@ -45,20 +51,20 @@ def test_layer_actions(shortcut_editor_widget): assert widget.layer_combo_box.currentText() == widget.VIEWER_KEYBINDINGS actions1 = widget._get_layer_actions() assert actions1 == widget.key_bindings_strs[widget.VIEWER_KEYBINDINGS] - widget.layer_combo_box.setCurrentText("Labels layer") + widget.layer_combo_box.setCurrentText('Labels layer') actions2 = widget._get_layer_actions() - assert actions2 == {**widget.key_bindings_strs["Labels layer"], **actions1} + assert actions2 == {**widget.key_bindings_strs['Labels layer'], **actions1} def test_mark_conflicts(shortcut_editor_widget, qtbot): widget = shortcut_editor_widget() - widget._table.item(0, widget._shortcut_col).setText("U") + widget._table.item(0, widget._shortcut_col).setText('U') act = widget._table.item(0, widget._action_col).text() - assert action_manager._shortcuts[act][0] == "U" - with patch.object(WarnPopup, "exec_") as mock: + assert action_manager._shortcuts[act][0] == 'U' + with patch.object(WarnPopup, 'exec_') as mock: assert not widget._mark_conflicts(action_manager._shortcuts[act][0], 1) assert mock.called - assert widget._mark_conflicts("Y", 1) + assert widget._mark_conflicts('Y', 1) # "Y" is arbitrary chosen and on conflict with existing shortcut should be changed qtbot.add_widget(widget._warn_dialog) @@ -66,42 +72,52 @@ def test_mark_conflicts(shortcut_editor_widget, qtbot): def test_restore_defaults(shortcut_editor_widget): widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() - assert shortcut == KEY_SYMBOLS["Ctrl"] - widget._table.item(0, widget._shortcut_col).setText("R") + assert shortcut == KEY_SYMBOLS['Ctrl'] + widget._table.item(0, widget._shortcut_col).setText('R') shortcut = widget._table.item(0, widget._shortcut_col).text() - assert shortcut == "R" + assert shortcut == 'R' with patch( - "napari._qt.widgets.qt_keyboard_settings.QMessageBox.question" + 'napari._qt.widgets.qt_keyboard_settings.QMessageBox.question' ) as mock: mock.return_value = QMessageBox.RestoreDefaults widget._restore_button.click() assert mock.called shortcut = widget._table.item(0, widget._shortcut_col).text() - assert shortcut == KEY_SYMBOLS["Ctrl"] + assert shortcut == KEY_SYMBOLS['Ctrl'] +@skip_local_focus @pytest.mark.parametrize( - "key, modifier, key_symbols", + 'key, modifier, key_symbols', [ ( - "U", - ( - Qt.KeyboardModifier.MetaModifier - if sys.platform == "darwin" - else Qt.KeyboardModifier.ControlModifier - ), - [KEY_SYMBOLS["Ctrl"], "U"], + Qt.Key.Key_U, + META_CONTROL_KEY, + [KEY_SYMBOLS['Ctrl'], 'U'], + ), + ( + Qt.Key.Key_Y, + META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier, + [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift'], 'Y'], + ), + ( + Qt.Key.Key_Backspace, + META_CONTROL_KEY, + [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Backspace']], + ), + ( + Qt.Key.Key_Delete, + META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier, + [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift'], KEY_SYMBOLS['Delete']], ), ( - "Y", - ( - Qt.KeyboardModifier.MetaModifier - | Qt.KeyboardModifier.ShiftModifier - if sys.platform == "darwin" - else Qt.KeyboardModifier.ControlModifier - | Qt.KeyboardModifier.ShiftModifier - ), - [KEY_SYMBOLS["Ctrl"], KEY_SYMBOLS["Shift"], "Y"], + Qt.Key.Key_Backspace, + META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier, + [ + KEY_SYMBOLS['Ctrl'], + KEY_SYMBOLS['Shift'], + KEY_SYMBOLS['Backspace'], + ], ), ], ) @@ -110,7 +126,7 @@ def test_keybinding_with_modifiers( ): widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() - assert shortcut == KEY_SYMBOLS["Ctrl"] + assert shortcut == KEY_SYMBOLS['Ctrl'] x = widget._table.columnViewportPosition(widget._shortcut_col) y = widget._table.rowViewportPosition(0) @@ -122,7 +138,7 @@ def test_keybinding_with_modifiers( widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos ) qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) - qtbot.keyClicks(QApplication.focusWidget(), key, modifier=modifier) + qtbot.keyClick(QApplication.focusWidget(), key, modifier=modifier) assert len([warn for warn in recwarn if warn.category is UserWarning]) == 0 shortcut = widget._table.item(0, widget._shortcut_col).text() @@ -130,18 +146,19 @@ def test_keybinding_with_modifiers( assert key_symbol in shortcut +@skip_local_focus @pytest.mark.parametrize( - "modifiers, key_symbols, valid", + 'modifiers, key_symbols, valid', [ ( Qt.KeyboardModifier.ShiftModifier, - [KEY_SYMBOLS["Shift"]], + [KEY_SYMBOLS['Shift']], True, ), ( Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ShiftModifier, - [KEY_SYMBOLS["Ctrl"]], + [KEY_SYMBOLS['Ctrl']], False, ), ], @@ -151,7 +168,7 @@ def test_keybinding_with_only_modifiers( ): widget = shortcut_editor_widget() shortcut = widget._table.item(0, widget._shortcut_col).text() - assert shortcut == KEY_SYMBOLS["Ctrl"] + assert shortcut == KEY_SYMBOLS['Ctrl'] x = widget._table.columnViewportPosition(widget._shortcut_col) y = widget._table.rowViewportPosition(0) @@ -163,7 +180,7 @@ def test_keybinding_with_only_modifiers( widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos ) qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) - with patch.object(WarnPopup, "exec_") as mock: + with patch.object(WarnPopup, 'exec_') as mock: qtbot.keyClick( QApplication.focusWidget(), Qt.Key_Enter, modifier=modifiers ) @@ -176,3 +193,117 @@ def test_keybinding_with_only_modifiers( shortcut = widget._table.item(0, widget._shortcut_col).text() for key_symbol in key_symbols: assert key_symbol in shortcut + + +@skip_local_focus +@pytest.mark.parametrize( + 'removal_trigger_key', + [ + Qt.Key.Key_Delete, + Qt.Key.Key_Backspace, + ], +) +def test_remove_shortcut(shortcut_editor_widget, qtbot, removal_trigger_key): + widget = shortcut_editor_widget() + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == KEY_SYMBOLS['Ctrl'] + + x = widget._table.columnViewportPosition(widget._shortcut_col) + y = widget._table.rowViewportPosition(0) + item_pos = QPoint(x, y) + qtbot.mouseClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.mouseDClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) + qtbot.keyClick(QApplication.focusWidget(), removal_trigger_key) + qtbot.keyClick(QApplication.focusWidget(), Qt.Key.Key_Enter) + + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == '' + + +@skip_local_focus +@skip_on_mac_ci +@pytest.mark.parametrize( + 'modifier_key, modifiers, key_symbols', + [ + ( + 'shift', + None, + [KEY_SYMBOLS['Shift']], + ), + ( + 'ctrl', + 'shift', + [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift']], + ), + ], +) +def test_keybinding_editor_modifier_key_detection( + shortcut_editor_widget, + qtbot, + recwarn, + modifier_key, + modifiers, + key_symbols, +): + """ + Test modifier keys detection with pyautogui to trigger keyboard events + from the OS. + + Notes: + * Skipped on macOS CI due to accessibility permissions not being + settable on macOS GitHub Actions runners. + * For this test to pass locally, you need to give the Terminal/iTerm + application accessibility permissions: + `System Settings > Privacy & Security > Accessibility` + + See https://github.com/asweigart/pyautogui/issues/247 and + https://github.com/asweigart/pyautogui/issues/247#issuecomment-437668855 + """ + widget = shortcut_editor_widget() + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == KEY_SYMBOLS['Ctrl'] + + x = widget._table.columnViewportPosition(widget._shortcut_col) + y = widget._table.rowViewportPosition(0) + item_pos = QPoint(x, y) + qtbot.mouseClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.mouseDClick( + widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos + ) + qtbot.waitUntil(lambda: QApplication.focusWidget() is not None) + + line_edit = QApplication.focusWidget() + with pyautogui.hold(modifier_key): + if modifiers: + pyautogui.keyDown(modifiers) + + def press_check(): + line_edit.selectAll() + shortcut = line_edit.selectedText() + all_pressed = True + for key_symbol in key_symbols: + all_pressed &= key_symbol in shortcut + return all_pressed + + qtbot.waitUntil(lambda: press_check()) + + if modifiers: + pyautogui.keyUp(modifiers) + + def release_check(): + line_edit.selectAll() + shortcut = line_edit.selectedText() + return shortcut == '' + + qtbot.waitUntil(lambda: release_check()) + + qtbot.keyClick(line_edit, Qt.Key_Escape) + shortcut = widget._table.item(0, widget._shortcut_col).text() + assert shortcut == KEY_SYMBOLS['Ctrl'] diff --git a/napari/_qt/widgets/qt_color_swatch.py b/napari/_qt/widgets/qt_color_swatch.py index b9482d1c42c..5f9617b557d 100644 --- a/napari/_qt/widgets/qt_color_swatch.py +++ b/napari/_qt/widgets/qt_color_swatch.py @@ -28,7 +28,7 @@ # captures the numbers into groups. # this is used to allow users to enter colors as e.g.: "(1, 0.7, 0)" rgba_regex = re.compile( - r"\(?([\d.]+),\s*([\d.]+),\s*([\d.]+),?\s*([\d.]+)?\)?" + r'\(?([\d.]+),\s*([\d.]+),\s*([\d.]+),?\s*([\d.]+)?\)?' ) TRANSPARENT = np.array([0, 0, 0, 0], np.float32) @@ -215,7 +215,7 @@ class QColorLineEdit(QLineEdit): def __init__(self, parent=None) -> None: super().__init__(parent) - self._compl = QCompleter([*get_color_dict(), "transparent"]) + self._compl = QCompleter([*get_color_dict(), 'transparent']) self._compl.setCompletionMode(QCompleter.InlineCompletion) self.setCompleter(self._compl) self.setTextMargins(2, 2, 2, 2) diff --git a/napari/_qt/widgets/qt_dict_table.py b/napari/_qt/widgets/qt_dict_table.py index 46536a9f979..d303f70f413 100644 --- a/napari/_qt/widgets/qt_dict_table.py +++ b/napari/_qt/widgets/qt_dict_table.py @@ -1,5 +1,5 @@ import re -from typing import List, Optional +from typing import Optional from qtpy.QtCore import QSize, Slot from qtpy.QtGui import QFont @@ -7,10 +7,10 @@ from napari.utils.translations import trans -email_pattern = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") +email_pattern = re.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$') url_pattern = re.compile( - r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}" - r"\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)" + r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}' + r'\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)' ) @@ -47,9 +47,9 @@ class QtDictTable(QTableWidget): def __init__( self, parent=None, - source: Optional[List[dict]] = None, + source: Optional[list[dict]] = None, *, - headers: Optional[List[str]] = None, + headers: Optional[list[str]] = None, min_section_width: Optional[int] = None, max_section_width: int = 480, ) -> None: @@ -63,7 +63,7 @@ def __init__( self.cellClicked.connect(self._go_to_links) self.setMouseTracking(True) - def set_data(self, data: List[dict], headers: Optional[List[str]] = None): + def set_data(self, data: list[dict], headers: Optional[list[str]] = None): """Set the data in the table, given a list of dicts. Parameters diff --git a/napari/_qt/widgets/qt_dims.py b/napari/_qt/widgets/qt_dims.py index d5e22992bab..a984d1c9b14 100644 --- a/napari/_qt/widgets/qt_dims.py +++ b/napari/_qt/widgets/qt_dims.py @@ -1,5 +1,5 @@ import warnings -from typing import Optional, Tuple +from typing import Optional import numpy as np from qtpy.QtCore import Slot @@ -174,8 +174,8 @@ def _resize_slice_labels(self): if length > width: width = length # gui width of a string of length `width` - fm = QFontMetrics(QFont("", 0)) - width = fm.boundingRect("8" * width).width() + fm = QFontMetrics(QFont('', 0)) + width = fm.boundingRect('8' * width).width() for labl in self.findChildren(QWidget, 'slice_label'): labl.setFixedWidth(width + 6) @@ -243,7 +243,7 @@ def play( axis: int = 0, fps: Optional[float] = None, loop_mode: Optional[str] = None, - frame_range: Optional[Tuple[int, int]] = None, + frame_range: Optional[tuple[int, int]] = None, ): """Animate (play) axis. @@ -340,8 +340,8 @@ def is_playing(self): ) except RuntimeError as e: # pragma: no cover if ( - "wrapped C/C++ object of type" not in e.args[0] - and "Internal C++ object" not in e.args[0] + 'wrapped C/C++ object of type' not in e.args[0] + and 'Internal C++ object' not in e.args[0] ): # checking if threat is partially deleted. Otherwise # reraise exception. For more details see: diff --git a/napari/_qt/widgets/qt_dims_slider.py b/napari/_qt/widgets/qt_dims_slider.py index 2b667a6f28a..8c7d3299b66 100644 --- a/napari/_qt/widgets/qt_dims_slider.py +++ b/napari/_qt/widgets/qt_dims_slider.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional from weakref import ref import numpy as np @@ -79,14 +79,14 @@ def __init__(self, parent: QWidget, axis: int) -> None: settings = get_settings() self._fps = settings.application.playback_fps connect_setattr_value( - settings.application.events.playback_fps, self, "fps" + settings.application.events.playback_fps, self, 'fps' ) self._minframe = None self._maxframe = None self._loop_mode = settings.application.playback_mode connect_setattr_value( - settings.application.events.playback_mode, self, "loop_mode" + settings.application.events.playback_mode, self, 'loop_mode' ) layout = QHBoxLayout() @@ -375,7 +375,7 @@ def _play( self, fps: Optional[float] = None, loop_mode: Optional[str] = None, - frame_range: Optional[Tuple[int, int]] = None, + frame_range: Optional[tuple[int, int]] = None, ): """Animate (play) axis. Same API as QtDims.play() @@ -516,7 +516,7 @@ def __init__( self.popup.frame.setLayout(form_layout) fpsspin = QtCustomDoubleSpinBox(self.popup) - fpsspin.setObjectName("fpsSpinBox") + fpsspin.setObjectName('fpsSpinBox') fpsspin.setAlignment(Qt.AlignmentFlag.AlignCenter) fpsspin.setValue(self.fps) if hasattr(fpsspin, 'setStepType'): @@ -532,7 +532,7 @@ def __init__( self.fpsspin = fpsspin revcheck = QCheckBox(self.popup) - revcheck.setObjectName("playDirectionCheckBox") + revcheck.setObjectName('playDirectionCheckBox') form_layout.insertRow( 1, QLabel(trans._('play direction:'), parent=self.popup), revcheck ) @@ -567,7 +567,7 @@ def _on_click(self): qt_dims = self.qt_dims_ref() if not qt_dims: # pragma: no cover return None - if self.property('playing') == "True": + if self.property('playing') == 'True': return qt_dims.stop() self.play_requested.emit(self.axis) return None @@ -684,12 +684,12 @@ def set_frame_range(self, frame_range): if frame_range is not None: if frame_range[0] >= frame_range[1]: raise ValueError( - trans._("frame_range[0] must be <= frame_range[1]") + trans._('frame_range[0] must be <= frame_range[1]') ) if frame_range[0] < self.dimsrange[0]: - raise IndexError(trans._("frame_range[0] out of range")) + raise IndexError(trans._('frame_range[0] out of range')) if frame_range[1] * self.dimsrange[2] >= self.dimsrange[1]: - raise IndexError(trans._("frame_range[1] out of range")) + raise IndexError(trans._('frame_range[1] out of range')) self.frame_range = frame_range if self.frame_range is not None: diff --git a/napari/_qt/widgets/qt_dims_sorter.py b/napari/_qt/widgets/qt_dims_sorter.py index 65a2732155e..2b4ec3e25b9 100644 --- a/napari/_qt/widgets/qt_dims_sorter.py +++ b/napari/_qt/widgets/qt_dims_sorter.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Tuple, Union +from typing import TYPE_CHECKING, Union import numpy as np from qtpy.QtWidgets import QGridLayout, QLabel, QWidget @@ -36,7 +36,7 @@ def __eq__(self, other: Union[int, str]) -> bool: return repr(self) == other -def set_dims_order(dims: Dims, order: Tuple[int, ...]): +def set_dims_order(dims: Dims, order: tuple[int, ...]): if type(order[0]) == AxisModel: order = [a.axis for a in order] dims.order = order @@ -46,7 +46,7 @@ def _array_in_range(arr: np.ndarray, low: int, high: int) -> bool: return (arr >= low) & (arr < high) -def move_indices(axes_list: SelectableEventedList, order: Tuple[int, ...]): +def move_indices(axes_list: SelectableEventedList, order: tuple[int, ...]): with axes_list.events.blocker_all(): if tuple(axes_list) == tuple(order): return diff --git a/napari/_qt/widgets/qt_extension2reader.py b/napari/_qt/widgets/qt_extension2reader.py index 047b401e39f..ddbb5b62eaa 100644 --- a/napari/_qt/widgets/qt_extension2reader.py +++ b/napari/_qt/widgets/qt_extension2reader.py @@ -105,7 +105,7 @@ def _make_new_preference_row(self): self._fn_pattern_edit = QLineEdit() self._fn_pattern_edit.setPlaceholderText( - trans._("Start typing filename pattern...") + trans._('Start typing filename pattern...') ) self._fn_pattern_edit.textChanged.connect( self._filter_compatible_readers @@ -173,7 +173,7 @@ def _filter_compatible_readers(self, new_pattern): try: compatible_readers = get_potential_readers(new_pattern) except ValueError as e: - if "empty name" not in str(e): + if 'empty name' not in str(e): raise compatible_readers = {} for plugin_name in readers: @@ -190,7 +190,7 @@ def _filter_compatible_readers(self, new_pattern): ): self._add_reader_choice(i, plugin_name, display_name) if self._new_reader_dropdown.count() == 0: - self._new_reader_dropdown.addItem(trans._("None available")) + self._new_reader_dropdown.addItem(trans._('None available')) def _save_new_preference(self, event): """Save current preference to settings and show in table""" diff --git a/napari/_qt/widgets/qt_font_size.py b/napari/_qt/widgets/qt_font_size.py index d34fffe3dfe..c8c942e2492 100644 --- a/napari/_qt/widgets/qt_font_size.py +++ b/napari/_qt/widgets/qt_font_size.py @@ -18,7 +18,7 @@ class QtFontSizeWidget(QWidget): def __init__(self, parent: QWidget = None) -> None: super().__init__(parent=parent) self._spinbox = QtSpinBox() - self._reset_button = QPushButton(trans._("Reset font size")) + self._reset_button = QPushButton(trans._('Reset font size')) layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) @@ -34,7 +34,7 @@ def _reset(self) -> None: Reset the widget value to the current selected theme font size value. """ current_theme_name = get_settings().appearance.theme - if current_theme_name == "system": + if current_theme_name == 'system': # system isn't a theme, so get the name current_theme_name = get_system_theme() current_theme = get_theme(current_theme_name) diff --git a/napari/_qt/widgets/qt_highlight_preview.py b/napari/_qt/widgets/qt_highlight_preview.py index cb6b53cab1c..a0d96abccfc 100644 --- a/napari/_qt/widgets/qt_highlight_preview.py +++ b/napari/_qt/widgets/qt_highlight_preview.py @@ -13,6 +13,7 @@ QWidget, ) +from napari._qt.widgets.qt_color_swatch import QColorSwatchEdit from napari.utils.translations import translator trans = translator.load() @@ -34,6 +35,7 @@ def __init__( ) -> None: super().__init__(parent) self._value = value + self._color = QColor(135, 206, 235) def sizeHint(self): """Override Qt sizeHint.""" @@ -62,7 +64,7 @@ def value(self): """ return self._value - def setValue(self, value: int): + def setValue(self, value: int, color: QColor = None): """Set line width value of star widget. Parameters @@ -72,6 +74,8 @@ def setValue(self, value: int): """ self._value = value + if color is not None: + self._color = color self.update() def drawStar(self, qp): @@ -83,7 +87,7 @@ def drawStar(self, qp): """ width = self.rect().width() height = self.rect().height() - col = QColor(135, 206, 235) + col = self._color pen = QPen(col, self._value) pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) qp.setPen(pen) @@ -149,6 +153,7 @@ def __init__( self._max_value = max_value self._min_value = min_value self._value = value + self._color = QColor(135, 206, 235) def mousePressEvent(self, event): """When mouse is clicked, adjust to new values.""" @@ -185,7 +190,7 @@ def drawTriangle(self, qp): """ width = self.rect().width() - col = QColor(135, 206, 235) + col = self._color qp.setPen(QPen(col, 1)) qp.setBrush(col) path = QPainterPath() @@ -210,7 +215,7 @@ def value(self): """ return self._value - def setValue(self, value): + def setValue(self, value, color=None): """Set value for triangle widget. Parameters @@ -219,6 +224,8 @@ def setValue(self, value): Value to use for line in triangle widget. """ self._value = value + if color is not None: + self._color = color self.update() def minimum(self): @@ -286,7 +293,7 @@ def drawLine(self, qp, value: int): self.valueChanged.emit(self._value) -class QtHighlightSizePreviewWidget(QWidget): +class QtHighlightPreviewWidget(QWidget): """Creates custom widget to set highlight size. Parameters @@ -303,25 +310,37 @@ class QtHighlightSizePreviewWidget(QWidget): Unit of highlight size. """ - valueChanged = Signal(int) + valueChanged = Signal(dict) def __init__( self, parent: QWidget = None, - description: str = "", - value: int = 1, + description: str = '', + value: Optional[dict] = None, min_value: int = 1, max_value: int = 10, - unit: str = "px", + unit: str = 'px', ) -> None: super().__init__(parent) self.setGeometry(300, 300, 125, 110) - self._value = value or self.fontMetrics().height() + if value is None: + value = { + 'highlight_thickness': 1, + 'highlight_color': [0.0, 0.6, 1.0, 1.0], + } + self._value = value + self._thickness_value = ( + value['highlight_thickness'] or self.fontMetrics().height() + ) + self._color_value = value['highlight_color'] or [0.0, 0.6, 1.0, 1.0] self._min_value = min_value self._max_value = max_value # Widget + self._color_swatch_edit = QColorSwatchEdit( + self, initial_color=self._color_value + ) self._lineedit = QLineEdit() self._description = QLabel(self) self._unit = QLabel(self) @@ -347,19 +366,20 @@ def __init__( self._slider_max_label.setAlignment(Qt.AlignmentFlag.AlignBottom) self._slider.setMinimum(min_value) self._slider.setMaximum(max_value) - self._preview.setValue(value) - self._triangle.setValue(value) + self._preview.setValue(self._thickness_value) + self._triangle.setValue(self._thickness_value) self._triangle.setMinimum(min_value) self._triangle.setMaximum(max_value) - self._preview_label.setText(trans._("Preview")) + self._preview_label.setText(trans._('Preview')) self._preview_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) self._preview_label.setAlignment(Qt.AlignmentFlag.AlignBottom) self._preview.setStyleSheet('border: 1px solid white;') # Signals - self._slider.valueChanged.connect(self._update_value) - self._lineedit.textChanged.connect(self._update_value) - self._triangle.valueChanged.connect(self._update_value) + self._slider.valueChanged.connect(self._update_thickness_value) + self._lineedit.textChanged.connect(self._update_thickness_value) + self._triangle.valueChanged.connect(self._update_thickness_value) + self._color_swatch_edit.color_changed.connect(self._update_color_value) # Layout triangle_layout = QHBoxLayout() @@ -372,7 +392,8 @@ def __init__( triangle_slider_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) # Bottom row layout - lineedit_layout = QHBoxLayout() + lineedit_layout = QVBoxLayout() + lineedit_layout.addWidget(self._color_swatch_edit) lineedit_layout.addWidget(self._lineedit) lineedit_layout.setAlignment(Qt.AlignmentFlag.AlignBottom) bottom_left_layout = QHBoxLayout() @@ -405,31 +426,56 @@ def __init__( self._refresh() - def _update_value(self, value): - """Update highlight value. + def _update_thickness_value(self, thickness_value): + """Update highlight thickness value. Parameters ---------- - value : int - Highlight value. + thickness_value : int + Highlight thickness value. """ - if value == "": + if thickness_value == '': return - value = int(value) - value = max(min(value, self._max_value), self._min_value) - if value == self._value: + thickness_value = int(thickness_value) + thickness_value = max( + min(thickness_value, self._max_value), self._min_value + ) + if thickness_value == self._thickness_value: return - self._value = value + self._thickness_value = thickness_value + self._value['highlight_thickness'] = self._thickness_value + self.valueChanged.emit(self._value) + self._refresh() + + def _update_color_value(self, color_value): + """Update highlight color value. + + Parameters + ---------- + color_value : List[float] + Highlight color value as a list of floats. + """ + if isinstance(color_value, np.ndarray): + color_value = color_value.tolist() + if color_value == self._color_value: + return + self._color_value = color_value + self._value['highlight_color'] = self._color_value self.valueChanged.emit(self._value) self._refresh() def _refresh(self): """Set every widget value to the new set value.""" self.blockSignals(True) - self._lineedit.setText(str(self._value)) - self._slider.setValue(self._value) - self._triangle.setValue(self._value) - self._preview.setValue(self._value) + # Transform color value from a float representation + # (values from 0.0 to 1.0) to RGB values (values from 0 to 255) + # to set widgets color. + color = QColor(*[int(np.ceil(v * 255)) for v in self._color_value[:3]]) + self._lineedit.setText(str(self._thickness_value)) + self._slider.setValue(self._thickness_value) + self._triangle.setValue(self._thickness_value, color=color) + self._preview.setValue(self._thickness_value, color=color) + self._color_swatch_edit.setColor(self._color_value) self.blockSignals(False) def value(self): @@ -437,20 +483,21 @@ def value(self): Returns ------- - int - Current value of highlight widget. + dict + Current value of highlight widget (thickness and color). """ return self._value - def setValue(self, value): + def setValue(self, value: dict): """Set new value and update widget. Parameters ---------- - value : int - Highlight value. + value : dict + Highlight value (thickness and color). """ - self._update_value(value) + self._update_thickness_value(value['highlight_thickness']) + self._update_color_value(value['highlight_color']) self._refresh() def description(self): @@ -505,7 +552,7 @@ def setMinimum(self, value): if value >= self._max_value: raise ValueError( trans._( - "Minimum value must be smaller than {max_value}", + 'Minimum value must be smaller than {max_value}', deferred=True, max_value=self._max_value, ) @@ -514,7 +561,10 @@ def setMinimum(self, value): self._slider_min_label.setText(str(value)) self._slider.setMinimum(value) self._triangle.setMinimum(value) - self._value = max(self._value, self._min_value) + self._thickness_value = max( + self._value['highlight_thickness'], self._min_value + ) + self._value['highlight_thickness'] = self._thickness_value self._refresh() def minimum(self): @@ -539,7 +589,7 @@ def setMaximum(self, value): if value <= self._min_value: raise ValueError( trans._( - "Maximum value must be larger than {min_value}", + 'Maximum value must be larger than {min_value}', deferred=True, min_value=self._min_value, ) @@ -548,7 +598,10 @@ def setMaximum(self, value): self._slider_max_label.setText(str(value)) self._slider.setMaximum(value) self._triangle.setMaximum(value) - self._value = min(self._value, self._max_value) + self._thickness_value = min( + self._value['highlight_thickness'], self._max_value + ) + self._value['highlight_thickness'] = self._thickness_value self._refresh() def maximum(self): diff --git a/napari/_qt/widgets/qt_keyboard_settings.py b/napari/_qt/widgets/qt_keyboard_settings.py index 4cbe77e2c15..02cf6a38604 100644 --- a/napari/_qt/widgets/qt_keyboard_settings.py +++ b/napari/_qt/widgets/qt_keyboard_settings.py @@ -4,7 +4,6 @@ from typing import Optional from app_model.backends.qt import ( - qkey2modelkey, qkeysequence2modelkeybinding, ) from qtpy.QtCore import QEvent, QPoint, Qt, Signal @@ -51,7 +50,7 @@ class ShortcutEditor(QWidget): def __init__( self, parent: QWidget = None, - description: str = "", + description: str = '', value: Optional[dict] = None, ) -> None: super().__init__(parent=parent) @@ -80,7 +79,7 @@ def __init__( self._table.setSelectionBehavior(QAbstractItemView.SelectItems) self._table.setSelectionMode(QAbstractItemView.SingleSelection) self._table.setShowGrid(False) - self._restore_button = QPushButton(trans._("Restore All Keybindings")) + self._restore_button = QPushButton(trans._('Restore All Keybindings')) # Set up dictionary for layers and associated actions. all_actions = action_manager._actions.copy() @@ -93,7 +92,7 @@ def __init__( actions = action_manager._get_provider_actions(layer) for name in actions: all_actions.pop(name) - self.key_bindings_strs[f"{layer.__name__} layer"] = actions + self.key_bindings_strs[f'{layer.__name__} layer'] = actions # Left over actions can go here. self.key_bindings_strs[self.VIEWER_KEYBINDINGS] = all_actions @@ -103,7 +102,7 @@ def __init__( self.layer_combo_box.currentTextChanged.connect(self._set_table) self.layer_combo_box.setCurrentText(self.VIEWER_KEYBINDINGS) self._set_table() - self._label.setText(trans._("Group")) + self._label.setText(trans._('Group')) self._restore_button.clicked.connect(self.restore_defaults) # layout @@ -124,7 +123,7 @@ def __init__( layout.addWidget( QLabel( trans._( - "To edit, double-click the keybinding. To unbind a shortcut, use Backspace or Delete. To set Backspace or Delete, first unbind." + 'To edit, double-click the keybinding. To unbind a shortcut, use Backspace or Delete. To set Backspace or Delete, first unbind.' ) ) ) @@ -141,8 +140,8 @@ def restore_defaults(self): ) response = QMessageBox.question( self, - trans._("Restore Shortcuts"), - trans._("Are you sure you want to restore default shortcuts?"), + trans._('Restore Shortcuts'), + trans._('Are you sure you want to restore default shortcuts?'), QMessageBox.StandardButton.RestoreDefaults | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.RestoreDefaults, @@ -238,7 +237,7 @@ def _set_table(self, layer_str: str = ''): self._table.setWordWrap(True) # Add some padding to rows - self._table.setStyleSheet("QTableView::item { padding: 6px; }") + self._table.setStyleSheet('QTableView::item { padding: 6px; }') # Go through all the actions in the layer and add them to the table. for row, (action_name, action) in enumerate(actions.items()): @@ -252,7 +251,7 @@ def _set_table(self, layer_str: str = ''): # Create empty item in order to make sure this column is not # selectable/editable. - item = QTableWidgetItem("") + item = QTableWidgetItem('') item.setFlags(Qt.ItemFlag.NoItemFlags) self._table.setItem(row, self._icon_col, item) @@ -260,14 +259,14 @@ def _set_table(self, layer_str: str = ''): item_shortcut = QTableWidgetItem( Shortcut(next(iter(shortcuts))).platform if shortcuts - else "" + else '' ) self._table.setItem(row, self._shortcut_col, item_shortcut) item_shortcut2 = QTableWidgetItem( Shortcut(list(shortcuts)[1]).platform if len(shortcuts) > 1 - else "" + else '' ) self._table.setItem(row, self._shortcut_col2, item_shortcut2) @@ -304,12 +303,12 @@ def _restore_shortcuts(self, row): shortcuts = action_manager._shortcuts.get(action_name, []) with lock_keybind_update(self): self._table.item(row, self._shortcut_col).setText( - Shortcut(next(iter(shortcuts))).platform if shortcuts else "" + Shortcut(next(iter(shortcuts))).platform if shortcuts else '' ) self._table.item(row, self._shortcut_col2).setText( Shortcut(list(shortcuts)[1]).platform if len(shortcuts) > 1 - else "" + else '' ) def _mark_conflicts(self, new_shortcut, row) -> bool: @@ -331,7 +330,7 @@ def _mark_conflicts(self, new_shortcut, row) -> bool: # show warning message message = trans._( - "The keybinding {new_shortcut} is already assigned to {action_description}; change or clear that shortcut before assigning {new_shortcut} to this one.", + 'The keybinding {new_shortcut} is already assigned to {action_description}; change or clear that shortcut before assigning {new_shortcut} to this one.', new_shortcut=new_shortcut, action_description=action.description, ) @@ -363,7 +362,7 @@ def _show_bind_shortcut_error( self._show_warning_icons([row]) message = trans._( - "{new_shortcut} is not a valid keybinding.", + '{new_shortcut} is not a valid keybinding.', new_shortcut=Shortcut(new_shortcut).platform, ) self._show_warning(row, message) @@ -410,11 +409,12 @@ def _set_keybinding(self, row, col): # get the current action name current_action = self._table.item(row, self._action_col).text() - # get the original shortcutS + # get the original shortcuts current_shortcuts = list( action_manager._shortcuts.get(current_action, []) ) - for mod in {"Shift", "Ctrl", "Alt", "Cmd", "Super", 'Meta'}: + for mod in {'Shift', 'Ctrl', 'Alt', 'Cmd', 'Super', 'Meta'}: + # we want to prevent multiple modifiers but still allow single modifiers. if new_shortcut.endswith('-' + mod): self._show_bind_shortcut_error( current_action, @@ -434,7 +434,7 @@ def _set_keybinding(self, row, col): action_manager.unbind_shortcut(current_action) shortcuts_list = list(current_shortcuts) ind = col - self._shortcut_col - if new_shortcut != "": + if new_shortcut != '': if ind < len(shortcuts_list): shortcuts_list[ind] = new_shortcut else: @@ -476,7 +476,7 @@ def _show_warning_icons(self, rows): for row in rows: self.warning_indicator = QLabel(self) - self.warning_indicator.setObjectName("error_label") + self.warning_indicator.setObjectName('error_label') self._table.setCellWidget( row, self._icon_col, self.warning_indicator @@ -492,7 +492,7 @@ def _cleanup_warning_icons(self, rows): """ for row in rows: - self._table.setCellWidget(row, self._icon_col, QLabel("")) + self._table.setCellWidget(row, self._icon_col, QLabel('')) def _show_warning(self, row: int, message: str) -> None: """Creates and displays warning message when shortcut is already assigned. @@ -558,7 +558,7 @@ def createEditor(self, widget, style_option, model_index): def setEditorData(self, widget, model_index): text = model_index.model().data(model_index, Qt.ItemDataRole.EditRole) - widget.setText(str(text) if text else "") + widget.setText(str(text) if text else '') def updateEditorGeometry(self, widget, style_option, model_index): widget.setGeometry(style_option.rect) @@ -581,7 +581,18 @@ def event(self, event): if event.type() == QEvent.Type.ShortcutOverride: self.keyPressEvent(event) return True - if event.type() in [QEvent.Type.KeyPress, QEvent.Type.Shortcut]: + if event.type() == QEvent.Type.Shortcut: + return True + if event.type() == QEvent.Type.KeyPress and event.key() in ( + Qt.Key.Key_Delete, + Qt.Key.Key_Backspace, + ): + # If there is a shortcut set already, two events are being emitted when + # pressing `Delete` or `Backspace`. First a `ShortcutOverride` event and + # then a `KeyPress` event. We need to mark the second event (`KeyPress`) + # as handled for those keys to not end up processing it multiple times. + # Without that, pressing `Backspace` or `Delete` will set those keys as shortcuts + # instead of just cleaning/removing the previous shortcut that was set. return True return super().event(event) @@ -642,6 +653,7 @@ def keyPressEvent(self, event) -> None: if ( event_key in {Qt.Key.Key_Delete, Qt.Key.Key_Backspace} and self.text() != '' + and self.hasSelectedText() ): # Allow user to delete shortcut. self.setText('') @@ -655,8 +667,6 @@ def keyPressEvent(self, event) -> None: ): self._handleEditModifiersOnly(event) return - if event_key == Qt.Key.Key_Delete: - self.setText(Shortcut(qkey2modelkey(event_key)).platform) if event_key in { Qt.Key.Key_Return, diff --git a/napari/_qt/widgets/qt_message_popup.py b/napari/_qt/widgets/qt_message_popup.py index 91c453f375b..4c0bd4e63ff 100644 --- a/napari/_qt/widgets/qt_message_popup.py +++ b/napari/_qt/widgets/qt_message_popup.py @@ -11,7 +11,7 @@ class WarnPopup(QDialog): def __init__( self, parent=None, - text: str = "", + text: str = '', ) -> None: super().__init__(parent) @@ -26,7 +26,7 @@ def __init__( self._message.setText(text) self._message.setWordWrap(True) self._xbutton.clicked.connect(self._close) - self._xbutton.setStyleSheet("background-color: rgba(0, 0, 0, 0);") + self._xbutton.setStyleSheet('background-color: rgba(0, 0, 0, 0);') # Layout main_layout = QVBoxLayout() diff --git a/napari/_qt/widgets/qt_plugin_sorter.py b/napari/_qt/widgets/qt_plugin_sorter.py index 7fd3bd2602c..fe60c3a1dbf 100644 --- a/napari/_qt/widgets/qt_plugin_sorter.py +++ b/napari/_qt/widgets/qt_plugin_sorter.py @@ -4,7 +4,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, List, Optional, Union +from typing import TYPE_CHECKING, Optional, Union from napari_plugin_engine import HookCaller, HookImplementation from qtpy.QtCore import QEvent, Qt, Signal, Slot @@ -38,8 +38,8 @@ def rst2html(text): def ref(match): _text = match.groups()[0].split()[0] - if _text.startswith("~"): - _text = _text.split(".")[-1] + if _text.startswith('~'): + _text = _text.split('.')[-1] return f'``{_text}``' def link(match): @@ -51,7 +51,7 @@ def link(match): text = re.sub(r':[a-z]+:`([^`]+)`', ref, text, flags=re.DOTALL) text = re.sub(r'`([^`]+)`_', link, text, flags=re.DOTALL) text = re.sub(r'``([^`]+)``', '\\1', text) - return text.replace("\n", "
") + return text.replace('\n', '
') class ImplementationListItem(QFrame): @@ -90,7 +90,7 @@ def __init__(self, item: QListWidgetItem, parent: QWidget = None) -> None: self.position_label = QLabel() self.update_position_label() - self.setToolTip(trans._("Click and drag to change call order")) + self.setToolTip(trans._('Click and drag to change call order')) self.plugin_name_label = QElidingLabel() self.plugin_name_label.setObjectName('small_text') self.plugin_name_label.setText(item.hook_implementation.plugin_name) @@ -106,7 +106,7 @@ def __init__(self, item: QListWidgetItem, parent: QWidget = None) -> None: self.enabled_checkbox = QCheckBox(self) self.enabled_checkbox.setToolTip( - trans._("Uncheck to disable this plugin") + trans._('Uncheck to disable this plugin') ) self.enabled_checkbox.stateChanged.connect(self._set_enabled) self.enabled_checkbox.setChecked( @@ -236,7 +236,7 @@ def startDrag(self, supported_actions): drag.exec_(supported_actions, Qt.DropAction.MoveAction) @Slot(list) - def permute_hook(self, order: List[HookImplementation]): + def permute_hook(self, order: list[HookImplementation]): """Rearrage the call order of the hooks for the current hook impl. Parameters @@ -316,14 +316,14 @@ def __init__( ): continue self.hook_combo_box.addItem( - name.replace("napari_", ""), hook_caller + name.replace('napari_', ''), hook_caller ) self.plugin_manager.events.disabled.connect(self._on_disabled) self.plugin_manager.events.registered.connect(self.refresh) self.hook_combo_box.setToolTip( - trans._("select the hook specification to reorder") + trans._('select the hook specification to reorder') ) self.hook_combo_box.currentIndexChanged.connect(self._on_hook_change) self.hook_list = QtHookImplementationListWidget(parent=self) @@ -339,7 +339,7 @@ def __init__( self.docstring = QLabel(self) self.info = QtToolTipLabel(self) - self.info.setObjectName("info_icon") + self.info.setObjectName('info_icon') doc_lay = QHBoxLayout() doc_lay.addWidget(self.docstring) doc_lay.setStretch(0, 1) @@ -372,7 +372,7 @@ def set_hookname(self, hook: str): hook : str Name of the new hook specification to show. """ - self.hook_combo_box.setCurrentText(hook.replace("napari_", '')) + self.hook_combo_box.setCurrentText(hook.replace('napari_', '')) def _on_hook_change(self, index): hook_caller = self.hook_combo_box.currentData() @@ -380,7 +380,7 @@ def _on_hook_change(self, index): if hook_caller: doc = hook_caller.spec.function.__doc__ - html = rst2html(doc.split("Parameters")[0].strip()) + html = rst2html(doc.split('Parameters')[0].strip()) summary, fulldoc = html.split('
', 1) while fulldoc.startswith('
'): fulldoc = fulldoc[4:] diff --git a/napari/_qt/widgets/qt_progress_bar.py b/napari/_qt/widgets/qt_progress_bar.py index b8622430cd5..d93fdbe33a8 100644 --- a/napari/_qt/widgets/qt_progress_bar.py +++ b/napari/_qt/widgets/qt_progress_bar.py @@ -44,23 +44,23 @@ def __init__( base_layout.addLayout(pbar_layout) line = QFrame(self) - line.setObjectName("QtCustomTitleBarLine") + line.setObjectName('QtCustomTitleBarLine') line.setFixedHeight(1) base_layout.addWidget(line) self.setLayout(base_layout) @rename_argument( - from_name="min", - to_name="min_val", - version="0.6.0", - since_version="0.4.18", + from_name='min', + to_name='min_val', + version='0.6.0', + since_version='0.4.18', ) @rename_argument( - from_name="max", - to_name="max_val", - version="0.6.0", - since_version="0.4.18", + from_name='max', + to_name='max_val', + version='0.6.0', + since_version='0.4.18', ) def setRange(self, min_val, max_val): self.qt_progress_bar.setRange(min_val, max_val) @@ -115,7 +115,7 @@ def __init__( pbr_group_layout.setContentsMargins(0, 0, 0, 0) line = QFrame(self) - line.setObjectName("QtCustomTitleBarLine") + line.setObjectName('QtCustomTitleBarLine') line.setFixedHeight(1) pbr_group_layout.addWidget(line) diff --git a/napari/_qt/widgets/qt_scrollbar.py b/napari/_qt/widgets/qt_scrollbar.py index e9d03a35a98..20c1564d2cd 100644 --- a/napari/_qt/widgets/qt_scrollbar.py +++ b/napari/_qt/widgets/qt_scrollbar.py @@ -25,7 +25,7 @@ def _move_to_mouse_position(self, event): # https://doc-snapshots.qt.io/qt6-dev/qmouseevent-obsolete.html#pos point = ( event.position().toPoint() - if hasattr(event, "position") + if hasattr(event, 'position') else event.pos() ) control = self.style().hitTestComplexControl( diff --git a/napari/_qt/widgets/qt_size_preview.py b/napari/_qt/widgets/qt_size_preview.py index 46870a9c28d..c36c3a75859 100644 --- a/napari/_qt/widgets/qt_size_preview.py +++ b/napari/_qt/widgets/qt_size_preview.py @@ -33,7 +33,7 @@ def __init__( ) -> None: super().__init__(parent) - self._text = text or "" + self._text = text or '' # Widget self._preview = QPlainTextEdit(self) @@ -107,12 +107,12 @@ def __init__( value: typing.Optional[int] = None, min_value: int = 1, max_value: int = 50, - unit: str = "px", + unit: str = 'px', ) -> None: super().__init__(parent) - description = description or "" - preview_text = preview_text or "" + description = description or '' + preview_text = preview_text or '' self._value = value if value else self.fontMetrics().height() self._min_value = min_value self._max_value = max_value @@ -138,7 +138,7 @@ def __init__( self._slider.setMinimum(min_value) self._slider.setMaximum(max_value) self._preview.setText(preview_text) - self._preview_label.setText(trans._("preview")) + self._preview_label.setText(trans._('preview')) self._preview_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.setFocusProxy(self._lineedit) @@ -178,7 +178,7 @@ def _update_validator(self): def _update_line_width(self): """Update width ofg line text edit.""" - txt = "m" * (1 + len(str(self._max_value))) + txt = 'm' * (1 + len(str(self._max_value))) fm = self._lineedit.fontMetrics() if hasattr(fm, 'horizontalAdvance'): # Qt >= 5.11 @@ -191,7 +191,7 @@ def _update_line_width(self): def _update_value(self, value: typing.Union[int, str]): """Update internal value and emit if changed.""" - if value == "": + if value == '': value = int(self._value) value = int(value) @@ -304,7 +304,7 @@ def setMinimum(self, value: int): if value >= self._max_value: raise ValueError( trans._( - "Minimum value must be smaller than {max_value}", + 'Minimum value must be smaller than {max_value}', max_value=self._max_value, ) ) @@ -336,7 +336,7 @@ def setMaximum(self, value: int): if value <= self._min_value: raise ValueError( trans._( - "Maximum value must be larger than {min_value}", + 'Maximum value must be larger than {min_value}', min_value=self._min_value, ) ) diff --git a/napari/_qt/widgets/qt_theme_sample.py b/napari/_qt/widgets/qt_theme_sample.py index 607733dda85..65678a147a7 100644 --- a/napari/_qt/widgets/qt_theme_sample.py +++ b/napari/_qt/widgets/qt_theme_sample.py @@ -67,24 +67,24 @@ def __init__(self, parent=None, emphasized=False) -> None: self.tab2 = QWidget() self.tab2.setProperty('emphasized', emphasized) - self.addTab(self.tab1, "Tab 1") - self.addTab(self.tab2, "Tab 2") + self.addTab(self.tab1, 'Tab 1') + self.addTab(self.tab2, 'Tab 2') layout = QFormLayout() - layout.addRow("Height", QSpinBox()) - layout.addRow("Weight", QDoubleSpinBox()) - self.setTabText(0, "Tab 1") + layout.addRow('Height', QSpinBox()) + layout.addRow('Weight', QDoubleSpinBox()) + self.setTabText(0, 'Tab 1') self.tab1.setLayout(layout) layout2 = QFormLayout() sex = QHBoxLayout() - sex.addWidget(QRadioButton("Male")) - sex.addWidget(QRadioButton("Female")) - layout2.addRow(QLabel("Sex"), sex) - layout2.addRow("Date of Birth", QLineEdit()) - self.setTabText(1, "Tab 2") + sex.addWidget(QRadioButton('Male')) + sex.addWidget(QRadioButton('Female')) + layout2.addRow(QLabel('Sex'), sex) + layout2.addRow('Date of Birth', QLineEdit()) + self.setTabText(1, 'Tab 2') self.tab2.setLayout(layout2) - self.setWindowTitle("tab demo") + self.setWindowTitle('tab demo') class SampleWidget(QWidget): @@ -133,10 +133,10 @@ def __init__(self, theme='dark', emphasized=False) -> None: prog = QProgressBar() prog.setValue(50) lay.addWidget(prog) - group_box = QGroupBox("Exclusive Radio Buttons") - radio1 = QRadioButton("&Radio button 1") - radio2 = QRadioButton("R&adio button 2") - radio3 = QRadioButton("Ra&dio button 3") + group_box = QGroupBox('Exclusive Radio Buttons') + radio1 = QRadioButton('&Radio button 1') + radio2 = QRadioButton('R&adio button 2') + radio3 = QRadioButton('Ra&dio button 3') radio1.setChecked(True) hbox = QHBoxLayout() hbox.addWidget(radio1) @@ -153,7 +153,7 @@ def screenshot(self, path=None): return QImg2array(img) -if __name__ == "__main__": +if __name__ == '__main__': import sys from napari._qt.qt_event_loop import get_app @@ -166,7 +166,7 @@ def screenshot(self, path=None): try: w = SampleWidget(theme) except KeyError: - print(f"{theme} is not a recognized theme") + print(f'{theme} is not a recognized theme') continue w.setGeometry(10 + 430 * n, 0, 425, 600) w.show() diff --git a/napari/_qt/widgets/qt_viewer_buttons.py b/napari/_qt/widgets/qt_viewer_buttons.py index 5e571876550..0b2bc5add1a 100644 --- a/napari/_qt/widgets/qt_viewer_buttons.py +++ b/napari/_qt/widgets/qt_viewer_buttons.py @@ -240,7 +240,7 @@ def _open_grid_popup(self): stride_min = self.viewer.grid.__fields__['stride'].type_.ge stride_max = self.viewer.grid.__fields__['stride'].type_.le stride_not = self.viewer.grid.__fields__['stride'].type_.ne - grid_stride.setObjectName("gridStrideBox") + grid_stride.setObjectName('gridStrideBox') grid_stride.setAlignment(Qt.AlignmentFlag.AlignCenter) grid_stride.setRange(stride_min, stride_max) grid_stride.setProhibitValue(stride_not) @@ -250,7 +250,7 @@ def _open_grid_popup(self): width_min = self.viewer.grid.__fields__['shape'].sub_fields[1].type_.ge width_not = self.viewer.grid.__fields__['shape'].sub_fields[1].type_.ne - grid_width.setObjectName("gridWidthBox") + grid_width.setObjectName('gridWidthBox') grid_width.setAlignment(Qt.AlignmentFlag.AlignCenter) grid_width.setMinimum(width_min) grid_width.setProhibitValue(width_not) @@ -264,7 +264,7 @@ def _open_grid_popup(self): height_not = ( self.viewer.grid.__fields__['shape'].sub_fields[0].type_.ne ) - grid_height.setObjectName("gridStrideBox") + grid_height.setObjectName('gridStrideBox') grid_height.setAlignment(Qt.AlignmentFlag.AlignCenter) grid_height.setMinimum(height_min) grid_height.setProhibitValue(height_not) @@ -272,10 +272,10 @@ def _open_grid_popup(self): grid_height.valueChanged.connect(self._update_grid_height) self.grid_height_box = grid_height - shape_help_symbol.setObjectName("help_label") + shape_help_symbol.setObjectName('help_label') shape_help_symbol.setToolTip(shape_help_msg) - stride_help_symbol.setObjectName("help_label") + stride_help_symbol.setObjectName('help_label') stride_help_symbol.setToolTip(stride_help_msg) # layout @@ -347,21 +347,21 @@ def _func(*args, **kwargs): if len(args) > 1 and not isinstance(args[1], str): warnings.warn( trans._( - "viewer argument is deprecated since 0.4.14 and should not be used" + 'viewer argument is deprecated since 0.4.14 and should not be used' ), category=FutureWarning, stacklevel=2, ) args = args[:1] + args[2:] - if "viewer" in kwargs: + if 'viewer' in kwargs: warnings.warn( trans._( - "viewer argument is deprecated since 0.4.14 and should not be used" + 'viewer argument is deprecated since 0.4.14 and should not be used' ), category=FutureWarning, stacklevel=2, ) - del kwargs["viewer"] + del kwargs['viewer'] return constructor(*args, **kwargs) return _func diff --git a/napari/_qt/widgets/qt_viewer_dock_widget.py b/napari/_qt/widgets/qt_viewer_dock_widget.py index 083e0cd78e8..1b46bff7118 100644 --- a/napari/_qt/widgets/qt_viewer_dock_widget.py +++ b/napari/_qt/widgets/qt_viewer_dock_widget.py @@ -1,9 +1,8 @@ -import contextlib import warnings from functools import reduce from itertools import count from operator import ior -from typing import TYPE_CHECKING, List, Optional, Union +from typing import TYPE_CHECKING, Optional, Union from weakref import ReferenceType, ref from qtpy.QtCore import Qt @@ -31,7 +30,7 @@ _SHORTCUT_DEPRECATION_STRING = trans._( 'The shortcut parameter is deprecated since version 0.4.8, please use the action and shortcut manager APIs. The new action manager and shortcut API allow user configuration and localisation. (got {shortcut})', - shortcut="{shortcut}", + shortcut='{shortcut}', ) @@ -73,7 +72,7 @@ def __init__( *, name: str = '', area: str = 'right', - allowed_areas: Optional[List[str]] = None, + allowed_areas: Optional[list[str]] = None, shortcut=_sentinel, object_name: str = '', add_vertical_stretch=True, @@ -265,22 +264,6 @@ def is_vertical(self): return self.size().height() > self.size().width() def _on_visibility_changed(self, visible): - from napari.viewer import Viewer - - with contextlib.suppress(AttributeError, ValueError): - viewer = self._ref_qt_viewer().viewer - if isinstance(viewer, Viewer): - actions = [ - action.text() - for action in viewer.window.plugins_menu.actions() - ] - idx = actions.index(self.name) - - viewer.window.plugins_menu.actions()[idx].setChecked(visible) - - # AttributeError: This error happens when the plugins menu is not yet built. - # ValueError: This error is when the action is from the windows menu. - if not visible: return with qt_signals_blocked(self): @@ -320,23 +303,23 @@ def __init__( self, parent, title: str = '', vertical=False, close_btn=True ) -> None: super().__init__(parent) - self.setObjectName("QtCustomTitleBar") + self.setObjectName('QtCustomTitleBar') self.setProperty('vertical', str(vertical)) self.vertical = vertical self.setToolTip(trans._('drag to move. double-click to float')) line = QFrame(self) - line.setObjectName("QtCustomTitleBarLine") + line.setObjectName('QtCustomTitleBarLine') self.hide_button = QPushButton(self) self.hide_button.setToolTip(trans._('hide this panel')) - self.hide_button.setObjectName("QTitleBarHideButton") + self.hide_button.setObjectName('QTitleBarHideButton') self.hide_button.setCursor(Qt.CursorShape.ArrowCursor) self.hide_button.clicked.connect(lambda: self.parent().close()) self.float_button = QPushButton(self) self.float_button.setToolTip(trans._('float this panel')) - self.float_button.setObjectName("QTitleBarFloatButton") + self.float_button.setObjectName('QTitleBarFloatButton') self.float_button.setCursor(Qt.CursorShape.ArrowCursor) self.float_button.clicked.connect( lambda: self.parent().setFloating(not self.parent().isFloating()) @@ -349,7 +332,7 @@ def __init__( if close_btn: self.close_button = QPushButton(self) self.close_button.setToolTip(trans._('close this panel')) - self.close_button.setObjectName("QTitleBarCloseButton") + self.close_button.setObjectName('QTitleBarCloseButton') self.close_button.setCursor(Qt.CursorShape.ArrowCursor) self.close_button.clicked.connect( lambda: self.parent().destroyOnClose() diff --git a/napari/_qt/widgets/qt_viewer_status_bar.py b/napari/_qt/widgets/qt_viewer_status_bar.py index 03124b1796e..554b405ad1d 100644 --- a/napari/_qt/widgets/qt_viewer_status_bar.py +++ b/napari/_qt/widgets/qt_viewer_status_bar.py @@ -71,11 +71,11 @@ def setHelpText(self, text: str) -> None: def setStatusText( self, - text: str = "", - layer_base: str = "", + text: str = '', + layer_base: str = '', source_type=None, - plugin: str = "", - coordinates: str = "", + plugin: str = '', + coordinates: str = '', ) -> None: # The method used to set a single value as the status and not # all the layer information. diff --git a/napari/_qt/widgets/qt_welcome.py b/napari/_qt/widgets/qt_welcome.py index 54662633fef..0b583d212f2 100644 --- a/napari/_qt/widgets/qt_welcome.py +++ b/napari/_qt/widgets/qt_welcome.py @@ -28,18 +28,18 @@ class QtShortcutLabel(QLabel): class QtWelcomeWidget(QWidget): """Welcome widget to display initial information and shortcuts to user.""" - sig_dropped = Signal("QEvent") + sig_dropped = Signal('QEvent') def __init__(self, parent) -> None: super().__init__(parent) # Create colored icon using theme self._image = QLabel() - self._image.setObjectName("logo_silhouette") + self._image.setObjectName('logo_silhouette') self._image.setMinimumSize(300, 300) self._label = QtWelcomeLabel( trans._( - "Drag image(s) here to open\nor\nUse the menu shortcuts below:" + 'Drag image(s) here to open\nor\nUse the menu shortcuts below:' ) ) @@ -60,19 +60,19 @@ def __init__(self, parent) -> None: ) shortcut_layout.addRow( QtShortcutLabel(sc), - QtShortcutLabel(trans._("New Image from Clipboard")), + QtShortcutLabel(trans._('New Image from Clipboard')), ) sc = QKeySequence('Ctrl+O', QKeySequence.PortableText).toString( QKeySequence.NativeText ) shortcut_layout.addRow( QtShortcutLabel(sc), - QtShortcutLabel(trans._("open image(s)")), + QtShortcutLabel(trans._('open image(s)')), ) - self._shortcut_label = QtShortcutLabel("") + self._shortcut_label = QtShortcutLabel('') shortcut_layout.addRow( self._shortcut_label, - QtShortcutLabel(trans._("show all key bindings")), + QtShortcutLabel(trans._('show all key bindings')), ) shortcut_layout.setSpacing(0) @@ -98,7 +98,7 @@ def minimumSizeHint(self): def _show_shortcuts_updated(self): shortcut_list = list( - action_manager._shortcuts["napari:show_shortcuts"] + action_manager._shortcuts['napari:show_shortcuts'] ) if not shortcut_list: return @@ -141,7 +141,7 @@ def dragEnterEvent(self, event): event : qtpy.QtCore.QDragEnterEvent Event from the Qt context. """ - self._update_property("drag", True) + self._update_property('drag', True) if event.mimeData().hasUrls(): viewer = self.parentWidget().nativeParentWidget()._qt_viewer viewer._set_drag_status() @@ -159,7 +159,7 @@ def dragLeaveEvent(self, event): event : qtpy.QtCore.QDragLeaveEvent Event from the Qt context. """ - self._update_property("drag", False) + self._update_property('drag', False) def dropEvent(self, event): """Override Qt method. @@ -171,7 +171,7 @@ def dropEvent(self, event): event : qtpy.QtCore.QDropEvent Event from the Qt context. """ - self._update_property("drag", False) + self._update_property('drag', False) self.sig_dropped.emit(event) @@ -180,7 +180,7 @@ class QtWidgetOverlay(QStackedWidget): Stacked widget providing switching between the widget and a welcome page. """ - sig_dropped = Signal("QEvent") + sig_dropped = Signal('QEvent') resized = Signal() leave = Signal() enter = Signal() diff --git a/napari/_tests/test_advanced.py b/napari/_tests/test_advanced.py index 770c0c61a2a..30f9a441566 100644 --- a/napari/_tests/test_advanced.py +++ b/napari/_tests/test_advanced.py @@ -158,7 +158,7 @@ def test_range_one_images_and_points(make_napari_viewer): @pytest.mark.enable_console -@pytest.mark.filterwarnings("ignore::DeprecationWarning:jupyter_client") +@pytest.mark.filterwarnings('ignore::DeprecationWarning:jupyter_client') def test_update_console(make_napari_viewer): """Test updating the console with local variables.""" viewer = make_napari_viewer() @@ -182,7 +182,7 @@ def test_update_console(make_napari_viewer): @pytest.mark.enable_console -@pytest.mark.filterwarnings("ignore::DeprecationWarning:jupyter_client") +@pytest.mark.filterwarnings('ignore::DeprecationWarning:jupyter_client') def test_update_lazy_console(make_napari_viewer, capsys): """Test updating the console with local variables, before console is instantiated.""" @@ -191,12 +191,12 @@ def test_update_lazy_console(make_napari_viewer, capsys): a = 4 b = 5 - viewer.update_console(["a", "b"]) + viewer.update_console(['a', 'b']) x = np.arange(5) - viewer.update_console("x") + viewer.update_console('x') - viewer.update_console("missing") + viewer.update_console('missing') captured = capsys.readouterr() assert 'Could not get' in captured.out with pytest.raises(TypeError): diff --git a/napari/_tests/test_cli.py b/napari/_tests/test_cli.py index 423b3c84657..ad164b062ea 100644 --- a/napari/_tests/test_cli.py +++ b/napari/_tests/test_cli.py @@ -37,7 +37,7 @@ def test_cli_parses_unknowns(mock_run, monkeypatch, make_napari_viewer): v = make_napari_viewer() # our mock view_path will return this object def assert_kwargs(*args, **kwargs): - assert ["file"] in args + assert ['file'] in args assert kwargs['contrast_limits'] == (0, 1) # testing all the variants of literal_evals @@ -158,8 +158,8 @@ def _check_refs(**kwargs): gc.collect() if sys.getrefcount(v) <= ref_count: # pragma: no cover raise AssertionError( - "Reference to napari.viewer has been lost by " - "the time the event loop started in napari.__main__" + 'Reference to napari.viewer has been lost by ' + 'the time the event loop started in napari.__main__' ) mock_run.side_effect = _check_refs diff --git a/napari/_tests/test_conftest_fixtures.py b/napari/_tests/test_conftest_fixtures.py index bf959fec8c2..2767c1780db 100644 --- a/napari/_tests/test_conftest_fixtures.py +++ b/napari/_tests/test_conftest_fixtures.py @@ -49,8 +49,8 @@ def test_disable_qtimer(qtbot): assert not th.isRunning() -@pytest.mark.usefixtures("disable_throttling") -@patch("qtpy.QtCore.QTimer.start") +@pytest.mark.usefixtures('disable_throttling') +@patch('qtpy.QtCore.QTimer.start') def test_disable_throttle(start_mock): mock = Mock() @@ -63,8 +63,8 @@ def f() -> str: mock.assert_called_once() -@patch("qtpy.QtCore.QTimer.start") -@patch("qtpy.QtCore.QTimer.isActive", return_value=True) +@patch('qtpy.QtCore.QTimer.start') +@patch('qtpy.QtCore.QTimer.isActive', return_value=True) def test_lack_disable_throttle(_active_mock, start_mock, monkeypatch): """This is test showing that if we do not use disable_throttling then timer is started""" mock = Mock() diff --git a/napari/_tests/test_examples.py b/napari/_tests/test_examples.py index 85bc7bac5fd..95b925f8aac 100644 --- a/napari/_tests/test_examples.py +++ b/napari/_tests/test_examples.py @@ -29,24 +29,26 @@ 'embed_ipython_.py', # fails without monkeypatch 'new_theme.py', # testing theme is extremely slow on CI 'dynamic-projections-dask.py', # extremely slow / does not finish + 'surface_multi_texture_.py', # resource not available ] +# To skip examples during docs build end name with `_.py` EXAMPLE_DIR = Path(napari.__file__).parent.parent / 'examples' # using f.name here and re-joining at `run_path()` for test key presentation # (works even if the examples list is empty, as opposed to using an ids lambda) -examples = [f.name for f in EXAMPLE_DIR.glob("*.py") if f.name not in skip] +examples = [f.name for f in EXAMPLE_DIR.glob('*.py') if f.name not in skip] # still some CI segfaults, but only on windows with pyqt5 -if os.getenv("CI") and os.name == 'nt' and API_NAME == 'PyQt5': +if os.getenv('CI') and os.name == 'nt' and API_NAME == 'PyQt5': examples = [] -if os.getenv("CI") and os.name == 'nt' and 'to_screenshot.py' in examples: +if os.getenv('CI') and os.name == 'nt' and 'to_screenshot.py' in examples: examples.remove('to_screenshot.py') -@pytest.mark.filterwarnings("ignore") -@pytest.mark.skipif(not examples, reason="No examples were found.") -@pytest.mark.parametrize("fname", examples) +@pytest.mark.filterwarnings('ignore') +@pytest.mark.skipif(not examples, reason='No examples were found.') +@pytest.mark.parametrize('fname', examples) def test_examples(builtins, fname, monkeypatch): """Test that all of our examples are still working without warnings.""" diff --git a/napari/_tests/test_import_time.py b/napari/_tests/test_import_time.py index 4800d288632..9b9f2c54d7f 100644 --- a/napari/_tests/test_import_time.py +++ b/napari/_tests/test_import_time.py @@ -13,13 +13,13 @@ def test_import_time(tmp_path): proc = subprocess.run(cmd, capture_output=True, check=True) log = proc.stderr.decode() last_line = log.splitlines()[-1] - time, name = (i.strip() for i in last_line.split("|")[-2:]) + time, name = (i.strip() for i in last_line.split('|')[-2:]) # # This is too hard to do on CI... but we have the time here if we can # # figure out how to use it # assert name == 'napari' # assert int(time) < 1_000_000, "napari import taking longer than 1 sec!" - print(f"\nnapari took {int(time)/1e6:0.3f} seconds to import") + print(f'\nnapari took {int(time)/1e6:0.3f} seconds to import') # common culprit of slow imports assert 'pkg_resources' not in log diff --git a/napari/_tests/test_magicgui.py b/napari/_tests/test_magicgui.py index 97333b995f2..f975fce5b90 100644 --- a/napari/_tests/test_magicgui.py +++ b/napari/_tests/test_magicgui.py @@ -1,7 +1,7 @@ import contextlib import sys import time -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING import numpy as np import pytest @@ -190,7 +190,7 @@ def test_magicgui_add_layer_list(make_napari_viewer): viewer = make_napari_viewer() @magicgui - def add_layer() -> List[Layer]: + def add_layer() -> list[Layer]: a = Image(data=np.random.randint(0, 10, size=(10, 10))) b = Labels(data=np.random.randint(0, 10, size=(10, 10))) return [a, b] @@ -230,7 +230,7 @@ def test_magicgui_add_layer_data_tuple_list(make_napari_viewer): viewer = make_napari_viewer() @magicgui - def add_layer() -> List[types.LayerDataTuple]: + def add_layer() -> list[types.LayerDataTuple]: data1 = (np.random.rand(10, 10), {'name': 'hi'}) data2 = ( np.random.randint(0, 10, size=(10, 10)), @@ -339,7 +339,7 @@ def test_layers_populate_immediately(make_napari_viewer): """make sure that the layers dropdown is populated upon adding to viewer""" from magicgui.widgets import create_widget - labels_layer = create_widget(annotation=Labels, label="ROI") + labels_layer = create_widget(annotation=Labels, label='ROI') viewer = make_napari_viewer() viewer.add_labels(np.zeros((10, 10), dtype=int)) assert not len(labels_layer.choices) diff --git a/napari/_tests/test_mouse_bindings.py b/napari/_tests/test_mouse_bindings.py index 34483576509..54084088831 100644 --- a/napari/_tests/test_mouse_bindings.py +++ b/napari/_tests/test_mouse_bindings.py @@ -13,7 +13,7 @@ def test_viewer_mouse_bindings(qtbot, make_napari_viewer): viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas - if os.getenv("CI"): + if os.getenv('CI'): viewer.show() mock_press = Mock() @@ -94,7 +94,7 @@ def test_layer_mouse_bindings(qtbot, make_napari_viewer): viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas - if os.getenv("CI"): + if os.getenv('CI'): viewer.show() layer = viewer.add_image(np.random.random((10, 20))) @@ -177,7 +177,7 @@ def test_unselected_layer_mouse_bindings(qtbot, make_napari_viewer): viewer = make_napari_viewer() canvas = viewer.window._qt_viewer.canvas - if os.getenv("CI"): + if os.getenv('CI'): viewer.show() layer = viewer.add_image(np.random.random((10, 20))) diff --git a/napari/_tests/test_notebook_display.py b/napari/_tests/test_notebook_display.py index 5c8feb35d7f..3ff0ffe2a4e 100644 --- a/napari/_tests/test_notebook_display.py +++ b/napari/_tests/test_notebook_display.py @@ -31,38 +31,38 @@ def test_nbscreenshot(make_napari_viewer): @skip_on_win_ci @pytest.mark.parametrize( - "alt_text_input, expected_alt_text", + 'alt_text_input, expected_alt_text', [ (None, None), - ("Good alt text", "Good alt text"), + ('Good alt text', 'Good alt text'), # Naughty strings https://github.com/minimaxir/big-list-of-naughty-strings # ASCII punctuation (r",./;'[]\-=", ',./;'[]\\-='), # ASCII punctuation 2, skipping < because that is interpreted as the start # of an HTML element. ('>?:"{}|_+', '>?:"{}|_+'), - ("!@#$%^&*()`~", '!@#$%^&*()`~'), # ASCII punctuation 3 + ('!@#$%^&*()`~', '!@#$%^&*()`~'), # ASCII punctuation 3 # # Emojis - ("😍", "😍"), # emoji 1 + ('😍', '😍'), # emoji 1 ( - "👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️", - "👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️", + '👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️', + '👨‍🦰 👨🏿‍🦰 👨‍🦱 👨🏿‍🦱 🦹🏿‍♂️', ), # emoji 2 - (r"¯\_(ツ)_/¯", '¯\\_(ツ)_/¯'), # Japanese emoticon + (r'¯\_(ツ)_/¯', '¯\\_(ツ)_/¯'), # Japanese emoticon # # Special characters ( - "田中さんにあげて下さい", - "田中さんにあげて下さい", + '田中さんにあげて下さい', + '田中さんにあげて下さい', ), # two-byte characters ( - "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀", # noqa: RUF001 - "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀", # noqa: RUF001 + '表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀', # noqa: RUF001 + '表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀', # noqa: RUF001 ), # special unicode chars - ("گچپژ", "گچپژ"), # Persian special characters + ('گچپژ', 'گچپژ'), # Persian special characters # # Script injection - ("", None), # script injection 1 - ("<script>alert('1');</script>", None), - ("", None), + ('', None), # script injection 1 + ('<script>alert('1');</script>', None), + ('', None), ], ) def test_safe_alt_text(alt_text_input, expected_alt_text): @@ -76,10 +76,10 @@ def test_safe_alt_text(alt_text_input, expected_alt_text): def test_invalid_alt_text(): with pytest.warns(UserWarning): # because string with only whitespace messes up with the parser - display_obj = nbscreenshot(Mock(), alt_text=" ") + display_obj = nbscreenshot(Mock(), alt_text=' ') assert display_obj.alt_text is None with pytest.warns(UserWarning): # because string with only whitespace messes up with the parser - display_obj = nbscreenshot(Mock(), alt_text="") + display_obj = nbscreenshot(Mock(), alt_text='') assert display_obj.alt_text is None diff --git a/napari/_tests/test_providers.py b/napari/_tests/test_providers.py new file mode 100644 index 00000000000..05732f80749 --- /dev/null +++ b/napari/_tests/test_providers.py @@ -0,0 +1,65 @@ +"""Test app-model providers. + +Because `_provide_viewer` needs `_QtMainWindow` (otherwise returns `None`) +tests are here in `napari/_tests`, which are not run in headless mode. +""" + +import pytest +from app_model.types import Action + +from napari._app_model._app import get_app +from napari._app_model.injection._providers import ( + _provide_viewer, + _provide_viewer_or_raise, +) +from napari.utils._proxies import PublicOnlyProxy +from napari.viewer import Viewer + + +def test_publicproxy_provide_viewer(capsys, make_napari_viewer): + """Test `_provide_viewer` outputs a `PublicOnlyProxy` when appropriate. + + Check manual (e.g., internal) `_provide_viewer` calls can disable + `PublicOnlyProxy` via `public_proxy` parameter but `PublicOnlyProxy` is always + used when it is used as a provider. + """ + # No current viewer, `None` should be returned + viewer = _provide_viewer() + assert viewer is None + + # Create a viewer + make_napari_viewer() + # Ensure we can disable via `public_proxy` + viewer = _provide_viewer(public_proxy=False) + assert isinstance(viewer, Viewer) + + # Ensure we get a `PublicOnlyProxy` when used as a provider + def my_viewer(viewer: Viewer) -> Viewer: + # Allows us to check type when `Action` executed + print(type(viewer)) + + action = Action( + id='some.command.id', + title='some title', + callback=my_viewer, + ) + app = get_app() + app.register_action(action) + app.commands.execute_command('some.command.id') + captured = capsys.readouterr() + assert 'napari.utils._proxies.PublicOnlyProxy' in captured.out + + +def test_provide_viewer_or_raise(make_napari_viewer): + """Check `_provide_viewer_or_raise` raises or returns correct `Viewer`.""" + # raises when no viewer + with pytest.raises(RuntimeError, match='No current `Viewer` found. test'): + _provide_viewer_or_raise(msg='test') + + # create viewer + make_napari_viewer() + viewer = _provide_viewer_or_raise() + assert isinstance(viewer, Viewer) + + viewer = _provide_viewer_or_raise(public_proxy=True) + assert isinstance(viewer, PublicOnlyProxy) diff --git a/napari/_tests/test_pytest_plugin.py b/napari/_tests/test_pytest_plugin.py index f74837059a6..20816d43e22 100644 --- a/napari/_tests/test_pytest_plugin.py +++ b/napari/_tests/test_pytest_plugin.py @@ -6,11 +6,11 @@ import pytest -pytest_plugins = "pytester" +pytest_plugins = 'pytester' -@pytest.mark.filterwarnings("ignore:`type` argument to addoption()::") -@pytest.mark.filterwarnings("ignore:The TerminalReporter.writer::") +@pytest.mark.filterwarnings('ignore:`type` argument to addoption()::') +@pytest.mark.filterwarnings('ignore:The TerminalReporter.writer::') def test_make_napari_viewer(pytester_pretty): """Make sure that our make_napari_viewer plugin works.""" diff --git a/napari/_tests/test_view_layers.py b/napari/_tests/test_view_layers.py index a5f33520960..d617cadb2b5 100644 --- a/napari/_tests/test_view_layers.py +++ b/napari/_tests/test_view_layers.py @@ -42,7 +42,10 @@ def test_docstring(layer): # check summary section method_summary = ' '.join(method_doc['Summary']) # join multi-line summary - summary_format = 'Add an? .+? layer to the layer list.' + if name == 'Image': + summary_format = 'Add one or more Image layers to the layer list.' + else: + summary_format = 'Add an? .+? layers? to the layer list.' assert re.match( summary_format, method_summary diff --git a/napari/_tests/test_viewer.py b/napari/_tests/test_viewer.py index fcacff36e31..fb209622e98 100644 --- a/napari/_tests/test_viewer.py +++ b/napari/_tests/test_viewer.py @@ -67,10 +67,10 @@ def test_non_existing_bindings(): def test_viewer_actions(make_napari_viewer, func): viewer = make_napari_viewer() - if func.__name__ == 'toggle_fullscreen' and not os.getenv("CI"): - pytest.skip("Fullscreen cannot be tested in CI") + if func.__name__ == 'toggle_fullscreen' and not os.getenv('CI'): + pytest.skip('Fullscreen cannot be tested in CI') if func.__name__ == 'play': - pytest.skip("Play cannot be tested with Pytest") + pytest.skip('Play cannot be tested with Pytest') func(viewer) @@ -137,10 +137,10 @@ def test_add_layer_magic_name( # Tests for issue #1709 viewer = make_napari_viewer() # noqa: F841 layer = eval_with_filename( - "add_layer_by_type(viewer, layer_class, a_unique_name)", - "somefile.py", + 'add_layer_by_type(viewer, layer_class, a_unique_name)', + 'somefile.py', ) - assert layer.name == "a_unique_name" + assert layer.name == 'a_unique_name' @skip_on_win_ci @@ -208,7 +208,7 @@ def test_changing_theme(make_napari_viewer): equal = (screenshot_dark == screenshot_light).min(-1) # more than 99.5% of the pixels have changed - assert (np.count_nonzero(equal) / equal.size) < 0.05, "Themes too similar" + assert (np.count_nonzero(equal) / equal.size) < 0.05, 'Themes too similar' with pytest.raises(ValueError): viewer.theme = 'nonexistent_theme' diff --git a/napari/_tests/test_windowsettings.py b/napari/_tests/test_windowsettings.py index 84f7bf27266..74f47ea2332 100644 --- a/napari/_tests/test_windowsettings.py +++ b/napari/_tests/test_windowsettings.py @@ -21,7 +21,7 @@ def test_singlescreen_window_settings(make_napari_viewer, monkeypatch): """Test whether valid screen position is returned even after disconnected secondary screen.""" monkeypatch.setattr( - "napari._qt.qt_main_window.QApplication.screenAt", screen_at + 'napari._qt.qt_main_window.QApplication.screenAt', screen_at ) settings = get_settings() viewer = make_napari_viewer() diff --git a/napari/_tests/test_with_screenshot.py b/napari/_tests/test_with_screenshot.py index 5e043776817..9e61b4ef116 100644 --- a/napari/_tests/test_with_screenshot.py +++ b/napari/_tests/test_with_screenshot.py @@ -407,7 +407,7 @@ def test_labels_painting(make_napari_viewer): assert screenshot[:, :, :2].max() > 0 -@pytest.mark.skip("Welcome visual temporarily disabled") +@pytest.mark.skip('Welcome visual temporarily disabled') @skip_on_win_ci @skip_local_popups def test_welcome(make_napari_viewer): diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py index 56df52ff899..2a11848c938 100644 --- a/napari/_tests/utils.py +++ b/napari/_tests/utils.py @@ -3,7 +3,7 @@ from collections import abc from contextlib import suppress from threading import RLock -from typing import Any, Dict, Tuple, Union +from typing import Any, Union import networkx as nx import numpy as np @@ -32,12 +32,23 @@ reason='Screenshot tests are not supported on windows CI.', ) +skip_on_mac_ci = pytest.mark.skipif( + sys.platform.startswith('darwin') and os.getenv('CI', '0') != '0', + reason='Unsupported test on macOS CI.', +) + skip_local_popups = pytest.mark.skipif( not os.getenv('CI') and os.getenv('NAPARI_POPUP_TESTS', '0') == '0', reason='Tests requiring GUI windows are skipped locally by default.' ' Set NAPARI_POPUP_TESTS=1 environment variable to enable.', ) +skip_local_focus = pytest.mark.skipif( + not os.getenv('CI') and os.getenv('NAPARI_FOCUS_TESTS', '0') == '0', + reason='Tests requiring GUI windows focus are skipped locally by default.' + ' Set NAPARI_FOCUS_TESTS=1 environment variable to enable.', +) + """ The default timeout duration in seconds when waiting on tasks running in non-main threads. The value was chosen to be consistent with `QtBot.waitSignal` and `QtBot.waitUntil`. @@ -166,7 +177,7 @@ def dtype(self) -> DTypeLike: return self.data.dtype @property - def shape(self) -> Tuple[int, ...]: + def shape(self) -> tuple[int, ...]: return self.data.shape @property @@ -175,7 +186,7 @@ def ndim(self) -> int: return len(self.data.shape) def __getitem__( - self, key: Union[Index, Tuple[Index, ...], LayerDataProtocol] + self, key: Union[Index, tuple[Index, ...], LayerDataProtocol] ) -> LayerDataProtocol: with self.lock: return self.data[key] @@ -309,7 +320,7 @@ def check_layer_world_data_extent(layer, extent, scale, translate): def assert_layer_state_equal( - actual: Dict[str, Any], expected: Dict[str, Any] + actual: dict[str, Any], expected: dict[str, Any] ) -> None: """Asserts that an layer state dictionary is equal to an expected one. diff --git a/napari/_vendor/experimental/cachetools/CHANGELOG.rst b/napari/_vendor/experimental/cachetools/CHANGELOG.rst deleted file mode 100644 index eb28e16ba7c..00000000000 --- a/napari/_vendor/experimental/cachetools/CHANGELOG.rst +++ /dev/null @@ -1,319 +0,0 @@ -v4.1.1 (2020-06-28) -=================== - -- Improve ``popitem()`` exception context handling. - -- Replace ``float('inf')`` with ``math.inf``. - -- Improve "envkey" documentation example. - - -v4.1.0 (2020-04-08) -=================== - -- Support ``user_function`` with ``cachetools.func`` decorators - (Python 3.8 compatibility). - -- Support ``cache_parameters()`` with ``cachetools.func`` decorators - (Python 3.9 compatibility). - - -v4.0.0 (2019-12-15) -=================== - -- Require Python 3.5 or later. - - -v3.1.1 (2019-05-23) -=================== - -- Document how to use shared caches with ``@cachedmethod``. - -- Fix pickling/unpickling of cache keys - - -v3.1.0 (2019-01-29) -=================== - -- Fix Python 3.8 compatibility issue. - -- Use ``time.monotonic`` as default timer if available. - -- Improve documentation regarding thread safety. - - -v3.0.0 (2018-11-04) -=================== - -- Officially support Python 3.7. - -- Drop Python 3.3 support (breaking change). - -- Remove ``missing`` cache constructor parameter (breaking change). - -- Remove ``self`` from ``@cachedmethod`` key arguments (breaking - change). - -- Add support for ``maxsize=None`` in ``cachetools.func`` decorators. - - -v2.1.0 (2018-05-12) -=================== - -- Deprecate ``missing`` cache constructor parameter. - -- Handle overridden ``getsizeof()`` method in subclasses. - -- Fix Python 2.7 ``RRCache`` pickling issues. - -- Various documentation improvements. - - -v2.0.1 (2017-08-11) -=================== - -- Officially support Python 3.6. - -- Move documentation to RTD. - -- Documentation: Update import paths for key functions (courtesy of - slavkoja). - - -v2.0.0 (2016-10-03) -=================== - -- Drop Python 3.2 support (breaking change). - -- Drop support for deprecated features (breaking change). - -- Move key functions to separate package (breaking change). - -- Accept non-integer ``maxsize`` in ``Cache.__repr__()``. - - -v1.1.6 (2016-04-01) -=================== - -- Reimplement ``LRUCache`` and ``TTLCache`` using - ``collections.OrderedDict``. Note that this will break pickle - compatibility with previous versions. - -- Fix ``TTLCache`` not calling ``__missing__()`` of derived classes. - -- Handle ``ValueError`` in ``Cache.__missing__()`` for consistency - with caching decorators. - -- Improve how ``TTLCache`` handles expired items. - -- Use ``Counter.most_common()`` for ``LFUCache.popitem()``. - - -v1.1.5 (2015-10-25) -=================== - -- Refactor ``Cache`` base class. Note that this will break pickle - compatibility with previous versions. - -- Clean up ``LRUCache`` and ``TTLCache`` implementations. - - -v1.1.4 (2015-10-24) -=================== - -- Refactor ``LRUCache`` and ``TTLCache`` implementations. Note that - this will break pickle compatibility with previous versions. - -- Document pending removal of deprecated features. - -- Minor documentation improvements. - - -v1.1.3 (2015-09-15) -=================== - -- Fix pickle tests. - - -v1.1.2 (2015-09-15) -=================== - -- Fix pickling of large ``LRUCache`` and ``TTLCache`` instances. - - -v1.1.1 (2015-09-07) -=================== - -- Improve key functions. - -- Improve documentation. - -- Improve unit test coverage. - - -v1.1.0 (2015-08-28) -=================== - -- Add ``@cached`` function decorator. - -- Add ``hashkey`` and ``typedkey`` fuctions. - -- Add `key` and `lock` arguments to ``@cachedmethod``. - -- Set ``__wrapped__`` attributes for Python versions < 3.2. - -- Move ``functools`` compatible decorators to ``cachetools.func``. - -- Deprecate ``@cachedmethod`` `typed` argument. - -- Deprecate `cache` attribute for ``@cachedmethod`` wrappers. - -- Deprecate `getsizeof` and `lock` arguments for `cachetools.func` - decorator. - - -v1.0.3 (2015-06-26) -=================== - -- Clear cache statistics when calling ``clear_cache()``. - - -v1.0.2 (2015-06-18) -=================== - -- Allow simple cache instances to be pickled. - -- Refactor ``Cache.getsizeof`` and ``Cache.missing`` default - implementation. - - -v1.0.1 (2015-06-06) -=================== - -- Code cleanup for improved PEP 8 conformance. - -- Add documentation and unit tests for using ``@cachedmethod`` with - generic mutable mappings. - -- Improve documentation. - - -v1.0.0 (2014-12-19) -=================== - -- Provide ``RRCache.choice`` property. - -- Improve documentation. - - -v0.8.2 (2014-12-15) -=================== - -- Use a ``NestedTimer`` for ``TTLCache``. - - -v0.8.1 (2014-12-07) -=================== - -- Deprecate ``Cache.getsize()``. - - -v0.8.0 (2014-12-03) -=================== - -- Ignore ``ValueError`` raised on cache insertion in decorators. - -- Add ``Cache.getsize()``. - -- Add ``Cache.__missing__()``. - -- Feature freeze for `v1.0`. - - -v0.7.1 (2014-11-22) -=================== - -- Fix `MANIFEST.in`. - - -v0.7.0 (2014-11-12) -=================== - -- Deprecate ``TTLCache.ExpiredError``. - -- Add `choice` argument to ``RRCache`` constructor. - -- Refactor ``LFUCache``, ``LRUCache`` and ``TTLCache``. - -- Use custom ``NullContext`` implementation for unsynchronized - function decorators. - - -v0.6.0 (2014-10-13) -=================== - -- Raise ``TTLCache.ExpiredError`` for expired ``TTLCache`` items. - -- Support unsynchronized function decorators. - -- Allow ``@cachedmethod.cache()`` to return None - - -v0.5.1 (2014-09-25) -=================== - -- No formatting of ``KeyError`` arguments. - -- Update ``README.rst``. - - -v0.5.0 (2014-09-23) -=================== - -- Do not delete expired items in TTLCache.__getitem__(). - -- Add ``@ttl_cache`` function decorator. - -- Fix public ``getsizeof()`` usage. - - -v0.4.0 (2014-06-16) -=================== - -- Add ``TTLCache``. - -- Add ``Cache`` base class. - -- Remove ``@cachedmethod`` `lock` parameter. - - -v0.3.1 (2014-05-07) -=================== - -- Add proper locking for ``cache_clear()`` and ``cache_info()``. - -- Report `size` in ``cache_info()``. - - -v0.3.0 (2014-05-06) -=================== - -- Remove ``@cache`` decorator. - -- Add ``size``, ``getsizeof`` members. - -- Add ``@cachedmethod`` decorator. - - -v0.2.0 (2014-04-02) -=================== - -- Add ``@cache`` decorator. - -- Update documentation. - - -v0.1.0 (2014-03-27) -=================== - -- Initial release. diff --git a/napari/_vendor/experimental/cachetools/LICENSE b/napari/_vendor/experimental/cachetools/LICENSE deleted file mode 100644 index 0dc18643434..00000000000 --- a/napari/_vendor/experimental/cachetools/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014-2020 Thomas Kemmer - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/napari/_vendor/experimental/cachetools/__init__.py b/napari/_vendor/experimental/cachetools/__init__.py deleted file mode 100644 index 4e0a9c481e9..00000000000 --- a/napari/_vendor/experimental/cachetools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .cachetools.lru import LRUCache diff --git a/napari/_vendor/experimental/cachetools/cachetools/__init__.py b/napari/_vendor/experimental/cachetools/cachetools/__init__.py deleted file mode 100644 index 51d8f7c82f1..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Extensible memoizing collections and decorators.""" - -from .cache import Cache -from .decorators import cached, cachedmethod -from .lfu import LFUCache -from .lru import LRUCache -from .rr import RRCache -from .ttl import TTLCache - -__all__ = ( - 'Cache', - 'LFUCache', - 'LRUCache', - 'RRCache', - 'TTLCache', - 'cached', - 'cachedmethod' -) - -__version__ = '4.1.1' diff --git a/napari/_vendor/experimental/cachetools/cachetools/abc.py b/napari/_vendor/experimental/cachetools/cachetools/abc.py deleted file mode 100644 index b61e49bba64..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/abc.py +++ /dev/null @@ -1,46 +0,0 @@ -from abc import abstractmethod -from collections.abc import MutableMapping - - -class DefaultMapping(MutableMapping): - - __slots__ = () - - @abstractmethod - def __contains__(self, key): # pragma: nocover - return False - - @abstractmethod - def __getitem__(self, key): # pragma: nocover - if hasattr(self.__class__, '__missing__'): - return self.__class__.__missing__(self, key) - else: - raise KeyError(key) - - def get(self, key, default=None): - if key in self: - return self[key] - else: - return default - - __marker = object() - - def pop(self, key, default=__marker): - if key in self: - value = self[key] - del self[key] - elif default is self.__marker: - raise KeyError(key) - else: - value = default - return value - - def setdefault(self, key, default=None): - if key in self: - value = self[key] - else: - self[key] = value = default - return value - - -DefaultMapping.register(dict) diff --git a/napari/_vendor/experimental/cachetools/cachetools/cache.py b/napari/_vendor/experimental/cachetools/cachetools/cache.py deleted file mode 100644 index 4354ca69bee..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/cache.py +++ /dev/null @@ -1,89 +0,0 @@ -from .abc import DefaultMapping - - -class _DefaultSize(object): - def __getitem__(self, _): - return 1 - - def __setitem__(self, _, value): - assert value == 1 - - def pop(self, _): - return 1 - - -class Cache(DefaultMapping): - """Mutable mapping to serve as a simple cache or cache base class.""" - - __size = _DefaultSize() - - def __init__(self, maxsize, getsizeof=None): - if getsizeof: - self.getsizeof = getsizeof - if self.getsizeof is not Cache.getsizeof: - self.__size = dict() - self.__data = dict() - self.__currsize = 0 - self.__maxsize = maxsize - - def __repr__(self): - return '%s(%r, maxsize=%r, currsize=%r)' % ( - self.__class__.__name__, - list(self.__data.items()), - self.__maxsize, - self.__currsize, - ) - - def __getitem__(self, key): - try: - return self.__data[key] - except KeyError: - return self.__missing__(key) - - def __setitem__(self, key, value): - maxsize = self.__maxsize - size = self.getsizeof(value) - if size > maxsize: - raise ValueError('value too large') - if key not in self.__data or self.__size[key] < size: - while self.__currsize + size > maxsize: - self.popitem() - if key in self.__data: - diffsize = size - self.__size[key] - else: - diffsize = size - self.__data[key] = value - self.__size[key] = size - self.__currsize += diffsize - - def __delitem__(self, key): - size = self.__size.pop(key) - del self.__data[key] - self.__currsize -= size - - def __contains__(self, key): - return key in self.__data - - def __missing__(self, key): - raise KeyError(key) - - def __iter__(self): - return iter(self.__data) - - def __len__(self): - return len(self.__data) - - @property - def maxsize(self): - """The maximum size of the cache.""" - return self.__maxsize - - @property - def currsize(self): - """The current size of the cache.""" - return self.__currsize - - @staticmethod - def getsizeof(value): - """Return the size of a cache element's value.""" - return 1 diff --git a/napari/_vendor/experimental/cachetools/cachetools/decorators.py b/napari/_vendor/experimental/cachetools/cachetools/decorators.py deleted file mode 100644 index cbea9fcb381..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/decorators.py +++ /dev/null @@ -1,88 +0,0 @@ -import functools - -from .keys import hashkey - - -def cached(cache, key=hashkey, lock=None): - """Decorator to wrap a function with a memoizing callable that saves - results in a cache. - - """ - def decorator(func): - if cache is None: - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - elif lock is None: - def wrapper(*args, **kwargs): - k = key(*args, **kwargs) - try: - return cache[k] - except KeyError: - pass # key not found - v = func(*args, **kwargs) - try: - cache[k] = v - except ValueError: - pass # value too large - return v - else: - def wrapper(*args, **kwargs): - k = key(*args, **kwargs) - try: - with lock: - return cache[k] - except KeyError: - pass # key not found - v = func(*args, **kwargs) - try: - with lock: - cache[k] = v - except ValueError: - pass # value too large - return v - return functools.update_wrapper(wrapper, func) - return decorator - - -def cachedmethod(cache, key=hashkey, lock=None): - """Decorator to wrap a class or instance method with a memoizing - callable that saves results in a cache. - - """ - def decorator(method): - if lock is None: - def wrapper(self, *args, **kwargs): - c = cache(self) - if c is None: - return method(self, *args, **kwargs) - k = key(*args, **kwargs) - try: - return c[k] - except KeyError: - pass # key not found - v = method(self, *args, **kwargs) - try: - c[k] = v - except ValueError: - pass # value too large - return v - else: - def wrapper(self, *args, **kwargs): - c = cache(self) - if c is None: - return method(self, *args, **kwargs) - k = key(*args, **kwargs) - try: - with lock(self): - return c[k] - except KeyError: - pass # key not found - v = method(self, *args, **kwargs) - try: - with lock(self): - c[k] = v - except ValueError: - pass # value too large - return v - return functools.update_wrapper(wrapper, method) - return decorator diff --git a/napari/_vendor/experimental/cachetools/cachetools/func.py b/napari/_vendor/experimental/cachetools/cachetools/func.py deleted file mode 100644 index 5baf6de7edb..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/func.py +++ /dev/null @@ -1,147 +0,0 @@ -"""`functools.lru_cache` compatible memoizing function decorators.""" - -import collections -import functools -import math -import random -import time - -try: - from threading import RLock -except ImportError: # pragma: no cover - from dummy_threading import RLock - -from . import keys -from .lfu import LFUCache -from .lru import LRUCache -from .rr import RRCache -from .ttl import TTLCache - -__all__ = ('lfu_cache', 'lru_cache', 'rr_cache', 'ttl_cache') - - -_CacheInfo = collections.namedtuple('CacheInfo', [ - 'hits', 'misses', 'maxsize', 'currsize' -]) - - -class _UnboundCache(dict): - - @property - def maxsize(self): - return None - - @property - def currsize(self): - return len(self) - - -class _UnboundTTLCache(TTLCache): - def __init__(self, ttl, timer): - TTLCache.__init__(self, math.inf, ttl, timer) - - @property - def maxsize(self): - return None - - -def _cache(cache, typed): - maxsize = cache.maxsize - - def decorator(func): - key = keys.typedkey if typed else keys.hashkey - lock = RLock() - stats = [0, 0] - - def wrapper(*args, **kwargs): - k = key(*args, **kwargs) - with lock: - try: - v = cache[k] - stats[0] += 1 - return v - except KeyError: - stats[1] += 1 - v = func(*args, **kwargs) - try: - with lock: - cache[k] = v - except ValueError: - pass # value too large - return v - - def cache_info(): - with lock: - hits, misses = stats - maxsize = cache.maxsize - currsize = cache.currsize - return _CacheInfo(hits, misses, maxsize, currsize) - - def cache_clear(): - with lock: - try: - cache.clear() - finally: - stats[:] = [0, 0] - - wrapper.cache_info = cache_info - wrapper.cache_clear = cache_clear - wrapper.cache_parameters = lambda: {'maxsize': maxsize, 'typed': typed} - functools.update_wrapper(wrapper, func) - return wrapper - return decorator - - -def lfu_cache(maxsize=128, typed=False): - """Decorator to wrap a function with a memoizing callable that saves - up to `maxsize` results based on a Least Frequently Used (LFU) - algorithm. - - """ - if maxsize is None: - return _cache(_UnboundCache(), typed) - elif callable(maxsize): - return _cache(LFUCache(128), typed)(maxsize) - else: - return _cache(LFUCache(maxsize), typed) - - -def lru_cache(maxsize=128, typed=False): - """Decorator to wrap a function with a memoizing callable that saves - up to `maxsize` results based on a Least Recently Used (LRU) - algorithm. - - """ - if maxsize is None: - return _cache(_UnboundCache(), typed) - elif callable(maxsize): - return _cache(LRUCache(128), typed)(maxsize) - else: - return _cache(LRUCache(maxsize), typed) - - -def rr_cache(maxsize=128, choice=random.choice, typed=False): - """Decorator to wrap a function with a memoizing callable that saves - up to `maxsize` results based on a Random Replacement (RR) - algorithm. - - """ - if maxsize is None: - return _cache(_UnboundCache(), typed) - elif callable(maxsize): - return _cache(RRCache(128, choice), typed)(maxsize) - else: - return _cache(RRCache(maxsize, choice), typed) - - -def ttl_cache(maxsize=128, ttl=600, timer=time.monotonic, typed=False): - """Decorator to wrap a function with a memoizing callable that saves - up to `maxsize` results based on a Least Recently Used (LRU) - algorithm with a per-item time-to-live (TTL) value. - """ - if maxsize is None: - return _cache(_UnboundTTLCache(ttl, timer), typed) - elif callable(maxsize): - return _cache(TTLCache(128, ttl, timer), typed)(maxsize) - else: - return _cache(TTLCache(maxsize, ttl, timer), typed) diff --git a/napari/_vendor/experimental/cachetools/cachetools/keys.py b/napari/_vendor/experimental/cachetools/cachetools/keys.py deleted file mode 100644 index 355d742df21..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/keys.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Key functions for memoizing decorators.""" - -__all__ = ('hashkey', 'typedkey') - - -class _HashedTuple(tuple): - """A tuple that ensures that hash() will be called no more than once - per element, since cache decorators will hash the key multiple - times on a cache miss. See also _HashedSeq in the standard - library functools implementation. - - """ - - __hashvalue = None - - def __hash__(self, hash=tuple.__hash__): - hashvalue = self.__hashvalue - if hashvalue is None: - self.__hashvalue = hashvalue = hash(self) - return hashvalue - - def __add__(self, other, add=tuple.__add__): - return _HashedTuple(add(self, other)) - - def __radd__(self, other, add=tuple.__add__): - return _HashedTuple(add(other, self)) - - def __getstate__(self): - return {} - - -# used for separating keyword arguments; we do not use an object -# instance here so identity is preserved when pickling/unpickling -_kwmark = (_HashedTuple,) - - -def hashkey(*args, **kwargs): - """Return a cache key for the specified hashable arguments.""" - - if kwargs: - return _HashedTuple(args + sum(sorted(kwargs.items()), _kwmark)) - else: - return _HashedTuple(args) - - -def typedkey(*args, **kwargs): - """Return a typed cache key for the specified hashable arguments.""" - - key = hashkey(*args, **kwargs) - key += tuple(type(v) for v in args) - key += tuple(type(v) for _, v in sorted(kwargs.items())) - return key diff --git a/napari/_vendor/experimental/cachetools/cachetools/lfu.py b/napari/_vendor/experimental/cachetools/cachetools/lfu.py deleted file mode 100644 index adb45ee27c9..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/lfu.py +++ /dev/null @@ -1,34 +0,0 @@ -import collections - -from .cache import Cache - - -class LFUCache(Cache): - """Least Frequently Used (LFU) cache implementation.""" - - def __init__(self, maxsize, getsizeof=None): - Cache.__init__(self, maxsize, getsizeof) - self.__counter = collections.Counter() - - def __getitem__(self, key, cache_getitem=Cache.__getitem__): - value = cache_getitem(self, key) - self.__counter[key] -= 1 - return value - - def __setitem__(self, key, value, cache_setitem=Cache.__setitem__): - cache_setitem(self, key, value) - self.__counter[key] -= 1 - - def __delitem__(self, key, cache_delitem=Cache.__delitem__): - cache_delitem(self, key) - del self.__counter[key] - - def popitem(self): - """Remove and return the `(key, value)` pair least frequently used.""" - try: - (key, _), = self.__counter.most_common(1) - except ValueError: - msg = '%s is empty' % self.__class__.__name__ - raise KeyError(msg) from None - else: - return (key, self.pop(key)) diff --git a/napari/_vendor/experimental/cachetools/cachetools/lru.py b/napari/_vendor/experimental/cachetools/cachetools/lru.py deleted file mode 100644 index 7634f9cf4e1..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/lru.py +++ /dev/null @@ -1,40 +0,0 @@ -import collections - -from .cache import Cache - - -class LRUCache(Cache): - """Least Recently Used (LRU) cache implementation.""" - - def __init__(self, maxsize, getsizeof=None): - Cache.__init__(self, maxsize, getsizeof) - self.__order = collections.OrderedDict() - - def __getitem__(self, key, cache_getitem=Cache.__getitem__): - value = cache_getitem(self, key) - self.__update(key) - return value - - def __setitem__(self, key, value, cache_setitem=Cache.__setitem__): - cache_setitem(self, key, value) - self.__update(key) - - def __delitem__(self, key, cache_delitem=Cache.__delitem__): - cache_delitem(self, key) - del self.__order[key] - - def popitem(self): - """Remove and return the `(key, value)` pair least recently used.""" - try: - key = next(iter(self.__order)) - except StopIteration: - msg = '%s is empty' % self.__class__.__name__ - raise KeyError(msg) from None - else: - return (key, self.pop(key)) - - def __update(self, key): - try: - self.__order.move_to_end(key) - except KeyError: - self.__order[key] = None diff --git a/napari/_vendor/experimental/cachetools/cachetools/rr.py b/napari/_vendor/experimental/cachetools/cachetools/rr.py deleted file mode 100644 index 30f38226dde..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/rr.py +++ /dev/null @@ -1,35 +0,0 @@ -import random - -from .cache import Cache - - -# random.choice cannot be pickled in Python 2.7 -def _choice(seq): - return random.choice(seq) - - -class RRCache(Cache): - """Random Replacement (RR) cache implementation.""" - - def __init__(self, maxsize, choice=random.choice, getsizeof=None): - Cache.__init__(self, maxsize, getsizeof) - # TODO: use None as default, assing to self.choice directly? - if choice is random.choice: - self.__choice = _choice - else: - self.__choice = choice - - @property - def choice(self): - """The `choice` function used by the cache.""" - return self.__choice - - def popitem(self): - """Remove and return a random `(key, value)` pair.""" - try: - key = self.__choice(list(self)) - except IndexError: - msg = '%s is empty' % self.__class__.__name__ - raise KeyError(msg) from None - else: - return (key, self.pop(key)) diff --git a/napari/_vendor/experimental/cachetools/cachetools/ttl.py b/napari/_vendor/experimental/cachetools/cachetools/ttl.py deleted file mode 100644 index 7822e8beaff..00000000000 --- a/napari/_vendor/experimental/cachetools/cachetools/ttl.py +++ /dev/null @@ -1,209 +0,0 @@ -import collections -import time - -from .cache import Cache - - -class _Link(object): - - __slots__ = ('key', 'expire', 'next', 'prev') - - def __init__(self, key=None, expire=None): - self.key = key - self.expire = expire - - def __reduce__(self): - return _Link, (self.key, self.expire) - - def unlink(self): - next = self.next - prev = self.prev - prev.next = next - next.prev = prev - - -class _Timer(object): - - def __init__(self, timer): - self.__timer = timer - self.__nesting = 0 - - def __call__(self): - if self.__nesting == 0: - return self.__timer() - else: - return self.__time - - def __enter__(self): - if self.__nesting == 0: - self.__time = time = self.__timer() - else: - time = self.__time - self.__nesting += 1 - return time - - def __exit__(self, *exc): - self.__nesting -= 1 - - def __reduce__(self): - return _Timer, (self.__timer,) - - def __getattr__(self, name): - return getattr(self.__timer, name) - - -class TTLCache(Cache): - """LRU Cache implementation with per-item time-to-live (TTL) value.""" - - def __init__(self, maxsize, ttl, timer=time.monotonic, getsizeof=None): - Cache.__init__(self, maxsize, getsizeof) - self.__root = root = _Link() - root.prev = root.next = root - self.__links = collections.OrderedDict() - self.__timer = _Timer(timer) - self.__ttl = ttl - - def __contains__(self, key): - try: - link = self.__links[key] # no reordering - except KeyError: - return False - else: - return not (link.expire < self.__timer()) - - def __getitem__(self, key, cache_getitem=Cache.__getitem__): - try: - link = self.__getlink(key) - except KeyError: - expired = False - else: - expired = link.expire < self.__timer() - if expired: - return self.__missing__(key) - else: - return cache_getitem(self, key) - - def __setitem__(self, key, value, cache_setitem=Cache.__setitem__): - with self.__timer as time: - self.expire(time) - cache_setitem(self, key, value) - try: - link = self.__getlink(key) - except KeyError: - self.__links[key] = link = _Link(key) - else: - link.unlink() - link.expire = time + self.__ttl - link.next = root = self.__root - link.prev = prev = root.prev - prev.next = root.prev = link - - def __delitem__(self, key, cache_delitem=Cache.__delitem__): - cache_delitem(self, key) - link = self.__links.pop(key) - link.unlink() - if link.expire < self.__timer(): - raise KeyError(key) - - def __iter__(self): - root = self.__root - curr = root.next - while curr is not root: - # "freeze" time for iterator access - with self.__timer as time: - if not (curr.expire < time): - yield curr.key - curr = curr.next - - def __len__(self): - root = self.__root - curr = root.next - time = self.__timer() - count = len(self.__links) - while curr is not root and curr.expire < time: - count -= 1 - curr = curr.next - return count - - def __setstate__(self, state): - self.__dict__.update(state) - root = self.__root - root.prev = root.next = root - for link in sorted(self.__links.values(), key=lambda obj: obj.expire): - link.next = root - link.prev = prev = root.prev - prev.next = root.prev = link - self.expire(self.__timer()) - - def __repr__(self, cache_repr=Cache.__repr__): - with self.__timer as time: - self.expire(time) - return cache_repr(self) - - @property - def currsize(self): - with self.__timer as time: - self.expire(time) - return super(TTLCache, self).currsize - - @property - def timer(self): - """The timer function used by the cache.""" - return self.__timer - - @property - def ttl(self): - """The time-to-live value of the cache's items.""" - return self.__ttl - - def expire(self, time=None): - """Remove expired items from the cache.""" - if time is None: - time = self.__timer() - root = self.__root - curr = root.next - links = self.__links - cache_delitem = Cache.__delitem__ - while curr is not root and curr.expire < time: - cache_delitem(self, curr.key) - del links[curr.key] - next = curr.next - curr.unlink() - curr = next - - def clear(self): - with self.__timer as time: - self.expire(time) - Cache.clear(self) - - def get(self, *args, **kwargs): - with self.__timer: - return Cache.get(self, *args, **kwargs) - - def pop(self, *args, **kwargs): - with self.__timer: - return Cache.pop(self, *args, **kwargs) - - def setdefault(self, *args, **kwargs): - with self.__timer: - return Cache.setdefault(self, *args, **kwargs) - - def popitem(self): - """Remove and return the `(key, value)` pair least recently used that - has not already expired. - - """ - with self.__timer as time: - self.expire(time) - try: - key = next(iter(self.__links)) - except StopIteration: - msg = '%s is empty' % self.__class__.__name__ - raise KeyError(msg) from None - else: - return (key, self.pop(key)) - - def __getlink(self, key): - value = self.__links[key] - self.__links.move_to_end(key) - return value diff --git a/napari/_vendor/experimental/cachetools/docs/.gitignore b/napari/_vendor/experimental/cachetools/docs/.gitignore deleted file mode 100644 index e35d8850c96..00000000000 --- a/napari/_vendor/experimental/cachetools/docs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_build diff --git a/napari/_vendor/experimental/cachetools/docs/Makefile b/napari/_vendor/experimental/cachetools/docs/Makefile deleted file mode 100644 index c88ce225cfd..00000000000 --- a/napari/_vendor/experimental/cachetools/docs/Makefile +++ /dev/null @@ -1,153 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/cachetools.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/cachetools.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/cachetools" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/cachetools" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/napari/_vendor/experimental/cachetools/docs/conf.py b/napari/_vendor/experimental/cachetools/docs/conf.py deleted file mode 100644 index 92dda3a987f..00000000000 --- a/napari/_vendor/experimental/cachetools/docs/conf.py +++ /dev/null @@ -1,24 +0,0 @@ -def get_version(): - import configparser - import pathlib - - cp = configparser.ConfigParser() - # Python 3.5 ConfigParser does not accept Path as filename - cp.read(str(pathlib.Path(__file__).parent.parent / "setup.cfg")) - return cp["metadata"]["version"] - - -project = 'cachetools' -copyright = '2014-2020 Thomas Kemmer' -version = get_version() -release = version - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.coverage', - 'sphinx.ext.doctest', - 'sphinx.ext.todo' -] -exclude_patterns = ['_build'] -master_doc = 'index' -html_theme = 'default' diff --git a/napari/_vendor/experimental/cachetools/docs/index.rst b/napari/_vendor/experimental/cachetools/docs/index.rst deleted file mode 100644 index e27c9bcf913..00000000000 --- a/napari/_vendor/experimental/cachetools/docs/index.rst +++ /dev/null @@ -1,487 +0,0 @@ -********************************************************************* -:mod:`cachetools` --- Extensible memoizing collections and decorators -********************************************************************* - -.. module:: cachetools - -This module provides various memoizing collections and decorators, -including variants of the Python Standard Library's `@lru_cache`_ -function decorator. - -For the purpose of this module, a *cache* is a mutable_ mapping_ of a -fixed maximum size. When the cache is full, i.e. by adding another -item the cache would exceed its maximum size, the cache must choose -which item(s) to discard based on a suitable `cache algorithm`_. In -general, a cache's size is the total size of its items, and an item's -size is a property or function of its value, e.g. the result of -``sys.getsizeof(value)``. For the trivial but common case that each -item counts as :const:`1`, a cache's size is equal to the number of -its items, or ``len(cache)``. - -Multiple cache classes based on different caching algorithms are -implemented, and decorators for easily memoizing function and method -calls are provided, too. - - -.. testsetup:: * - - import operator - from cachetools import cached, cachedmethod, LRUCache - - from unittest import mock - urllib = mock.MagicMock() - - -Cache implementations -===================== - -This module provides several classes implementing caches using -different cache algorithms. All these classes derive from class -:class:`Cache`, which in turn derives from -:class:`collections.MutableMapping`, and provide :attr:`maxsize` and -:attr:`currsize` properties to retrieve the maximum and current size -of the cache. When a cache is full, :meth:`Cache.__setitem__()` calls -:meth:`self.popitem()` repeatedly until there is enough room for the -item to be added. - -:class:`Cache` also features a :meth:`getsizeof` method, which returns -the size of a given `value`. The default implementation of -:meth:`getsizeof` returns :const:`1` irrespective of its argument, -making the cache's size equal to the number of its items, or -``len(cache)``. For convenience, all cache classes accept an optional -named constructor parameter `getsizeof`, which may specify a function -of one argument used to retrieve the size of an item's value. - -.. note:: - - Please be aware that all these classes are *not* thread-safe. - Access to a shared cache from multiple threads must be properly - synchronized, e.g. by using one of the memoizing decorators with a - suitable `lock` object. - -.. autoclass:: Cache(maxsize, getsizeof=None) - :members: - - This class discards arbitrary items using :meth:`popitem` to make - space when necessary. Derived classes may override :meth:`popitem` - to implement specific caching strategies. If a subclass has to - keep track of item access, insertion or deletion, it may - additionally need to override :meth:`__getitem__`, - :meth:`__setitem__` and :meth:`__delitem__`. - -.. autoclass:: LFUCache(maxsize, getsizeof=None) - :members: - - This class counts how often an item is retrieved, and discards the - items used least often to make space when necessary. - -.. autoclass:: LRUCache(maxsize, getsizeof=None) - :members: - - This class discards the least recently used items first to make - space when necessary. - -.. autoclass:: RRCache(maxsize, choice=random.choice, getsizeof=None) - :members: - - This class randomly selects candidate items and discards them to - make space when necessary. - - By default, items are selected from the list of cache keys using - :func:`random.choice`. The optional argument `choice` may specify - an alternative function that returns an arbitrary element from a - non-empty sequence. - -.. autoclass:: TTLCache(maxsize, ttl, timer=time.monotonic, getsizeof=None) - :members: popitem, timer, ttl - - This class associates a time-to-live value with each item. Items - that expire because they have exceeded their time-to-live will be - no longer accessible, and will be removed eventually. If no - expired items are there to remove, the least recently used items - will be discarded first to make space when necessary. - - By default, the time-to-live is specified in seconds and - :func:`time.monotonic` is used to retrieve the current time. A - custom `timer` function can be supplied if needed. - - .. method:: expire(self, time=None) - - Expired items will be removed from a cache only at the next - mutating operation, e.g. :meth:`__setitem__` or - :meth:`__delitem__`, and therefore may still claim memory. - Calling this method removes all items whose time-to-live would - have expired by `time`, so garbage collection is free to reuse - their memory. If `time` is :const:`None`, this removes all - items that have expired by the current value returned by - :attr:`timer`. - - -Extending cache classes ------------------------ - -Sometimes it may be desirable to notice when and what cache items are -evicted, i.e. removed from a cache to make room for new items. Since -all cache implementations call :meth:`popitem` to evict items from the -cache, this can be achieved by overriding this method in a subclass: - -.. doctest:: - :pyversion: >= 3 - - >>> class MyCache(LRUCache): - ... def popitem(self): - ... key, value = super().popitem() - ... print('Key "%s" evicted with value "%s"' % (key, value)) - ... return key, value - - >>> c = MyCache(maxsize=2) - >>> c['a'] = 1 - >>> c['b'] = 2 - >>> c['c'] = 3 - Key "a" evicted with value "1" - -Similar to the standard library's :class:`collections.defaultdict`, -subclasses of :class:`Cache` may implement a :meth:`__missing__` -method which is called by :meth:`Cache.__getitem__` if the requested -key is not found: - -.. doctest:: - :pyversion: >= 3 - - >>> class PepStore(LRUCache): - ... def __missing__(self, key): - ... """Retrieve text of a Python Enhancement Proposal""" - ... url = 'http://www.python.org/dev/peps/pep-%04d/' % key - ... try: - ... with urllib.request.urlopen(url) as s: - ... pep = s.read() - ... self[key] = pep # store text in cache - ... return pep - ... except urllib.error.HTTPError: - ... return 'Not Found' # do not store in cache - - >>> peps = PepStore(maxsize=4) - >>> for n in 8, 9, 290, 308, 320, 8, 218, 320, 279, 289, 320: - ... pep = peps[n] - >>> print(sorted(peps.keys())) - [218, 279, 289, 320] - -Note, though, that such a class does not really behave like a *cache* -any more, and will lead to surprising results when used with any of -the memoizing decorators described below. However, it may be useful -in its own right. - - -Memoizing decorators -==================== - -The :mod:`cachetools` module provides decorators for memoizing -function and method calls. This can save time when a function is -often called with the same arguments: - -.. doctest:: - - >>> @cached(cache={}) - ... def fib(n): - ... 'Compute the nth number in the Fibonacci sequence' - ... return n if n < 2 else fib(n - 1) + fib(n - 2) - - >>> fib(42) - 267914296 - -.. decorator:: cached(cache, key=cachetools.keys.hashkey, lock=None) - - Decorator to wrap a function with a memoizing callable that saves - results in a cache. - - The `cache` argument specifies a cache object to store previous - function arguments and return values. Note that `cache` need not - be an instance of the cache implementations provided by the - :mod:`cachetools` module. :func:`cached` will work with any - mutable mapping type, including plain :class:`dict` and - :class:`weakref.WeakValueDictionary`. - - `key` specifies a function that will be called with the same - positional and keyword arguments as the wrapped function itself, - and which has to return a suitable cache key. Since caches are - mappings, the object returned by `key` must be hashable. The - default is to call :func:`cachetools.keys.hashkey`. - - If `lock` is not :const:`None`, it must specify an object - implementing the `context manager`_ protocol. Any access to the - cache will then be nested in a ``with lock:`` statement. This can - be used for synchronizing thread access to the cache by providing a - :class:`threading.RLock` instance, for example. - - .. note:: - - The `lock` context manager is used only to guard access to the - cache object. The underlying wrapped function will be called - outside the `with` statement, and must be thread-safe by itself. - - The original underlying function is accessible through the - :attr:`__wrapped__` attribute of the memoizing wrapper function. - This can be used for introspection or for bypassing the cache. - - To perform operations on the cache object, for example to clear the - cache during runtime, the cache should be assigned to a variable. - When a `lock` object is used, any access to the cache from outside - the function wrapper should also be performed within an appropriate - `with` statement: - - .. testcode:: - - from threading import RLock - - cache = LRUCache(maxsize=32) - lock = RLock() - - @cached(cache, lock=lock) - def get_pep(num): - 'Retrieve text of a Python Enhancement Proposal' - url = 'http://www.python.org/dev/peps/pep-%04d/' % num - with urllib.request.urlopen(url) as s: - return s.read() - - # make sure access to cache is synchronized - with lock: - cache.clear() - - It is also possible to use a single shared cache object with - multiple functions. However, care must be taken that different - cache keys are generated for each function, even for identical - function arguments: - - .. doctest:: - :options: +ELLIPSIS - - >>> from cachetools.keys import hashkey - >>> from functools import partial - - >>> # shared cache for integer sequences - >>> numcache = {} - - >>> # compute Fibonacci numbers - >>> @cached(numcache, key=partial(hashkey, 'fib')) - ... def fib(n): - ... return n if n < 2 else fib(n - 1) + fib(n - 2) - - >>> # compute Lucas numbers - >>> @cached(numcache, key=partial(hashkey, 'luc')) - ... def luc(n): - ... return 2 - n if n < 2 else luc(n - 1) + luc(n - 2) - - >>> fib(42) - 267914296 - >>> luc(42) - 599074578 - >>> list(sorted(numcache.items())) - [..., (('fib', 42), 267914296), ..., (('luc', 42), 599074578)] - -.. decorator:: cachedmethod(cache, key=cachetools.keys.hashkey, lock=None) - - Decorator to wrap a class or instance method with a memoizing - callable that saves results in a (possibly shared) cache. - - The main difference between this and the :func:`cached` function - decorator is that `cache` and `lock` are not passed objects, but - functions. Both will be called with :const:`self` (or :const:`cls` - for class methods) as their sole argument to retrieve the cache or - lock object for the method's respective instance or class. - - .. note:: - - As with :func:`cached`, the context manager obtained by calling - ``lock(self)`` will only guard access to the cache itself. It - is the user's responsibility to handle concurrent calls to the - underlying wrapped method in a multithreaded environment. - - One advantage of :func:`cachedmethod` over the :func:`cached` - function decorator is that cache properties such as `maxsize` can - be set at runtime: - - .. testcode:: - - class CachedPEPs(object): - - def __init__(self, cachesize): - self.cache = LRUCache(maxsize=cachesize) - - @cachedmethod(operator.attrgetter('cache')) - def get(self, num): - """Retrieve text of a Python Enhancement Proposal""" - url = 'http://www.python.org/dev/peps/pep-%04d/' % num - with urllib.request.urlopen(url) as s: - return s.read() - - peps = CachedPEPs(cachesize=10) - print("PEP #1: %s" % peps.get(1)) - - .. testoutput:: - :hide: - :options: +ELLIPSIS - - PEP #1: ... - - - When using a shared cache for multiple methods, be aware that - different cache keys must be created for each method even when - function arguments are the same, just as with the `@cached` - decorator: - - .. testcode:: - - class CachedReferences(object): - - def __init__(self, cachesize): - self.cache = LRUCache(maxsize=cachesize) - - @cachedmethod(lambda self: self.cache, key=partial(hashkey, 'pep')) - def get_pep(self, num): - """Retrieve text of a Python Enhancement Proposal""" - url = 'http://www.python.org/dev/peps/pep-%04d/' % num - with urllib.request.urlopen(url) as s: - return s.read() - - @cachedmethod(lambda self: self.cache, key=partial(hashkey, 'rfc')) - def get_rfc(self, num): - """Retrieve text of an IETF Request for Comments""" - url = 'https://tools.ietf.org/rfc/rfc%d.txt' % num - with urllib.request.urlopen(url) as s: - return s.read() - - docs = CachedReferences(cachesize=100) - print("PEP #1: %s" % docs.get_pep(1)) - print("RFC #1: %s" % docs.get_rfc(1)) - - .. testoutput:: - :hide: - :options: +ELLIPSIS - - PEP #1: ... - RFC #1: ... - - -***************************************************************** -:mod:`cachetools.keys` --- Key functions for memoizing decorators -***************************************************************** - -.. module:: cachetools.keys - -This module provides several functions that can be used as key -functions with the :func:`cached` and :func:`cachedmethod` decorators: - -.. autofunction:: hashkey - - This function returns a :class:`tuple` instance suitable as a cache - key, provided the positional and keywords arguments are hashable. - -.. autofunction:: typedkey - - This function is similar to :func:`hashkey`, but arguments of - different types will yield distinct cache keys. For example, - ``typedkey(3)`` and ``typedkey(3.0)`` will return different - results. - -These functions can also be helpful when implementing custom key -functions for handling some non-hashable arguments. For example, -calling the following function with a dictionary as its `env` argument -will raise a :class:`TypeError`, since :class:`dict` is not hashable:: - - @cached(LRUCache(maxsize=128)) - def foo(x, y, z, env={}): - pass - -However, if `env` always holds only hashable values itself, a custom -key function can be written that handles the `env` keyword argument -specially:: - - def envkey(*args, env={}, **kwargs): - key = hashkey(*args, **kwargs) - key += tuple(sorted(env.items())) - return key - -The :func:`envkey` function can then be used in decorator declarations -like this:: - - @cached(LRUCache(maxsize=128), key=envkey) - def foo(x, y, z, env={}): - pass - - foo(1, 2, 3, env=dict(a='a', b='b')) - - -**************************************************************************** -:mod:`cachetools.func` --- :func:`functools.lru_cache` compatible decorators -**************************************************************************** - -.. module:: cachetools.func - -To ease migration from (or to) Python 3's :func:`functools.lru_cache`, -this module provides several memoizing function decorators with a -similar API. All these decorators wrap a function with a memoizing -callable that saves up to the `maxsize` most recent calls, using -different caching strategies. If `maxsize` is set to :const:`None`, -the caching strategy is effectively disabled and the cache can grow -without bound. - -If the optional argument `typed` is set to :const:`True`, function -arguments of different types will be cached separately. For example, -``f(3)`` and ``f(3.0)`` will be treated as distinct calls with -distinct results. - -If a `user_function` is specified instead, it must be a callable. -This allows the decorator to be applied directly to a user function, -leaving the `maxsize` at its default value of 128:: - - @cachetools.func.lru_cache - def count_vowels(sentence): - sentence = sentence.casefold() - return sum(sentence.count(vowel) for vowel in 'aeiou') - -The wrapped function is instrumented with a :func:`cache_parameters` -function that returns a new :class:`dict` showing the values for -`maxsize` and `typed`. This is for information purposes only. -Mutating the values has no effect. - -The wrapped function is also instrumented with :func:`cache_info` and -:func:`cache_clear` functions to provide information about cache -performance and clear the cache. Please see the -:func:`functools.lru_cache` documentation for details. Also note that -all the decorators in this module are thread-safe by default. - - -.. decorator:: lfu_cache(user_function) - lfu_cache(maxsize=128, typed=False) - - Decorator that wraps a function with a memoizing callable that - saves up to `maxsize` results based on a Least Frequently Used - (LFU) algorithm. - -.. decorator:: lru_cache(user_function) - lru_cache(maxsize=128, typed=False) - - Decorator that wraps a function with a memoizing callable that - saves up to `maxsize` results based on a Least Recently Used (LRU) - algorithm. - -.. decorator:: rr_cache(user_function) - rr_cache(maxsize=128, choice=random.choice, typed=False) - - Decorator that wraps a function with a memoizing callable that - saves up to `maxsize` results based on a Random Replacement (RR) - algorithm. - -.. decorator:: ttl_cache(user_function) - ttl_cache(maxsize=128, ttl=600, timer=time.monotonic, typed=False) - - Decorator to wrap a function with a memoizing callable that saves - up to `maxsize` results based on a Least Recently Used (LRU) - algorithm with a per-item time-to-live (TTL) value. - - -.. _@lru_cache: http://docs.python.org/3/library/functools.html#functools.lru_cache -.. _cache algorithm: http://en.wikipedia.org/wiki/Cache_algorithms -.. _context manager: http://docs.python.org/dev/glossary.html#term-context-manager -.. _mapping: http://docs.python.org/dev/glossary.html#term-mapping -.. _mutable: http://docs.python.org/dev/glossary.html#term-mutable diff --git a/napari/_vendor/experimental/humanize/LICENCE b/napari/_vendor/experimental/humanize/LICENCE deleted file mode 100644 index e0687959f9e..00000000000 --- a/napari/_vendor/experimental/humanize/LICENCE +++ /dev/null @@ -1,20 +0,0 @@ -Copyright (c) 2010-2020 Jason Moiron and Contributors - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/napari/_vendor/experimental/humanize/README.md b/napari/_vendor/experimental/humanize/README.md deleted file mode 100644 index 5ebeab8c014..00000000000 --- a/napari/_vendor/experimental/humanize/README.md +++ /dev/null @@ -1,210 +0,0 @@ -# humanize - -[![PyPI version](https://img.shields.io/pypi/v/humanize.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/humanize/) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/humanize.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/humanize/) -[![Documentation Status](https://readthedocs.org/projects/python-humanize/badge/?version=latest)](https://python-humanize.readthedocs.io/en/latest/?badge=latest) -[![PyPI downloads](https://img.shields.io/pypi/dm/humanize.svg)](https://pypistats.org/packages/humanize) -[![Travis CI Status](https://travis-ci.com/hugovk/humanize.svg?branch=master)](https://travis-ci.com/hugovk/humanize) -[![GitHub Actions status](https://github.com/jmoiron/humanize/workflows/Test/badge.svg)](https://github.com/jmoiron/humanize/actions) -[![codecov](https://codecov.io/gh/hugovk/humanize/branch/master/graph/badge.svg)](https://codecov.io/gh/hugovk/humanize) -[![MIT License](https://img.shields.io/github/license/jmoiron/humanize.svg)](LICENCE) -[![Tidelift](https://tidelift.com/badges/package/pypi/humanize)](https://tidelift.com/subscription/pkg/pypi-humanize?utm_source=pypi-humanize&utm_medium=badge) - -This modest package contains various common humanization utilities, like turning -a number into a fuzzy human readable duration ("3 minutes ago") or into a human -readable size or throughput. It is localized to: - -* Brazilian Portuguese -* Dutch -* European Portuguese -* Finnish -* French -* German -* Indonesian -* Italian -* Japanese -* Korean -* Persian -* Polish -* Russian -* Simplified Chinese -* Slovak -* Spanish -* Turkish -* Ukrainian -* Vietnamese - -## Usage - -### Integer humanization - -```pycon ->>> import humanize ->>> humanize.intcomma(12345) -'12,345' ->>> humanize.intword(123455913) -'123.5 million' ->>> humanize.intword(12345591313) -'12.3 billion' ->>> humanize.apnumber(4) -'four' ->>> humanize.apnumber(41) -'41' -``` - -### Date & time humanization - -```pycon ->>> import humanize ->>> import datetime as dt ->>> humanize.naturalday(dt.datetime.now()) -'today' ->>> humanize.naturaldelta(dt.timedelta(seconds=1001)) -'16 minutes' ->>> humanize.naturalday(dt.datetime.now() - dt.timedelta(days=1)) -'yesterday' ->>> humanize.naturalday(dt.date(2007, 6, 5)) -'Jun 05' ->>> humanize.naturaldate(dt.date(2007, 6, 5)) -'Jun 05 2007' ->>> humanize.naturaltime(dt.datetime.now() - dt.timedelta(seconds=1)) -'a second ago' ->>> humanize.naturaltime(dt.datetime.now() - dt.timedelta(seconds=3600)) -'an hour ago' -``` - -### Precise time delta - -```pycon ->>> import humanize ->>> import datetime as dt ->>> delta = dt.timedelta(seconds=3633, days=2, microseconds=123000) ->>> humanize.precisedelta(delta) -'2 days, 1 hour and 33.12 seconds' ->>> humanize.precisedelta(delta, minimum_unit="microseconds") -'2 days, 1 hour, 33 seconds and 123 milliseconds' ->>> humanize.precisedelta(delta, suppress=["days"], format="%0.4f") -'49 hours and 33.1230 seconds' -``` - -#### Smaller units - -If seconds are too large, set `minimum_unit` to milliseconds or microseconds: - -```pycon ->>> import humanize ->>> import datetime as dt ->>> humanize.naturaldelta(dt.timedelta(seconds=2)) -'2 seconds' -``` -```pycon ->>> delta = dt.timedelta(milliseconds=4) ->>> humanize.naturaldelta(delta) -'a moment' ->>> humanize.naturaldelta(delta, minimum_unit="milliseconds") -'4 milliseconds' ->>> humanize.naturaldelta(delta, minimum_unit="microseconds") -'4000 microseconds' -``` -```pycon ->>> humanize.naturaltime(delta) -'now' ->>> humanize.naturaltime(delta, minimum_unit="milliseconds") -'4 milliseconds ago' ->>> humanize.naturaltime(delta, minimum_unit="microseconds") -'4000 microseconds ago' -``` - -### File size humanization - -```pycon ->>> import humanize ->>> humanize.naturalsize(1_000_000) -'1.0 MB' ->>> humanize.naturalsize(1_000_000, binary=True) -'976.6 KiB' ->>> humanize.naturalsize(1_000_000, gnu=True) -'976.6K' -``` - -### Human-readable floating point numbers - -```pycon ->>> import humanize ->>> humanize.fractional(1/3) -'1/3' ->>> humanize.fractional(1.5) -'1 1/2' ->>> humanize.fractional(0.3) -'3/10' ->>> humanize.fractional(0.333) -'333/1000' ->>> humanize.fractional(1) -'1' -``` - -### Scientific notation - -```pycon ->>> import humanize ->>> humanize.scientific(0.3) -'3.00 x 10⁻¹' ->>> humanize.scientific(500) -'5.00 x 10²' ->>> humanize.scientific("20000") -'2.00 x 10⁴' ->>> humanize.scientific(1**10) -'1.00 x 10⁰' ->>> humanize.scientific(1**10, precision=1) -'1.0 x 10⁰' ->>> humanize.scientific(1**10, precision=0) -'1 x 10⁰' -``` - -## Localization - -How to change locale at runtime: - -```pycon ->>> import humanize ->>> import datetime as dt ->>> humanize.naturaltime(dt.timedelta(seconds=3)) -'3 seconds ago' ->>> _t = humanize.i18n.activate("ru_RU") ->>> humanize.naturaltime(dt.timedelta(seconds=3)) -'3 секунды назад' ->>> humanize.i18n.deactivate() ->>> humanize.naturaltime(dt.timedelta(seconds=3)) -'3 seconds ago' -``` - -You can pass additional parameter `path` to `activate` to specify a path to search -locales in. - -```pycon ->>> import humanize ->>> humanize.i18n.activate("xx_XX") -<...> -FileNotFoundError: [Errno 2] No translation file found for domain: 'humanize' ->>> humanize.i18n.activate("pt_BR", path="path/to/my/portuguese/translation/") - -``` - -How to add new phrases to existing locale files: - -```console -$ xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -l python src/humanize/*.py # extract new phrases -$ msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files -$ msgfmt --check -o src/humanize/locale/ru_RU/LC_MESSAGES/humanize{.mo,.po} # compile to binary .mo -``` - -How to add a new locale: - -```console -$ msginit -i humanize.pot -o humanize/locale//LC_MESSAGES/humanize.po --locale -``` - -Where `` is a locale abbreviation, eg. `en_GB`, `pt_BR` or just `ru`, `fr` -etc. - -List the language at the top of this README. diff --git a/napari/_vendor/experimental/humanize/src/humanize/__init__.py b/napari/_vendor/experimental/humanize/src/humanize/__init__.py deleted file mode 100644 index 54265a2b886..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -from .filesize import naturalsize -from .i18n import activate, deactivate -from .number import apnumber, fractional, intcomma, intword, ordinal, scientific -from .time import ( - naturaldate, - naturalday, - naturaldelta, - naturaltime, - precisedelta, -) - -__version__ = VERSION = "2.5.0" - - -__all__ = [ - "__version__", - "activate", - "apnumber", - "deactivate", - "fractional", - "intcomma", - "intword", - "naturaldate", - "naturalday", - "naturaldelta", - "naturalsize", - "naturaltime", - "ordinal", - "precisedelta", - "scientific", - "VERSION", -] diff --git a/napari/_vendor/experimental/humanize/src/humanize/filesize.py b/napari/_vendor/experimental/humanize/src/humanize/filesize.py deleted file mode 100644 index 149e2c40ba8..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/filesize.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python - -"""Bits and bytes related humanization.""" - -suffixes = { - "decimal": ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), - "binary": ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"), - "gnu": "KMGTPEZY", -} - - -def naturalsize(value, binary=False, gnu=False, format="%.1f"): - """Format a number of bytes like a human readable filesize (e.g. 10 kB). - - By default, decimal suffixes (kB, MB) are used. - - Non-GNU modes are compatible with jinja2's `filesizeformat` filter. - - Args: - value (int, float, str): Integer to convert. - binary (bool): If `True`, uses binary suffixes (KiB, MiB) with base - 210 instead of 103. - gnu (bool): If `True`, the binary argument is ignored and GNU-style - (`ls -sh` style) prefixes are used (K, M) with the 2**10 definition. - format (str): Custom formatter. - """ - if gnu: - suffix = suffixes["gnu"] - elif binary: - suffix = suffixes["binary"] - else: - suffix = suffixes["decimal"] - - base = 1024 if (gnu or binary) else 1000 - bytes = float(value) - abs_bytes = abs(bytes) - - if abs_bytes == 1 and not gnu: - return "%d Byte" % bytes - elif abs_bytes < base and not gnu: - return "%d Bytes" % bytes - elif abs_bytes < base and gnu: - return "%dB" % bytes - - for i, s in enumerate(suffix): - unit = base ** (i + 2) - if abs_bytes < unit and not gnu: - return (format + " %s") % ((base * bytes / unit), s) - elif abs_bytes < unit and gnu: - return (format + "%s") % ((base * bytes / unit), s) - if gnu: - return (format + "%s") % ((base * bytes / unit), s) - return (format + " %s") % ((base * bytes / unit), s) diff --git a/napari/_vendor/experimental/humanize/src/humanize/i18n.py b/napari/_vendor/experimental/humanize/src/humanize/i18n.py deleted file mode 100644 index 05438c38453..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/i18n.py +++ /dev/null @@ -1,127 +0,0 @@ -import gettext as gettext_module -import os.path -from threading import local - -__all__ = ["activate", "deactivate", "gettext", "ngettext"] - -_TRANSLATIONS = {None: gettext_module.NullTranslations()} -_CURRENT = local() - - -def _get_default_locale_path(): - try: - if __file__ is None: - return None - return os.path.join(os.path.dirname(__file__), "locale") - except NameError: - return None - - -def get_translation(): - try: - return _TRANSLATIONS[_CURRENT.locale] - except (AttributeError, KeyError): - return _TRANSLATIONS[None] - - -def activate(locale, path=None): - """Activate internationalisation. - - Set `locale` as current locale. Search for locale in directory `path`. - - Args: - locale (str): Language name, e.g. `en_GB`. - path (str): Path to search for locales. - - Returns: - dict: Translations. - """ - if path is None: - path = _get_default_locale_path() - - if path is None: - raise Exception( - "Humanize cannot determinate the default location of the 'locale' folder. " - "You need to pass the path explicitly." - ) - if locale not in _TRANSLATIONS: - translation = gettext_module.translation("humanize", path, [locale]) - _TRANSLATIONS[locale] = translation - _CURRENT.locale = locale - return _TRANSLATIONS[locale] - - -def deactivate(): - """Deactivate internationalisation.""" - _CURRENT.locale = None - - -def gettext(message): - """Get translation. - - Args: - message (str): Text to translate. - - Returns: - str: Translated text. - """ - return get_translation().gettext(message) - - -def pgettext(msgctxt, message): - """'Particular gettext' function. - - It works with `msgctxt` .po modifiers and allows duplicate keys with different - translations. - - Args: - msgctxt (str): Context of the translation. - message (str): Text to translate. - - Returns: - str: Translated text. - """ - # This GNU gettext function was added in Python 3.8, so for older versions we - # reimplement it. It works by joining `msgctx` and `message` by '4' byte. - try: - # Python 3.8+ - return get_translation().pgettext(msgctxt, message) - except AttributeError: - # Python 3.7 and older - key = msgctxt + "\x04" + message - translation = get_translation().gettext(key) - return message if translation == key else translation - - -def ngettext(message, plural, num): - """Plural version of gettext. - - Args: - message (str): Singlular text to translate. - plural (str): Plural text to translate. - num (str): The number (e.g. item count) to determine translation for the - respective grammatical number. - - Returns: - str: Translated text. - """ - return get_translation().ngettext(message, plural, num) - - -def gettext_noop(message): - """Mark a string as a translation string without translating it. - - Example usage: - ```python - CONSTANTS = [gettext_noop('first'), gettext_noop('second')] - def num_name(n): - return gettext(CONSTANTS[n]) - ``` - - Args: - message (str): Text to translate in the future. - - Returns: - str: Original text, unchanged. - """ - return message diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po deleted file mode 100644 index f2e151a0771..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,282 +0,0 @@ -# German translation for humanize. -# Copyright (C) 2016 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the humanize package. -# Christian Klein , 2016. -# -msgid "" -msgstr "" -"Project-Id-Version: humanize\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:30+0200\n" -"PO-Revision-Date: 2016-12-18 11:50+0100\n" -"Last-Translator: Christian Klein \n" -"Language-Team: German\n" -"Language: de\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Generated-By: Christian Klein\n" -"X-Generator: Sublime Text 3\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "." - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "." - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "." - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "." - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "." - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "." - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "." - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "." - -#: src/humanize/number.py:73 -msgid "million" -msgstr "Million" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "Milliarde" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "Billion" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "Billiarde" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "Trillion" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "Trilliarde" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "Quadrillion" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "Quadrillarde" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "Quintillion" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "Quintilliarde" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "Googol" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "null" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "eins" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "zwei" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "drei" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "vier" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "fünf" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "sechs" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "sieben" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "acht" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "neun" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d Mikrosekunde" -msgstr[1] "%d Mikrosekunden" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d Millisekunde" -msgstr[1] "%d Millisekunden" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "ein Moment" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "eine Sekunde" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d Sekunde" -msgstr[1] "%d Sekunden" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "eine Minute" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d Minute" -msgstr[1] "%d Minuten" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "eine Stunde" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d Stunde" -msgstr[1] "%d Stunden" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "ein Tag" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d Tag" -msgstr[1] "%d Tage" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "ein Monat" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d Monat" -msgstr[1] "%d Monate" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "ein Jahr" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "ein Jahr und %d Tag" -msgstr[1] "ein Jahr und %d Tage" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "ein Monat" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "ein Jahr und %d Monat" -msgstr[1] "ein Jahr und %d Monate" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d Jahr" -msgstr[1] "%d Jahre" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "%s ab jetzt" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "vor %s" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "jetzt" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "heute" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "morgen" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "gestern" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/es_ES/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/es_ES/LC_MESSAGES/humanize.po deleted file mode 100644 index cf07b97eaa4..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/es_ES/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,281 +0,0 @@ -# Spanish (Spain) translations for PROJECT. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Álvaro Mondéjar , 2020. -# -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:30+0200\n" -"PO-Revision-Date: 2020-03-31 21:08+0200\n" -"Last-Translator: Álvaro Mondéjar \n" -"Language-Team: \n" -"Language: es_ES\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 2.3\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "º" - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "º" - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "º" - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:73 -msgid "million" -msgstr "millón" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "billón" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "trillón" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "quatrillón" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "quintillón" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "sextillón" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "septillón" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "octillón" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "nonillón" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "decillón" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "gúgol" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "cero" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "uno" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "dos" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "tres" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "cuatro" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "cinco" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "seis" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "siete" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "ocho" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "nueve" - -#: src/humanize/time.py:87 -#, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d microsegundo" -msgstr[1] "%d microsegundos" - -#: src/humanize/time.py:93 -#, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d milisegundo" -msgstr[1] "%d milisegundos" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "un momento" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "un segundo" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d segundo" -msgstr[1] "%d segundos" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "un minuto" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minuto" -msgstr[1] "%d minutos" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "una hora" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d hora" -msgstr[1] "%d horas" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "un día" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d día" -msgstr[1] "%d días" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "un mes" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d mes" -msgstr[1] "%d meses" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "un año" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 año y %d día" -msgstr[1] "1 año y %d días" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "1 año y 1 mes" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 año y %d mes" -msgstr[1] "1 año y %d meses" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d año" -msgstr[1] "%d años" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "en %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "hace %s" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "ahora" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "hoy" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "mañana" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "ayer" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/fa_IR/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/fa_IR/LC_MESSAGES/humanize.po deleted file mode 100644 index 413247e9d6a..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/fa_IR/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,264 +0,0 @@ -# German translation for humanize. -# Copyright (C) 2016 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the humanize package. -# Christian Klein , 2016. -# -msgid "" -msgstr "" -"Project-Id-Version: humanize\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 20:21+0200\n" -"PO-Revision-Date: 2017-01-10 02:44+0330\n" -"Last-Translator: Christian Klein \n" -"Language-Team: German\n" -"Language: de\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Generated-By: Christian Klein\n" -"X-Generator: Poedit 1.5.4\n" - -#: src/humanize/number.py:24 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "1" -msgid "st" -msgstr "اولین" - -#: src/humanize/number.py:26 -msgctxt "2" -msgid "nd" -msgstr "دومین" - -#: src/humanize/number.py:27 -msgctxt "3" -msgid "rd" -msgstr "سومین" - -#: src/humanize/number.py:28 -msgctxt "4" -msgid "th" -msgstr "چهارمین" - -#: src/humanize/number.py:29 -msgctxt "5" -msgid "th" -msgstr "پنجمین" - -#: src/humanize/number.py:30 -msgctxt "6" -msgid "th" -msgstr "ششمین" - -#: src/humanize/number.py:31 -msgctxt "7" -msgid "th" -msgstr "هفتمین" - -#: src/humanize/number.py:32 -msgctxt "8" -msgid "th" -msgstr "هشتمین" - -#: src/humanize/number.py:33 -msgctxt "9" -msgid "th" -msgstr "نهمین" - -#: src/humanize/number.py:62 -msgid "million" -msgstr "میلیون" - -#: src/humanize/number.py:63 -msgid "billion" -msgstr "میلیارد" - -#: src/humanize/number.py:64 -msgid "trillion" -msgstr "ترلیون" - -#: src/humanize/number.py:65 -msgid "quadrillion" -msgstr "کوادریلیون" - -#: src/humanize/number.py:66 -msgid "quintillion" -msgstr "کوانتیلیون" - -#: src/humanize/number.py:67 -msgid "sextillion" -msgstr "سکستیلیون" - -#: src/humanize/number.py:68 -msgid "septillion" -msgstr "سپتیلیون" - -#: src/humanize/number.py:69 -msgid "octillion" -msgstr "اوکتیلیون" - -#: src/humanize/number.py:70 -msgid "nonillion" -msgstr "نونیلیون" - -#: src/humanize/number.py:71 -msgid "decillion" -msgstr "دسیلیون" - -#: src/humanize/number.py:72 -msgid "googol" -msgstr "گوگول" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "یک" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "دو" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "سه" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "چهار" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "پنج" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "شش" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "هفت" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "هشت" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "نه" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -msgid "a moment" -msgstr "یک لحظه" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "یک ثانیه" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d ثانیه" -msgstr[1] "%d ثانیه" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "یک دقیقه" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d دقیقه" -msgstr[1] "%d دقیقه" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "یک ساعت" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d ساعت" -msgstr[1] "%d ساعت" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "یک روز" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d روز" -msgstr[1] "%d روز" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "یک ماه" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d ماه" -msgstr[1] "%d ماه" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "یک سال" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "۱ سال و %d روز" -msgstr[1] "۱ سال و %d روز" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "۱ سال و ۱ ماه" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "۱ سال و %d ماه" -msgstr[1] "۱ سال و %d ماه" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d سال" -msgstr[1] "%d سال" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "%s تا به اکنون" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s پیش" - -#: src/humanize/time.py:132 -msgid "now" -msgstr "اکنون" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "امروز" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "فردا" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "دیروز" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/fi_FI/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/fi_FI/LC_MESSAGES/humanize.po deleted file mode 100644 index 2c5debbd7ba..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/fi_FI/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,281 +0,0 @@ -# Finnish translations for humanize package -# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the humanize package. -# Ville Skyttä , 2017. -# -msgid "" -msgstr "" -"Project-Id-Version: humanize\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:30+0200\n" -"PO-Revision-Date: 2017-03-02 11:26+0200\n" -"Last-Translator: Ville Skyttä \n" -"Language-Team: Finnish\n" -"Language: fi\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 1.8.12\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "." - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "." - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "." - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "." - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "." - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "." - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "." - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "." - -#: src/humanize/number.py:73 -msgid "million" -msgstr "miljoonaa" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "miljardia" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "biljoonaa" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "kvadriljoonaa" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "kvintiljoonaa" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "sekstiljoonaa" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "septiljoonaa" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "oktiljoonaa" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "noniljoonaa" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "dekiljoonaa" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "googol" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "nolla" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "yksi" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "kaksi" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "kolme" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "neljä" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "viisi" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "kuusi" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "seitsemän" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "kahdeksan" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "yhdeksän" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d mikrosekunti" -msgstr[1] "%d mikrosekuntia" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d millisekunti" -msgstr[1] "%d millisekuntia" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "hetki" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "sekunti" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d sekunti" -msgstr[1] "%d sekuntia" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "minuutti" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minuutti" -msgstr[1] "%d minuuttia" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "tunti" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d tunti" -msgstr[1] "%d tuntia" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "päivä" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d päivä" -msgstr[1] "%d päivää" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "kuukausi" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d kuukausi" -msgstr[1] "%d kuukautta" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "vuosi" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 vuosi, %d päivä" -msgstr[1] "1 vuosi, %d päivää" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "1 vuosi, 1 kuukausi" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 vuosi, %d kuukausi" -msgstr[1] "1 vuosi, %d kuukautta" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d vuosi" -msgstr[1] "%d vuotta" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "%s tästä" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "%s sitten" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "nyt" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "tänään" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "huomenna" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "eilen" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po deleted file mode 100644 index e2b3c06ceff..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,302 +0,0 @@ -# French (France) translations for PROJECT. -# Copyright (C) 2013 ORGANIZATION -# This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2013. -# -msgid "" -msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:30+0200\n" -"PO-Revision-Date: 2013-06-22 08:52+0100\n" -"Last-Translator: Olivier Cortès \n" -"Language-Team: fr_FR \n" -"Language: fr\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -"Generated-By: Babel 0.9.6\n" -"X-Generator: Poedit 1.5.5\n" - -#: src/humanize/number.py:22 -#, fuzzy -msgctxt "0" -msgid "th" -msgstr "e" - -#: src/humanize/number.py:23 -#, fuzzy -msgctxt "1" -msgid "st" -msgstr "er" - -#: src/humanize/number.py:24 -#, fuzzy -msgctxt "2" -msgid "nd" -msgstr "e" - -#: src/humanize/number.py:25 -#, fuzzy -msgctxt "3" -msgid "rd" -msgstr "e" - -#: src/humanize/number.py:26 -#, fuzzy -msgctxt "4" -msgid "th" -msgstr "e" - -#: src/humanize/number.py:27 -#, fuzzy -msgctxt "5" -msgid "th" -msgstr "e" - -#: src/humanize/number.py:28 -#, fuzzy -msgctxt "6" -msgid "th" -msgstr "e" - -#: src/humanize/number.py:29 -#, fuzzy -msgctxt "7" -msgid "th" -msgstr "e" - -#: src/humanize/number.py:30 -#, fuzzy -msgctxt "8" -msgid "th" -msgstr "e" - -#: src/humanize/number.py:31 -#, fuzzy -msgctxt "9" -msgid "th" -msgstr "e" - -#: src/humanize/number.py:73 -#, fuzzy -msgid "million" -msgstr "%(value)s million" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "milliard" - -#: src/humanize/number.py:75 -#, fuzzy -msgid "trillion" -msgstr "%(value)s billion" - -#: src/humanize/number.py:76 -#, fuzzy -msgid "quadrillion" -msgstr "%(value)s billiard" - -#: src/humanize/number.py:77 -#, fuzzy -msgid "quintillion" -msgstr "%(value)s trillion" - -#: src/humanize/number.py:78 -#, fuzzy -msgid "sextillion" -msgstr "%(value)s trilliard" - -#: src/humanize/number.py:79 -#, fuzzy -msgid "septillion" -msgstr "%(value)s quatrillion" - -#: src/humanize/number.py:80 -#, fuzzy -msgid "octillion" -msgstr "%(value)s quadrilliard" - -#: src/humanize/number.py:81 -#, fuzzy -msgid "nonillion" -msgstr "%(value)s quintillion" - -#: src/humanize/number.py:82 -#, fuzzy -msgid "decillion" -msgstr "%(value)s quintilliard" - -#: src/humanize/number.py:83 -#, fuzzy -msgid "googol" -msgstr "%(value)s gogol" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "zéro" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "un" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "deux" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "trois" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "quatre" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "cinq" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "six" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "sept" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "huit" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "neuf" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d microseconde" -msgstr[1] "%d microsecondes" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d milliseconde" -msgstr[1] "%d millisecondes" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "un moment" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "une seconde" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d seconde" -msgstr[1] "%d secondes" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "une minute" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minute" -msgstr[1] "%d minutes" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "une heure" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d heure" -msgstr[1] "%d heures" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "un jour" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d jour" -msgstr[1] "%d jours" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "un mois" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d mois" -msgstr[1] "%d mois" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "un an" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "un an et %d jour" -msgstr[1] "un an et %d jours" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "un an et un mois" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "un an et %d mois" -msgstr[1] "un an et %d mois" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d an" -msgstr[1] "%d ans" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "dans %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "il y a %s" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "maintenant" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "aujourd'hui" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "demain" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "hier" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/id_ID/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/id_ID/LC_MESSAGES/humanize.po deleted file mode 100644 index c544ea846c1..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/id_ID/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,255 +0,0 @@ -# Indonesian translations for PACKAGE package. -# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# , 2017. -# -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 20:05+0200\n" -"PO-Revision-Date: 2017-03-18 15:41+0700\n" -"Last-Translator: adie.rebel@gmail.com\n" -"Language-Team: Indonesian\n" -"Language: id\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=ASCII\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Poedit 1.8.11\n" - -#: src/humanize/number.py:24 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "1" -msgid "st" -msgstr "." - -#: src/humanize/number.py:26 -msgctxt "2" -msgid "nd" -msgstr "." - -#: src/humanize/number.py:27 -msgctxt "3" -msgid "rd" -msgstr "." - -#: src/humanize/number.py:28 -msgctxt "4" -msgid "th" -msgstr "." - -#: src/humanize/number.py:29 -msgctxt "5" -msgid "th" -msgstr "." - -#: src/humanize/number.py:30 -msgctxt "6" -msgid "th" -msgstr "." - -#: src/humanize/number.py:31 -msgctxt "7" -msgid "th" -msgstr "." - -#: src/humanize/number.py:32 -msgctxt "8" -msgid "th" -msgstr "." - -#: src/humanize/number.py:33 -msgctxt "9" -msgid "th" -msgstr "." - -#: src/humanize/number.py:62 -msgid "million" -msgstr "juta" - -#: src/humanize/number.py:63 -msgid "billion" -msgstr "miliar" - -#: src/humanize/number.py:64 -msgid "trillion" -msgstr "triliun" - -#: src/humanize/number.py:65 -msgid "quadrillion" -msgstr "quadrillion" - -#: src/humanize/number.py:66 -msgid "quintillion" -msgstr "quintillion" - -#: src/humanize/number.py:67 -msgid "sextillion" -msgstr "sextillion" - -#: src/humanize/number.py:68 -msgid "septillion" -msgstr "septillion" - -#: src/humanize/number.py:69 -msgid "octillion" -msgstr "octillion" - -#: src/humanize/number.py:70 -msgid "nonillion" -msgstr "nonillion" - -#: src/humanize/number.py:71 -msgid "decillion" -msgstr "decillion" - -#: src/humanize/number.py:72 -msgid "googol" -msgstr "googol" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "satu" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "dua" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "tiga" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "empat" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "lima" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "enam" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "tujuh" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "delapan" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "sembilan" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -msgid "a moment" -msgstr "beberapa saat" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "sedetik" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d detik" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "semenit" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d menit" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "sejam" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d jam" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "sehari" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d hari" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "sebulan" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d bulan" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "setahun" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 tahun, %d hari" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "1 tahun, 1 bulan" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 tahun, %d bulan" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d tahun" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "%s dari sekarang" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s yang lalu" - -#: src/humanize/time.py:132 -msgid "now" -msgstr "sekarang" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "hari ini" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "besok" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "kemarin" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/it_IT/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/it_IT/LC_MESSAGES/humanize.po deleted file mode 100644 index b80e720f485..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/it_IT/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,281 +0,0 @@ -# Italian translations for PACKAGE package. -# Copyright (C) 2018 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# derfel , 2018. -# -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:30+0200\n" -"PO-Revision-Date: 2018-10-27 22:52+0200\n" -"Last-Translator: derfel \n" -"Language-Team: Italian\n" -"Language: it\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 2.2\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "º" - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "º" - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "º" - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:73 -msgid "million" -msgstr "milioni" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "miliardi" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "bilioni" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "biliardi" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "trilioni" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "triliardi" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "quadrilioni" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "quadriliardi" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "quintilioni" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "quintiliardi" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "googol" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "zero" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "uno" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "due" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "tre" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "quattro" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "cinque" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "sei" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "sette" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "otto" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "nove" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d microsecondo" -msgstr[1] "%d microsecondi" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d millisecondo" -msgstr[1] "%d millisecondi" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "un momento" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "un secondo" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d secondo" -msgstr[1] "%d secondi" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "un minuto" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minuto" -msgstr[1] "%d minuti" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "un'ora" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d ora" -msgstr[1] "%d ore" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "un giorno" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d giorno" -msgstr[1] "%d giorni" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "un mese" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d mese" -msgstr[1] "%d mesi" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "un anno" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "un anno e %d giorno" -msgstr[1] "un anno e %d giorni" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "un anno ed un mese" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "un anno e %d mese" -msgstr[1] "un anno e %d mesi" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d anno" -msgstr[1] "%d anni" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "fra %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "%s fa" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "adesso" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "oggi" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "domani" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "ieri" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/ja_JP/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/ja_JP/LC_MESSAGES/humanize.po deleted file mode 100644 index b4820d60317..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/ja_JP/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,267 +0,0 @@ -# Japanese (Japan) translations for humanize. -# Copyright (C) 2018 -# This file is distributed under the same license as the humanize project. -# @qoolloop, 2018. -# -msgid "" -msgstr "" -"Project-Id-Version: humanize\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 20:05+0200\n" -"PO-Revision-Date: 2018-01-22 10:48+0900\n" -"Last-Translator: Kan Torii \n" -"Language-Team: Japanese\n" -"Language: ja\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=1; plural=0;\n" -"Generated-By: Babel 0.9.6\n" -"X-Generator: Poedit 2.0.6\n" - -#: src/humanize/number.py:24 -msgctxt "0" -msgid "th" -msgstr "番目" - -#: src/humanize/number.py:25 -msgctxt "1" -msgid "st" -msgstr "番目" - -#: src/humanize/number.py:26 -msgctxt "2" -msgid "nd" -msgstr "番目" - -#: src/humanize/number.py:27 -msgctxt "3" -msgid "rd" -msgstr "番目" - -#: src/humanize/number.py:28 -msgctxt "4" -msgid "th" -msgstr "番目" - -#: src/humanize/number.py:29 -msgctxt "5" -msgid "th" -msgstr "番目" - -#: src/humanize/number.py:30 -msgctxt "6" -msgid "th" -msgstr "番目" - -#: src/humanize/number.py:31 -msgctxt "7" -msgid "th" -msgstr "番目" - -#: src/humanize/number.py:32 -msgctxt "8" -msgid "th" -msgstr "番目" - -#: src/humanize/number.py:33 -msgctxt "9" -msgid "th" -msgstr "番目" - -#: src/humanize/number.py:62 -msgid "million" -msgstr "百万" - -#: src/humanize/number.py:63 -#, fuzzy -msgid "billion" -msgstr "十億" - -#: src/humanize/number.py:64 -msgid "trillion" -msgstr "兆" - -#: src/humanize/number.py:65 -#, fuzzy -msgid "quadrillion" -msgstr "千兆" - -#: src/humanize/number.py:66 -#, fuzzy -msgid "quintillion" -msgstr "百京" - -#: src/humanize/number.py:67 -#, fuzzy -msgid "sextillion" -msgstr "十垓" - -#: src/humanize/number.py:68 -#, fuzzy -msgid "septillion" -msgstr "じょ" - -#: src/humanize/number.py:69 -#, fuzzy -msgid "octillion" -msgstr "千じょ" - -#: src/humanize/number.py:70 -#, fuzzy -msgid "nonillion" -msgstr "百穣" - -#: src/humanize/number.py:71 -#, fuzzy -msgid "decillion" -msgstr "十溝" - -#: src/humanize/number.py:72 -#, fuzzy -msgid "googol" -msgstr "溝無量大数" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "一" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "二" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "三" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "四" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "五" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "六" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "七" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "八" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "九" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -#, fuzzy -msgid "a moment" -msgstr "短時間" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "1秒" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d秒" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "1分" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d分" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "1時間" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d時間" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "1日" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d日" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "1ヶ月" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%dヶ月" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "1年" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1年 %d日" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "1年 1ヶ月" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1年 %dヶ月" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d年" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "%s後" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s前" - -#: src/humanize/time.py:132 -#, fuzzy -msgid "now" -msgstr "今" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "本日" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "明日" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "昨日" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/ko_KR/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/ko_KR/LC_MESSAGES/humanize.po deleted file mode 100644 index 05be5ad6d38..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/ko_KR/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,283 +0,0 @@ -# Korean (Korea) translations for humanize. -# Copyright (C) 2013 -# This file is distributed under the same license as the humanize project. -# @youngrok, 2013. -# -msgid "" -msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 20:05+0200\n" -"PO-Revision-Date: 2013-07-10 11:38+0900\n" -"Last-Translator: @youngrok\n" -"Language-Team: ko_KR \n" -"Language: ko\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -"Generated-By: Babel 0.9.6\n" -"X-Generator: Poedit 1.5.7\n" - -#: src/humanize/number.py:24 -#, fuzzy -msgctxt "0" -msgid "th" -msgstr "번째" - -#: src/humanize/number.py:25 -#, fuzzy -msgctxt "1" -msgid "st" -msgstr "번째" - -#: src/humanize/number.py:26 -#, fuzzy -msgctxt "2" -msgid "nd" -msgstr "번째" - -#: src/humanize/number.py:27 -#, fuzzy -msgctxt "3" -msgid "rd" -msgstr "번째" - -#: src/humanize/number.py:28 -#, fuzzy -msgctxt "4" -msgid "th" -msgstr "번째" - -#: src/humanize/number.py:29 -#, fuzzy -msgctxt "5" -msgid "th" -msgstr "번째" - -#: src/humanize/number.py:30 -#, fuzzy -msgctxt "6" -msgid "th" -msgstr "번째" - -#: src/humanize/number.py:31 -#, fuzzy -msgctxt "7" -msgid "th" -msgstr "번째" - -#: src/humanize/number.py:32 -#, fuzzy -msgctxt "8" -msgid "th" -msgstr "번째" - -#: src/humanize/number.py:33 -#, fuzzy -msgctxt "9" -msgid "th" -msgstr "번째" - -#: src/humanize/number.py:62 -msgid "million" -msgstr "%(value)s million" - -#: src/humanize/number.py:63 -msgid "billion" -msgstr "milliard" - -#: src/humanize/number.py:64 -#, fuzzy -msgid "trillion" -msgstr "%(value)s billion" - -#: src/humanize/number.py:65 -#, fuzzy -msgid "quadrillion" -msgstr "%(value)s quadrillion" - -#: src/humanize/number.py:66 -#, fuzzy -msgid "quintillion" -msgstr "%(value)s quintillion" - -#: src/humanize/number.py:67 -#, fuzzy -msgid "sextillion" -msgstr "%(value)s sextillion" - -#: src/humanize/number.py:68 -#, fuzzy -msgid "septillion" -msgstr "%(value)s septillion" - -#: src/humanize/number.py:69 -#, fuzzy -msgid "octillion" -msgstr "%(value)s octillion" - -#: src/humanize/number.py:70 -#, fuzzy -msgid "nonillion" -msgstr "%(value)s nonillion" - -#: src/humanize/number.py:71 -#, fuzzy -msgid "decillion" -msgstr "%(value)s décillion" - -#: src/humanize/number.py:72 -#, fuzzy -msgid "googol" -msgstr "%(value)s gogol" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "하나" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "둘" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "셋" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "넷" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "다섯" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "여섯" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "일곱" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "여덟" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "아홉" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -msgid "a moment" -msgstr "잠깐" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "1초" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d초" -msgstr[1] "%d초" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "1분" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d분" -msgstr[1] "%d분" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "1시간" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d heure" -msgstr[1] "%d시간" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "하루" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d jour" -msgstr[1] "%d일" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "한달" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d mois" -msgstr[1] "%d개월" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "1년" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1년 %d일" -msgstr[1] "1년 %d일" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "1년, 1개월" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1년, %d개월" -msgstr[1] "1년, %d개월" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d 년" -msgstr[1] "%d ans" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "%s 후" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s 전" - -#: src/humanize/time.py:132 -msgid "now" -msgstr "방금" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "오늘" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "내일" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "어제" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/nl_NL/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/nl_NL/LC_MESSAGES/humanize.po deleted file mode 100644 index 9dcfd4c32f0..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/nl_NL/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,282 +0,0 @@ -# French (France) translations for PROJECT. -# Copyright (C) 2013 ORGANIZATION -# This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2013. -# -msgid "" -msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:30+0200\n" -"PO-Revision-Date: 2015-03-25 21:08+0100\n" -"Last-Translator: Martin van Wingerden\n" -"Language-Team: nl_NL\n" -"Language: nl_NL\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"Generated-By: Babel 0.9.6\n" -"X-Generator: Poedit 1.7.5\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "de" - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "ste" - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "de" - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "de" - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "de" - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "de" - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "de" - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "de" - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "de" - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "de" - -#: src/humanize/number.py:73 -msgid "million" -msgstr "miljoen" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "miljard" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "biljoen" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "biljard" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "triljoen" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "triljard" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "quadriljoen" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "quadriljard" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "quintiljoen" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "quintiljard" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "googol" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "nul" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "één" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "twee" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "drie" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "vier" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "vijf" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "zes" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "zeven" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "acht" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "negen" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d microseconde" -msgstr[1] "%d microseconden" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d milliseconde" -msgstr[1] "%d milliseconden" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "een moment" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "een seconde" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d seconde" -msgstr[1] "%d seconden" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "een minuut" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minuut" -msgstr[1] "%d minuten" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "een uur" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d uur" -msgstr[1] "%d uren" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "een dag" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d dag" -msgstr[1] "%d dagen" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "een maand" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d maand" -msgstr[1] "%d maanden" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "een jaar" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 jaar, %d dag" -msgstr[1] "1 jaar, %d dagen" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "1 jaar, 1 maand" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 jaar, %d maand" -msgstr[1] "1 jaar, %d maanden" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d jaar" -msgstr[1] "%d jaar" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "over %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "%s geleden" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "nu" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "vandaag" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "morgen" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "gisteren" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po deleted file mode 100644 index 25b5a39481c..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,291 +0,0 @@ -# Polish translations for PACKAGE package. -# Copyright (C) 2020 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Bartosz Bubak , 2020. -# -msgid "" -msgstr "" -"Project-Id-Version: 0.0.1\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-04-22 10:01+0200\n" -"PO-Revision-Date: 2020-04-22 10:02+0200\n" -"Last-Translator: Bartosz Bubak \n" -"Language-Team: Polish\n" -"Language: pl\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " -"|| n%100>=20) ? 1 : 2);\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "." - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "." - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "." - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "." - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "." - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "." - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "." - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "." - -#: src/humanize/number.py:73 -msgid "million" -msgstr "milion" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "bilion" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "trylion" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "kwadrylion" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "kwintylion" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "sekstylion" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "septylion" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "oktylion" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "nonilion" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "decylion" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "googol" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "zero" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "jeden" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "dwa" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "trzy" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "cztery" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "pięć" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "sześć" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "siedem" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "osiem" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "dziewięć" - -#: src/humanize/time.py:87 -#, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d mikrosekunda" -msgstr[1] "%d mikrosekundy" -msgstr[2] "%d mikrosekund" - -#: src/humanize/time.py:93 -#, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d milisekunda" -msgstr[1] "%d milisekundy" -msgstr[2] "%d milisekund" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "chwila" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "sekunda" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d sekunda" -msgstr[1] "%d sekundy" -msgstr[2] "%d sekund" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "minuta" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minuta" -msgstr[1] "%d minuty" -msgstr[2] "%d minut" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "godzina" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d godzina" -msgstr[1] "%d godziny" -msgstr[2] "%d godzin" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "dzień" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d dzień" -msgstr[1] "%d dni" -msgstr[2] "%d dni" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "miesiąc" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d miesiąc" -msgstr[1] "%d miesiące" -msgstr[2] "%d miesięcy" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "rok" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 rok, %d dzień" -msgstr[1] "1 rok, %d dni" -msgstr[2] "1 rok, %d dni" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 rok, %d miesiąc" -msgstr[1] "1 rok, %d miesiące" -msgstr[2] "1 rok, %d miesięcy" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d rok" -msgstr[1] "%d lat" -msgstr[2] "%d lata" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "%s od teraz" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "%s temu" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "teraz" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "dziś" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "jutro" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "wczoraj" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/pt_BR/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/pt_BR/LC_MESSAGES/humanize.po deleted file mode 100644 index 91a2be986ab..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/pt_BR/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,281 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-06-20 23:04-0300\n" -"PO-Revision-Date: 2016-06-15 15:58-0300\n" -"Last-Translator: \n" -"Language-Team: \n" -"Language: pt_BR\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Generator: Poedit 1.8.5\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "º" - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "º" - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "º" - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:73 -msgid "million" -msgstr "milhão" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "bilhão" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "trilhão" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "quatrilhão" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "quintilhão" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "sextilhão" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "septilhão" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "octilhão" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "nonilhão" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "decilhão" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "undecilhão" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "zero" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "um" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "dois" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "três" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "quatro" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "cinco" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "seis" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "sete" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "oito" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "nove" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d microssegundo" -msgstr[1] "%d microssegundos" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d milissegundo" -msgstr[1] "%d milissegundos" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "um momento" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "um segundo" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d segundo" -msgstr[1] "%d segundos" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "um minuto" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minuto" -msgstr[1] "%d minutos" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "uma hora" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d hora" -msgstr[1] "%d horas" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "um dia" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d dia" -msgstr[1] "%d dias" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "um mês" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d mês" -msgstr[1] "%d meses" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "um ano" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 ano e %d dia" -msgstr[1] "1 ano e %d dias" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "1 ano e 1 mês" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 ano e %d mês" -msgstr[1] "1 ano e %d meses" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d ano" -msgstr[1] "%d anos" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "em %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "há %s" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "agora" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "hoje" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "amanhã" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "ontem" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/pt_PT/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/pt_PT/LC_MESSAGES/humanize.po deleted file mode 100644 index 54494424c16..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/pt_PT/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,281 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-06-20 23:04-0300\n" -"PO-Revision-Date: 2020-07-05 18:17+0100\n" -"Last-Translator: \n" -"Language-Team: \n" -"Language: pt_PT\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Generator: Poedit 2.3.1\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "º" - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "º" - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "º" - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "º" - -#: src/humanize/number.py:73 -msgid "million" -msgstr "milhão" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "milhar de milhão" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "bilião" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "mil biliões" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "trilião" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "mil triliões" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "quatrilião" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "mil quatriliões" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "quintilhão" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "mil quintilhões" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "sextilhão" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "zero" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "um" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "dois" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "três" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "quatro" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "cinco" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "seis" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "sete" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "oito" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "nove" - -#: src/humanize/time.py:87 -#, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d microssegundo" -msgstr[1] "%d microssegundos" - -#: src/humanize/time.py:93 -#, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d milissegundo" -msgstr[1] "%d milissegundos" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "um momento" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "um segundo" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d segundo" -msgstr[1] "%d segundos" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "um minuto" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minuto" -msgstr[1] "%d minutos" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "uma hora" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d hora" -msgstr[1] "%d horas" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "um dia" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d dia" -msgstr[1] "%d dias" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "um mês" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d mês" -msgstr[1] "%d meses" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "um ano" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 ano e %d dia" -msgstr[1] "1 ano e %d dias" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "1 ano e 1 mês" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 ano e %d mês" -msgstr[1] "1 ano e %d meses" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d ano" -msgstr[1] "%d anos" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "daqui a %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "há %s" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "agora" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "hoje" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "amanhã" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "ontem" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po deleted file mode 100644 index fc58b2d5690..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,280 +0,0 @@ -# Russian (Russia) translations for PROJECT. -# Copyright (C) 2013 ORGANIZATION -# This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2013. -# -msgid "" -msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 19:58+0200\n" -"PO-Revision-Date: 2014-03-24 20:32+0300\n" -"Last-Translator: Sergey Prokhorov \n" -"Language-Team: ru_RU \n" -"Language: ru\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" -"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"Generated-By: Babel 0.9.6\n" -"X-Generator: Poedit 1.5.4\n" - -# в Django тут "ий" но на самом деле оба варианта работают плохо -#: src/humanize/number.py:24 -msgctxt "0" -msgid "th" -msgstr "ой" - -#: src/humanize/number.py:25 -msgctxt "1" -msgid "st" -msgstr "ый" - -#: src/humanize/number.py:26 -msgctxt "2" -msgid "nd" -msgstr "ой" - -#: src/humanize/number.py:27 -msgctxt "3" -msgid "rd" -msgstr "ий" - -# в Django тут "ий" но на самом деле оба варианта работают плохо -#: src/humanize/number.py:28 -msgctxt "4" -msgid "th" -msgstr "ый" - -# в Django тут "ий" но на самом деле оба варианта работают плохо -#: src/humanize/number.py:29 -msgctxt "5" -msgid "th" -msgstr "ый" - -# в Django тут "ий" но на самом деле оба варианта работают плохо -#: src/humanize/number.py:30 -msgctxt "6" -msgid "th" -msgstr "ой" - -# в Django тут "ий" но на самом деле оба варианта работают плохо -#: src/humanize/number.py:31 -msgctxt "7" -msgid "th" -msgstr "ой" - -# в Django тут "ий" но на самом деле оба варианта работают плохо -#: src/humanize/number.py:32 -msgctxt "8" -msgid "th" -msgstr "ой" - -# в Django тут "ий" но на самом деле оба варианта работают плохо -#: src/humanize/number.py:33 -msgctxt "9" -msgid "th" -msgstr "ый" - -#: src/humanize/number.py:62 -msgid "million" -msgstr "миллиона" - -#: src/humanize/number.py:63 -msgid "billion" -msgstr "миллиарда" - -#: src/humanize/number.py:64 -msgid "trillion" -msgstr "триллиона" - -#: src/humanize/number.py:65 -msgid "quadrillion" -msgstr "квадриллиона" - -#: src/humanize/number.py:66 -msgid "quintillion" -msgstr "квинтиллиона" - -#: src/humanize/number.py:67 -msgid "sextillion" -msgstr "сикстиллиона" - -#: src/humanize/number.py:68 -msgid "septillion" -msgstr "септиллиона" - -#: src/humanize/number.py:69 -msgid "octillion" -msgstr "октиллиона" - -#: src/humanize/number.py:70 -msgid "nonillion" -msgstr "нониллиона" - -#: src/humanize/number.py:71 -msgid "decillion" -msgstr "децилиона" - -#: src/humanize/number.py:72 -msgid "googol" -msgstr "гогола" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "один" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "два" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "три" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "четыре" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "пять" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "шесть" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "семь" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "восемь" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "девять" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -msgid "a moment" -msgstr "только что" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "секунду" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d секунда" -msgstr[1] "%d секунды" -msgstr[2] "%d секунд" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "минуту" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d минута" -msgstr[1] "%d минуты" -msgstr[2] "%d минут" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "час" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d час" -msgstr[1] "%d часа" -msgstr[2] "%d часов" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "день" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d день" -msgstr[1] "%d дня" -msgstr[2] "%d дней" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "месяц" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d месяц" -msgstr[1] "%d месяца" -msgstr[2] "%d месяцев" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "год" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 год, %d день" -msgstr[1] "1 год, %d дня" -msgstr[2] "1 год, %d дней" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "1 год, 1 месяц" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 год, %d месяц" -msgstr[1] "1 год, %d месяца" -msgstr[2] "1 год, %d месяцев" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d год" -msgstr[1] "%d года" -msgstr[2] "%d лет" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "через %s" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s назад" - -#: src/humanize/time.py:132 -msgid "now" -msgstr "сейчас" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "сегодня" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "завтра" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "вчера" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/sk_SK/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/sk_SK/LC_MESSAGES/humanize.po deleted file mode 100644 index b74cb8dcaf4..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/sk_SK/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,291 +0,0 @@ -# Slovak translation of humanize -# Copyright (C) 2016 -# This file is distributed under the same license as the PACKAGE package. -# Jose Riha , 2016. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:30+0200\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Jose Riha \n" -"Language-Team: sk \n" -"Language: Slovak\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "." - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "." - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "." - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "." - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "." - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "." - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "." - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "." - -#: src/humanize/number.py:73 -msgid "million" -msgstr "milióna/ov" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "miliardy/árd" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "bilióna/ov" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "biliardy/árd" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "trilióna/árd" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "triliardy/árd" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "kvadrilióna/ov" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "kvadriliardy/árd" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "kvintilióna/ov" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "kvintiliardy/árd" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "googola/ov" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "nula" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "jedna" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "dve" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "tri" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "štyri" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "päť" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "šesť" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "sedem" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "osem" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "deväť" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d mikrosekundu" -msgstr[1] "%d mikrosekundy" -msgstr[2] "%d mikrosekúnd" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d milisekundu" -msgstr[1] "%d milisekundy" -msgstr[2] "%d milisekúnd" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "chvíľku" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "sekundu" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d sekundu" -msgstr[1] "%d sekundy" -msgstr[2] "%d sekúnd" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "minútu" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d minútu" -msgstr[1] "%d minúty" -msgstr[2] "%d minút" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "hodinu" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d hodina" -msgstr[1] "%d hodiny" -msgstr[2] "%d hodín" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "deň" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d deň" -msgstr[1] "%d dni" -msgstr[2] "%d dní" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "mesiac" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d mesiac" -msgstr[1] "%d mesiace" -msgstr[2] "%d mesiacov" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "rok" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 rok, %d deň" -msgstr[1] "1 rok, %d dni" -msgstr[2] "1 rok, %d dní" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "1 rok, 1 mesiac" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 rok, %d mesiac" -msgstr[1] "1 rok, %d mesiace" -msgstr[2] "1 rok, %d mesiacov" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d rok" -msgstr[1] "%d roky" -msgstr[2] "%d rokov" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "o %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "%s naspäť" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "teraz" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "dnes" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "zajtra" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "včera" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/tr_TR/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/tr_TR/LC_MESSAGES/humanize.po deleted file mode 100644 index 0694bea1fd4..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/tr_TR/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,264 +0,0 @@ -# Turkish translation for humanize. -# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the humanize package. -# Emre Çintay , 2017. -# -msgid "" -msgstr "" -"Project-Id-Version: humanize\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 20:05+0200\n" -"PO-Revision-Date: 2017-02-23 20:00+0300\n" -"Last-Translator: Emre Çintay \n" -"Language-Team: Turkish\n" -"Language: tr_TR\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 1.8.7.1\n" -"Generated-By: Emre Çintay\n" - -#: src/humanize/number.py:24 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "1" -msgid "st" -msgstr "." - -#: src/humanize/number.py:26 -msgctxt "2" -msgid "nd" -msgstr "." - -#: src/humanize/number.py:27 -msgctxt "3" -msgid "rd" -msgstr "." - -#: src/humanize/number.py:28 -msgctxt "4" -msgid "th" -msgstr "." - -#: src/humanize/number.py:29 -msgctxt "5" -msgid "th" -msgstr "." - -#: src/humanize/number.py:30 -msgctxt "6" -msgid "th" -msgstr "." - -#: src/humanize/number.py:31 -msgctxt "7" -msgid "th" -msgstr "." - -#: src/humanize/number.py:32 -msgctxt "8" -msgid "th" -msgstr "." - -#: src/humanize/number.py:33 -msgctxt "9" -msgid "th" -msgstr "." - -#: src/humanize/number.py:62 -msgid "million" -msgstr "milyon" - -#: src/humanize/number.py:63 -msgid "billion" -msgstr "milyar" - -#: src/humanize/number.py:64 -msgid "trillion" -msgstr "trilyon" - -#: src/humanize/number.py:65 -msgid "quadrillion" -msgstr "katrilyon" - -#: src/humanize/number.py:66 -msgid "quintillion" -msgstr "kentilyon" - -#: src/humanize/number.py:67 -msgid "sextillion" -msgstr "sekstilyon" - -#: src/humanize/number.py:68 -msgid "septillion" -msgstr "septilyon" - -#: src/humanize/number.py:69 -msgid "octillion" -msgstr "oktilyon" - -#: src/humanize/number.py:70 -msgid "nonillion" -msgstr "nonilyon" - -#: src/humanize/number.py:71 -msgid "decillion" -msgstr "desilyon" - -#: src/humanize/number.py:72 -msgid "googol" -msgstr "googol" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "bir" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "iki" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "üç" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "dört" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "beş" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "altı" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "yedi" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "sekiz" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "dokuz" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -msgid "a moment" -msgstr "biraz" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "bir saniye" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d saniye" -msgstr[1] "%d saniye" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "bir dakika" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d dakika" -msgstr[1] "%d dakika" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "bir saat" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d saat" -msgstr[1] "%d saat" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "bir gün" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d gün" -msgstr[1] "%d gün" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "bir ay" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d ay" -msgstr[1] "%d ay" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "bir yıl" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 yıl, %d gün" -msgstr[1] "1 yıl, %d gün" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "1 yıl, 1 ay" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 yıl, %d ay" -msgstr[1] "1 yıl, %d ay" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d yıl" -msgstr[1] "%d yıl" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "şu andan itibaren %s" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s önce" - -#: src/humanize/time.py:132 -msgid "now" -msgstr "şimdi" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "bugün" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "yarın" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "dün" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/uk_UA/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/uk_UA/LC_MESSAGES/humanize.po deleted file mode 100644 index 5ef50166dca..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/uk_UA/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,288 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-22 16:59+0200\n" -"PO-Revision-Date: \n" -"Last-Translator: TL\n" -"Language-Team: uk_UA\n" -"Language: uk\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" -"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"Generated-By:\n" -"X-Generator: \n" - -#: src/humanize/number.py:22 -msgctxt "0" -msgid "th" -msgstr "ий" - -#: src/humanize/number.py:23 -msgctxt "1" -msgid "st" -msgstr "ий" - -#: src/humanize/number.py:24 -msgctxt "2" -msgid "nd" -msgstr "ий" - -#: src/humanize/number.py:25 -msgctxt "3" -msgid "rd" -msgstr "ій" - -#: src/humanize/number.py:26 -msgctxt "4" -msgid "th" -msgstr "ий" - -#: src/humanize/number.py:27 -msgctxt "5" -msgid "th" -msgstr "ий" - -#: src/humanize/number.py:28 -msgctxt "6" -msgid "th" -msgstr "ий" - -#: src/humanize/number.py:29 -msgctxt "7" -msgid "th" -msgstr "ий" - -#: src/humanize/number.py:30 -msgctxt "8" -msgid "th" -msgstr "ий" - -#: src/humanize/number.py:31 -msgctxt "9" -msgid "th" -msgstr "ий" - -#: src/humanize/number.py:73 -msgid "million" -msgstr "мільйонів" - -#: src/humanize/number.py:74 -msgid "billion" -msgstr "мільярдів" - -#: src/humanize/number.py:75 -msgid "trillion" -msgstr "трильйонів" - -#: src/humanize/number.py:76 -msgid "quadrillion" -msgstr "квадрильйонів" - -#: src/humanize/number.py:77 -msgid "quintillion" -msgstr "квинтиліонів" - -#: src/humanize/number.py:78 -msgid "sextillion" -msgstr "сикстильйонів" - -#: src/humanize/number.py:79 -msgid "septillion" -msgstr "септильйонів" - -#: src/humanize/number.py:80 -msgid "octillion" -msgstr "октильйонів" - -#: src/humanize/number.py:81 -msgid "nonillion" -msgstr "нонильйонів" - -#: src/humanize/number.py:82 -msgid "decillion" -msgstr "децильйонів" - -#: src/humanize/number.py:83 -msgid "googol" -msgstr "гугола" - -#: src/humanize/number.py:138 -msgid "zero" -msgstr "нуль" - -#: src/humanize/number.py:139 -msgid "one" -msgstr "один" - -#: src/humanize/number.py:140 -msgid "two" -msgstr "два" - -#: src/humanize/number.py:141 -msgid "three" -msgstr "три" - -#: src/humanize/number.py:142 -msgid "four" -msgstr "чотири" - -#: src/humanize/number.py:143 -msgid "five" -msgstr "п'ять" - -#: src/humanize/number.py:144 -msgid "six" -msgstr "шість" - -#: src/humanize/number.py:145 -msgid "seven" -msgstr "сім" - -#: src/humanize/number.py:146 -msgid "eight" -msgstr "вісім" - -#: src/humanize/number.py:147 -msgid "nine" -msgstr "дев'ять" - -#: src/humanize/time.py:87 -#, fuzzy, python-format -msgid "%d microsecond" -msgid_plural "%d microseconds" -msgstr[0] "%d мікросекунда" -msgstr[1] "%d мікросекунд" -msgstr[2] "%d мікросекунди" - -#: src/humanize/time.py:93 -#, fuzzy, python-format -msgid "%d millisecond" -msgid_plural "%d milliseconds" -msgstr[0] "%d мілісекунда" -msgstr[1] "%d мілісекунди" -msgstr[2] "%d мілісекунди" - -#: src/humanize/time.py:96 src/humanize/time.py:170 -msgid "a moment" -msgstr "у цей момент" - -#: src/humanize/time.py:98 -msgid "a second" -msgstr "секунду" - -#: src/humanize/time.py:100 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d секунда" -msgstr[1] "%d секунд" -msgstr[2] "%d секунд" - -#: src/humanize/time.py:102 -msgid "a minute" -msgstr "хвилина" - -#: src/humanize/time.py:105 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d хвилина" -msgstr[1] "%d хвилини" -msgstr[2] "%d хвилин" - -#: src/humanize/time.py:107 -msgid "an hour" -msgstr "година" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d година" -msgstr[1] "%d годин" -msgstr[2] "%d годин" - -#: src/humanize/time.py:113 -msgid "a day" -msgstr "день" - -#: src/humanize/time.py:115 src/humanize/time.py:118 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d день" -msgstr[1] "%d дня" -msgstr[2] "%d дні" - -#: src/humanize/time.py:120 -msgid "a month" -msgstr "місяць" - -#: src/humanize/time.py:122 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d місяць" -msgstr[1] "%d місяця" -msgstr[2] "%d місяців" - -#: src/humanize/time.py:125 -msgid "a year" -msgstr "рік" - -#: src/humanize/time.py:127 src/humanize/time.py:136 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 рік, %d день" -msgstr[1] "1 рік, %d дня" -msgstr[2] "1 рік, %d днів" - -#: src/humanize/time.py:130 -msgid "1 year, 1 month" -msgstr "1 рік, 1 місяць" - -#: src/humanize/time.py:133 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 рік, %d місяць" -msgstr[1] "1 рік, %d місяця" -msgstr[2] "1 рік, %d місяців" - -#: src/humanize/time.py:138 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d рік" -msgstr[1] "%d роки" -msgstr[2] "%d років" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s from now" -msgstr "через %s" - -#: src/humanize/time.py:167 -#, python-format -msgid "%s ago" -msgstr "%s назад" - -#: src/humanize/time.py:171 -msgid "now" -msgstr "зараз" - -#: src/humanize/time.py:190 -msgid "today" -msgstr "сьогодні" - -#: src/humanize/time.py:192 -msgid "tomorrow" -msgstr "завтра" - -#: src/humanize/time.py:194 -msgid "yesterday" -msgstr "вчора" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/vi_VI/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/vi_VI/LC_MESSAGES/humanize.po deleted file mode 100644 index 06d224eb622..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/vi_VI/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,271 +0,0 @@ -# French (France) translations for PROJECT. -# Copyright (C) 2013 ORGANIZATION -# This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2013. -# -msgid "" -msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 20:05+0200\n" -"PO-Revision-Date: 2017-05-30 11:51+0700\n" -"Last-Translator: Olivier Cortès \n" -"Language-Team: vi_VI \n" -"Language: vi_VN\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" -"Generated-By: Babel 0.9.6\n" -"X-Generator: Poedit 1.8.7.1\n" - -#: src/humanize/number.py:24 -msgctxt "0" -msgid "th" -msgstr "." - -#: src/humanize/number.py:25 -msgctxt "1" -msgid "st" -msgstr "." - -#: src/humanize/number.py:26 -msgctxt "2" -msgid "nd" -msgstr "." - -#: src/humanize/number.py:27 -msgctxt "3" -msgid "rd" -msgstr "." - -#: src/humanize/number.py:28 -msgctxt "4" -msgid "th" -msgstr "." - -#: src/humanize/number.py:29 -msgctxt "5" -msgid "th" -msgstr "." - -#: src/humanize/number.py:30 -msgctxt "6" -msgid "th" -msgstr "." - -#: src/humanize/number.py:31 -msgctxt "7" -msgid "th" -msgstr "." - -#: src/humanize/number.py:32 -msgctxt "8" -msgid "th" -msgstr "." - -#: src/humanize/number.py:33 -msgctxt "9" -msgid "th" -msgstr "." - -#: src/humanize/number.py:62 -msgid "million" -msgstr "%(value)s triệu" - -#: src/humanize/number.py:63 -msgid "billion" -msgstr "tỷ" - -#: src/humanize/number.py:64 -msgid "trillion" -msgstr "%(value)s nghìn tỷ" - -#: src/humanize/number.py:65 -msgid "quadrillion" -msgstr "%(value)s triệu tỷ" - -#: src/humanize/number.py:66 -#, fuzzy -msgid "quintillion" -msgstr "%(value)s quintillion" - -#: src/humanize/number.py:67 -#, fuzzy -msgid "sextillion" -msgstr "%(value)s sextillion" - -#: src/humanize/number.py:68 -#, fuzzy -msgid "septillion" -msgstr "%(value)s septillion" - -#: src/humanize/number.py:69 -#, fuzzy -msgid "octillion" -msgstr "%(value)s octillion" - -#: src/humanize/number.py:70 -#, fuzzy -msgid "nonillion" -msgstr "%(value)s nonillion" - -#: src/humanize/number.py:71 -#, fuzzy -msgid "decillion" -msgstr "%(value)s décillion" - -#: src/humanize/number.py:72 -#, fuzzy -msgid "googol" -msgstr "%(value)s gogol" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "một" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "hai" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "ba" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "bốn" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "năm" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "sáu" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "bảy" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "tám" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "chín" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -msgid "a moment" -msgstr "ngay lúc này" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "một giây" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d giây" -msgstr[1] "%d giây" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "một phút" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d phút" -msgstr[1] "%d phút" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "một giờ" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d giờ" -msgstr[1] "%d giờ" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "một ngày" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d ngày" -msgstr[1] "%d ngày" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "một tháng" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d tháng" -msgstr[1] "%d tháng" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "một năm" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "1 năm %d ngày" -msgstr[1] "1 năm %d ngày" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "1 năm 1 tháng" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1 năm %d tháng" -msgstr[1] "un an et %d mois" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d năm" -msgstr[1] "%d năm" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "%s ngày tới" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s trước" - -#: src/humanize/time.py:132 -msgid "now" -msgstr "ngay bây giờ" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "hôm nay" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "ngày mai" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "ngày hôm qua" diff --git a/napari/_vendor/experimental/humanize/src/humanize/locale/zh_CN/LC_MESSAGES/humanize.po b/napari/_vendor/experimental/humanize/src/humanize/locale/zh_CN/LC_MESSAGES/humanize.po deleted file mode 100644 index b45e0cade66..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/locale/zh_CN/LC_MESSAGES/humanize.po +++ /dev/null @@ -1,263 +0,0 @@ -# Simplified Chinese (China) translation for the project -# Copyright (C) 2016 -# This file is distributed under the same license as the PACKAGE package. -# AZLisme , 2016. -# Liwen SUN , 2019. -# -msgid "" -msgstr "" -"Project-Id-Version: 1.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-08 20:05+0200\n" -"PO-Revision-Date: 2016-11-14 23:02+0000\n" -"Last-Translator: Liwen SUN \n" -"Language-Team: Chinese (simplified)\n" -"Language: zh_CN\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" - -#: src/humanize/number.py:24 -msgctxt "0" -msgid "th" -msgstr "第" - -#: src/humanize/number.py:25 -msgctxt "1" -msgid "st" -msgstr "第" - -#: src/humanize/number.py:26 -msgctxt "2" -msgid "nd" -msgstr "第" - -#: src/humanize/number.py:27 -msgctxt "3" -msgid "rd" -msgstr "第" - -#: src/humanize/number.py:28 -msgctxt "4" -msgid "th" -msgstr "第" - -#: src/humanize/number.py:29 -msgctxt "5" -msgid "th" -msgstr "第" - -#: src/humanize/number.py:30 -msgctxt "6" -msgid "th" -msgstr "第" - -#: src/humanize/number.py:31 -msgctxt "7" -msgid "th" -msgstr "第" - -#: src/humanize/number.py:32 -msgctxt "8" -msgid "th" -msgstr "第" - -#: src/humanize/number.py:33 -msgctxt "9" -msgid "th" -msgstr "第" - -#: src/humanize/number.py:62 -msgid "million" -msgstr "百万" - -#: src/humanize/number.py:63 -msgid "billion" -msgstr "十亿" - -#: src/humanize/number.py:64 -msgid "trillion" -msgstr "兆" - -#: src/humanize/number.py:65 -msgid "quadrillion" -msgstr "万亿" - -#: src/humanize/number.py:66 -msgid "quintillion" -msgstr "百京" - -#: src/humanize/number.py:67 -msgid "sextillion" -msgstr "十垓" - -#: src/humanize/number.py:68 -msgid "septillion" -msgstr "秭" - -#: src/humanize/number.py:69 -msgid "octillion" -msgstr "千秭" - -#: src/humanize/number.py:70 -msgid "nonillion" -msgstr "百穰" - -#: src/humanize/number.py:71 -msgid "decillion" -msgstr "十沟" - -#: src/humanize/number.py:72 -msgid "googol" -msgstr "古高尔" - -#: src/humanize/number.py:108 -msgid "one" -msgstr "一" - -#: src/humanize/number.py:109 -msgid "two" -msgstr "二" - -#: src/humanize/number.py:110 -msgid "three" -msgstr "三" - -#: src/humanize/number.py:111 -msgid "four" -msgstr "四" - -#: src/humanize/number.py:112 -msgid "five" -msgstr "五" - -#: src/humanize/number.py:113 -msgid "six" -msgstr "六" - -#: src/humanize/number.py:114 -msgid "seven" -msgstr "七" - -#: src/humanize/number.py:115 -msgid "eight" -msgstr "八" - -#: src/humanize/number.py:116 -msgid "nine" -msgstr "九" - -#: src/humanize/time.py:68 src/humanize/time.py:131 -msgid "a moment" -msgstr "一会儿" - -#: src/humanize/time.py:70 -msgid "a second" -msgstr "1秒" - -#: src/humanize/time.py:72 -#, python-format -msgid "%d second" -msgid_plural "%d seconds" -msgstr[0] "%d秒" -msgstr[1] "%d秒" - -#: src/humanize/time.py:74 -msgid "a minute" -msgstr "1分" - -#: src/humanize/time.py:77 -#, python-format -msgid "%d minute" -msgid_plural "%d minutes" -msgstr[0] "%d分" -msgstr[1] "%d分" - -#: src/humanize/time.py:79 -msgid "an hour" -msgstr "1小时" - -#: src/humanize/time.py:82 -#, python-format -msgid "%d hour" -msgid_plural "%d hours" -msgstr[0] "%d小时" -msgstr[1] "%d小时" - -#: src/humanize/time.py:85 -msgid "a day" -msgstr "1天" - -#: src/humanize/time.py:87 src/humanize/time.py:90 -#, python-format -msgid "%d day" -msgid_plural "%d days" -msgstr[0] "%d天" -msgstr[1] "%d天" - -#: src/humanize/time.py:92 -msgid "a month" -msgstr "1月" - -#: src/humanize/time.py:94 -#, python-format -msgid "%d month" -msgid_plural "%d months" -msgstr[0] "%d月" -msgstr[1] "%d月" - -#: src/humanize/time.py:97 -msgid "a year" -msgstr "1年" - -#: src/humanize/time.py:99 src/humanize/time.py:108 -#, python-format -msgid "1 year, %d day" -msgid_plural "1 year, %d days" -msgstr[0] "%d年" -msgstr[1] "%d年" - -#: src/humanize/time.py:102 -msgid "1 year, 1 month" -msgstr "1年又1月" - -#: src/humanize/time.py:105 -#, python-format -msgid "1 year, %d month" -msgid_plural "1 year, %d months" -msgstr[0] "1年又%d月" -msgstr[1] "1年又%d月" - -#: src/humanize/time.py:110 -#, python-format -msgid "%d year" -msgid_plural "%d years" -msgstr[0] "%d年" -msgstr[1] "%d年" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s from now" -msgstr "%s之后" - -#: src/humanize/time.py:128 -#, python-format -msgid "%s ago" -msgstr "%s之前" - -#: src/humanize/time.py:132 -msgid "now" -msgstr "现在" - -#: src/humanize/time.py:151 -msgid "today" -msgstr "今天" - -#: src/humanize/time.py:153 -msgid "tomorrow" -msgstr "明天" - -#: src/humanize/time.py:155 -msgid "yesterday" -msgstr "昨天" diff --git a/napari/_vendor/experimental/humanize/src/humanize/number.py b/napari/_vendor/experimental/humanize/src/humanize/number.py deleted file mode 100644 index e108ebcf9b5..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/number.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python - -"""Humanizing functions for numbers.""" - -import re -from fractions import Fraction - -from .i18n import gettext as _ -from .i18n import gettext_noop as N_ -from .i18n import pgettext as P_ - - -def ordinal(value): - """Converts an integer to its ordinal as a string. - - For example, 1 is "1st", 2 is "2nd", 3 is "3rd", etc. Works for any integer or - anything `int()` will turn into an integer. Anything other value will have nothing - done to it. - - Args: - value (int, str, float): Integer to convert. - - Returns: - str: Ordinal string. - """ - try: - value = int(value) - except (TypeError, ValueError): - return value - t = ( - P_("0", "th"), - P_("1", "st"), - P_("2", "nd"), - P_("3", "rd"), - P_("4", "th"), - P_("5", "th"), - P_("6", "th"), - P_("7", "th"), - P_("8", "th"), - P_("9", "th"), - ) - if value % 100 in (11, 12, 13): # special case - return f"{value}{t[0]}" - return f"{value}{t[value % 10]}" - - -def intcomma(value, ndigits=None): - """Converts an integer to a string containing commas every three digits. - - For example, 3000 becomes "3,000" and 45000 becomes "45,000". To maintain some - compatibility with Django's `intcomma`, this function also accepts floats. - - Args: - value (int, float, str): Integer or float to convert. - ndigits (int, None): Digits of precision for rounding after the decimal point. - - Returns: - str: string containing commas every three digits. - """ - try: - if isinstance(value, str): - float(value.replace(",", "")) - else: - float(value) - except (TypeError, ValueError): - return value - - if ndigits: - orig = "{0:.{1}f}".format(value, ndigits) - else: - orig = str(value) - - new = re.sub(r"^(-?\d+)(\d{3})", r"\g<1>,\g<2>", orig) - if orig == new: - return new - else: - return intcomma(new) - - -powers = [10 ** x for x in (6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 100)] -human_powers = ( - N_("million"), - N_("billion"), - N_("trillion"), - N_("quadrillion"), - N_("quintillion"), - N_("sextillion"), - N_("septillion"), - N_("octillion"), - N_("nonillion"), - N_("decillion"), - N_("googol"), -) - - -def intword(value, format="%.1f"): - """Converts a large integer to a friendly text representation. - - Works best for numbers over 1 million. For example, 1_000_000 becomes "1.0 million", - 1200000 becomes "1.2 million" and "1_200_000_000" becomes "1.2 billion". Supports up - to decillion (33 digits) and googol (100 digits). - - Args: - value (int, float, str): Integer to convert. - format (str): To change the number of decimal or general format of the number - portion. - - Returns: - str: Friendly text representation as a string, unless the value passed could not - be coaxed into an `int`. - """ - try: - value = int(value) - except (TypeError, ValueError): - return value - - if value < powers[0]: - return str(value) - for ordinal, power in enumerate(powers[1:], 1): - if value < power: - chopped = value / float(powers[ordinal - 1]) - if float(format % chopped) == float(10 ** 3): - chopped = value / float(powers[ordinal]) - return (" ".join([format, _(human_powers[ordinal])])) % chopped - else: - return (" ".join([format, _(human_powers[ordinal - 1])])) % chopped - return str(value) - - -def apnumber(value): - """Converts an integer to Associated Press style. - - Args: - value (int, float, str): Integer to convert. - - Returns: - str: For numbers 0-9, the number spelled out. Otherwise, the number. This always - returns a string unless the value was not `int`-able, unlike the Django filter. - """ - try: - value = int(value) - except (TypeError, ValueError): - return value - if not 0 <= value < 10: - return str(value) - return ( - _("zero"), - _("one"), - _("two"), - _("three"), - _("four"), - _("five"), - _("six"), - _("seven"), - _("eight"), - _("nine"), - )[value] - - -def fractional(value): - """Convert to fractional number. - - There will be some cases where one might not want to show ugly decimal places for - floats and decimals. - - This function returns a human-readable fractional number in form of fractions and - mixed fractions. - - Pass in a string, or a number or a float, and this function returns: - - * a string representation of a fraction - * or a whole number - * or a mixed fraction - - Examples: - ```pycon - >>> fractional(0.3) - '3/10' - >>> fractional(1.3) - '1 3/10' - >>> fractional(float(1/3)) - '1/3' - >>> fractional(1) - '1' - ``` - Args: - value (int, float, str): Integer to convert. - - Returns: - str: Fractional number as a string. - """ - try: - number = float(value) - except (TypeError, ValueError): - return value - whole_number = int(number) - frac = Fraction(number - whole_number).limit_denominator(1000) - numerator = frac._numerator - denominator = frac._denominator - if whole_number and not numerator and denominator == 1: - # this means that an integer was passed in - # (or variants of that integer like 1.0000) - return f"{whole_number:.0f}" - elif not whole_number: - return f"{numerator:.0f}/{denominator:.0f}" - else: - return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" - - -def scientific(value, precision=2): - """Return number in string scientific notation z.wq x 10ⁿ. - - Examples: - ```pycon - >>> scientific(float(0.3)) - '3.00 x 10⁻¹' - >>> scientific(int(500)) - '5.00 x 10²' - ``` - - Args: - value (int, float, str): Input number. - precision (int): Number of decimal for first part of the number. - - Returns: - str: Number in scientific notation z.wq x 10ⁿ. - """ - exponents = { - "0": "⁰", - "1": "¹", - "2": "²", - "3": "³", - "4": "⁴", - "5": "⁵", - "6": "⁶", - "7": "⁷", - "8": "⁸", - "9": "⁹", - "+": "⁺", - "-": "⁻", - } - negative = False - try: - if "-" in str(value): - value = str(value).replace("-", "") - negative = True - - if isinstance(value, str): - value = float(value) - - fmt = "{:.%se}" % str(int(precision)) - n = fmt.format(value) - - except (ValueError, TypeError): - return value - - part1, part2 = n.split("e") - if "-0" in part2: - part2 = part2.replace("-0", "-") - - if "+0" in part2: - part2 = part2.replace("+0", "") - - new_part2 = [] - if negative: - new_part2.append(exponents["-"]) - - for char in part2: - new_part2.append(exponents[char]) - - final_str = part1 + " x 10" + "".join(new_part2) - - return final_str diff --git a/napari/_vendor/experimental/humanize/src/humanize/time.py b/napari/_vendor/experimental/humanize/src/humanize/time.py deleted file mode 100644 index 18990b1ffa4..00000000000 --- a/napari/_vendor/experimental/humanize/src/humanize/time.py +++ /dev/null @@ -1,504 +0,0 @@ -#!/usr/bin/env python - -"""Time humanizing functions. These are largely borrowed from Django's -`contrib.humanize`.""" - -import datetime as dt -import math -from enum import Enum -from functools import total_ordering - -from .i18n import gettext as _ -from .i18n import ngettext - -__all__ = [ - "naturaldelta", - "naturaltime", - "naturalday", - "naturaldate", - "precisedelta", -] - - -@total_ordering -class Unit(Enum): - MICROSECONDS = 0 - MILLISECONDS = 1 - SECONDS = 2 - MINUTES = 3 - HOURS = 4 - DAYS = 5 - MONTHS = 6 - YEARS = 7 - - def __lt__(self, other): - if self.__class__ is other.__class__: - return self.value < other.value - return NotImplemented - - -def _now(): - return dt.datetime.now() - - -def abs_timedelta(delta): - """Return an "absolute" value for a timedelta, always representing a - time distance. - - Args: - delta (datetime.timedelta): Input timedelta. - - Returns: - datetime.timedelta: Absolute timedelta. - """ - if delta.days < 0: - now = _now() - return now - (now + delta) - return delta - - -def date_and_delta(value, *, now=None): - """Turn a value into a date and a timedelta which represents how long ago it was. - - If that's not possible, return `(None, value)`. - """ - if not now: - now = _now() - if isinstance(value, dt.datetime): - date = value - delta = now - value - elif isinstance(value, dt.timedelta): - date = now - value - delta = value - else: - try: - value = int(value) - delta = dt.timedelta(seconds=value) - date = now - delta - except (ValueError, TypeError): - return None, value - return date, abs_timedelta(delta) - - -def naturaldelta(value, months=True, minimum_unit="seconds"): - """Return a natural representation of a timedelta or number of seconds. - - This is similar to `naturaltime`, but does not add tense to the result. - - Args: - value (datetime.timedelta): A timedelta or a number of seconds. - months (bool): If `True`, then a number of months (based on 30.5 days) will be - used for fuzziness between years. - minimum_unit (str): The lowest unit that can be used. - - Returns: - str: A natural representation of the amount of time elapsed. - """ - tmp = Unit[minimum_unit.upper()] - if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS): - raise ValueError(f"Minimum unit '{minimum_unit}' not supported") - minimum_unit = tmp - - date, delta = date_and_delta(value) - if date is None: - return value - - use_months = months - - seconds = abs(delta.seconds) - days = abs(delta.days) - years = days // 365 - days = days % 365 - months = int(days // 30.5) - - if not years and days < 1: - if seconds == 0: - if minimum_unit == Unit.MICROSECONDS and delta.microseconds < 1000: - return ( - ngettext("%d microsecond", "%d microseconds", delta.microseconds) - % delta.microseconds - ) - elif minimum_unit == Unit.MILLISECONDS or ( - minimum_unit == Unit.MICROSECONDS - and 1000 <= delta.microseconds < 1_000_000 - ): - milliseconds = delta.microseconds / 1000 - return ( - ngettext("%d millisecond", "%d milliseconds", milliseconds) - % milliseconds - ) - return _("a moment") - elif seconds == 1: - return _("a second") - elif seconds < 60: - return ngettext("%d second", "%d seconds", seconds) % seconds - elif 60 <= seconds < 120: - return _("a minute") - elif 120 <= seconds < 3600: - minutes = seconds // 60 - return ngettext("%d minute", "%d minutes", minutes) % minutes - elif 3600 <= seconds < 3600 * 2: - return _("an hour") - elif 3600 < seconds: - hours = seconds // 3600 - return ngettext("%d hour", "%d hours", hours) % hours - elif years == 0: - if days == 1: - return _("a day") - if not use_months: - return ngettext("%d day", "%d days", days) % days - else: - if not months: - return ngettext("%d day", "%d days", days) % days - elif months == 1: - return _("a month") - else: - return ngettext("%d month", "%d months", months) % months - elif years == 1: - if not months and not days: - return _("a year") - elif not months: - return ngettext("1 year, %d day", "1 year, %d days", days) % days - elif use_months: - if months == 1: - return _("1 year, 1 month") - else: - return ( - ngettext("1 year, %d month", "1 year, %d months", months) % months - ) - else: - return ngettext("1 year, %d day", "1 year, %d days", days) % days - else: - return ngettext("%d year", "%d years", years) % years - - -def naturaltime(value, future=False, months=True, minimum_unit="seconds"): - """Return a natural representation of a time in a resolution that makes sense. - - This is more or less compatible with Django's `naturaltime` filter. - - Args: - value (datetime.datetime, int): A `datetime` or a number of seconds. - future (bool): Ignored for `datetime`s, where the tense is always figured out - based on the current time. For integers, the return value will be past tense - by default, unless future is `True`. - months (bool): If `True`, then a number of months (based on 30.5 days) will be - used for fuzziness between years. - minimum_unit (str): The lowest unit that can be used. - - Returns: - str: A natural representation of the input in a resolution that makes sense. - """ - now = _now() - date, delta = date_and_delta(value, now=now) - if date is None: - return value - # determine tense by value only if datetime/timedelta were passed - if isinstance(value, (dt.datetime, dt.timedelta)): - future = date > now - - ago = _("%s from now") if future else _("%s ago") - delta = naturaldelta(delta, months, minimum_unit) - - if delta == _("a moment"): - return _("now") - - return ago % delta - - -def naturalday(value, format="%b %d"): - """For date values that are tomorrow, today or yesterday compared to - present day returns representing string. Otherwise, returns a string - formatted according to `format`.""" - try: - value = dt.date(value.year, value.month, value.day) - except AttributeError: - # Passed value wasn't date-ish - return value - except (OverflowError, ValueError): - # Date arguments out of range - return value - delta = value - dt.date.today() - if delta.days == 0: - return _("today") - elif delta.days == 1: - return _("tomorrow") - elif delta.days == -1: - return _("yesterday") - return value.strftime(format) - - -def naturaldate(value): - """Like `naturalday`, but append a year for dates more than about five months away. - """ - try: - value = dt.date(value.year, value.month, value.day) - except AttributeError: - # Passed value wasn't date-ish - return value - except (OverflowError, ValueError): - # Date arguments out of range - return value - delta = abs_timedelta(value - dt.date.today()) - if delta.days >= 5 * 365 / 12: - return naturalday(value, "%b %d %Y") - return naturalday(value) - - -def _quotient_and_remainder(value, divisor, unit, minimum_unit, suppress): - """Divide `value` by `divisor` returning the quotient and - the remainder as follows: - - If `unit` is `minimum_unit`, makes the quotient a float number - and the remainder will be zero. The rational is that if unit - is the unit of the quotient, we cannot - represent the remainder because it would require a unit smaller - than the minimum_unit. - - >>> from humanize.time import _quotient_and_remainder, Unit - >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.DAYS, []) - (1.5, 0) - - If unit is in suppress, the quotient will be zero and the - remainder will be the initial value. The idea is that if we - cannot use unit, we are forced to use a lower unit so we cannot - do the division. - - >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, [Unit.DAYS]) - (0, 36) - - In other case return quotient and remainder as `divmod` would - do it. - - >>> _quotient_and_remainder(36, 24, Unit.DAYS, Unit.HOURS, []) - (1, 12) - - """ - - if unit == minimum_unit: - return (value / divisor, 0) - elif unit in suppress: - return (0, value) - else: - return divmod(value, divisor) - - -def _carry(value1, value2, ratio, unit, min_unit, suppress): - """Return a tuple with two values as follows: - - If the unit is in suppress multiplies value1 - by ratio and add it to value2 (carry to right). - The idea is that if we cannot represent value1 we need - to represent it in a lower unit. - - >>> from humanize.time import _carry, Unit - >>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, [Unit.DAYS]) - (0, 54) - - If the unit is the minimum unit, value2 is divided - by ratio and added to value1 (carry to left). - We assume that value2 has a lower unit so we need to - carry it to value1. - - >>> _carry(2, 6, 24, Unit.DAYS, Unit.DAYS, []) - (2.25, 0) - - Otherwise, just return the same input: - - >>> _carry(2, 6, 24, Unit.DAYS, Unit.SECONDS, []) - (2, 6) - """ - if unit == min_unit: - return (value1 + value2 / ratio, 0) - elif unit in suppress: - return (0, value2 + value1 * ratio) - else: - return (value1, value2) - - -def _suitable_minimum_unit(min_unit, suppress): - """Return a minimum unit suitable that is not suppressed. - - If not suppressed, return the same unit: - - >>> from humanize.time import _suitable_minimum_unit, Unit - >>> _suitable_minimum_unit(Unit.HOURS, []) - - - But if suppressed, find a unit greather than the original one - that is not suppressed: - - >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS]) - - - >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS, Unit.DAYS]) - - """ - if min_unit in suppress: - for unit in Unit: - if unit > min_unit and unit not in suppress: - return unit - - raise ValueError( - "Minimum unit is suppressed and no suitable replacement was found" - ) - - return min_unit - - -def _suppress_lower_units(min_unit, suppress): - """Extend the suppressed units (if any) with all the units that are - lower than the minimum unit. - - >>> from humanize.time import _suppress_lower_units, Unit - >>> list(sorted(_suppress_lower_units(Unit.SECONDS, [Unit.DAYS]))) - [, , ] - """ - suppress = set(suppress) - for u in Unit: - if u == min_unit: - break - suppress.add(u) - - return suppress - - -def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f"): - """Return a precise representation of a timedelta. - - ```pycon - >>> import datetime as dt - >>> from humanize.time import precisedelta - - >>> delta = dt.timedelta(seconds=3633, days=2, microseconds=123000) - >>> precisedelta(delta) - '2 days, 1 hour and 33.12 seconds' - ``` - - A custom `format` can be specified to control how the fractional part - is represented: - - ```pycon - >>> precisedelta(delta, format="%0.4f") - '2 days, 1 hour and 33.1230 seconds' - ``` - - Instead, the `minimum_unit` can be changed to have a better resolution; - the function will still readjust the unit to use the greatest of the - units that does not lose precision. - - For example setting microseconds but still representing the date with milliseconds: - - ```pycon - >>> precisedelta(delta, minimum_unit="microseconds") - '2 days, 1 hour, 33 seconds and 123 milliseconds' - ``` - - If desired, some units can be suppressed: you will not see them represented and the - time of the other units will be adjusted to keep representing the same timedelta: - - ```pycon - >>> precisedelta(delta, suppress=['days']) - '49 hours and 33.12 seconds' - ``` - - Note that microseconds precision is lost if the seconds and all - the units below are suppressed: - - ```pycon - >>> delta = dt.timedelta(seconds=90, microseconds=100) - >>> precisedelta(delta, suppress=['seconds', 'milliseconds', 'microseconds']) - '1.50 minutes' - ``` - """ - - date, delta = date_and_delta(value) - if date is None: - return value - - suppress = [Unit[s.upper()] for s in suppress] - - # Find a suitable minimum unit (it can be greater the one that the - # user gave us if it is suppressed). - min_unit = Unit[minimum_unit.upper()] - min_unit = _suitable_minimum_unit(min_unit, suppress) - del minimum_unit - - # Expand the suppressed units list/set to include all the units - # that are below the minimum unit - suppress = _suppress_lower_units(min_unit, suppress) - - # handy aliases - days = delta.days - secs = delta.seconds - usecs = delta.microseconds - - MICROSECONDS, MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS, MONTHS, YEARS = list( - Unit - ) - - # Given DAYS compute YEARS and the remainder of DAYS as follows: - # if YEARS is the minimum unit, we cannot use DAYS so - # we will use a float for YEARS and 0 for DAYS: - # years, days = years/days, 0 - # - # if YEARS is suppressed, use DAYS: - # years, days = 0, days - # - # otherwise: - # years, days = divmod(years, days) - # - # The same applies for months, hours, minutes and milliseconds below - years, days = _quotient_and_remainder(days, 365, YEARS, min_unit, suppress) - months, days = _quotient_and_remainder(days, 30.5, MONTHS, min_unit, suppress) - - # If DAYS is not in suppress, we can represent the days but - # if it is a suppressed unit, we need to carry it to a lower unit, - # seconds in this case. - # - # The same applies for secs and usecs below - days, secs = _carry(days, secs, 24 * 3600, DAYS, min_unit, suppress) - - hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress) - minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress) - - secs, usecs = _carry(secs, usecs, 1e6, SECONDS, min_unit, suppress) - - msecs, usecs = _quotient_and_remainder( - usecs, 1000, MILLISECONDS, min_unit, suppress - ) - - # if _unused != 0 we had lost some precision - usecs, _unused = _carry(usecs, 0, 1, MICROSECONDS, min_unit, suppress) - - fmts = [ - ("%d year", "%d years", years), - ("%d month", "%d months", months), - ("%d day", "%d days", days), - ("%d hour", "%d hours", hours), - ("%d minute", "%d minutes", minutes), - ("%d second", "%d seconds", secs), - ("%d millisecond", "%d milliseconds", msecs), - ("%d microsecond", "%d microseconds", usecs), - ] - - texts = [] - for unit, fmt in zip(reversed(Unit), fmts): - singular_txt, plural_txt, value = fmt - if value > 0: - fmt_txt = ngettext(singular_txt, plural_txt, value) - if unit == min_unit and math.modf(value)[0] > 0: - fmt_txt = fmt_txt.replace("%d", format) - - texts.append(fmt_txt % value) - - if unit == min_unit: - break - - if len(texts) == 1: - return texts[0] - - head = ", ".join(texts[:-1]) - tail = texts[-1] - - return " and ".join((head, tail)) diff --git a/napari/_vendor/experimental/vendor.txt b/napari/_vendor/experimental/vendor.txt deleted file mode 100644 index b3fb82f7fa7..00000000000 --- a/napari/_vendor/experimental/vendor.txt +++ /dev/null @@ -1,2 +0,0 @@ -cachetools==4.1.1 -humanize==2.5.0 diff --git a/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py b/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py index a1f6004893b..e39a8f4e9fa 100644 --- a/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py +++ b/napari/_vendor/qt_json_builder/qt_jsonschema_form/form.py @@ -29,6 +29,7 @@ class WidgetBuilder: "plugins": widgets.PluginWidget, "shortcuts": widgets.ShortcutsWidget, "extension2reader": widgets.Extension2ReaderWidget, + "highlight": widgets.HighlightPreviewWidget, }, "number": { "spin": widgets.SpinDoubleSchemaWidget, @@ -48,7 +49,6 @@ class WidgetBuilder: "text": widgets.TextSchemaWidget, "range": widgets.IntegerRangeSchemaWidget, "enum": widgets.EnumSchemaWidget, - "highlight": widgets.HighlightSizePreviewWidget, "font_size": widgets.FontSizeSchemaWidget, }, "array": { diff --git a/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py b/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py index 7168ee8c32f..90d0c143086 100644 --- a/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py +++ b/napari/_vendor/qt_json_builder/qt_jsonschema_form/widgets.py @@ -4,7 +4,7 @@ from qtpy import QtCore, QtGui, QtWidgets from ...._qt.widgets.qt_extension2reader import Extension2ReaderTable -from ...._qt.widgets.qt_highlight_preview import QtHighlightSizePreviewWidget +from ...._qt.widgets.qt_highlight_preview import QtHighlightPreviewWidget from ...._qt.widgets.qt_keyboard_settings import ShortcutEditor from ...._qt.widgets.qt_font_size import QtFontSizeWidget @@ -585,18 +585,18 @@ def widget_on_changed(self, row: ArrayRowWidget, value): self.on_changed.emit(self.state) -class HighlightSizePreviewWidget( - SchemaWidgetMixin, QtHighlightSizePreviewWidget +class HighlightPreviewWidget( + SchemaWidgetMixin, QtHighlightPreviewWidget ): @state_property - def state(self) -> int: + def state(self) -> dict: return self.value() def setDescription(self, description: str): self._description.setText(description) @state.setter - def state(self, state: int): + def state(self, state: dict): self.setValue(state) def configure(self): diff --git a/napari/_vispy/__init__.py b/napari/_vispy/__init__.py index b3a257c2f67..62c74353697 100644 --- a/napari/_vispy/__init__.py +++ b/napari/_vispy/__init__.py @@ -26,15 +26,15 @@ from napari._vispy.utils.visual import create_vispy_layer, create_vispy_overlay __all__ = [ - "VispyCamera", - "VispyCanvas", - "VispyAxesOverlay", - "VispySelectionBoxOverlay", - "VispyScaleBarOverlay", - "VispyTransformBoxOverlay", - "VispyTextOverlay", - "VispyLabelsPolygonOverlay", - "quaternion2euler_degrees", - "create_vispy_layer", - "create_vispy_overlay", + 'VispyCamera', + 'VispyCanvas', + 'VispyAxesOverlay', + 'VispySelectionBoxOverlay', + 'VispyScaleBarOverlay', + 'VispyTransformBoxOverlay', + 'VispyTextOverlay', + 'VispyLabelsPolygonOverlay', + 'quaternion2euler_degrees', + 'create_vispy_layer', + 'create_vispy_overlay', ] diff --git a/napari/_vispy/_tests/test_utils.py b/napari/_vispy/_tests/test_utils.py index ddeab067a83..441488505f7 100644 --- a/napari/_vispy/_tests/test_utils.py +++ b/napari/_vispy/_tests/test_utils.py @@ -94,4 +94,4 @@ def test_set_cursor(make_napari_viewer): assert viewer._brush_circle_overlay.size == viewer.cursor.size with pytest.raises(ValidationError): - viewer.cursor.style = "invalid" + viewer.cursor.style = 'invalid' diff --git a/napari/_vispy/_tests/test_vispy_big_images.py b/napari/_vispy/_tests/test_vispy_big_images.py index e77f0494b9a..8894623ec75 100644 --- a/napari/_vispy/_tests/test_vispy_big_images.py +++ b/napari/_vispy/_tests/test_vispy_big_images.py @@ -31,7 +31,7 @@ def test_big_3D_image(make_napari_viewer): @pytest.mark.parametrize( - "shape", + 'shape', [(2, 4), (256, 4048), (4, 20_000), (20_000, 4)], ) def test_downsample_value(make_napari_viewer, shape): diff --git a/napari/_vispy/_tests/test_vispy_image_layer.py b/napari/_vispy/_tests/test_vispy_image_layer.py index b64c32dbdb3..61434ea06af 100644 --- a/napari/_vispy/_tests/test_vispy_image_layer.py +++ b/napari/_vispy/_tests/test_vispy_image_layer.py @@ -71,3 +71,15 @@ def test_3d_slice_of_4d_image_with_order(order): scene_size = vispy_image_scene_size(vispy_image) np.testing.assert_array_equal((16, 16, 16), scene_size) + + +def test_no_float32_texture_support(monkeypatch): + """Ensure Image node can be created if OpenGL driver lacks float textures. + + See #3988, #3990, #6652. + """ + monkeypatch.setattr( + 'napari._vispy.layers.image.get_gl_extensions', lambda: '' + ) + image = Image(np.zeros((16, 8, 4, 2), dtype='uint8'), scale=(1, 2, 4, 8)) + VispyImageLayer(image) diff --git a/napari/_vispy/_tests/test_vispy_labels.py b/napari/_vispy/_tests/test_vispy_labels.py index fe4d7dea643..124db916936 100644 --- a/napari/_vispy/_tests/test_vispy_labels.py +++ b/napari/_vispy/_tests/test_vispy_labels.py @@ -17,7 +17,7 @@ def test_build_textures_from_dict(): def test_build_textures_from_dict_exc(): - with pytest.raises(ValueError, match="Cannot create a 2D texture"): + with pytest.raises(ValueError, match='Cannot create a 2D texture'): build_textures_from_dict( {0: (0, 0, 0, 0), 1: (1, 1, 1, 1), 2: (2, 2, 2, 2)}, max_size=1, diff --git a/napari/_vispy/_tests/test_vispy_labels_layer.py b/napari/_vispy/_tests/test_vispy_labels_layer.py index 4b415cfbcbe..51822f17d4e 100644 --- a/napari/_vispy/_tests/test_vispy_labels_layer.py +++ b/napari/_vispy/_tests/test_vispy_labels_layer.py @@ -19,10 +19,10 @@ def make_labels_layer(array_type, shape): spec = { 'driver': 'zarr', 'kvstore': {'driver': 'memory'}, - "metadata": {"chunks": chunks}, + 'metadata': {'chunks': chunks}, } labels = ts.open( - spec, create=True, dtype="uint32", shape=shape + spec, create=True, dtype='uint32', shape=shape ).result() else: pytest.fail("array_type must be 'numpy', 'zarr', or 'tensorstore'") diff --git a/napari/_vispy/_tests/test_vispy_points_layer.py b/napari/_vispy/_tests/test_vispy_points_layer.py index 02175362c13..b968d5243ca 100644 --- a/napari/_vispy/_tests/test_vispy_points_layer.py +++ b/napari/_vispy/_tests/test_vispy_points_layer.py @@ -5,7 +5,7 @@ from napari.layers import Points -@pytest.mark.parametrize("opacity", [0, 0.3, 0.7, 1]) +@pytest.mark.parametrize('opacity', [0, 0.3, 0.7, 1]) def test_VispyPointsLayer(opacity): points = np.array([[100, 100], [200, 200], [300, 100]]) layer = Points(points, size=30, opacity=opacity) diff --git a/napari/_vispy/_tests/test_vispy_surface_layer.py b/napari/_vispy/_tests/test_vispy_surface_layer.py index 4842dd3db0b..d9552b6ee3e 100644 --- a/napari/_vispy/_tests/test_vispy_surface_layer.py +++ b/napari/_vispy/_tests/test_vispy_surface_layer.py @@ -13,7 +13,7 @@ def cube_layer(): return Surface((vertices['position'] * 100, faces)) -@pytest.mark.parametrize("opacity", [0, 0.3, 0.7, 1]) +@pytest.mark.parametrize('opacity', [0, 0.3, 0.7, 1]) def test_VispySurfaceLayer(cube_layer, opacity): cube_layer.opacity = opacity visual = VispySurfaceLayer(cube_layer) @@ -22,23 +22,23 @@ def test_VispySurfaceLayer(cube_layer, opacity): def test_shading(cube_layer): cube_layer._slice_dims(Dims(ndim=3, ndisplay=3)) - cube_layer.shading = "flat" + cube_layer.shading = 'flat' visual = VispySurfaceLayer(cube_layer) assert visual.node.shading_filter.attached - assert visual.node.shading_filter.shading == "flat" - cube_layer.shading = "smooth" - assert visual.node.shading_filter.shading == "smooth" + assert visual.node.shading_filter.shading == 'flat' + cube_layer.shading = 'smooth' + assert visual.node.shading_filter.shading == 'smooth' @pytest.mark.parametrize( - "texture_shape", + 'texture_shape', ( (32, 32), (32, 32, 1), (32, 32, 3), (32, 32, 4), ), - ids=("2D", "1Ch", "RGB", "RGBA"), + ids=('2D', '1Ch', 'RGB', 'RGBA'), ) def test_add_texture(cube_layer, texture_shape): np.random.seed(0) @@ -51,7 +51,7 @@ def test_add_texture(cube_layer, texture_shape): assert visual._texture_filter is None # the texture filter is created when texture + texcoords are added - texcoords = create_cube()[0]["texcoord"] + texcoords = create_cube()[0]['texcoord'] cube_layer.texcoords = texcoords assert visual._texture_filter.attached assert visual._texture_filter.enabled @@ -70,7 +70,7 @@ def test_add_texture(cube_layer, texture_shape): def test_change_texture(cube_layer): np.random.seed(0) visual = VispySurfaceLayer(cube_layer) - texcoords = create_cube()[0]["texcoord"] + texcoords = create_cube()[0]['texcoord'] cube_layer.texcoords = texcoords texture0 = np.random.random((32, 32, 3)).astype(np.float32) diff --git a/napari/_vispy/_tests/test_vispy_vectors_layer.py b/napari/_vispy/_tests/test_vispy_vectors_layer.py index 7ba90d8127c..942f868a30c 100644 --- a/napari/_vispy/_tests/test_vispy_vectors_layer.py +++ b/napari/_vispy/_tests/test_vispy_vectors_layer.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize( - "edge_width, length, dims, style", + 'edge_width, length, dims, style', [ [0, 0, 2, 'line'], [0.3, 0.3, 2, 'line'], @@ -58,7 +58,7 @@ def test_generate_vector_meshes(edge_width, length, dims, style): @pytest.mark.parametrize( - "edge_width, length, style, p", + 'edge_width, length, style, p', [ [0, 0, 'line', (1, 0, 0)], [0.3, 0.3, 'line', (0, 1, 0)], @@ -97,7 +97,7 @@ def test_generate_vector_meshes_2D(edge_width, length, style, p): @pytest.mark.parametrize( - "initial_vector_style, new_vector_style", + 'initial_vector_style, new_vector_style', [ ['line', 'line'], ['line', 'triangle'], diff --git a/napari/_vispy/camera.py b/napari/_vispy/camera.py index fe7b88bb92b..954b84ccbfd 100644 --- a/napari/_vispy/camera.py +++ b/napari/_vispy/camera.py @@ -1,5 +1,3 @@ -from typing import Type - import numpy as np from vispy.scene import ArcballCamera, BaseCamera, PanZoomCamera @@ -210,8 +208,8 @@ def viewbox_key_event(event): def add_mouse_pan_zoom_toggles( - vispy_camera_cls: Type[BaseCamera], -) -> Type[BaseCamera]: + vispy_camera_cls: type[BaseCamera], +) -> type[BaseCamera]: """Add separate mouse pan and mouse zoom toggles to VisPy. By default, VisPy uses an ``interactive`` toggle that turns *both* diff --git a/napari/_vispy/canvas.py b/napari/_vispy/canvas.py index b99636a29e0..1a4a5c71aff 100644 --- a/napari/_vispy/canvas.py +++ b/napari/_vispy/canvas.py @@ -27,7 +27,7 @@ from napari.utils.theme import get_theme if TYPE_CHECKING: - from typing import Callable, Dict, Optional, Tuple, Union + from typing import Callable, Optional, Union import numpy.typing as npt from qtpy.QtCore import Qt, pyqtBoundSignal @@ -116,8 +116,8 @@ def __init__( self.camera = VispyCamera( self.view, self.viewer.camera, self.viewer.dims ) - self.layer_to_visual: Dict[Layer, VispyBaseLayer] = {} - self._overlay_to_visual: Dict[Overlay, VispyBaseOverlay] = {} + self.layer_to_visual: dict[Layer, VispyBaseLayer] = {} + self._overlay_to_visual: dict[Overlay, VispyBaseOverlay] = {} self._key_map_handler = key_map_handler self._instances.add(self) @@ -244,13 +244,13 @@ def central_widget(self) -> Widget: return self._scene_canvas._central_widget @property - def size(self) -> Tuple[int, int]: + def size(self) -> tuple[int, int]: """Return canvas size as tuple (height, width) or accepts size as tuple (height, width) and sets Vispy SceneCanvas size as (width, height).""" return self._scene_canvas.size[::-1] @size.setter - def size(self, size: Tuple[int, int]): + def size(self, size: tuple[int, int]): self._scene_canvas.size = size[::-1] @property @@ -311,8 +311,8 @@ def _on_interactive(self) -> None: ) def _map_canvas2world( - self, position: Tuple[int, ...] - ) -> Tuple[float, float]: + self, position: tuple[int, ...] + ) -> tuple[float, float]: """Map position from canvas pixels into world coordinates. Parameters diff --git a/napari/_vispy/filters/tracks.py b/napari/_vispy/filters/tracks.py index 8bcb2e7d9f9..f3241e7ff93 100644 --- a/napari/_vispy/filters/tracks.py +++ b/napari/_vispy/filters/tracks.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Optional, Union import numpy as np from vispy.gloo import VertexBuffer @@ -91,7 +91,7 @@ def __init__( tail_length: float = 30, head_length: float = 0, use_fade: bool = True, - vertex_time: Optional[Union[List, np.ndarray]] = None, + vertex_time: Optional[Union[list, np.ndarray]] = None, ) -> None: super().__init__( vcode=self.VERT_SHADER, vpos=3, fcode=self.FRAG_SHADER, fpos=9 diff --git a/napari/_vispy/layers/base.py b/napari/_vispy/layers/base.py index a62e5e04ecf..115ced14f7d 100644 --- a/napari/_vispy/layers/base.py +++ b/napari/_vispy/layers/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, Generic, TypeVar, cast +from typing import Generic, TypeVar, cast import numpy as np from vispy.scene import VisualNode @@ -15,7 +15,7 @@ from napari.layers import Layer from napari.utils.events import disconnect_events -_L = TypeVar("_L", bound=Layer) +_L = TypeVar('_L', bound=Layer) class VispyBaseLayer(ABC, Generic[_L]): @@ -53,7 +53,7 @@ class VispyBaseLayer(ABC, Generic[_L]): """ layer: _L - overlays: Dict[Overlay, VispyBaseOverlay] + overlays: dict[Overlay, VispyBaseOverlay] def __init__(self, layer: _L, node: VisualNode) -> None: super().__init__() @@ -155,16 +155,16 @@ def _on_blending_change(self, event=None): src_color_blending = 'src_alpha' dst_color_blending = 'one_minus_src_alpha' blending_kwargs = { - "depth_test": blending_kwargs['depth_test'], - "cull_face": False, - "blend": True, - "blend_func": ( + 'depth_test': blending_kwargs['depth_test'], + 'cull_face': False, + 'blend': True, + 'blend_func': ( src_color_blending, dst_color_blending, 'one', 'one', ), - "blend_equation": 'func_add', + 'blend_equation': 'func_add', } self.node.set_gl_state(**blending_kwargs) @@ -200,7 +200,7 @@ def _on_overlays_change(self): def _on_matrix_change(self): # mypy: self.layer._transforms.simplified cannot be None - transform = self.layer._transforms.simplified.set_slice( # type: ignore [union-attr] + transform = self.layer._transforms.simplified.set_slice( self.layer._slice_input.displayed ) # convert NumPy axis ordering to VisPy axis ordering @@ -214,6 +214,8 @@ def _on_matrix_change(self): affine_matrix[: matrix.shape[0], : matrix.shape[1]] = matrix affine_matrix[-1, : len(translate)] = translate + offset = np.zeros(len(self.layer._slice_input.displayed)) + if self._array_like and self.layer._slice_input.ndisplay == 2: # Perform pixel offset to shift origin from top left corner # of pixel to center of pixel. @@ -239,12 +241,12 @@ def _on_matrix_change(self): simplified_transform = self.layer._transforms.simplified if simplified_transform is None: raise ValueError( - "simplified transform is None" + 'simplified transform is None' ) # pragma: no cover translate_child = ( self.layer.translate[dims_displayed] + self.layer.affine.translate[dims_displayed] - )[::-1] + )[::-1] - offset[::-1] trans_rotate = simplified_transform.rotate[ np.ix_(dims_displayed, dims_displayed) ] diff --git a/napari/_vispy/layers/image.py b/napari/_vispy/layers/image.py index 398c2a0944d..1b19a5a5764 100644 --- a/napari/_vispy/layers/image.py +++ b/napari/_vispy/layers/image.py @@ -1,24 +1,27 @@ from __future__ import annotations -import warnings -from typing import Dict, Optional +from typing import Optional import numpy as np from vispy.color import Colormap as VispyColormap -from vispy.scene.node import Node -from vispy.visuals import ImageVisual - -from napari._vispy.layers.base import VispyBaseLayer -from napari._vispy.utils.gl import fix_data_dtype, get_gl_extensions +from vispy.scene import Node + +from napari._vispy.layers.scalar_field import ( + _VISPY_FORMAT_TO_DTYPE, + ScalarFieldLayerNode, + VispyScalarFieldBaseLayer, +) +from napari._vispy.utils.gl import get_gl_extensions from napari._vispy.visuals.image import Image as ImageNode from napari._vispy.visuals.volume import Volume as VolumeNode from napari.layers.base._base_constants import Blending -from napari.layers.image.image import Image, _ImageBase +from napari.layers.image.image import Image from napari.utils.colormaps.colormap_utils import _coerce_contrast_limits from napari.utils.translations import trans -class ImageLayerNode: +class ImageLayerNode(ScalarFieldLayerNode): + def __init__( self, custom_node: Node = None, texture_format: Optional[str] = None ) -> None: @@ -57,7 +60,7 @@ def get_node( # Return Image or Volume node based on 2D or 3D. res = self._image_node if ndisplay == 2 else self._volume_node if ( - res.texture_format != "auto" + res.texture_format not in {'auto', None} and dtype is not None and _VISPY_FORMAT_TO_DTYPE[res.texture_format] != dtype ): @@ -66,7 +69,7 @@ def get_node( # textures for our data raise ValueError( trans._( - "dtype {dtype} does not match texture_format={texture_format}", + 'dtype {dtype} does not match texture_format={texture_format}', dtype=dtype, texture_format=res.texture_format, ) @@ -74,200 +77,6 @@ def get_node( return res -class VispyScalarFieldBaseLayer(VispyBaseLayer[_ImageBase]): - def __init__( - self, - layer: _ImageBase, - node=None, - texture_format='auto', - layer_node_class=ImageLayerNode, - ) -> None: - # Use custom node from caller, or our standard image/volume nodes. - self._layer_node = layer_node_class( - node, texture_format=texture_format - ) - - # Default to 2D (image) node. - super().__init__(layer, self._layer_node.get_node(2)) - - self._array_like = True - - self.layer.events.rendering.connect(self._on_rendering_change) - self.layer.events.depiction.connect(self._on_depiction_change) - self.layer.events.colormap.connect(self._on_colormap_change) - self.layer.plane.events.position.connect( - self._on_plane_position_change - ) - self.layer.plane.events.thickness.connect( - self._on_plane_thickness_change - ) - self.layer.plane.events.normal.connect(self._on_plane_normal_change) - self.layer.events.custom_interpolation_kernel_2d.connect( - self._on_custom_interpolation_kernel_2d_change - ) - - # display_change is special (like data_change) because it requires a - # self.reset(). This means that we have to call it manually. Also, - # it must be called before reset in order to set the appropriate node - # first - self._on_display_change() - self.reset() - self._on_data_change() - - def _on_display_change(self, data=None) -> None: - parent = self.node.parent - self.node.parent = None - ndisplay = self.layer._slice_input.ndisplay - self.node = self._layer_node.get_node( - ndisplay, getattr(data, "dtype", None) - ) - - if data is None: - texture_format = self.node.texture_format - data = np.zeros( - (1,) * ndisplay, - dtype=get_dtype_from_vispy_texture_format(texture_format), - ) - - self.node.visible = not self.layer._slice.empty and self.layer.visible - - self.node.set_data(data) - - self.node.parent = parent - self.node.order = self.order - for overlay_visual in self.overlays.values(): - overlay_visual.node.parent = self.node - self.reset() - - def _on_data_change(self) -> None: - data = fix_data_dtype(self.layer._data_view) - ndisplay = self.layer._slice_input.ndisplay - - node = self._layer_node.get_node( - ndisplay, getattr(data, "dtype", None) - ) - - if ndisplay == 3 and self.layer.ndim == 2: - data = np.expand_dims(data, axis=0) - - # Check if data exceeds MAX_TEXTURE_SIZE and downsample - if self.MAX_TEXTURE_SIZE_2D is not None and ndisplay == 2: - data = self.downsample_texture(data, self.MAX_TEXTURE_SIZE_2D) - elif self.MAX_TEXTURE_SIZE_3D is not None and ndisplay == 3: - data = self.downsample_texture(data, self.MAX_TEXTURE_SIZE_3D) - - # Check if ndisplay has changed current node type needs updating - if (ndisplay == 3 and not isinstance(node, VolumeNode)) or ( - ndisplay == 2 - and not isinstance(node, ImageVisual) - or node != self.node - ): - self._on_display_change(data) - else: - node.set_data(data) - node.visible = not self.layer._slice.empty and self.layer.visible - - # Call to update order of translation values with new dims: - self._on_matrix_change() - node.update() - - def _on_custom_interpolation_kernel_2d_change(self) -> None: - if self.layer._slice_input.ndisplay == 2: - self.node.custom_kernel = self.layer.custom_interpolation_kernel_2d - - def _on_rendering_change(self) -> None: - if isinstance(self.node, VolumeNode): - self.node.method = self.layer.rendering - - def _on_depiction_change(self) -> None: - if isinstance(self.node, VolumeNode): - self.node.raycasting_mode = str(self.layer.depiction) - - def _on_blending_change(self, event=None) -> None: - super()._on_blending_change() - - def _on_plane_thickness_change(self) -> None: - if isinstance(self.node, VolumeNode): - self.node.plane_thickness = self.layer.plane.thickness - - def _on_plane_position_change(self) -> None: - if isinstance(self.node, VolumeNode): - self.node.plane_position = self.layer.plane.position - - def _on_plane_normal_change(self) -> None: - if isinstance(self.node, VolumeNode): - self.node.plane_normal = self.layer.plane.normal - - def _on_colormap_change(self, event=None) -> None: - raise NotImplementedError - - def reset(self, event=None) -> None: - super().reset() - self._on_rendering_change() - self._on_depiction_change() - self._on_plane_position_change() - self._on_plane_normal_change() - self._on_plane_thickness_change() - self._on_custom_interpolation_kernel_2d_change() - - def downsample_texture( - self, data: np.ndarray, MAX_TEXTURE_SIZE: int - ) -> np.ndarray: - """Downsample data based on maximum allowed texture size. - - Parameters - ---------- - data : array - Data to be downsampled if needed. - MAX_TEXTURE_SIZE : int - Maximum allowed texture size. - - Returns - ------- - data : array - Data that now fits inside texture. - """ - if np.any(np.greater(data.shape, MAX_TEXTURE_SIZE)): - if self.layer.multiscale: - raise ValueError( - trans._( - "Shape of individual tiles in multiscale {shape} cannot " - "exceed GL_MAX_TEXTURE_SIZE {texture_size}. Rendering is " - "currently in {ndisplay}D mode.", - deferred=True, - shape=data.shape, - texture_size=MAX_TEXTURE_SIZE, - ndisplay=self.layer._slice_input.ndisplay, - ) - ) - warnings.warn( - trans._( - "data shape {shape} exceeds GL_MAX_TEXTURE_SIZE {texture_size}" - " in at least one axis and will be downsampled." - " Rendering is currently in {ndisplay}D mode.", - deferred=True, - shape=data.shape, - texture_size=MAX_TEXTURE_SIZE, - ndisplay=self.layer._slice_input.ndisplay, - ) - ) - downsample = np.ceil( - np.divide(data.shape, MAX_TEXTURE_SIZE) - ).astype(int) - scale = np.ones(self.layer.ndim) - for i, d in enumerate(self.layer._slice_input.displayed): - scale[d] = downsample[i] - - # tile2data is a ScaleTransform thus is has a .scale attribute, but - # mypy cannot know this. - self.layer._transforms['tile2data'].scale = scale - - self._on_matrix_change() - slices = tuple(slice(None, None, ds) for ds in downsample) - data = data[slices] - return data - - class VispyImageLayer(VispyScalarFieldBaseLayer): layer: Image @@ -350,8 +159,7 @@ def _on_blending_change(self, event=None) -> None: self._update_mip_minip_cutoff() def _on_gamma_change(self) -> None: - if len(self.node.shared_program.frag._set_items) > 0: - self.node.gamma = self.layer.gamma + self.node.gamma = self.layer.gamma def _on_iso_threshold_change(self) -> None: if isinstance(self.node, VolumeNode): @@ -373,32 +181,3 @@ def reset(self, event=None) -> None: self._on_colormap_change() self._on_contrast_limits_change() self._on_gamma_change() - - -_VISPY_FORMAT_TO_DTYPE: Dict[Optional[str], np.dtype] = { - "r8": np.dtype(np.uint8), - "r16": np.dtype(np.uint16), - "r32f": np.dtype(np.float32), -} - -_DTYPE_TO_VISPY_FORMAT = {v: k for k, v in _VISPY_FORMAT_TO_DTYPE.items()} - -# this is moved after reverse mapping is defined -# to always have non None values in _DTYPE_TO_VISPY_FORMAT -_VISPY_FORMAT_TO_DTYPE[None] = np.dtype(np.float32) - - -def get_dtype_from_vispy_texture_format(format_str: str) -> np.dtype: - """Get the numpy dtype from a vispy texture format string. - - Parameters - ---------- - format_str : str - The vispy texture format string. - - Returns - ------- - dtype : numpy.dtype - The numpy dtype corresponding to the vispy texture format string. - """ - return _VISPY_FORMAT_TO_DTYPE.get(format_str, np.dtype(np.float32)) diff --git a/napari/_vispy/layers/labels.py b/napari/_vispy/layers/labels.py index c0320b5fb19..221919a8f56 100644 --- a/napari/_vispy/layers/labels.py +++ b/napari/_vispy/layers/labels.py @@ -1,15 +1,15 @@ import math -from typing import TYPE_CHECKING, Dict, Tuple +from typing import TYPE_CHECKING import numpy as np from vispy.color import Colormap as VispyColormap from vispy.gloo import Texture2D from vispy.scene.node import Node -from napari._vispy.layers.image import ( +from napari._vispy.layers.scalar_field import ( _DTYPE_TO_VISPY_FORMAT, _VISPY_FORMAT_TO_DTYPE, - ImageLayerNode, + ScalarFieldLayerNode, VispyScalarFieldBaseLayer, get_dtype_from_vispy_texture_format, ) @@ -25,7 +25,7 @@ from napari.layers import Labels -ColorTuple = Tuple[float, float, float, float] +ColorTuple = tuple[float, float, float, float] auto_lookup_shader_uint8 = """ @@ -99,7 +99,7 @@ def __init__( raw_dtype: np.dtype, ): super().__init__( - colors=["w", "w"], controls=None, interpolation='zero' + colors=['w', 'w'], controls=None, interpolation='zero' ) if view_dtype.itemsize == 1: shader = auto_lookup_shader_uint8 @@ -113,7 +113,7 @@ def __init__( # to 8-bit on the CPU before sending to the shader. # It should thus be impossible to reach this condition. raise ValueError( # pragma: no cover - f"Cannot use dtype {view_dtype} with LabelVispyColormap" + f'Cannot use dtype {view_dtype} with LabelVispyColormap' ) selection = colormap._selection_as_minimum_dtype(raw_dtype) @@ -138,15 +138,15 @@ def __init__( super().__init__(colors, controls=None, interpolation='zero') shader = direct_lookup_shader_many if multi else direct_lookup_shader self.glsl_map = ( - shader.replace("$use_selection", str(use_selection).lower()) - .replace("$selection", str(selection)) - .replace("$scale", str(scale)) - .replace("$color_map_size", str(color_map_size)) + shader.replace('$use_selection', str(use_selection).lower()) + .replace('$selection', str(selection)) + .replace('$scale', str(scale)) + .replace('$color_map_size', str(color_map_size)) ) def build_textures_from_dict( - color_dict: Dict[int, ColorTuple], max_size: int + color_dict: dict[int, ColorTuple], max_size: int ) -> np.ndarray: """This code assumes that the keys in the color_dict are sequential from 0. @@ -155,14 +155,14 @@ def build_textures_from_dict( """ if len(color_dict) > 2**23: raise ValueError( # pragma: no cover - "Cannot map more than 2**23 colors because of float32 precision. " - f"Got {len(color_dict)}" + 'Cannot map more than 2**23 colors because of float32 precision. ' + f'Got {len(color_dict)}' ) if len(color_dict) > max_size**2: raise ValueError( - "Cannot create a 2D texture holding more than " - f"{max_size}**2={max_size ** 2} colors." - f"Got {len(color_dict)}" + 'Cannot create a 2D texture holding more than ' + f'{max_size}**2={max_size ** 2} colors.' + f'Got {len(color_dict)}' ) data = np.zeros( ( @@ -187,7 +187,7 @@ def _select_colormap_texture( if color_texture is None: raise ValueError( # pragma: no cover - f"Cannot build a texture for dtype {raw_dtype=} and {view_dtype=}" + f'Cannot build a texture for dtype {raw_dtype=} and {view_dtype=}' ) return color_texture.reshape(256, -1, 4) @@ -312,7 +312,7 @@ def reset(self, event=None) -> None: self._on_colormap_change() -class LabelLayerNode(ImageLayerNode): +class LabelLayerNode(ScalarFieldLayerNode): def __init__(self, custom_node: Node = None, texture_format=None): self._custom_node = custom_node self._setup_nodes(texture_format) @@ -345,7 +345,7 @@ def get_node(self, ndisplay: int, dtype=None) -> Node: res = self._image_node if ndisplay == 2 else self._volume_node if ( - res.texture_format != "auto" + res.texture_format != 'auto' and dtype is not None and _VISPY_FORMAT_TO_DTYPE[res.texture_format] != dtype ): diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index d22ab8ebf00..b74a70c1e98 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -11,8 +11,7 @@ class VispyPointsLayer(VispyBaseLayer): - _highlight_color = (0, 0.6, 1) - _visual = PointsVisual + node: PointsVisual def __init__(self, layer) -> None: node = self._visual() @@ -61,7 +60,7 @@ def _on_data_change(self): border_width = self.layer._view_border_width symbol = [str(x) for x in self.layer._view_symbol] - set_data = self.node._subvisuals[0].set_data + set_data = self.node.points_markers.set_data # use only last dimension to scale point sizes, see #5582 scale = self.layer.scale[-1] @@ -81,6 +80,7 @@ def _on_data_change(self): data[:, ::-1], size=size * scale, symbol=symbol, + # edge_color is the name of the vispy marker visual kwarg edge_color=border_color, face_color=face_color, **border_kw, @@ -113,15 +113,17 @@ def _on_highlight_change(self): scale = self.layer.scale[-1] scaled_highlight = ( - settings.appearance.highlight_thickness * self.layer.scale_factor + settings.appearance.highlight.highlight_thickness + * self.layer.scale_factor ) + highlight_color = tuple(settings.appearance.highlight.highlight_color) - self.node._subvisuals[1].set_data( + self.node.selection_markers.set_data( data[:, ::-1], size=(size + border_width) * scale, symbol=symbol, edge_width=scaled_highlight * 2, - edge_color=self._highlight_color, + edge_color=highlight_color, face_color=transform_color('transparent'), ) @@ -136,11 +138,11 @@ def _on_highlight_change(self): width = scaled_highlight # FIXME: vispy bug? LineVisual error when going from 2d to 3d (or the opposite) - self.node._subvisuals[2]._line_visual._pos_vbo = gloo.VertexBuffer() + self.node.highlight_lines._line_visual._pos_vbo = gloo.VertexBuffer() - self.node._subvisuals[2].set_data( + self.node.highlight_lines.set_data( pos=pos[:, ::-1], - color=self._highlight_color, + color=highlight_color, width=width, ) @@ -160,8 +162,7 @@ def _update_text(self, *, update_node=True): def _get_text_node(self): """Function to get the text node from the Compound visual""" - text_node = self.node._subvisuals[3] - return text_node + return self.node.text def _on_text_change(self, event=None): if event is not None: @@ -172,7 +173,7 @@ def _on_text_change(self, event=None): return self._update_text() - def _on_blending_change(self): + def _on_blending_change(self, event=None): """Function to set the blending mode""" points_blending_kwargs = BLENDING_MODES[self.layer.blending] self.node.set_gl_state(**points_blending_kwargs) @@ -183,7 +184,7 @@ def _on_blending_change(self): # selection box is always without depth box_blending_kwargs = BLENDING_MODES['translucent_no_depth'] - self.node._subvisuals[2].set_gl_state(**box_blending_kwargs) + self.node.highlight_lines.set_gl_state(**box_blending_kwargs) self.node.update() @@ -192,10 +193,7 @@ def _on_antialiasing_change(self): def _on_shading_change(self): shading = self.layer.shading - if shading == 'spherical': - self.node.spherical = True - else: - self.node.spherical = False + self.node.spherical = shading == 'spherical' def _on_canvas_size_limits_change(self): self.node.canvas_size_limits = self.layer.canvas_size_limits diff --git a/napari/_vispy/layers/scalar_field.py b/napari/_vispy/layers/scalar_field.py new file mode 100644 index 00000000000..2db95dae00e --- /dev/null +++ b/napari/_vispy/layers/scalar_field.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import warnings +from abc import ABC, abstractmethod +from typing import Optional + +import numpy as np +from vispy.scene import Node +from vispy.visuals import ImageVisual + +from napari._vispy.layers.base import VispyBaseLayer +from napari._vispy.utils.gl import fix_data_dtype +from napari._vispy.visuals.volume import Volume as VolumeNode +from napari.layers._scalar_field.scalar_field import ScalarFieldBase +from napari.utils.translations import trans + + +class ScalarFieldLayerNode(ABC): + """Abstract base class for scalar field layer nodes.""" + + @abstractmethod + def __init__(self, node=None, texture_format='auto') -> None: + raise NotImplementedError + + @abstractmethod + def get_node( + self, ndisplay: int, dtype: Optional[np.dtype] = None + ) -> Node: + """Return the appropriate node for the given ndisplay and dtype.""" + raise NotImplementedError + + +class VispyScalarFieldBaseLayer(VispyBaseLayer[ScalarFieldBase]): + def __init__( + self, + layer: ScalarFieldBase, + node=None, + texture_format='auto', + layer_node_class=ScalarFieldLayerNode, + ) -> None: + # Use custom node from caller, or our standard image/volume nodes. + self._layer_node = layer_node_class( + node, texture_format=texture_format + ) + + # Default to 2D (image) node. + super().__init__(layer, self._layer_node.get_node(2)) + + self._array_like = True + + self.layer.events.rendering.connect(self._on_rendering_change) + self.layer.events.depiction.connect(self._on_depiction_change) + self.layer.events.colormap.connect(self._on_colormap_change) + self.layer.plane.events.position.connect( + self._on_plane_position_change + ) + self.layer.plane.events.thickness.connect( + self._on_plane_thickness_change + ) + self.layer.plane.events.normal.connect(self._on_plane_normal_change) + self.layer.events.custom_interpolation_kernel_2d.connect( + self._on_custom_interpolation_kernel_2d_change + ) + + # display_change is special (like data_change) because it requires a + # self.reset(). This means that we have to call it manually. Also, + # it must be called before reset in order to set the appropriate node + # first + self._on_display_change() + self.reset() + self._on_data_change() + + def _on_display_change(self, data=None) -> None: + parent = self.node.parent + self.node.parent = None + ndisplay = self.layer._slice_input.ndisplay + self.node = self._layer_node.get_node( + ndisplay, getattr(data, 'dtype', None) + ) + + if data is None: + texture_format = self.node.texture_format + data = np.zeros( + (1,) * ndisplay, + dtype=get_dtype_from_vispy_texture_format(texture_format), + ) + + self.node.visible = not self.layer._slice.empty and self.layer.visible + + self.node.set_data(data) + + self.node.parent = parent + self.node.order = self.order + for overlay_visual in self.overlays.values(): + overlay_visual.node.parent = self.node + self.reset() + + def _on_data_change(self) -> None: + data = fix_data_dtype(self.layer._data_view) + ndisplay = self.layer._slice_input.ndisplay + + node = self._layer_node.get_node( + ndisplay, getattr(data, 'dtype', None) + ) + + if ndisplay == 3 and self.layer.ndim == 2: + data = np.expand_dims(data, axis=0) + + # Check if data exceeds MAX_TEXTURE_SIZE and downsample + if self.MAX_TEXTURE_SIZE_2D is not None and ndisplay == 2: + data = self.downsample_texture(data, self.MAX_TEXTURE_SIZE_2D) + elif self.MAX_TEXTURE_SIZE_3D is not None and ndisplay == 3: + data = self.downsample_texture(data, self.MAX_TEXTURE_SIZE_3D) + + # Check if ndisplay has changed current node type needs updating + if (ndisplay == 3 and not isinstance(node, VolumeNode)) or ( + ndisplay == 2 + and not isinstance(node, ImageVisual) + or node != self.node + ): + self._on_display_change(data) + else: + node.set_data(data) + node.visible = not self.layer._slice.empty and self.layer.visible + + # Call to update order of translation values with new dims: + self._on_matrix_change() + node.update() + + def _on_custom_interpolation_kernel_2d_change(self) -> None: + if self.layer._slice_input.ndisplay == 2: + self.node.custom_kernel = self.layer.custom_interpolation_kernel_2d + + def _on_rendering_change(self) -> None: + if isinstance(self.node, VolumeNode): + self.node.method = self.layer.rendering + + def _on_depiction_change(self) -> None: + if isinstance(self.node, VolumeNode): + self.node.raycasting_mode = str(self.layer.depiction) + + def _on_blending_change(self, event=None) -> None: + super()._on_blending_change() + + def _on_plane_thickness_change(self) -> None: + if isinstance(self.node, VolumeNode): + self.node.plane_thickness = self.layer.plane.thickness + + def _on_plane_position_change(self) -> None: + if isinstance(self.node, VolumeNode): + self.node.plane_position = self.layer.plane.position + + def _on_plane_normal_change(self) -> None: + if isinstance(self.node, VolumeNode): + self.node.plane_normal = self.layer.plane.normal + + def _on_colormap_change(self, event=None) -> None: + raise NotImplementedError + + def reset(self, event=None) -> None: + super().reset() + self._on_rendering_change() + self._on_depiction_change() + self._on_plane_position_change() + self._on_plane_normal_change() + self._on_plane_thickness_change() + self._on_custom_interpolation_kernel_2d_change() + + def downsample_texture( + self, data: np.ndarray, MAX_TEXTURE_SIZE: int + ) -> np.ndarray: + """Downsample data based on maximum allowed texture size. + + Parameters + ---------- + data : array + Data to be downsampled if needed. + MAX_TEXTURE_SIZE : int + Maximum allowed texture size. + + Returns + ------- + data : array + Data that now fits inside texture. + """ + if np.any(np.greater(data.shape, MAX_TEXTURE_SIZE)): + if self.layer.multiscale: + raise ValueError( + trans._( + 'Shape of individual tiles in multiscale {shape} cannot ' + 'exceed GL_MAX_TEXTURE_SIZE {texture_size}. Rendering is ' + 'currently in {ndisplay}D mode.', + deferred=True, + shape=data.shape, + texture_size=MAX_TEXTURE_SIZE, + ndisplay=self.layer._slice_input.ndisplay, + ) + ) + warnings.warn( + trans._( + 'data shape {shape} exceeds GL_MAX_TEXTURE_SIZE {texture_size}' + ' in at least one axis and will be downsampled.' + ' Rendering is currently in {ndisplay}D mode.', + deferred=True, + shape=data.shape, + texture_size=MAX_TEXTURE_SIZE, + ndisplay=self.layer._slice_input.ndisplay, + ) + ) + downsample = np.ceil( + np.divide(data.shape, MAX_TEXTURE_SIZE) + ).astype(int) + scale = np.ones(self.layer.ndim) + for i, d in enumerate(self.layer._slice_input.displayed): + scale[d] = downsample[i] + + # tile2data is a ScaleTransform thus is has a .scale attribute, but + # mypy cannot know this. + self.layer._transforms['tile2data'].scale = scale + + self._on_matrix_change() + slices = tuple(slice(None, None, ds) for ds in downsample) + data = data[slices] + return data + + +_VISPY_FORMAT_TO_DTYPE: dict[Optional[str], np.dtype] = { + 'r8': np.dtype(np.uint8), + 'r16': np.dtype(np.uint16), + 'r32f': np.dtype(np.float32), +} +_DTYPE_TO_VISPY_FORMAT = {v: k for k, v in _VISPY_FORMAT_TO_DTYPE.items()} + +# this is moved after reverse mapping is defined +# to always have non None values in _DTYPE_TO_VISPY_FORMAT +_VISPY_FORMAT_TO_DTYPE[None] = np.dtype(np.float32) + + +def get_dtype_from_vispy_texture_format(format_str: str) -> np.dtype: + """Get the numpy dtype from a vispy texture format string. + + Parameters + ---------- + format_str : str + The vispy texture format string. + + Returns + ------- + dtype : numpy.dtype + The numpy dtype corresponding to the vispy texture format string. + """ + return _VISPY_FORMAT_TO_DTYPE.get(format_str, np.dtype(np.float32)) diff --git a/napari/_vispy/layers/shapes.py b/napari/_vispy/layers/shapes.py index 07bce048d19..beeb54097d2 100644 --- a/napari/_vispy/layers/shapes.py +++ b/napari/_vispy/layers/shapes.py @@ -61,7 +61,12 @@ def _on_data_change(self): def _on_highlight_change(self): settings = get_settings() - self.layer._highlight_width = settings.appearance.highlight_thickness + self.layer._highlight_width = ( + settings.appearance.highlight.highlight_thickness + ) + self.layer._highlight_color = ( + settings.appearance.highlight.highlight_color + ) # Compute the vertices and faces of any shape outlines vertices, faces = self.layer._outline_shapes() @@ -86,7 +91,7 @@ def _on_highlight_change(self): _, ) = self.layer._compute_vertices_and_box() - width = settings.appearance.highlight_thickness + width = settings.appearance.highlight.highlight_thickness if vertices is None or len(vertices) == 0: vertices = np.zeros((1, self.layer._slice_input.ndisplay)) diff --git a/napari/_vispy/overlays/base.py b/napari/_vispy/overlays/base.py index be2055c9572..ff2b68fb9b5 100644 --- a/napari/_vispy/overlays/base.py +++ b/napari/_vispy/overlays/base.py @@ -148,7 +148,7 @@ def __init__(self, *, overlay, node, parent=None) -> None: class LayerOverlayMixin: - def __init__(self, *, layer: "Layer", overlay, node, parent=None) -> None: + def __init__(self, *, layer: 'Layer', overlay, node, parent=None) -> None: super().__init__( node=node, overlay=overlay, diff --git a/napari/_vispy/overlays/bounding_box.py b/napari/_vispy/overlays/bounding_box.py index d8ad3276f62..b7834fd571a 100644 --- a/napari/_vispy/overlays/bounding_box.py +++ b/napari/_vispy/overlays/bounding_box.py @@ -29,11 +29,6 @@ def _on_bounds_change(self): if len(bounds) == 2: # 2d layers are assumed to be at 0 in the 3rd dimension bounds = np.pad(bounds, ((1, 0), (0, 0))) - if self.layer._array_like and self.layer._slice_input.ndisplay == 2: - # array-like layers (images) are offset by 0.5 in 2d. - # This is not needed in 3D because vispy's VolumeVisual - # is already centered on voxels - bounds += 0.5 self.node.set_bounds(bounds[::-1]) # invert for vispy self._on_lines_change() diff --git a/napari/_vispy/overlays/scale_bar.py b/napari/_vispy/overlays/scale_bar.py index 3f3f3eccb79..2d10a15f534 100644 --- a/napari/_vispy/overlays/scale_bar.py +++ b/napari/_vispy/overlays/scale_bar.py @@ -1,7 +1,6 @@ import bisect from decimal import Decimal from math import floor, log -from typing import Tuple import numpy as np import pint @@ -48,7 +47,7 @@ def _on_unit_change(self): def _calculate_best_length( self, desired_length: float - ) -> Tuple[float, pint.Quantity]: + ) -> tuple[float, pint.Quantity]: """Calculate new quantity based on the pixel length of the bar. Parameters diff --git a/napari/_vispy/overlays/text.py b/napari/_vispy/overlays/text.py index 424249c4e84..53e4071450e 100644 --- a/napari/_vispy/overlays/text.py +++ b/napari/_vispy/overlays/text.py @@ -16,7 +16,7 @@ def __init__(self, *, viewer, overlay, parent=None) -> None: ) self.node.font_size = self.overlay.font_size - self.node.anchors = ("left", "top") + self.node.anchors = ('left', 'top') self.overlay.events.text.connect(self._on_text_change) self.overlay.events.color.connect(self._on_color_change) @@ -38,17 +38,17 @@ def _on_position_change(self, event=None): position = self.overlay.position if position == CanvasPosition.TOP_LEFT: - anchors = ("left", "bottom") + anchors = ('left', 'bottom') elif position == CanvasPosition.TOP_RIGHT: - anchors = ("right", "bottom") + anchors = ('right', 'bottom') elif position == CanvasPosition.TOP_CENTER: - anchors = ("center", "bottom") + anchors = ('center', 'bottom') elif position == CanvasPosition.BOTTOM_RIGHT: - anchors = ("right", "top") + anchors = ('right', 'top') elif position == CanvasPosition.BOTTOM_LEFT: - anchors = ("left", "top") + anchors = ('left', 'top') elif position == CanvasPosition.BOTTOM_CENTER: - anchors = ("center", "top") + anchors = ('center', 'top') self.node.anchors = anchors diff --git a/napari/_vispy/utils/gl.py b/napari/_vispy/utils/gl.py index 3f33a5b4f51..479e1d5349d 100644 --- a/napari/_vispy/utils/gl.py +++ b/napari/_vispy/utils/gl.py @@ -1,9 +1,10 @@ """OpenGL Utilities. """ +from collections.abc import Generator from contextlib import contextmanager from functools import lru_cache -from typing import Any, Generator, Tuple, Union, cast +from typing import Any, Union, cast import numpy as np import numpy.typing as npt @@ -45,7 +46,7 @@ def get_gl_extensions() -> str: @lru_cache -def get_max_texture_sizes() -> Tuple[int, int]: +def get_max_texture_sizes() -> tuple[int, int]: """Return the maximum texture sizes for 2D and 3D rendering. If this function is called without an OpenGL context it will create a @@ -101,10 +102,10 @@ def fix_data_dtype(data: npt.NDArray) -> npt.NDArray: dtype_ = cast( 'type[Union[np.unsignedinteger[Any], np.floating[Any]]]', { - "i": np.float32, - "f": np.float32, - "u": np.uint16, - "b": np.uint8, + 'i': np.float32, + 'f': np.float32, + 'u': np.uint16, + 'b': np.uint8, }[dtype.kind], ) if dtype_ == np.uint16 and dtype.itemsize > 2: @@ -130,35 +131,35 @@ def fix_data_dtype(data: npt.NDArray) -> npt.NDArray: BLENDING_MODES = { 'opaque': { - "depth_test": True, - "cull_face": False, - "blend": False, + 'depth_test': True, + 'cull_face': False, + 'blend': False, }, 'translucent': { - "depth_test": True, - "cull_face": False, - "blend": True, - "blend_func": ('src_alpha', 'one_minus_src_alpha', 'one', 'one'), - "blend_equation": 'func_add', + 'depth_test': True, + 'cull_face': False, + 'blend': True, + 'blend_func': ('src_alpha', 'one_minus_src_alpha', 'one', 'one'), + 'blend_equation': 'func_add', }, 'translucent_no_depth': { - "depth_test": False, - "cull_face": False, - "blend": True, - "blend_func": ('src_alpha', 'one_minus_src_alpha', 'one', 'one'), - "blend_equation": 'func_add', # see vispy/vispy#2324 + 'depth_test': False, + 'cull_face': False, + 'blend': True, + 'blend_func': ('src_alpha', 'one_minus_src_alpha', 'one', 'one'), + 'blend_equation': 'func_add', # see vispy/vispy#2324 }, 'additive': { - "depth_test": False, - "cull_face": False, - "blend": True, - "blend_func": ('src_alpha', 'dst_alpha', 'one', 'one'), - "blend_equation": 'func_add', + 'depth_test': False, + 'cull_face': False, + 'blend': True, + 'blend_func': ('src_alpha', 'dst_alpha', 'one', 'one'), + 'blend_equation': 'func_add', }, 'minimum': { - "depth_test": False, - "cull_face": False, - "blend": True, - "blend_equation": 'min', + 'depth_test': False, + 'cull_face': False, + 'blend': True, + 'blend_equation': 'min', }, } diff --git a/napari/_vispy/utils/quaternion.py b/napari/_vispy/utils/quaternion.py index 2fa5770f1ef..c4bbbb12093 100644 --- a/napari/_vispy/utils/quaternion.py +++ b/napari/_vispy/utils/quaternion.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Tuple, cast +from typing import TYPE_CHECKING, cast import numpy as np @@ -10,7 +10,7 @@ def quaternion2euler_degrees( quaternion: Quaternion, -) -> Tuple[float, float, float]: +) -> tuple[float, float, float]: """Converts VisPy quaternion into euler angle representation. Euler angles have degeneracies, so the output might different @@ -58,4 +58,4 @@ def quaternion2euler_degrees( angles = (theta_1, theta_2, theta_3) - return cast(Tuple[float, float, float], tuple(np.degrees(angles))) + return cast(tuple[float, float, float], tuple(np.degrees(angles))) diff --git a/napari/_vispy/utils/visual.py b/napari/_vispy/utils/visual.py index 54eff33d98d..45acc72919a 100644 --- a/napari/_vispy/utils/visual.py +++ b/napari/_vispy/utils/visual.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Dict, Optional, Tuple, Type +from typing import Optional import numpy as np from vispy.scene.widgets.viewbox import ViewBox @@ -61,7 +61,7 @@ } -overlay_to_visual: Dict[Type[Overlay], Type[VispyBaseOverlay]] = { +overlay_to_visual: dict[type[Overlay], type[VispyBaseOverlay]] = { ScaleBarOverlay: VispyScaleBarOverlay, TextOverlay: VispyTextOverlay, AxesOverlay: VispyAxesOverlay, @@ -129,7 +129,7 @@ def create_vispy_overlay(overlay: Overlay, **kwargs) -> VispyBaseOverlay: def get_view_direction_in_scene_coordinates( view: ViewBox, ndim: int, - dims_displayed: Tuple[int], + dims_displayed: tuple[int], ) -> Optional[np.ndarray]: """Calculate the unit vector pointing in the direction of the view. diff --git a/napari/_vispy/visuals/clipping_planes_mixin.py b/napari/_vispy/visuals/clipping_planes_mixin.py index db83ae494ce..6dcd5945ba1 100644 --- a/napari/_vispy/visuals/clipping_planes_mixin.py +++ b/napari/_vispy/visuals/clipping_planes_mixin.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Protocol +from typing import Optional, Protocol from vispy.visuals.filters import Filter from vispy.visuals.filters.clipping_planes import PlanesClipper @@ -9,7 +9,7 @@ class _PVisual(Protocol): Type for vispy visuals that implement the attach method """ - _subvisuals: Optional[List['_PVisual']] + _subvisuals: Optional[list['_PVisual']] _clip_filter: PlanesClipper def attach(self, filt: Filter, view=None): ... diff --git a/napari/_vispy/visuals/labels.py b/napari/_vispy/visuals/labels.py index 087773cd0cc..99ab1689c6d 100644 --- a/napari/_vispy/visuals/labels.py +++ b/napari/_vispy/visuals/labels.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional from vispy.scene.visuals import create_visual_node from vispy.visuals.image import ImageVisual @@ -31,7 +31,7 @@ def _build_color_transform(self) -> FunctionChain: class LabelNode(BaseLabel): # type: ignore [valid-type,misc] def _compute_bounds( self, axis: int, view: 'VisualView' - ) -> Optional[Tuple[float, float]]: + ) -> Optional[tuple[float, float]]: if self._data is None: return None elif axis > 1: # noqa: RET505 diff --git a/napari/_vispy/visuals/markers.py b/napari/_vispy/visuals/markers.py index 05382210525..25d000d08b6 100644 --- a/napari/_vispy/visuals/markers.py +++ b/napari/_vispy/visuals/markers.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Dict +from typing import ClassVar from vispy.scene.visuals import Markers as BaseMarkers @@ -15,7 +15,7 @@ class Markers(BaseMarkers): - _shaders: ClassVar[Dict[str, str]] = { + _shaders: ClassVar[dict[str, str]] = { 'vertex': new_vshader, 'fragment': BaseMarkers._shaders['fragment'], } diff --git a/napari/_vispy/visuals/points.py b/napari/_vispy/visuals/points.py index b365734ce63..77006d440dd 100644 --- a/napari/_vispy/visuals/points.py +++ b/napari/_vispy/visuals/points.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from vispy.scene.visuals import Compound, Line, Text from napari._vispy.visuals.clipping_planes_mixin import ClippingPlanesMixin @@ -28,39 +30,60 @@ def __init__(self) -> None: self.scaling = True @property - def scaling(self): + def points_markers(self) -> Markers: + """Points markers visual""" + return self._subvisuals[0] + + @property + def selection_markers(self) -> Markers: + """Highlight markers visual""" + return self._subvisuals[1] + + @property + def highlight_lines(self) -> Line: + """Highlight lines visual""" + return self._subvisuals[2] + + @property + def text(self) -> Text: + """Text labels visual""" + return self._subvisuals[3] + + @property + def scaling(self) -> bool: """ Scaling property for both the markers visuals. If set to true, the points rescale based on zoom (i.e: constant world-space size) """ - return self._subvisuals[0].scaling == 'visual' + return self.points_markers.scaling == 'visual' @scaling.setter - def scaling(self, value): - for marker in self._subvisuals[:2]: - marker.scaling = 'visual' if value else 'fixed' + def scaling(self, value: bool) -> None: + scaling_txt = 'visual' if value else 'fixed' + self.points_markers.scaling = scaling_txt + self.selection_markers.scaling = scaling_txt @property - def antialias(self): - return self._subvisuals[0].antialias + def antialias(self) -> float: + return self.points_markers.antialias @antialias.setter - def antialias(self, value): - for marker in self._subvisuals[:2]: - marker.antialias = value + def antialias(self, value: float) -> None: + self.points_markers.antialias = value + self.selection_markers.antialias = value @property - def spherical(self): - return self._subvisuals[0].spherical + def spherical(self) -> bool: + return self.points_markers.spherical @spherical.setter - def spherical(self, value): - self._subvisuals[0].spherical = value + def spherical(self, value: bool) -> None: + self.points_markers.spherical = value @property - def canvas_size_limits(self): - return self._subvisuals[0].canvas_size_limits + def canvas_size_limits(self) -> tuple[int, int]: + return self.points_markers.canvas_size_limits @canvas_size_limits.setter - def canvas_size_limits(self, value): - self._subvisuals[0].canvas_size_limits = value + def canvas_size_limits(self, value: tuple[int, int]) -> None: + self.points_markers.canvas_size_limits = value diff --git a/napari/_vispy/visuals/scale_bar.py b/napari/_vispy/visuals/scale_bar.py index 93d0c1f3328..a999e61a849 100644 --- a/napari/_vispy/visuals/scale_bar.py +++ b/napari/_vispy/visuals/scale_bar.py @@ -22,8 +22,8 @@ def __init__(self) -> None: Text( text='1px', pos=[0.5, 0.5], - anchor_x="center", - anchor_y="top", + anchor_x='center', + anchor_y='top', font_size=10, ), Line(connect='segments', method='gl', width=3), diff --git a/napari/_vispy/visuals/volume.py b/napari/_vispy/visuals/volume.py index 4e07cf6f619..20fe9d44527 100644 --- a/napari/_vispy/visuals/volume.py +++ b/napari/_vispy/visuals/volume.py @@ -116,14 +116,14 @@ """ ISO_CATEGORICAL_SNIPPETS = { - "before_loop": """ + 'before_loop': """ vec4 color3 = vec4(0.0); // final color vec3 dstep = 1.5 / u_shape; // step to sample derivative, set to match iso shader gl_FragColor = vec4(0.0); bool discard_fragment = true; vec4 label_id = vec4(0.0); """, - "in_loop": """ + 'in_loop': """ // check if value is different from the background value if ( floatNotEqual(val, categorical_bg_value) ) { // Take the last interval in smaller steps @@ -149,20 +149,20 @@ } } """, - "after_loop": """ + 'after_loop': """ if (discard_fragment) discard; """, } TRANSLUCENT_CATEGORICAL_SNIPPETS = { - "before_loop": """ + 'before_loop': """ vec4 color3 = vec4(0.0); // final color gl_FragColor = vec4(0.0); bool discard_fragment = true; vec4 label_id = vec4(0.0); """, - "in_loop": """ + 'in_loop': """ // check if value is different from the background value if ( floatNotEqual(val, categorical_bg_value) ) { // Take the last interval in smaller steps @@ -187,7 +187,7 @@ } } """, - "after_loop": """ + 'after_loop': """ if (discard_fragment) discard; """, diff --git a/napari/benchmarks/benchmark_image_layer.py b/napari/benchmarks/benchmark_image_layer.py index cf8cfb5ccb9..498553fe490 100644 --- a/napari/benchmarks/benchmark_image_layer.py +++ b/napari/benchmarks/benchmark_image_layer.py @@ -14,7 +14,7 @@ class Image2DSuite: params = [2**i for i in range(4, 13)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 13)] def setup(self, n): @@ -60,13 +60,13 @@ class Image3DSuite: """Benchmarks for the Image layer with 3D data.""" params = [2**i for i in range(4, 11)] - if "PR" in os.environ: + if 'CI' in os.environ: + skip_params = [(2**i,) for i in range(10, 11)] + # not enough memory on CI + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 11)] def setup(self, n): - if "CI" in os.environ and n > 512: - raise NotImplementedError("Skip on CI (not enough memory)") - np.random.seed(0) self.data = np.random.random((n, n, n)) self.new_data = np.random.random((n, n, n)) diff --git a/napari/benchmarks/benchmark_labels_layer.py b/napari/benchmarks/benchmark_labels_layer.py index bf577c6d32d..99e271aeec9 100644 --- a/napari/benchmarks/benchmark_labels_layer.py +++ b/napari/benchmarks/benchmark_labels_layer.py @@ -2,18 +2,22 @@ # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md -import os from copy import copy import numpy as np +from packaging.version import parse as parse_version +import napari from napari.components.dims import Dims from napari.layers import Labels +from napari.utils.colormaps import DirectLabelColormap -from .utils import Skiper, labeled_particles +from .utils import Skip, labeled_particles MAX_VAL = 2**23 +NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') + class Labels2DSuite: """Benchmarks for the Labels layer with 2D data""" @@ -21,8 +25,7 @@ class Labels2DSuite: param_names = ['n', 'dtype'] params = ([2**i for i in range(4, 13)], [np.uint8, np.int32]) - if "PR" in os.environ: - skip_params = Skiper(lambda x: x[0] > 2**5) + skip_params = Skip(if_in_pr=lambda n, dtype: n > 2**5) def setup(self, n, dtype): np.random.seed(0) @@ -83,9 +86,9 @@ class LabelsDrawing2DSuite: param_names = ['n', 'brush_size', 'color_mode', 'contour'] params = ([512, 3072], [8, 64, 256], ['auto', 'direct'], [0, 1]) - - if "PR" in os.environ: - skip_params = Skiper(lambda x: x[0] > 512 or x[1] > 64) + skip_params = Skip( + if_in_pr=lambda n, brush_size, *_: n > 512 or brush_size > 64 + ) def setup(self, n, brush_size, color_mode, contour): np.random.seed(0) @@ -93,12 +96,13 @@ def setup(self, n, brush_size, color_mode, contour): (n, n), dtype=np.int32, n=int(np.log2(n) ** 2), seed=1 ) - colors = None + self.layer = Labels(self.data) + if color_mode == 'direct': random_label_ids = np.random.randint(64, size=50) colors = {i + 1: np.random.random(4) for i in random_label_ids} - - self.layer = Labels(self.data, color=colors) + colors[None] = np.array([0, 0, 0, 0.3]) + self.layer.colormap = DirectLabelColormap(color_dict=colors) self.layer.brush_size = brush_size self.layer.contour = contour @@ -119,9 +123,9 @@ def time_draw(self, n, brush_size, color_mode, contour): class Labels2DColorDirectSuite(Labels2DSuite): + skip_params = Skip(if_in_pr=lambda n, dtype: n > 32) + def setup(self, n, dtype): - if "PR" in os.environ and n > 32: - raise NotImplementedError("Skip on PR (speedup)") np.random.seed(0) info = np.iinfo(dtype) self.data = labeled_particles( @@ -130,9 +134,10 @@ def setup(self, n, dtype): random_label_ids = np.random.randint( low=max(-10000, info.min), high=min(10000, info.max), size=20 ) + colors = {i + 1: np.random.random(4) for i in random_label_ids} + colors[None] = np.array([0, 0, 0, 0.3]) self.layer = Labels( - self.data, - color={i + 1: np.random.random(4) for i in random_label_ids}, + self.data, colormap=DirectLabelColormap(color_dict=colors) ) self.layer._raw_to_displayed( self.layer._slice.image.raw, (slice(0, n), slice(0, n)) @@ -144,19 +149,22 @@ class Labels3DSuite: param_names = ['n', 'dtype'] params = ([2**i for i in range(4, 11)], [np.uint8, np.uint32]) - if "PR" in os.environ: - skip_params = [(2**i,) for i in range(6, 11)] - def setup(self, n, dtype): - if "CI" in os.environ and n > 512: - raise NotImplementedError("Skip on CI (not enough memory)") + skip_params = Skip( + if_in_pr=lambda n, dtype: n > 2**6, if_on_ci=lambda n, dtype: n > 2**9 + ) + # CI skip above 2**9 because of memory limits + def setup(self, n, dtype): np.random.seed(0) self.data = labeled_particles( (n, n, n), dtype=dtype, n=int(np.log2(n) ** 2), seed=1 ) self.layer = Labels(self.data) - self.layer._slice_dims(Dims(ndim=3, ndisplay=3)) + if NAPARI_0_4_19: + self.layer._slice_dims((0, 0, 0), ndisplay=3) + else: + self.layer._slice_dims(Dims(ndim=3, ndisplay=3)) self.layer._raw_to_displayed( self.layer._slice.image.raw, (slice(0, n), slice(0, n), slice(0, n)), diff --git a/napari/benchmarks/benchmark_points_layer.py b/napari/benchmarks/benchmark_points_layer.py index 2726ae84b1b..d4e02afbb57 100644 --- a/napari/benchmarks/benchmark_points_layer.py +++ b/napari/benchmarks/benchmark_points_layer.py @@ -5,11 +5,15 @@ import os import numpy as np +from packaging.version import parse as parse_version +import napari from napari.components import Dims from napari.layers import Points -from .utils import Skiper +from .utils import Skip + +NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') class Points2DSuite: @@ -17,7 +21,7 @@ class Points2DSuite: params = [2**i for i in range(4, 18, 2)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(8, 18, 2)] def setup(self, n): @@ -61,7 +65,7 @@ class Points3DSuite: """Benchmarks for the Points layer with 3D data.""" params = [2**i for i in range(4, 18, 2)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 18, 2)] def setup(self, n): @@ -103,10 +107,11 @@ class PointsSlicingSuite: params = [True, False] timeout = 300 + skip_params = Skip(always=lambda _: NAPARI_0_4_19) def setup(self, flatten_slice_axis): np.random.seed(0) - size = 20000 if "PR" in os.environ else 20000000 + size = 20000 if 'PR' in os.environ else 20000000 self.data = np.random.uniform(size=(size, 3), low=0, high=500) if flatten_slice_axis: self.data[:, 0] = np.round(self.data[:, 0]) @@ -136,8 +141,10 @@ class PointsToMaskSuite: [5, 10], ] - if "PR" in os.environ: - skip_params = Skiper(lambda x: x[0] > 256 or x[1][0] > 512) + skip_params = Skip( + if_in_pr=lambda num_points, mask_shape, points_size: num_points > 256 + or mask_shape[0] > 512 + ) def setup(self, num_points, mask_shape, point_size): np.random.seed(0) diff --git a/napari/benchmarks/benchmark_python_layer.py b/napari/benchmarks/benchmark_python_layer.py index 986f9933ea3..109dad62006 100644 --- a/napari/benchmarks/benchmark_python_layer.py +++ b/napari/benchmarks/benchmark_python_layer.py @@ -1,10 +1,12 @@ +import numpy as np + from napari.layers.points._points_utils import coerce_symbols class CoerceSymbolsSuite: def setup(self): - self.symbols1 = ['o' for _ in range(10**6)] - self.symbols2 = ['o' for _ in range(10**6)] + self.symbols1 = np.array(['o' for _ in range(10**6)]) + self.symbols2 = np.array(['o' for _ in range(10**6)]) self.symbols2[10000] = 's' def time_coerce_symbols1(self): diff --git a/napari/benchmarks/benchmark_qt_slicing.py b/napari/benchmarks/benchmark_qt_slicing.py index e2041acfb54..25a25b4f1f2 100644 --- a/napari/benchmarks/benchmark_qt_slicing.py +++ b/napari/benchmarks/benchmark_qt_slicing.py @@ -2,7 +2,6 @@ # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://github.com/napari/napari/blob/main/docs/BENCHMARKS.md -import os import time import numpy as np @@ -12,7 +11,7 @@ import napari from napari.layers import Image -from .utils import Skiper +from .utils import Skip SAMPLE_PARAMS = { 'skin_data': { @@ -50,12 +49,9 @@ def __getitem__(self, item: str): class AsyncImage2DSuite: - """TODO: these benchmarks are skipped. Remove the NotImplementedError in - setup to enable. - """ - params = get_image_params() timeout = 300 + skip_params = Skip(if_in_pr=lambda latency, dataname: latency > 0) def setup(self, latency, dataname): shape = SAMPLE_PARAMS[dataname]['shape'] @@ -71,7 +67,6 @@ def setup(self, latency, dataname): ) self.layer = Image(self.data) - raise NotImplementedError def time_create_layer(self, *args): """Time to create an image layer.""" @@ -86,12 +81,16 @@ def time_refresh(self, *args): self.layer.refresh() -class QtViewerAsyncImage2DSuite: - """TODO: these benchmarks are skipped. Remove the NotImplementedError in - setup to enable. - """ +def _skip_3d_rgb(_latency, dataname): + shape = SAMPLE_PARAMS[dataname]['shape'] + return len(shape) == 3 and shape[2] == 3 + +class QtViewerAsyncImage2DSuite: params = get_image_params() + skip_params = Skip( + always=_skip_3d_rgb, if_in_pr=lambda latency, dataname: latency > 0 + ) timeout = 300 def setup(self, latency, dataname): @@ -99,11 +98,6 @@ def setup(self, latency, dataname): chunk_shape = SAMPLE_PARAMS[dataname]['chunk_shape'] dtype = SAMPLE_PARAMS[dataname]['dtype'] - if len(shape) == 3 and shape[2] == 3: - # Skip 2D RGB tests -- scrolling does not apply - self.viewer = None - raise NotImplementedError - store = SlowMemoryStore(load_delay=latency) _ = QApplication.instance() or QApplication([]) self.data = zarr.zeros( @@ -115,7 +109,6 @@ def setup(self, latency, dataname): self.viewer = napari.Viewer() self.viewer.add_image(self.data) - raise NotImplementedError def time_z_scroll(self, *args): layers_to_scroll = 4 @@ -129,12 +122,9 @@ def teardown(self, *args): class QtViewerAsyncPointsSuite: - """TODO: these benchmarks are skipped. Remove the NotImplementedError in - setup to enable. - """ - n_points = [2**i for i in range(12, 18)] params = n_points + skip_params = Skip(if_in_pr=lambda n_points: n_points > 2**12) def setup(self, n_points): _ = QApplication.instance() or QApplication([]) @@ -142,35 +132,33 @@ def setup(self, n_points): np.random.seed(0) self.viewer = napari.Viewer() # Fake image layer to set bounds. Is this really needed? - self.empty_image = np.zeros((512, 512, 512), dtype="uint8") + self.empty_image = np.zeros((512, 512, 512), dtype='uint8') self.viewer.add_image(self.empty_image) self.point_data = np.random.randint(512, size=(n_points, 3)) self.viewer.add_points(self.point_data) - raise NotImplementedError + self.app = QApplication.instance() or QApplication([]) def time_z_scroll(self, *args): for z in range(self.empty_image.shape[0]): self.viewer.dims.set_current_step(0, z) + self.app.processEvents() def teardown(self, *args): self.viewer.window.close() class QtViewerAsyncPointsAndImage2DSuite: - """TODO: these benchmarks are skipped. Remove the NotImplementedError in - setup to enable. - """ - n_points = [2**i for i in range(12, 18, 2)] chunksize = [256, 512, 1024] latency = [0.05 * i for i in range(3)] params = (n_points, latency, chunksize) timeout = 600 - if "PR" in os.environ: - skip_params = Skiper( - lambda x: x[0] > 2**14 or x[2] > 512 or x[1] > 0.05 - ) + skip_params = Skip( + if_in_pr=lambda n_points, latency, chunksize: n_points > 2**14 + or chunksize > 512 + or latency > 0, + ) def setup(self, n_points, latency, chunksize): store = SlowMemoryStore(load_delay=latency) @@ -189,11 +177,12 @@ def setup(self, n_points, latency, chunksize): self.viewer.add_image(self.image_data) self.point_data = np.random.randint(512, size=(n_points, 3)) self.viewer.add_points(self.point_data) - raise NotImplementedError + self.app = QApplication.instance() or QApplication([]) def time_z_scroll(self, *args): for z in range(self.image_data.shape[0]): self.viewer.dims.set_current_step(0, z) + self.app.processEvents() def teardown(self, *args): self.viewer.window.close() diff --git a/napari/benchmarks/benchmark_qt_viewer_image.py b/napari/benchmarks/benchmark_qt_viewer_image.py index 4b860869cb3..96136fccfab 100644 --- a/napari/benchmarks/benchmark_qt_viewer_image.py +++ b/napari/benchmarks/benchmark_qt_viewer_image.py @@ -5,17 +5,20 @@ import os import numpy as np +from packaging.version import parse as parse_version from qtpy.QtWidgets import QApplication import napari +NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') + class QtViewerViewImageSuite: """Benchmarks for viewing images in the viewer.""" params = [2**i for i in range(4, 13)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 13)] def setup(self, n): @@ -37,7 +40,7 @@ class QtViewerAddImageSuite: params = [2**i for i in range(4, 13)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 13)] def setup(self, n): @@ -59,7 +62,7 @@ class QtViewerImageSuite: params = [2**i for i in range(4, 13)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 13)] def setup(self, n): @@ -73,12 +76,20 @@ def teardown(self, n): def time_zoom(self, n): """Time to zoom in and zoom out.""" - self.viewer.window._qt_viewer.canvas.view.camera.zoom( - 0.5, center=(0.5, 0.5) - ) - self.viewer.window._qt_viewer.canvas.view.camera.zoom( - 2.0, center=(0.5, 0.5) - ) + if NAPARI_0_4_19: + self.viewer.window._qt_viewer.view.camera.zoom( + 0.5, center=(0.5, 0.5) + ) + self.viewer.window._qt_viewer.view.camera.zoom( + 2.0, center=(0.5, 0.5) + ) + else: + self.viewer.window._qt_viewer.canvas.view.camera.zoom( + 0.5, center=(0.5, 0.5) + ) + self.viewer.window._qt_viewer.canvas.view.camera.zoom( + 2.0, center=(0.5, 0.5) + ) def time_refresh(self, n): """Time to refresh view.""" @@ -112,12 +123,20 @@ def teardown(self): def time_zoom(self): """Time to zoom in and zoom out.""" - self.viewer.window._qt_viewer.canvas.view.camera.zoom( - 0.5, center=(0.5, 0.5) - ) - self.viewer.window._qt_viewer.canvas.view.camera.zoom( - 2.0, center=(0.5, 0.5) - ) + if NAPARI_0_4_19: + self.viewer.window._qt_viewer.view.camera.zoom( + 0.5, center=(0.5, 0.5) + ) + self.viewer.window._qt_viewer.view.camera.zoom( + 2.0, center=(0.5, 0.5) + ) + else: + self.viewer.window._qt_viewer.canvas.view.camera.zoom( + 0.5, center=(0.5, 0.5) + ) + self.viewer.window._qt_viewer.canvas.view.camera.zoom( + 2.0, center=(0.5, 0.5) + ) def time_set_data(self): """Time to set view slice.""" @@ -159,12 +178,20 @@ def teardown(self): def time_zoom(self): """Time to zoom in and zoom out.""" - self.viewer.window._qt_viewer.canvas.view.camera.zoom( - 0.5, center=(0.5, 0.5) - ) - self.viewer.window._qt_viewer.canvas.view.camera.zoom( - 2.0, center=(0.5, 0.5) - ) + if NAPARI_0_4_19: + self.viewer.window._qt_viewer.view.camera.zoom( + 0.5, center=(0.5, 0.5) + ) + self.viewer.window._qt_viewer.view.camera.zoom( + 2.0, center=(0.5, 0.5) + ) + else: + self.viewer.window._qt_viewer.canvas.view.camera.zoom( + 0.5, center=(0.5, 0.5) + ) + self.viewer.window._qt_viewer.canvas.view.camera.zoom( + 2.0, center=(0.5, 0.5) + ) def time_set_data(self): """Time to set view slice.""" @@ -196,7 +223,7 @@ class QtImageRenderingSuite: params = [2**i for i in range(4, 13)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 13)] def setup(self, n): @@ -226,7 +253,7 @@ class QtVolumeRenderingSuite: params = [2**i for i in range(4, 10)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 10)] def setup(self, n): diff --git a/napari/benchmarks/benchmark_qt_viewer_labels.py b/napari/benchmarks/benchmark_qt_viewer_labels.py index 3b6a2cc0c00..393b51c00ec 100644 --- a/napari/benchmarks/benchmark_qt_viewer_labels.py +++ b/napari/benchmarks/benchmark_qt_viewer_labels.py @@ -6,17 +6,20 @@ from dataclasses import dataclass from functools import lru_cache from itertools import cycle -from typing import List import numpy as np +from packaging.version import parse as parse_version from qtpy.QtWidgets import QApplication from skimage.morphology import diamond, octahedron import napari from napari.components.viewer_model import ViewerModel from napari.qt import QtViewer +from napari.utils.colormaps import DirectLabelColormap -from .utils import Skiper +from .utils import Skip + +NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') @dataclass @@ -24,8 +27,8 @@ class MouseEvent: # mock mouse event class type: str is_dragging: bool - pos: List[int] - view_direction: List[int] + pos: list[int] + view_direction: list[int] class QtViewerSingleLabelsSuite: @@ -90,7 +93,10 @@ def time_fill(self): def time_on_mouse_move(self): """Time to drag paint on mouse move.""" - self.viewer.window._qt_viewer.canvas._on_mouse_move(self.event) + if NAPARI_0_4_19: + self.viewer.window._qt_viewer.on_mouse_move(self.event) + else: + self.viewer.window._qt_viewer.canvas._on_mouse_move(self.event) @lru_cache @@ -116,40 +122,40 @@ def setup_rendering_data(radius, dtype): class LabelRendering: """Benchmarks for rendering the Labels layer.""" - param_names = ["radius", "dtype", "mode"] + param_names = ['radius', 'dtype', 'mode'] params = ( [10, 30, 300, 1500], [np.uint8, np.uint16, np.uint32], - ["auto", "direct"], + ['auto', 'direct'], + ) + skip_params = Skip( + if_in_pr=lambda radius, *_: radius > 20, + if_on_ci=lambda radius, *_: radius > 20, ) - if "GITHUB_ACTIONS" in os.environ: - skip_params = Skiper(lambda x: x[0] > 20) - if "PR" in os.environ: - skip_params = Skiper(lambda x: x[0] > 20) def setup(self, radius, dtype, label_mode): - self.steps = 4 if "GITHUB_ACTIONS" in os.environ else 10 + self.steps = 4 if 'GITHUB_ACTIONS' in os.environ else 10 self.app = QApplication.instance() or QApplication([]) self.data = setup_rendering_data(radius, dtype) scale = self.data.shape[-1] / np.array(self.data.shape) self.viewer = ViewerModel() self.qt_viewr = QtViewer(self.viewer) self.layer = self.viewer.add_labels(self.data, scale=scale) - if label_mode == "direct": + if label_mode == 'direct': colors = dict( zip( range(10, 2000), - cycle(["red", "green", "blue", "pink", "magenta"]), + cycle(['red', 'green', 'blue', 'pink', 'magenta']), ) ) - colors[None] = "yellow" - colors[0] = "transparent" - self.layer.color = colors + colors[None] = 'yellow' + colors[0] = 'transparent' + self.layer.colormap = DirectLabelColormap(color_dict=colors) self.qt_viewr.show() @staticmethod def teardown(self, *_): - if hasattr(self, "viewer"): + if hasattr(self, 'viewer'): self.qt_viewr.close() def _time_iterate_components(self, *_): diff --git a/napari/benchmarks/benchmark_qt_viewer_vectors.py b/napari/benchmarks/benchmark_qt_viewer_vectors.py index d0debccd87f..d6780cabfb9 100644 --- a/napari/benchmarks/benchmark_qt_viewer_vectors.py +++ b/napari/benchmarks/benchmark_qt_viewer_vectors.py @@ -5,17 +5,20 @@ import os import numpy as np +from packaging.version import parse as parse_version from qtpy.QtWidgets import QApplication import napari +NAPARI_0_4_19 = parse_version(napari.__version__) <= parse_version('0.4.19') + class QtViewerViewVectorSuite: """Benchmarks for viewing vectors in the viewer.""" params = [2**i for i in range(4, 18, 2)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(8, 18, 2)] def setup(self, n): @@ -24,9 +27,14 @@ def setup(self, n): self.data = np.random.random((n, 2, 3)) self.viewer = napari.Viewer() self.layer = self.viewer.add_vectors(self.data) - self.visual = self.viewer.window._qt_viewer.canvas.layer_to_visual[ - self.layer - ] + if NAPARI_0_4_19: + self.visual = self.viewer.window._qt_viewer.layer_to_visual[ + self.layer + ] + else: + self.visual = self.viewer.window._qt_viewer.canvas.layer_to_visual[ + self.layer + ] def teardown(self, n): self.viewer.window.close() diff --git a/napari/benchmarks/benchmark_shapes_layer.py b/napari/benchmarks/benchmark_shapes_layer.py index 62d7c7dfb60..89aafa61612 100644 --- a/napari/benchmarks/benchmark_shapes_layer.py +++ b/napari/benchmarks/benchmark_shapes_layer.py @@ -20,7 +20,7 @@ class Shapes2DSuite: params = [2**i for i in range(4, 9)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 9)] def setup(self, n): @@ -61,7 +61,7 @@ class Shapes3DSuite: """Benchmarks for the Shapes layer with 3D data.""" params = [2**i for i in range(4, 9)] - if "PR" in os.environ: + if 'PR' in os.environ: skip_params = [(2**i,) for i in range(6, 9)] def setup(self, n): diff --git a/napari/benchmarks/benchmark_text_manager.py b/napari/benchmarks/benchmark_text_manager.py index b25b3c28a34..836562afb6a 100644 --- a/napari/benchmarks/benchmark_text_manager.py +++ b/napari/benchmarks/benchmark_text_manager.py @@ -2,14 +2,13 @@ # https://asv.readthedocs.io/en/latest/writing_benchmarks.html # or the napari documentation on benchmarking # https://napari.org/developers/benchmarks.html -import os import numpy as np import pandas as pd from napari.layers.utils.text_manager import TextManager -from .utils import Skiper +from .utils import Skip class TextManagerSuite: @@ -26,8 +25,7 @@ class TextManagerSuite: ], ] - if "PR" in os.environ: - skip_params = Skiper(lambda x: x[0] > 2**6) + skip_params = Skip(if_in_pr=lambda n, string: n > 2**6) def setup(self, n, string): np.random.seed(0) diff --git a/napari/benchmarks/benchmark_tracks_layer.py b/napari/benchmarks/benchmark_tracks_layer.py index 29e75d6e617..ce7e2457c19 100644 --- a/napari/benchmarks/benchmark_tracks_layer.py +++ b/napari/benchmarks/benchmark_tracks_layer.py @@ -1,28 +1,24 @@ -import os - import numpy as np from napari.layers import Tracks -from .utils import Skiper +from .utils import Skip class TracksSuite: param_names = ['size', 'n_tracks'] params = [(5 * np.power(10, np.arange(7))).tolist(), [1, 10, 100, 1000]] - if "PR" in os.environ: - skip_params = Skiper(lambda x: x[0] > 500 or x[1] > 10) + skip_params = Skip( + if_in_pr=lambda size, n_tracks: size > 500 or n_tracks > 10, + always=lambda size, n_tracks: n_tracks * 5 > size, + ) + # we skip cases where the number of tracks times five is larger than the size as it is not useful def setup(self, size, n_tracks): """ Create tracks data """ - - if 5 * n_tracks > size: - # not useful, tracks to short or larger than size - raise NotImplementedError - rng = np.random.default_rng(0) track_ids = rng.integers(n_tracks, size=size) @@ -41,5 +37,11 @@ def setup(self, size, n_tracks): self.data = data - def time_create_layer(self, *args) -> None: + # create layer for the update benchmark + self.layer = Tracks(self.data) + + def time_create_layer(self, *_) -> None: Tracks(self.data) + + def time_update_layer(self, *_) -> None: + self.layer.data = self.data diff --git a/napari/benchmarks/utils.py b/napari/benchmarks/utils.py index 085bcf24dab..e046dfd2fb9 100644 --- a/napari/benchmarks/utils.py +++ b/napari/benchmarks/utils.py @@ -1,11 +1,11 @@ import itertools +import os +from collections.abc import Sequence from functools import lru_cache from typing import ( Callable, Literal, Optional, - Sequence, - Tuple, Union, overload, ) @@ -14,12 +14,27 @@ from skimage import morphology -class Skiper: - def __init__(self, func): - self.func = func +def always_false(*_): + return False + + +class Skip: + def __init__( + self, + if_in_pr: Callable[..., bool] = always_false, + if_on_ci: Callable[..., bool] = always_false, + always: Callable[..., bool] = always_false, + ): + self.func_pr = if_in_pr if 'PR' in os.environ else always_false + self.func_ci = if_on_ci if 'CI' in os.environ else always_false + self.func_always = always def __contains__(self, item): - return self.func(item) + return ( + self.func_pr(*item) + or self.func_ci(*item) + or self.func_always(*item) + ) def _generate_ball(radius: int, ndim: int) -> np.ndarray: @@ -59,7 +74,7 @@ def _generate_density(radius: int, ndim: int) -> np.ndarray: def _structure_at_coordinates( - shape: Tuple[int], + shape: tuple[int], coordinates: np.ndarray, structure: np.ndarray, *, @@ -132,7 +147,7 @@ def _smallest_dtype(n: int) -> np.dtype: return dtype break else: - raise ValueError(f"{n=} is too large for any dtype.") + raise ValueError(f'{n=} is too large for any dtype.') @overload @@ -152,7 +167,7 @@ def labeled_particles( n: int = 144, seed: Optional[int] = None, return_density: Literal[True] = True, -) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: ... +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ... @lru_cache @@ -162,7 +177,7 @@ def labeled_particles( n: int = 144, seed: Optional[int] = None, return_density: bool = False, -) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray, np.ndarray]]: +) -> Union[np.ndarray, tuple[np.ndarray, np.ndarray, np.ndarray]]: """Generate labeled blobs of given shape and dtype. Parameters diff --git a/napari/components/_layer_slicer.py b/napari/components/_layer_slicer.py index 05cac581979..08b11c51599 100644 --- a/napari/components/_layer_slicer.py +++ b/napari/components/_layer_slicer.py @@ -7,17 +7,15 @@ import logging import weakref +from collections.abc import Iterable from concurrent.futures import Executor, Future, ThreadPoolExecutor, wait from contextlib import contextmanager from threading import RLock from typing import ( TYPE_CHECKING, Any, - Dict, - Iterable, Optional, Protocol, - Tuple, runtime_checkable, ) @@ -28,7 +26,7 @@ if TYPE_CHECKING: from napari.components import Dims -logger = logging.getLogger("napari.components._layer_slicer") +logger = logging.getLogger('napari.components._layer_slicer') # Layers that can be asynchronously sliced must be able to make @@ -113,8 +111,8 @@ def __init__(self) -> None: self.events = EmitterGroup(source=self, ready=Event) self._executor: Executor = ThreadPoolExecutor(max_workers=1) self._force_sync = not get_settings().experimental.async_ - self._layers_to_task: Dict[ - Tuple[weakref.ReferenceType[Layer], ...], Future + self._layers_to_task: dict[ + tuple[weakref.ReferenceType[Layer], ...], Future ] = {} self._lock_layers_to_task = RLock() @@ -208,11 +206,21 @@ def submit( # The following logic gives us a way to handle those in the short # term as we develop, and also in the long term if there are cases # when we want to perform sync slicing anyway. - requests: Dict[weakref.ref, _SliceRequest] = {} + requests: dict[weakref.ref, _SliceRequest] = {} sync_layers = [] - visible_layers = (layer for layer in layers if layer.visible) - for layer in visible_layers: - if isinstance(layer, _AsyncSliceable) and not self._force_sync: + for layer in layers: + # Slicing of non-visible layers is handled differently by sync + # and async slicing. For async, we do not make request since a + # later change to visibility triggers slicing. For sync, we want + # to set the slice input with `Layer._slice_dims` but don't want + # to fetch data yet (only if/when it becomes visible in the future). + # Further development should allow us to remove this special case + # by making the sync and async slicing code paths almost identical. + if ( + isinstance(layer, _AsyncSliceable) + and not self._force_sync + and layer.visible + ): logger.debug('Making async slice request for %s', layer) request = layer._make_slice_request(dims) weak_layer = weakref.ref(layer) @@ -260,7 +268,7 @@ def shutdown(self) -> None: self.events.disconnect() self.events.ready.disconnect() - def _slice_layers(self, requests: Dict) -> Dict: + def _slice_layers(self, requests: dict) -> dict: """ Iterates through a dictionary of request objects and call the slice on each individual layer. Can be called from the main or slicing thread. @@ -279,7 +287,7 @@ def _slice_layers(self, requests: Dict) -> Dict: self.events.ready(value=result) return result - def _on_slice_done(self, task: Future[Dict]) -> None: + def _on_slice_done(self, task: Future[dict]) -> None: """ This is the "done_callback" which is added to each task. Can be called from the main or slicing thread. @@ -292,7 +300,7 @@ def _on_slice_done(self, task: Future[Dict]) -> None: logger.debug('Cancelled task: %s', id(task)) return - def _try_to_remove_task(self, task: Future[Dict]) -> bool: + def _try_to_remove_task(self, task: Future[dict]) -> bool: """ Attempt to remove task, return false if task not found, return true if task is found and removed from layers_to_task dict. @@ -309,7 +317,7 @@ def _try_to_remove_task(self, task: Future[Dict]) -> bool: def _find_existing_task( self, layers: Iterable[Layer] - ) -> Optional[Future[Dict]]: + ) -> Optional[Future[dict]]: """Find the task associated with a list of layers. Returns the first task found for which the layers of the task are a subset of the input layers. diff --git a/napari/components/_tests/test_add_layers.py b/napari/components/_tests/test_add_layers.py index 03b9da6e34f..f0f0bd64b03 100644 --- a/napari/components/_tests/test_add_layers.py +++ b/napari/components/_tests/test_add_layers.py @@ -19,11 +19,11 @@ def _impl(path): _testimpl = HookImplementation(_impl, plugin_name='testimpl') -@pytest.mark.parametrize("layer_datum", layer_data) +@pytest.mark.parametrize('layer_datum', layer_data) def test_add_layers_with_plugins(layer_datum): """Test that add_layers_with_plugins adds the expected layer types.""" with patch( - "napari.plugins.io.read_data_with_plugins", + 'napari.plugins.io.read_data_with_plugins', MagicMock(return_value=([layer_datum], _testimpl)), ): v = ViewerModel() @@ -36,7 +36,7 @@ def test_add_layers_with_plugins(layer_datum): @patch( - "napari.plugins.io.read_data_with_plugins", + 'napari.plugins.io.read_data_with_plugins', MagicMock(return_value=([], _testimpl)), ) def test_plugin_returns_nothing(): @@ -47,7 +47,7 @@ def test_plugin_returns_nothing(): @patch( - "napari.plugins.io.read_data_with_plugins", + 'napari.plugins.io.read_data_with_plugins', MagicMock(return_value=([(img,)], _testimpl)), ) def test_viewer_open(): @@ -75,7 +75,7 @@ def test_viewer_open_no_plugin(tmp_path): viewer = ViewerModel() fname = tmp_path / 'gibberish.gbrsh' fname.touch() - with pytest.raises(ValueError, match=".*gibberish.gbrsh.*"): + with pytest.raises(ValueError, match='.*gibberish.gbrsh.*'): # will default to builtins viewer.open(fname) @@ -86,14 +86,14 @@ def test_viewer_open_no_plugin(tmp_path): ] -@pytest.mark.parametrize("layer_data, kwargs", plugin_returns) +@pytest.mark.parametrize('layer_data, kwargs', plugin_returns) def test_add_layers_with_plugins_and_kwargs(layer_data, kwargs): """Test that _add_layers_with_plugins kwargs override plugin kwargs. see also: napari.components._test.test_prune_kwargs """ with patch( - "napari.plugins.io.read_data_with_plugins", + 'napari.plugins.io.read_data_with_plugins', MagicMock(return_value=(layer_data, _testimpl)), ): v = ViewerModel() @@ -188,7 +188,7 @@ def test_add_points_layer_with_different_range_updates_all_slices(): assert viewer.dims.point == (0, 0) -@pytest.mark.xfail(reason="https://github.com/napari/napari/issues/6198") +@pytest.mark.xfail(reason='https://github.com/napari/napari/issues/6198') def test_last_point_is_visible_in_viewport(): viewer = ViewerModel() @@ -218,7 +218,7 @@ def test_last_point_is_visible_in_viewport(): np.testing.assert_array_equal(points._indices_view, [0]) -@pytest.mark.xfail(reason="https://github.com/napari/napari/issues/6199") +@pytest.mark.xfail(reason='https://github.com/napari/napari/issues/6199') def test_dimension_change_is_visible_in_viewport(): viewer = ViewerModel() diff --git a/napari/components/_tests/test_dims.py b/napari/components/_tests/test_dims.py index 14001035a8b..ee13491ee02 100644 --- a/napari/components/_tests/test_dims.py +++ b/napari/components/_tests/test_dims.py @@ -239,14 +239,14 @@ def test_labels_order_when_changing_dims(): @pytest.mark.parametrize( - "ndim, ax_input, expected", ((2, 1, 1), (2, -1, 1), (4, -3, 1)) + 'ndim, ax_input, expected', ((2, 1, 1), (2, -1, 1), (4, -3, 1)) ) def test_assert_axis_in_bounds(ndim, ax_input, expected): actual = ensure_axis_in_bounds(ax_input, ndim) assert actual == expected -@pytest.mark.parametrize("ndim, ax_input", ((2, 2), (2, -3))) +@pytest.mark.parametrize('ndim, ax_input', ((2, 2), (2, -3))) def test_assert_axis_out_of_bounds(ndim, ax_input): with pytest.raises(ValueError): ensure_axis_in_bounds(ax_input, ndim) diff --git a/napari/components/_tests/test_layer_slicer.py b/napari/components/_tests/test_layer_slicer.py index 48518195269..063fbf2c6f2 100644 --- a/napari/components/_tests/test_layer_slicer.py +++ b/napari/components/_tests/test_layer_slicer.py @@ -3,7 +3,7 @@ from concurrent.futures import Future, wait from dataclasses import dataclass from threading import RLock, current_thread, main_thread -from typing import Any, Dict +from typing import Any import numpy as np import pytest @@ -409,8 +409,8 @@ def _wait_for_result(future: 'Future[Any]') -> Any: # remove quotes in types once we are python 3.9+ only. def _wait_for_response( - task: 'Future[Dict[weakref.ReferenceType[Any], Any]]', -) -> Dict: + task: 'Future[dict[weakref.ReferenceType[Any], Any]]', +) -> dict: """Waits until the given slice task is finished and returns its result.""" weak_result = _wait_for_result(task) result = {} diff --git a/napari/components/_tests/test_layers_list.py b/napari/components/_tests/test_layers_list.py index 42a4f29c75b..60c66376306 100644 --- a/napari/components/_tests/test_layers_list.py +++ b/napari/components/_tests/test_layers_list.py @@ -533,9 +533,9 @@ def test_ndim(): def test_name_uniqueness(): layers = LayerList() - layers.append(Image(np.random.random((10, 15)), name="Image [1]")) - layers.append(Image(np.random.random((10, 15)), name="Image")) - layers.append(Image(np.random.random((10, 15)), name="Image")) + layers.append(Image(np.random.random((10, 15)), name='Image [1]')) + layers.append(Image(np.random.random((10, 15)), name='Image')) + layers.append(Image(np.random.random((10, 15)), name='Image')) assert [x.name for x in layers] == ['Image [1]', 'Image', 'Image [2]'] diff --git a/napari/components/_tests/test_multichannel.py b/napari/components/_tests/test_multichannel.py index 3ca1494d2f8..a4024f25d25 100644 --- a/napari/components/_tests/test_multichannel.py +++ b/napari/components/_tests/test_multichannel.py @@ -18,8 +18,8 @@ green_cmap = SIMPLE_COLORMAPS['green'] red_cmap = SIMPLE_COLORMAPS['red'] blue_cmap = AVAILABLE_COLORMAPS['blue'] -cmap_tuple = ("my_colormap", Colormap(['g', 'm', 'y'])) -cmap_dict = {"your_colormap": Colormap(['g', 'r', 'y'])} +cmap_tuple = ('my_colormap', Colormap(['g', 'm', 'y'])) +cmap_dict = {'your_colormap': Colormap(['g', 'r', 'y'])} MULTI_TUPLES = [[0.3, 0.7], [0.1, 0.9], [0.3, 0.9], [0.4, 0.9], [0.2, 0.9]] diff --git a/napari/components/_tests/test_prune_kwargs.py b/napari/components/_tests/test_prune_kwargs.py index 026a722eefa..832937c46c6 100644 --- a/napari/components/_tests/test_prune_kwargs.py +++ b/napari/components/_tests/test_prune_kwargs.py @@ -9,6 +9,7 @@ 'border_color': 'blue', 'z_index': 20, 'edge_width': 2, + 'border_width': 1, 'face_color': 'white', 'multiscale': False, 'name': 'name', @@ -39,8 +40,9 @@ { 'scale': (0.75, 1), 'blending': 'translucent', - 'face_color': 'white', 'border_color': 'blue', + 'border_width': 1, + 'face_color': 'white', 'name': 'name', }, ), diff --git a/napari/components/_tests/test_viewer_keybindings.py b/napari/components/_tests/test_viewer_keybindings.py index b618abe9eef..a4f68ae9db2 100644 --- a/napari/components/_tests/test_viewer_keybindings.py +++ b/napari/components/_tests/test_viewer_keybindings.py @@ -2,7 +2,11 @@ from napari.components._viewer_key_bindings import ( hold_for_pan_zoom, + show_only_layer_above, + show_only_layer_below, + toggle_selected_visibility, toggle_theme, + toggle_unselected_visibility, ) from napari.components.viewer_model import ViewerModel from napari.layers.points import Points @@ -73,3 +77,79 @@ def test_hold_for_pan_zoom(): with pytest.raises(StopIteration): next(gen) assert layer.mode == 'transform' + + +def test_selected_visibility_toggle(): + viewer = make_viewer_with_three_layers() + viewer.layers.selection.active = viewer.layers[0] + assert viewer.layers[0].visible + assert viewer.layers[1].visible + assert viewer.layers[2].visible + toggle_selected_visibility(viewer) + assert not viewer.layers[0].visible + assert viewer.layers[1].visible + assert viewer.layers[2].visible + toggle_selected_visibility(viewer) + assert viewer.layers[0].visible + assert viewer.layers[1].visible + assert viewer.layers[2].visible + + +def test_unselected_visibility_toggle(): + viewer = make_viewer_with_three_layers() + viewer.layers.selection.active = viewer.layers[0] + assert viewer.layers[0].visible + assert viewer.layers[1].visible + assert viewer.layers[2].visible + toggle_unselected_visibility(viewer) + assert viewer.layers[0].visible + assert not viewer.layers[1].visible + assert not viewer.layers[2].visible + toggle_unselected_visibility(viewer) + assert viewer.layers[0].visible + assert viewer.layers[1].visible + assert viewer.layers[2].visible + + +def test_show_only_layer_above(): + viewer = make_viewer_with_three_layers() + viewer.layers.selection.active = viewer.layers[0] + assert viewer.layers[0].visible + assert viewer.layers[1].visible + assert viewer.layers[2].visible + show_only_layer_above(viewer) + assert not viewer.layers[0].visible + assert viewer.layers[1].visible + assert not viewer.layers[2].visible + show_only_layer_above(viewer) + assert not viewer.layers[0].visible + assert not viewer.layers[1].visible + assert viewer.layers[2].visible + + +def test_show_only_layer_below(): + viewer = make_viewer_with_three_layers() + viewer.layers.selection.active = viewer.layers[2] + assert viewer.layers[0].visible + assert viewer.layers[1].visible + assert viewer.layers[2].visible + show_only_layer_below(viewer) + assert not viewer.layers[2].visible + assert viewer.layers[1].visible + assert not viewer.layers[0].visible + show_only_layer_below(viewer) + assert not viewer.layers[2].visible + assert not viewer.layers[1].visible + assert viewer.layers[0].visible + + +def make_viewer_with_three_layers(): + """Helper function to create a viewer with three layers""" + viewer = ViewerModel() + layer1 = Points() + layer2 = Points() + layer3 = Points() + viewer.layers.append(layer1) + viewer.layers.append(layer2) + viewer.layers.append(layer3) + return viewer diff --git a/napari/components/_tests/test_viewer_model.py b/napari/components/_tests/test_viewer_model.py index 310871b88c3..9eb8c6109bf 100644 --- a/napari/components/_tests/test_viewer_model.py +++ b/napari/components/_tests/test_viewer_model.py @@ -61,11 +61,11 @@ def test_add_image_colormap_variants(): assert viewer.add_image(data, colormap='fire') # as tuple - cmap_tuple = ("my_colormap", Colormap(['g', 'm', 'y'])) + cmap_tuple = ('my_colormap', Colormap(['g', 'm', 'y'])) assert viewer.add_image(data, colormap=cmap_tuple) # as dict - cmap_dict = {"your_colormap": Colormap(['g', 'r', 'y'])} + cmap_dict = {'your_colormap': Colormap(['g', 'r', 'y'])} assert viewer.add_image(data, colormap=cmap_dict) # as Colormap instance @@ -138,16 +138,6 @@ def test_add_labels(): assert viewer.dims.ndim == 2 -def test_add_labels_warnings(): - """Test adding labels image.""" - viewer = ViewerModel() - np.random.seed(0) - with pytest.warns( - FutureWarning, match="Setting Labels.num_colors is deprecated since" - ): - viewer.add_labels(np.zeros((10, 15), dtype=np.uint8), num_colors=20) - - def test_add_points(): """Test adding points.""" viewer = ViewerModel() @@ -984,3 +974,18 @@ def test_slice_order_with_mixed_dims(): assert image_2d._slice.image.view.shape == (4, 5) assert image_3d._slice.image.view.shape == (3, 5) assert image_4d._slice.image.view.shape == (2, 5) + + +def test_make_layer_visible_after_slicing(): + """See https://github.com/napari/napari/issues/6760""" + viewer = ViewerModel(ndisplay=2) + data = np.array([np.ones((2, 2)) * i for i in range(3)]) + layer: Image = viewer.add_image(data) + layer.visible = False + assert viewer.dims.current_step[0] != 0 + assert not np.array_equal(layer._slice.image.raw, data[0]) + + viewer.dims.current_step = (0, 0, 0) + layer.visible = True + + np.testing.assert_array_equal(layer._slice.image.raw, data[0]) diff --git a/napari/components/_tests/test_viewer_mouse_bindings.py b/napari/components/_tests/test_viewer_mouse_bindings.py index 6c9cc3e9e10..c89025c773b 100644 --- a/napari/components/_tests/test_viewer_mouse_bindings.py +++ b/napari/components/_tests/test_viewer_mouse_bindings.py @@ -15,16 +15,16 @@ def inverted(self): @pytest.mark.parametrize( - "modifiers, native, expected_dim", + 'modifiers, native, expected_dim', [ ([], WheelEvent(True), [[5, 5, 5], [5, 5, 5], [5, 5, 5], [5, 5, 5]]), ( - ["Control"], + ['Control'], WheelEvent(False), [[5, 5, 5], [4, 5, 5], [3, 5, 5], [0, 5, 5]], ), ( - ["Control"], + ['Control'], WheelEvent(True), [[5, 5, 5], [6, 5, 5], [7, 5, 5], [9, 5, 5]], ), @@ -42,14 +42,14 @@ def test_paint(modifiers, native, expected_dim): # Simulate tiny scroll event = read_only_mouse_event( - delta=[0, 0.6], modifiers=modifiers, native=native, type="wheel" + delta=[0, 0.6], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[0]).all() # Simulate tiny scroll event = read_only_mouse_event( - delta=[0, 0.6], modifiers=modifiers, native=native, type="wheel" + delta=[0, 0.6], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) @@ -57,14 +57,14 @@ def test_paint(modifiers, native, expected_dim): # Simulate tiny scroll event = read_only_mouse_event( - delta=[0, 0.9], modifiers=modifiers, native=native, type="wheel" + delta=[0, 0.9], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[2]).all() # Simulate large scroll event = read_only_mouse_event( - delta=[0, 3], modifiers=modifiers, native=native, type="wheel" + delta=[0, 3], modifiers=modifiers, native=native, type='wheel' ) mouse_wheel_callbacks(viewer, event) assert np.equal(viewer.dims.point, expected_dim[3]).all() diff --git a/napari/components/_viewer_constants.py b/napari/components/_viewer_constants.py index 696b7a0eee0..f1d37eeef03 100644 --- a/napari/components/_viewer_constants.py +++ b/napari/components/_viewer_constants.py @@ -14,10 +14,10 @@ class CanvasPosition(StrEnum): """ TOP_LEFT = 'top_left' - TOP_CENTER = "top_center" + TOP_CENTER = 'top_center' TOP_RIGHT = 'top_right' BOTTOM_RIGHT = 'bottom_right' - BOTTOM_CENTER = "bottom_center" + BOTTOM_CENTER = 'bottom_center' BOTTOM_LEFT = 'bottom_left' diff --git a/napari/components/_viewer_key_bindings.py b/napari/components/_viewer_key_bindings.py index b5dae722921..f89b64ad697 100644 --- a/napari/components/_viewer_key_bindings.py +++ b/napari/components/_viewer_key_bindings.py @@ -31,7 +31,7 @@ def _inner(func): return _inner -@register_viewer_action(trans._("Reset scroll.")) +@register_viewer_action(trans._('Reset scroll.')) def reset_scroll_progress(viewer: Viewer): # on key press viewer.dims._scroll_progress = 0 @@ -41,10 +41,10 @@ def reset_scroll_progress(viewer: Viewer): viewer.dims._scroll_progress = 0 -reset_scroll_progress.__doc__ = trans._("Reset dims scroll progress") +reset_scroll_progress.__doc__ = trans._('Reset dims scroll progress') -@register_viewer_action(trans._("Toggle 2D/3D view.")) +@register_viewer_action(trans._('Toggle 2D/3D view.')) def toggle_ndisplay(viewer: Viewer): if viewer.dims.ndisplay == 2: viewer.dims.ndisplay = 3 @@ -57,7 +57,7 @@ def toggle_ndisplay(viewer: Viewer): # ``` # RuntimeError: wrapped C/C++ object of type CanvasBackendDesktop has been deleted # ``` -@register_viewer_action(trans._("Toggle current viewer theme.")) +@register_viewer_action(trans._('Toggle current viewer theme.')) def toggle_theme(viewer: ViewerModel): """Toggle theme for current viewer""" themes = available_themes() @@ -74,32 +74,32 @@ def toggle_theme(viewer: ViewerModel): viewer.theme = themes[idx] -@register_viewer_action(trans._("Reset view to original state.")) +@register_viewer_action(trans._('Reset view to original state.')) def reset_view(viewer: Viewer): viewer.reset_view() -@register_viewer_action(trans._("Delete selected layers.")) +@register_viewer_action(trans._('Delete selected layers.')) def delete_selected_layers(viewer: Viewer): viewer.layers.remove_selected() -@register_viewer_action(trans._("Increment dimensions slider to the left.")) +@register_viewer_action(trans._('Increment dimensions slider to the left.')) def increment_dims_left(viewer: Viewer): viewer.dims._increment_dims_left() -@register_viewer_action(trans._("Increment dimensions slider to the right.")) +@register_viewer_action(trans._('Increment dimensions slider to the right.')) def increment_dims_right(viewer: Viewer): viewer.dims._increment_dims_right() -@register_viewer_action(trans._("Move focus of dimensions slider up.")) +@register_viewer_action(trans._('Move focus of dimensions slider up.')) def focus_axes_up(viewer: Viewer): viewer.dims._focus_up() -@register_viewer_action(trans._("Move focus of dimensions slider down.")) +@register_viewer_action(trans._('Move focus of dimensions slider down.')) def focus_axes_down(viewer: Viewer): viewer.dims._focus_down() @@ -107,7 +107,7 @@ def focus_axes_down(viewer: Viewer): # Use non-breaking spaces and non-breaking hyphen for Preferences table @register_viewer_action( trans._( - "Change order of the visible axes, e.g.\u00A0[0,\u00A01,\u00A02]\u00A0\u2011>\u00A0[2,\u00A00,\u00A01]." + 'Change order of the visible axes, e.g.\u00A0[0,\u00A01,\u00A02]\u00A0\u2011>\u00A0[2,\u00A00,\u00A01].' ), ) def roll_axes(viewer: Viewer): @@ -117,33 +117,52 @@ def roll_axes(viewer: Viewer): # Use non-breaking spaces and non-breaking hyphen for Preferences table @register_viewer_action( trans._( - "Transpose order of the last two visible axes, e.g.\u00A0[0,\u00A01]\u00A0\u2011>\u00A0[1,\u00A00]." + 'Transpose order of the last two visible axes, e.g.\u00A0[0,\u00A01]\u00A0\u2011>\u00A0[1,\u00A00].' ), ) def transpose_axes(viewer: Viewer): viewer.dims.transpose() -@register_viewer_action(trans._("Toggle grid mode.")) +@register_viewer_action(trans._('Toggle grid mode.')) def toggle_grid(viewer: Viewer): viewer.grid.enabled = not viewer.grid.enabled -@register_viewer_action(trans._("Toggle visibility of selected layers")) +@register_viewer_action(trans._('Toggle visibility of selected layers')) def toggle_selected_visibility(viewer: Viewer): viewer.layers.toggle_selected_visibility() +@register_viewer_action(trans._('Toggle visibility of unselected layers')) +def toggle_unselected_visibility(viewer: Viewer): + for layer in viewer.layers: + if layer not in viewer.layers.selection: + layer.visible = not layer.visible + + +@register_viewer_action(trans._('Select and show only layer above.')) +def show_only_layer_above(viewer): + viewer.layers.select_next() + _show_only_selected_layer(viewer) + + +@register_viewer_action(trans._('Select and show only layer below.')) +def show_only_layer_below(viewer): + viewer.layers.select_previous() + _show_only_selected_layer(viewer) + + @register_viewer_action( trans._( - "Show/Hide IPython console (only available when napari started as standalone application)" + 'Show/Hide IPython console (only available when napari started as standalone application)' ) ) def toggle_console_visibility(viewer: Viewer): viewer.window._qt_viewer.toggle_console_visibility() -@register_viewer_action(trans._("Press and hold for pan/zoom mode")) +@register_viewer_action(trans._('Press and hold for pan/zoom mode')) def hold_for_pan_zoom(viewer: ViewerModel): selected_layer = viewer.layers.selection.active if selected_layer is None: @@ -162,9 +181,18 @@ def hold_for_pan_zoom(viewer: ViewerModel): selected_layer.mode = previous_mode -@register_viewer_action(trans._("Show all key bindings")) +@register_viewer_action(trans._('Show all key bindings')) def show_shortcuts(viewer: Viewer): pref_list = viewer.window._open_preferences_dialog()._list for i in range(pref_list.count()): - if (item := pref_list.item(i)) and item.text() == "Shortcuts": + if (item := pref_list.item(i)) and item.text() == 'Shortcuts': pref_list.setCurrentRow(i) + + +def _show_only_selected_layer(viewer): + """Helper function to show only selected layer""" + for layer in viewer.layers: + if layer not in viewer.layers.selection: + layer.visible = False + else: + layer.visible = True diff --git a/napari/components/camera.py b/napari/components/camera.py index 42e096c73ea..48f8dba2138 100644 --- a/napari/components/camera.py +++ b/napari/components/camera.py @@ -1,5 +1,5 @@ import warnings -from typing import Optional, Tuple, Union +from typing import Optional, Union import numpy as np from scipy.spatial.transform import Rotation as R @@ -38,13 +38,13 @@ class Camera(EventedModel): """ # fields - center: Union[Tuple[float, float, float], Tuple[float, float]] = ( + center: Union[tuple[float, float, float], tuple[float, float]] = ( 0.0, 0.0, 0.0, ) zoom: float = 1.0 - angles: Tuple[float, float, float] = (0.0, 0.0, 90.0) + angles: tuple[float, float, float] = (0.0, 0.0, 90.0) perspective: float = 0 mouse_pan: bool = True mouse_zoom: bool = True @@ -55,7 +55,7 @@ def _ensure_3_tuple(cls, v): return ensure_n_tuple(v, n=3) @property - def view_direction(self) -> Tuple[float, float, float]: + def view_direction(self) -> tuple[float, float, float]: """3D view direction vector of the camera. View direction is calculated from the Euler angles and returned as a @@ -71,7 +71,7 @@ def view_direction(self) -> Tuple[float, float, float]: return view_direction @property - def up_direction(self) -> Tuple[float, float, float]: + def up_direction(self) -> tuple[float, float, float]: """3D direction vector pointing up on the canvas. Up direction is calculated from the Euler angles and returned as a @@ -89,8 +89,8 @@ def up_direction(self) -> Tuple[float, float, float]: def set_view_direction( self, - view_direction: Tuple[float, float, float], - up_direction: Tuple[float, float, float] = (0, -1, 0), + view_direction: tuple[float, float, float], + up_direction: tuple[float, float, float] = (0, -1, 0), ): """Set camera angles from direction vectors. @@ -137,7 +137,7 @@ def set_view_direction( if np.allclose(np.cross(view_vector, up_vector), 0): raise ValueError( trans._( - "view direction and up direction are parallel", + 'view direction and up direction are parallel', deferred=True, ) ) @@ -153,7 +153,7 @@ def set_view_direction( self.angles = euler_angles def calculate_nd_view_direction( - self, ndim: int, dims_displayed: Tuple[int, ...] + self, ndim: int, dims_displayed: tuple[int, ...] ) -> Optional[np.ndarray]: """Calculate the nD view direction vector of the camera. @@ -176,7 +176,7 @@ def calculate_nd_view_direction( return view_direction_nd def calculate_nd_up_direction( - self, ndim: int, dims_displayed: Tuple[int, ...] + self, ndim: int, dims_displayed: tuple[int, ...] ) -> Optional[np.ndarray]: """Calculate the nD up direction vector of the camera. diff --git a/napari/components/cursor.py b/napari/components/cursor.py index a6da757d3db..6708e10843c 100644 --- a/napari/components/cursor.py +++ b/napari/components/cursor.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import Optional from napari.components._viewer_constants import CursorStyle from napari.utils.events import EventedModel @@ -34,8 +34,8 @@ class Cursor(EventedModel): """ # fields - position: Tuple[float, ...] = (1, 1) + position: tuple[float, ...] = (1, 1) scaled: bool = True size = 1.0 style: CursorStyle = CursorStyle.STANDARD - _view_direction: Optional[Tuple[float, float, float]] = None + _view_direction: Optional[tuple[float, float, float]] = None diff --git a/napari/components/dims.py b/napari/components/dims.py index fb557e05b5e..96e1eceec63 100644 --- a/napari/components/dims.py +++ b/napari/components/dims.py @@ -1,12 +1,10 @@ +from collections.abc import Sequence from numbers import Integral from typing import ( Any, - List, Literal, NamedTuple, Optional, - Sequence, - Tuple, Union, ) @@ -92,13 +90,13 @@ class Dims(EventedModel): # fields ndim: int = 2 ndisplay: Literal[2, 3] = 2 - order: Tuple[int, ...] = () - axis_labels: Tuple[str, ...] = () + order: tuple[int, ...] = () + axis_labels: tuple[str, ...] = () - range: Tuple[RangeTuple, ...] = () - margin_left: Tuple[float, ...] = () - margin_right: Tuple[float, ...] = () - point: Tuple[float, ...] = () + range: tuple[RangeTuple, ...] = () + margin_left: tuple[float, ...] = () + margin_right: tuple[float, ...] = () + point: tuple[float, ...] = () last_used: int = 0 @@ -198,7 +196,7 @@ def _check_dims(cls, values): if set(updated['order']) != set(range(ndim)): raise ValueError( trans._( - "Invalid ordering {order} for {ndim} dimensions", + 'Invalid ordering {order} for {ndim} dimensions', deferred=True, order=updated['order'], ndim=ndim, @@ -233,7 +231,7 @@ def _check_dims(cls, values): return {**values, **updated} @staticmethod - def _nsteps_from_range(dims_range) -> Tuple[float, ...]: + def _nsteps_from_range(dims_range) -> tuple[float, ...]: return tuple( # "or 1" ensures degenerate dimension works int((rng.stop - rng.start) / (rng.step or 1)) + 1 @@ -241,7 +239,7 @@ def _nsteps_from_range(dims_range) -> Tuple[float, ...]: ) @property - def nsteps(self) -> Tuple[float, ...]: + def nsteps(self) -> tuple[float, ...]: return self._nsteps_from_range(self.range) @nsteps.setter @@ -268,7 +266,7 @@ def current_step(self, value): ) @property - def thickness(self) -> Tuple[float, ...]: + def thickness(self) -> tuple[float, ...]: return tuple( left + right for left, right in zip(self.margin_left, self.margin_right) @@ -279,17 +277,17 @@ def thickness(self, value): self.margin_left = self.margin_right = tuple(val / 2 for val in value) @property - def displayed(self) -> Tuple[int, ...]: + def displayed(self) -> tuple[int, ...]: """Tuple: Dimensions that are displayed.""" return self.order[-self.ndisplay :] @property - def not_displayed(self) -> Tuple[int, ...]: + def not_displayed(self) -> tuple[int, ...]: """Tuple: Dimensions that are not displayed.""" return self.order[: -self.ndisplay] @property - def displayed_order(self) -> Tuple[int, ...]: + def displayed_order(self) -> tuple[int, ...]: return tuple(argsort(self.displayed)) def set_range( @@ -451,7 +449,7 @@ def _go_to_center_step(self): def _sanitize_input( self, axis, value, value_is_sequence=False - ) -> Tuple[List[int], List]: + ) -> tuple[list[int], list]: """ Ensure that axis and value are the same length, that axes are not out of bounds, and coerces to lists for easier processing. @@ -473,7 +471,7 @@ def _sanitize_input( if len(axis) != len(value): raise ValueError( - trans._("axis and value sequences must have equal length") + trans._('axis and value sequences must have equal length') ) for ax in axis: @@ -481,7 +479,7 @@ def _sanitize_input( return axis, value -def ensure_len(value: Tuple, length: int, pad_width: Any): +def ensure_len(value: tuple, length: int, pad_width: Any): """ Ensure that the value has the required number of elements. diff --git a/napari/components/experimental/commands.py b/napari/components/experimental/commands.py index bd6cd365c94..fd683eb9a73 100644 --- a/napari/components/experimental/commands.py +++ b/napari/components/experimental/commands.py @@ -32,7 +32,7 @@ def loader(self): return LoaderCommands(self.layers) def __repr__(self): - return "Available Commands:\nexperimental.cmds.loader" + return 'Available Commands:\nexperimental.cmds.loader' class ExperimentalNamespace: @@ -55,4 +55,4 @@ def cmds(self): return CommandProcessor(self.layers) def __repr__(self): - return "Available Commands:\nexperimental.cmds.loader" + return 'Available Commands:\nexperimental.cmds.loader' diff --git a/napari/components/experimental/monitor/__init__.py b/napari/components/experimental/monitor/__init__.py index 71923590c4f..d5fa80d2d34 100644 --- a/napari/components/experimental/monitor/__init__.py +++ b/napari/components/experimental/monitor/__init__.py @@ -3,4 +3,4 @@ from napari.components.experimental.monitor._monitor import monitor from napari.components.experimental.monitor._utils import numpy_dumps -__all__ = ["monitor", "numpy_dumps"] +__all__ = ['monitor', 'numpy_dumps'] diff --git a/napari/components/experimental/monitor/_api.py b/napari/components/experimental/monitor/_api.py index b0c03e308fb..174c4271748 100644 --- a/napari/components/experimental/monitor/_api.py +++ b/napari/components/experimental/monitor/_api.py @@ -5,14 +5,14 @@ from multiprocessing.managers import SharedMemoryManager from queue import Empty, Queue from threading import Event -from typing import ClassVar, Dict, NamedTuple +from typing import ClassVar, NamedTuple from napari.utils.events import EmitterGroup -LOGGER = logging.getLogger("napari.monitor") +LOGGER = logging.getLogger('napari.monitor') # The client needs to know this. -AUTH_KEY = "napari" +AUTH_KEY = 'napari' # Port 0 means the OS chooses an available port. We send the server_port # port to the client in its NAPARI_MON_CLIENT variable. @@ -82,11 +82,11 @@ class MonitorApi: # BaseManager.register() is a bit weird. Not sure now to best deal with # it. Most ways I tried led to pickling errors, because this class is being run # in the shared memory server process? Feel free to find a better approach. - _napari_data_dict: ClassVar[Dict] = {} + _napari_data_dict: ClassVar[dict] = {} _napari_messages_queue: ClassVar[Queue] = Queue() _napari_shutdown_event: ClassVar[Event] = Event() - _client_data_dict: ClassVar[Dict] = {} + _client_data_dict: ClassVar[dict] = {} _client_messages_queue: ClassVar[Queue] = Queue() @staticmethod @@ -186,7 +186,7 @@ def _process_client_messages(self) -> None: if not isinstance(message, dict): LOGGER.warning( - "Ignore message that was not a dict: %s", message + 'Ignore message that was not a dict: %s', message ) continue diff --git a/napari/components/experimental/monitor/_monitor.py b/napari/components/experimental/monitor/_monitor.py index 10cfc7cd1ac..c0ee2e11dd0 100644 --- a/napari/components/experimental/monitor/_monitor.py +++ b/napari/components/experimental/monitor/_monitor.py @@ -10,13 +10,12 @@ import json import logging import os -import sys from pathlib import Path from typing import Optional from napari.utils.translations import trans -LOGGER = logging.getLogger("napari.monitor") +LOGGER = logging.getLogger('napari.monitor') # If False monitor is disabled even if we meet all other requirements. ENABLE_MONITOR = True @@ -40,7 +39,7 @@ def _load_config(path: str) -> dict: raise FileNotFoundError( errno.ENOENT, trans._( - "Monitor: Config file not found: {path}", + 'Monitor: Config file not found: {path}', deferred=True, path=path, ), @@ -60,8 +59,8 @@ def _load_monitor_config() -> Optional[dict]: """ # We shouldn't even call into this file unless NAPARI_MON is defined # but check to be sure. - value = os.getenv("NAPARI_MON") - if value in [None, "0"]: + value = os.getenv('NAPARI_MON') + if value in [None, '0']: return None return _load_config(value) @@ -86,7 +85,7 @@ def _setup_logging(config: dict) -> None: fh = logging.FileHandler(log_path) LOGGER.addHandler(fh) LOGGER.setLevel(logging.DEBUG) - LOGGER.info("Writing to log path %s", log_path) + LOGGER.info('Writing to log path %s', log_path) def _get_monitor_config() -> Optional[dict]: @@ -103,23 +102,16 @@ def _get_monitor_config() -> Optional[dict]: Optional[dict] The configuration for the MonitorService. """ - if sys.version_info[:2] < (3, 9): - # We require Python 3.9 for now. The shared memory features we need - # were added in 3.8, but the 3.8 implemention was buggy. It's - # possible we could backport to or otherwise fix 3.8 or even 3.7, - # but for now we're making 3.9 a requirement. - print("Monitor: not starting, requires Python 3.9 or newer") - return None if not ENABLE_MONITOR: - print("Monitor: not starting, disabled") + print('Monitor: not starting, disabled') return None # The NAPARI_MON environment variable points to our config file. config = _load_monitor_config() if config is None: - print("Monitor: not starting, no usable config file") + print('Monitor: not starting, no usable config file') return None return config diff --git a/napari/components/experimental/monitor/_service.py b/napari/components/experimental/monitor/_service.py index 25af9bbd44d..67d0707dda7 100644 --- a/napari/components/experimental/monitor/_service.py +++ b/napari/components/experimental/monitor/_service.py @@ -98,19 +98,19 @@ def _get_client_config() -> dict: import os import subprocess from multiprocessing.managers import SharedMemoryManager -from typing import Dict, Union +from typing import Union from napari.components.experimental.monitor._utils import base64_encoded_json -LOGGER = logging.getLogger("napari.monitor") +LOGGER = logging.getLogger('napari.monitor') # If False we don't start any clients, for debugging. START_CLIENTS = True # We pass the data in this template to each client as an encoded # NAPARI_MON_CLIENT environment variable. -client_config_template: Dict[str, Union[str, int]] = { - "server_port": "", +client_config_template: dict[str, Union[str, int]] = { + 'server_port': '', } @@ -129,7 +129,7 @@ def _create_client_env(server_port: int) -> dict: # Start with our environment and just add in the one variable. env = os.environ.copy() - env.update({"NAPARI_MON_CLIENT": base64_encoded_json(client_config)}) + env.update({'NAPARI_MON_CLIENT': base64_encoded_json(client_config)}) return env @@ -159,23 +159,23 @@ def _start_clients(self) -> None: # We asked for port 0 which means the OS will pick a port, we # save it off so we can send it the clients are starting up. server_port = self._manager.address[1] - LOGGER.info("Listening on port %s", server_port) + LOGGER.info('Listening on port %s', server_port) num_clients = len(self._config['clients']) - LOGGER.info("Starting %d clients...", num_clients) + LOGGER.info('Starting %d clients...', num_clients) env = _create_client_env(server_port) # Start every client. for args in self._config['clients']: - LOGGER.info("Starting client %s", args) + LOGGER.info('Starting client %s', args) # Use Popen to run and not wait for the process to finish. subprocess.Popen(args, env=env) - LOGGER.info("Started %d clients.", num_clients) + LOGGER.info('Started %d clients.', num_clients) def stop(self) -> None: """Stop the shared memory service.""" - LOGGER.info("MonitorService.stop") + LOGGER.info('MonitorService.stop') self._manager.shutdown() diff --git a/napari/components/experimental/remote/__init__.py b/napari/components/experimental/remote/__init__.py index eb3523b23a3..cdf974bfd10 100644 --- a/napari/components/experimental/remote/__init__.py +++ b/napari/components/experimental/remote/__init__.py @@ -1,3 +1,3 @@ from napari.components.experimental.remote._manager import RemoteManager -__all__ = ["RemoteManager"] +__all__ = ['RemoteManager'] diff --git a/napari/components/experimental/remote/_commands.py b/napari/components/experimental/remote/_commands.py index d43d7dd3dd1..b408bfdc1b4 100644 --- a/napari/components/experimental/remote/_commands.py +++ b/napari/components/experimental/remote/_commands.py @@ -6,7 +6,7 @@ from napari.components.layerlist import LayerList -LOGGER = logging.getLogger("napari.monitor") +LOGGER = logging.getLogger('napari.monitor') class RemoteCommands: @@ -48,7 +48,7 @@ def process_command(self, event) -> None: The remote command. """ command = event.command - LOGGER.info("RemoveCommands._process_command: %s", json.dumps(command)) + LOGGER.info('RemoveCommands._process_command: %s', json.dumps(command)) # Every top-level key in in the command should be a method # in this RemoveCommands class. @@ -60,7 +60,7 @@ def process_command(self, event) -> None: for name, args in command.items(): try: method = getattr(self, name) - LOGGER.info("Calling RemoteCommands.%s(%s)", name, args) + LOGGER.info('Calling RemoteCommands.%s(%s)', name, args) method(args) except AttributeError: - LOGGER.exception("RemoteCommands.%s does not exist.", name) + LOGGER.exception('RemoteCommands.%s does not exist.', name) diff --git a/napari/components/experimental/remote/_manager.py b/napari/components/experimental/remote/_manager.py index e102096b76e..60f44309407 100644 --- a/napari/components/experimental/remote/_manager.py +++ b/napari/components/experimental/remote/_manager.py @@ -8,7 +8,7 @@ from napari.components.layerlist import LayerList from napari.utils.events import Event -LOGGER = logging.getLogger("napari.monitor") +LOGGER = logging.getLogger('napari.monitor') class RemoteManager: diff --git a/napari/components/experimental/remote/_messages.py b/napari/components/experimental/remote/_messages.py index c60ea1131a9..63d2cd27b7c 100644 --- a/napari/components/experimental/remote/_messages.py +++ b/napari/components/experimental/remote/_messages.py @@ -5,12 +5,12 @@ import logging import time -from typing import Dict, Optional +from typing import Optional from napari.components.experimental.monitor import monitor from napari.components.layerlist import LayerList -LOGGER = logging.getLogger("napari.monitor") +LOGGER = logging.getLogger('napari.monitor') class RemoteMessages: @@ -49,9 +49,9 @@ def on_poll(self) -> None: """ self._frame_number += 1 - layers: Dict[int, dict] = {} + layers: dict[int, dict] = {} - monitor.add_data({"poll": {"layers": layers}}) + monitor.add_data({'poll': {'layers': layers}}) self._send_frame_time() def _send_frame_time(self) -> None: diff --git a/napari/components/grid.py b/napari/components/grid.py index 104b7e00771..c8618d4f037 100644 --- a/napari/components/grid.py +++ b/napari/components/grid.py @@ -1,5 +1,3 @@ -from typing import Tuple - import numpy as np from napari.settings._application import GridHeight, GridStride, GridWidth @@ -33,10 +31,10 @@ class GridCanvas(EventedModel): # See https://github.com/pydantic/pydantic/issues/156 for why # these need a type: ignore comment stride: GridStride = 1 # type: ignore[valid-type] - shape: Tuple[GridHeight, GridWidth] = (-1, -1) # type: ignore[valid-type] + shape: tuple[GridHeight, GridWidth] = (-1, -1) # type: ignore[valid-type] enabled: bool = False - def actual_shape(self, nlayers: int = 1) -> Tuple[int, int]: + def actual_shape(self, nlayers: int = 1) -> tuple[int, int]: """Return the actual shape of the grid. This will return the shape parameter, unless one of the row @@ -78,7 +76,7 @@ def actual_shape(self, nlayers: int = 1) -> Tuple[int, int]: return (n_row, n_column) - def position(self, index: int, nlayers: int) -> Tuple[int, int]: + def position(self, index: int, nlayers: int) -> tuple[int, int]: """Return the position of a given linear index in grid. If the grid is not enabled, this will return (0, 0). diff --git a/napari/components/layerlist.py b/napari/components/layerlist.py index 63677df506f..e4ce4783370 100644 --- a/napari/components/layerlist.py +++ b/napari/components/layerlist.py @@ -3,8 +3,9 @@ import itertools import typing import warnings +from collections.abc import Iterable from functools import cached_property -from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Optional, Union import numpy as np @@ -358,7 +359,7 @@ def extent(self) -> Extent: return self.get_extent(list(self)) @property - def _ranges(self) -> Tuple[RangeTuple, ...]: + def _ranges(self) -> tuple[RangeTuple, ...]: """Get ranges for Dims.range in world coordinates.""" ext = self.extent return tuple( @@ -416,7 +417,7 @@ def save( selected: bool = False, plugin: Optional[str] = None, _writer: Optional[WriterContribution] = None, - ) -> List[str]: + ) -> list[str]: """Save all or only selected layers to a path using writer plugins. If ``plugin`` is not provided and only one layer is targeted, then we @@ -480,9 +481,9 @@ def save( ) if selected: - msg = trans._("No layers selected", deferred=True) + msg = trans._('No layers selected', deferred=True) else: - msg = trans._("No layers to save", deferred=True) + msg = trans._('No layers to save', deferred=True) if not layers: warnings.warn(msg) diff --git a/napari/components/overlays/__init__.py b/napari/components/overlays/__init__.py index a13f4fe9869..a8a8fbb4b47 100644 --- a/napari/components/overlays/__init__.py +++ b/napari/components/overlays/__init__.py @@ -15,15 +15,15 @@ from napari.components.overlays.text import TextOverlay __all__ = [ - "AxesOverlay", - "Overlay", - "CanvasOverlay", - "BoundingBoxOverlay", - "SelectionBoxOverlay", - "TransformBoxOverlay", - "LabelsPolygonOverlay", - "ScaleBarOverlay", - "SceneOverlay", - "TextOverlay", - "BrushCircleOverlay", + 'AxesOverlay', + 'Overlay', + 'CanvasOverlay', + 'BoundingBoxOverlay', + 'SelectionBoxOverlay', + 'TransformBoxOverlay', + 'LabelsPolygonOverlay', + 'ScaleBarOverlay', + 'SceneOverlay', + 'TextOverlay', + 'BrushCircleOverlay', ] diff --git a/napari/components/overlays/base.py b/napari/components/overlays/base.py index 05d18b1abb0..50da4c2e0cd 100644 --- a/napari/components/overlays/base.py +++ b/napari/components/overlays/base.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union +from typing import Union from napari.components._viewer_constants import CanvasPosition from napari.layers.base._base_constants import Blending @@ -51,7 +51,7 @@ class CanvasOverlay(Overlay): The rendering order of the overlay: lower numbers get rendered first. """ - position: Union[CanvasPosition, Tuple[int, int]] = ( + position: Union[CanvasPosition, tuple[int, int]] = ( CanvasPosition.BOTTOM_RIGHT ) blending: Blending = Blending.TRANSLUCENT_NO_DEPTH diff --git a/napari/components/overlays/brush_circle.py b/napari/components/overlays/brush_circle.py index f007a59beed..d96981e0e89 100644 --- a/napari/components/overlays/brush_circle.py +++ b/napari/components/overlays/brush_circle.py @@ -1,5 +1,3 @@ -from typing import Tuple - from napari.components.overlays.base import CanvasOverlay @@ -18,5 +16,5 @@ class BrushCircleOverlay(CanvasOverlay): """ size: int = 10 - position: Tuple[int, int] = (0, 0) + position: tuple[int, int] = (0, 0) position_is_frozen: bool = False diff --git a/napari/components/overlays/interaction_box.py b/napari/components/overlays/interaction_box.py index d061b2ed2a7..2d2086b4eef 100644 --- a/napari/components/overlays/interaction_box.py +++ b/napari/components/overlays/interaction_box.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import Optional from napari.components.overlays.base import SceneOverlay from napari.layers.utils.interaction_box import ( @@ -26,7 +26,7 @@ class SelectionBoxOverlay(SceneOverlay): The rendering order of the overlay: lower numbers get rendered first. """ - bounds: Tuple[Tuple[float, float], Tuple[float, float]] = ((0, 0), (0, 0)) + bounds: tuple[tuple[float, float], tuple[float, float]] = ((0, 0), (0, 0)) handles: bool = False selected_handle: Optional[InteractionBoxHandle] = None diff --git a/napari/components/overlays/text.py b/napari/components/overlays/text.py index 00f69a8c303..209910d3442 100644 --- a/napari/components/overlays/text.py +++ b/napari/components/overlays/text.py @@ -30,4 +30,4 @@ class TextOverlay(CanvasOverlay): default_factory=lambda: ColorValue((0.5, 0.5, 0.5, 1.0)) ) font_size: float = 10 - text: str = "" + text: str = '' diff --git a/napari/components/tooltip.py b/napari/components/tooltip.py index 376a5ce4adc..fa28aaf341e 100644 --- a/napari/components/tooltip.py +++ b/napari/components/tooltip.py @@ -14,4 +14,4 @@ class Tooltip(EventedModel): # fields visible: bool = False - text: str = "" + text: str = '' diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py index 3d2ea6aef76..b4ea7c909c0 100644 --- a/napari/components/viewer_model.py +++ b/napari/components/viewer_model.py @@ -4,19 +4,13 @@ import itertools import os import warnings +from collections.abc import Iterator, Sequence from functools import lru_cache from pathlib import Path from typing import ( TYPE_CHECKING, Any, - Dict, - Iterator, - List, Optional, - Sequence, - Set, - Tuple, - Type, Union, cast, ) @@ -112,7 +106,7 @@ 'mouse_wheel_callbacks', } EXCLUDE_JSON = EXCLUDE_DICT.union({'layers', 'active_layer'}) - +Dict = dict # rename, because ViewerModel has method dict __all__ = ['ViewerModel', 'valid_add_kwargs'] @@ -192,7 +186,7 @@ class ViewerModel(KeymapProvider, MousemapProvider, EventedModel): default_factory=LayerList, allow_mutation=False ) # Need to create custom JSON encoder for layer! help: str = '' - status: Union[str, Dict] = 'Ready' + status: Union[str, dict] = 'Ready' tooltip: Tooltip = Field(default_factory=Tooltip, allow_mutation=False) theme: str = Field(default_factory=_current_theme) title: str = 'napari' @@ -201,7 +195,7 @@ class ViewerModel(KeymapProvider, MousemapProvider, EventedModel): default_factory=EventedDict ) # 2-tuple indicating height and width - _canvas_size: Tuple[int, int] = (800, 600) + _canvas_size: tuple[int, int] = (800, 600) _ctx: Context # To check if mouse is over canvas to avoid race conditions between # different events systems @@ -254,10 +248,10 @@ def __init__( self.events.add( layers_change=WarningEmitter( trans._( - "This event will be removed in 0.5.0. Please use viewer.layers.events instead", + 'This event will be removed in 0.5.0. Please use viewer.layers.events instead', deferred=True, ), - type_name="layers_change", + type_name='layers_change', ), reset_view=Event, ) @@ -333,7 +327,7 @@ def _valid_theme(cls, v): "Theme '{theme_name}' not found; options are {themes}.", deferred=True, theme_name=v, - themes=", ".join(available_themes()), + themes=', '.join(available_themes()), ) ) @@ -397,7 +391,7 @@ def reset_view(self) -> None: -self.dims.ndisplay : ] center = cast( - Union[Tuple[float, float, float], Tuple[float, float]], + Union[tuple[float, float, float], tuple[float, float]], tuple( [0.0] * (self.dims.ndisplay - len(center_array)) + list(center_array) @@ -437,7 +431,8 @@ def _new_labels(self): np.round(s / sc).astype('int') + 1 for s, sc in zip(scene_size, scale) ] - empty_labels = np.zeros(shape, dtype=np.uint8) + dtype_str = get_settings().application.new_labels_dtype + empty_labels = np.zeros(shape, dtype=dtype_str) self.add_labels(empty_labels, translate=np.array(corner), scale=scale) def _on_layer_reload(self, event: Event) -> None: @@ -621,7 +616,7 @@ def _on_add_layer(self, event): layer.events.affine.connect(self._on_layers_change) layer.events.name.connect(self.layers._update_name) layer.events.reload.connect(self._on_layer_reload) - if hasattr(layer.events, "mode"): + if hasattr(layer.events, 'mode'): layer.events.mode.connect(self._on_layer_mode_change) self._layer_help_from_mode(layer) @@ -641,7 +636,7 @@ def _layer_help_from_mode(layer: Layer): """ Update layer help text base on layer mode. """ - layer_to_func_and_mode: Dict[Type[Layer], List] = { + layer_to_func_and_mode: dict[type[Layer], list] = { Points: points_fun_to_mode, Labels: labels_fun_to_mode, Shapes: shapes_fun_to_mode, @@ -657,19 +652,19 @@ def _layer_help_from_mode(layer: Layer): for fun, mode_ in layer_to_func_and_mode.get(layer.__class__, []): if mode_ == layer.mode: continue - action_name = f"napari:{fun.__name__}" + action_name = f'napari:{fun.__name__}' desc = action_manager._actions[action_name].description.lower() if not shortcuts.get(action_name, []): continue help_li.append( trans._( - "use <{shortcut}> for {desc}", + 'use <{shortcut}> for {desc}', shortcut=shortcuts[action_name][0], desc=desc, ) ) - layer.help = ", ".join(help_li) + layer.help = ', '.join(help_li) def _on_layer_mode_change(self, event): self._layer_help_from_mode(event.source) @@ -723,44 +718,44 @@ def add_layer(self, layer: Layer) -> Layer: return layer @rename_argument( - from_name="interpolation", - to_name="interpolation2d", - version="0.6.0", - since_version="0.4.17", + from_name='interpolation', + to_name='interpolation2d', + version='0.6.0', + since_version='0.4.17', ) def add_image( self, data=None, *, channel_axis=None, - rgb=None, + affine=None, + attenuation=0.05, + blending=None, + cache=True, colormap=None, contrast_limits=None, + custom_interpolation_kernel_2d=None, + depiction='volume', + experimental_clipping_planes=None, gamma=1.0, interpolation2d='nearest', interpolation3d='linear', - rendering='mip', - depiction='volume', iso_threshold=None, - attenuation=0.05, - name=None, metadata=None, - scale=None, - translate=None, - rotate=None, - shear=None, - affine=None, - opacity=1.0, - blending=None, - visible=True, multiscale=None, - cache=True, + name=None, + opacity=1.0, plane=None, - experimental_clipping_planes=None, - custom_interpolation_kernel_2d=None, projection_mode='none', - ) -> Union[Image, List[Image]]: - """Add an image layer to the layer list. + rendering='mip', + rgb=None, + rotate=None, + scale=None, + shear=None, + translate=None, + visible=True, + ) -> Union[Image, list[Image]]: + """Add one or more Image layers to the layer list. Parameters ---------- @@ -772,129 +767,108 @@ def add_image( supported in 2D. In 3D, only the lowest resolution scale is displayed. channel_axis : int, optional - Axis to expand image along. If provided, each channel in the data - will be added as an individual image layer. In channel_axis mode, - all other parameters MAY be provided as lists, and the Nth value - will be applied to the Nth channel in the data. If a single value + Axis to expand image along. If provided, each channel in the data + will be added as an individual image layer. In channel_axis mode, + other parameters MAY be provided as lists. The Nth value of the list + will be applied to the Nth channel in the data. If a single value is provided, it will be broadcast to all Layers. - rgb : bool or list - Whether the image is rgb RGB or RGBA. If not specified by user and - the last dimension of the data has length 3 or 4 it will be set as - `True`. If `False` the image is interpreted as a luminance image. - If a list then must be same length as the axis that is being - expanded as channels. - colormap : str, napari.utils.Colormap, tuple, dict, list - Colormaps to use for luminance images. If a string must be the name - of a supported colormap from vispy or matplotlib. If a tuple the - first value must be a string to assign as a name to a colormap and - the second item must be a Colormap. If a dict the key must be a - string to assign as a name to a colormap and the value must be a - Colormap. If a list then must be same length as the axis that is - being expanded as channels, and each colormap is applied to each - new image layer. - contrast_limits : list (2,) - Color limits to be used for determining the colormap bounds for - luminance images. If not passed is calculated as the min and max of - the image. If list of lists then must be same length as the axis - that is being expanded and then each colormap is applied to each - image. - gamma : list, float - Gamma correction for determining colormap linearity. Defaults to 1. - If a list then must be same length as the axis that is being - expanded as channels. - interpolation : str or list - Deprecated, to be removed in 0.6.0 - interpolation2d : str or list - Interpolation mode used by vispy in 2D. Must be one of our supported - modes. If a list then must be same length as the axis that is being - expanded as channels. - interpolation3d : str or list - Interpolation mode used by vispy in 3D. Must be one of our supported - modes. If a list then must be same length as the axis that is being - expanded as channels. - rendering : str or list - Rendering mode used by vispy. Must be one of our supported - modes. If a list then must be same length as the axis that is being - expanded as channels. - depiction : str - Selects a preset volume depiction mode in vispy - - * volume: images are rendered as 3D volumes. - * plane: images are rendered as 2D planes embedded in 3D. - iso_threshold : float or list - Threshold for isosurface. If a list then must be same length as the - axis that is being expanded as channels. - attenuation : float or list - Attenuation rate for attenuated maximum intensity projection. If a - list then must be same length as the axis that is being expanded as - channels. - name : str or list of str - Name of the layer. If a list then must be same length as the axis - that is being expanded as channels. - metadata : dict or list of dict - Layer metadata. If a list then must be a list of dicts with the - same length as the axis that is being expanded as channels. - scale : tuple of float or list - Scale factors for the layer. If a list then must be a list of - tuples of float with the same length as the axis that is being - expanded as channels. - translate : tuple of float or list - Translation values for the layer. If a list then must be a list of - tuples of float with the same length as the axis that is being - expanded as channels. - rotate : float, 3-tuple of float, n-D array or list. - If a float convert into a 2D rotation matrix using that value as an - angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, - pitch, roll convention. Otherwise assume an nD rotation. Angles are - assumed to be in degrees. They can be converted from radians with - np.degrees if needed. If a list then must have same length as + All parameters except data, rgb, and multiscale can be provided as + list of values. If a list is provided, it must be the same length as the axis that is being expanded as channels. - shear : 1-D array or list. - A vector of shear values for an upper triangular n-D shear matrix. - If a list then must have same length as the axis that is being - expanded as channels. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. - opacity : float or list - Opacity of the layer visual, between 0.0 and 1.0. If a list then - must be same length as the axis that is being expanded as channels. - blending : str or list + attenuation : float or list of float + Attenuation rate for attenuated maximum intensity projection. + blending : str or list of str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are - {'opaque', 'translucent', and 'additive'}. If a list then - must be same length as the axis that is being expanded as channels. - visible : bool or list of bool - Whether the layer visual is currently being displayed. - If a list then must be same length as the axis that is - being expanded as channels. + {'translucent', 'translucent_no_depth', 'additive', 'minimum', 'opaque'}. + cache : bool or list of bool + Whether slices of out-of-core datasets should be cached upon + retrieval. Currently, this only applies to dask arrays. + colormap : str, napari.utils.Colormap, tuple, dict, list or list of these types + Colormaps to use for luminance images. If a string, it can be the name + of a supported colormap from vispy or matplotlib or the name of + a vispy color or a hexadecimal RGB color representation. + If a tuple, the first value must be a string to assign as a name to a + colormap and the second item must be a Colormap. If a dict, the key must + be a string to assign as a name to a colormap and the value must be a + Colormap. + contrast_limits : list (2,) + Intensity value limits to be used for determining the minimum and maximum colormap bounds for + luminance images. If not passed, they will be calculated as the min and max intensity value of + the image. + custom_interpolation_kernel_2d : np.ndarray + Convolution kernel used with the 'custom' interpolation mode in 2D rendering. + depiction : str or list of str + 3D Depiction mode. Must be one of {'volume', 'plane'}. + The default value is 'volume'. + experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList + Each dict defines a clipping plane in 3D in data coordinates. + Valid dictionary keys are {'position', 'normal', and 'enabled'}. + Values on the negative side of the normal are discarded if the plane is enabled. + gamma : float or list of float + Gamma correction for determining colormap linearity; defaults to 1. + interpolation2d : str or list of str + Interpolation mode used by vispy for rendering 2d data. + Must be one of our supported modes. + (for list of supported modes see Interpolation enum) + 'custom' is a special mode for 2D interpolation in which a regular grid + of samples is taken from the texture around a position using 'linear' + interpolation before being multiplied with a custom interpolation kernel + (provided with 'custom_interpolation_kernel_2d'). + interpolation3d : str or list of str + Same as 'interpolation2d' but for 3D rendering. + iso_threshold : float or list of float + Threshold for isosurface. + metadata : dict or list of dict + Layer metadata. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is - represented by a list of array like image data. If not specified by - the user and if the data is a list of arrays that decrease in shape + represented by a list of array-like image data. If not specified by + the user and if the data is a list of arrays that decrease in shape, then it will be taken to be multiscale. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. - cache : bool - Whether slices of out-of-core datasets should be cached upon - retrieval. Currently, this only applies to dask arrays. + name : str or list of str + Name of the layer. + opacity : float or list + Opacity of the layer visual, between 0.0 and 1.0. plane : dict or SlicingPlane Properties defining plane rendering in 3D. Properties are defined in data coordinates. Valid dictionary keys are {'position', 'normal', 'thickness', and 'enabled'}. projection_mode : str - How data outside the viewed dimensions but inside the thick Dims slice will + How data outside the viewed dimensions, but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to cls._projectionclass - experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList - Each dict defines a clipping plane in 3D in data coordinates. - Valid dictionary keys are {'position', 'normal', and 'enabled'}. - Values on the negative side of the normal are discarded if the plane is enabled. - custom_interpolation_kernel_2d : np.ndarray - Convolution kernel used with the 'custom' interpolation mode in 2D rendering. + rendering : str or list of str + Rendering mode used by vispy. Must be one of our supported + modes. If a list then must be same length as the axis that is being + expanded as channels. + rgb : bool, optional + Whether the image is RGB or RGBA if rgb. If not + specified by user, but the last dimension of the data has length 3 or 4, + it will be set as `True`. If `False`, the image is interpreted as a + luminance image. + rotate : float, 3-tuple of float, n-D array or list. + If a float, convert into a 2D rotation matrix using that value as an + angle. If 3-tuple, convert into a 3D rotation matrix, using a yaw, + pitch, roll convention. Otherwise, assume an nD rotation. Angles are + assumed to be in degrees. They can be converted from radians with + 'np.degrees' if needed. + scale : tuple of float or list of tuple of float + Scale factors for the layer. + shear : 1-D array or list. + A vector of shear values for an upper triangular n-D shear matrix. + translate : tuple of float or list of tuple of float + Translation values for the layer. + visible : bool or list of bool + Whether the layer visual is currently being displayed. Returns ------- @@ -989,7 +963,7 @@ def open_sample( sample: str, reader_plugin: Optional[str] = None, **kwargs, - ) -> List[Layer]: + ) -> list[Layer]: """Open `sample` from `plugin` and add it to the viewer. To see all available samples registered by plugins, use @@ -1033,7 +1007,7 @@ def open_sample( except KeyError: available += list(plugin_manager.available_samples()) # npe2 uri sample data, extract the path so we can use viewer.open - elif hasattr(data, "__self__") and hasattr(data.__self__, 'uri'): + elif hasattr(data, '__self__') and hasattr(data.__self__, 'uri'): if ( hasattr(data.__self__, 'reader_plugin') and data.__self__.reader_plugin != reader_plugin @@ -1046,14 +1020,14 @@ def open_sample( if data is None: msg = trans._( - "Plugin {plugin!r} does not provide sample data named {sample!r}. ", + 'Plugin {plugin!r} does not provide sample data named {sample!r}. ', plugin=plugin, sample=sample, deferred=True, ) if available: msg = trans._( - "Plugin {plugin!r} does not provide sample data named {sample!r}. Available samples include: {samples}.", + 'Plugin {plugin!r} does not provide sample data named {sample!r}. Available samples include: {samples}.', deferred=True, plugin=plugin, sample=sample, @@ -1061,7 +1035,7 @@ def open_sample( ) else: msg = trans._( - "Plugin {plugin!r} does not provide sample data named {sample!r}. No plugin samples have been registered.", + 'Plugin {plugin!r} does not provide sample data named {sample!r}. No plugin samples have been registered.', deferred=True, plugin=plugin, sample=sample, @@ -1087,7 +1061,7 @@ def open_sample( ): raise ValueError( trans._( - "Chosen reader {chosen_reader} failed to open sample. Plugin {plugin} declares {original_reader} as the reader for this sample - try calling `open_sample` with no `reader_plugin` or passing {original_reader} explicitly.", + 'Chosen reader {chosen_reader} failed to open sample. Plugin {plugin} declares {original_reader} as the reader for this sample - try calling `open_sample` with no `reader_plugin` or passing {original_reader} explicitly.', deferred=True, plugin=plugin, chosen_reader=reader_plugin, @@ -1110,11 +1084,11 @@ def open( self, path: PathOrPaths, *, - stack: Union[bool, List[List[PathLike]]] = False, + stack: Union[bool, list[list[PathLike]]] = False, plugin: Optional[str] = 'napari', layer_type: Optional[LayerTypeName] = None, **kwargs, - ) -> List[Layer]: + ) -> list[Layer]: """Open a path or list of paths with plugins, and add layers to viewer. A list of paths will be handed one-by-one to the napari_get_reader hook @@ -1163,7 +1137,7 @@ def open( ) plugin = 'napari' - paths_: List[PathLike] = ( + paths_: list[PathLike] = ( [os.fspath(path)] if isinstance(path, (Path, str)) else [os.fspath(p) for p in path] @@ -1178,7 +1152,7 @@ def open( paths = [paths_] paths.extend(stack) - added: List[Layer] = [] # for layers that get added + added: list[Layer] = [] # for layers that get added with progress( paths, desc=trans._('Opening Files'), @@ -1212,7 +1186,7 @@ def open( def _open_or_raise_error( self, - paths: List[Union[Path, str]], + paths: list[Union[Path, str]], kwargs: Optional[Dict[str, Any]] = None, layer_type: Optional[LayerTypeName] = None, stack: bool = False, @@ -1268,7 +1242,7 @@ def _open_or_raise_error( paths = [os.fspath(path) for path in paths] # PathObjects -> str _path = paths[0] # we want to display the paths nicely so make a help string here - path_message = f"[{_path}], ...]" if len(paths) > 1 else _path + path_message = f'[{_path}], ...]' if len(paths) > 1 else _path readers = get_potential_readers(_path) if not readers: raise NoAvailableReaderError( @@ -1293,7 +1267,7 @@ def _open_or_raise_error( "This may be because you've switched environments, or have uninstalled the plugin without updating the reader preference. " ) + trans._( - "You can remove this preference in the preference dialog, or by editing `settings.plugins.extension2reader`." + 'You can remove this preference in the preference dialog, or by editing `settings.plugins.extension2reader`.' ) ) ) @@ -1325,7 +1299,7 @@ def _open_or_raise_error( else: raise MultipleReaderError( trans._( - "Multiple plugins found capable of reading {path_message}. Select plugin from {plugins} and pass to reading function e.g. `viewer.open(..., plugin=...)`.", + 'Multiple plugins found capable of reading {path_message}. Select plugin from {plugins} and pass to reading function e.g. `viewer.open(..., plugin=...)`.', path_message=path_message, plugins=readers, deferred=True, @@ -1338,13 +1312,13 @@ def _open_or_raise_error( def _add_layers_with_plugins( self, - paths: List[PathLike], + paths: list[PathLike], *, stack: bool, kwargs: Optional[Dict] = None, plugin: Optional[str] = None, layer_type: Optional[LayerTypeName] = None, - ) -> List[Layer]: + ) -> list[Layer]: """Load a path or a list of paths into the viewer using plugins. This function is mostly called from self.open_path, where the ``stack`` @@ -1412,7 +1386,7 @@ def _add_layers_with_plugins( filenames = itertools.repeat(paths[0]) # add each layer to the viewer - added: List[Layer] = [] # for layers that get added + added: list[Layer] = [] # for layers that get added plugin = hookimpl.plugin_name if hookimpl else None for data, filename in zip(layer_data, filenames): basename, _ext = os.path.splitext(os.path.basename(filename)) @@ -1429,7 +1403,7 @@ def _add_layer_from_data( data, meta: Optional[Dict[str, Any]] = None, layer_type: Optional[str] = None, - ) -> List[Layer]: + ) -> list[Layer]: """Add arbitrary layer data to the viewer. Primarily intended for usage by reader plugin hooks. @@ -1501,7 +1475,7 @@ def _add_layer_from_data( bad_key = str(exc).split('keyword argument ')[-1] raise TypeError( trans._( - "_add_layer_from_data received an unexpected keyword argument ({bad_key}) for layer type {layer_type}", + '_add_layer_from_data received an unexpected keyword argument ({bad_key}) for layer type {layer_type}', deferred=True, bad_key=bad_key, layer_type=layer_type, @@ -1532,7 +1506,7 @@ def _normalize_layer_data(data: LayerData) -> FullLayerData: if not isinstance(data, tuple) and 0 < len(data) < 4: raise ValueError( trans._( - "LayerData must be a 1-, 2-, or 3-tuple", + 'LayerData must be a 1-, 2-, or 3-tuple', deferred=True, ) ) @@ -1542,7 +1516,7 @@ def _normalize_layer_data(data: LayerData) -> FullLayerData: if not isinstance(_data[1], dict): raise ValueError( trans._( - "The second item in a LayerData tuple must be a dict", + 'The second item in a LayerData tuple must be a dict', deferred=True, ) ) @@ -1552,7 +1526,7 @@ def _normalize_layer_data(data: LayerData) -> FullLayerData: if _data[2] not in layers.NAMES: raise ValueError( trans._( - "The third item in a LayerData tuple must be one of: {layers!r}.", + 'The third item in a LayerData tuple must be one of: {layers!r}.', deferred=True, layers=layers.NAMES, ) @@ -1624,7 +1598,7 @@ def _unify_data_and_user_kwargs( return (_data, _meta, _type) -def prune_kwargs(kwargs: Dict[str, Any], layer_type: str) -> Dict[str, Any]: +def prune_kwargs(kwargs: dict[str, Any], layer_type: str) -> dict[str, Any]: """Return copy of ``kwargs`` with only keys valid for ``add_`` Parameters @@ -1665,7 +1639,7 @@ def prune_kwargs(kwargs: Dict[str, Any], layer_type: str) -> Dict[str, Any]: if not add_method or layer_type == 'layer': raise ValueError( trans._( - "Invalid layer_type: {layer_type}", + 'Invalid layer_type: {layer_type}', deferred=True, layer_type=layer_type, ) @@ -1677,7 +1651,7 @@ def prune_kwargs(kwargs: Dict[str, Any], layer_type: str) -> Dict[str, Any]: @lru_cache(maxsize=1) -def valid_add_kwargs() -> Dict[str, Set[str]]: +def valid_add_kwargs() -> dict[str, set[str]]: """Return a dict where keys are layer types & values are valid kwargs.""" valid = {} for meth in dir(ViewerModel): diff --git a/napari/conftest.py b/napari/conftest.py index bdc6132bd99..a8ec1efd938 100644 --- a/napari/conftest.py +++ b/napari/conftest.py @@ -65,6 +65,12 @@ def get_reader(path): if TYPE_CHECKING: from npe2._pytest_plugin import TestPluginManager +# touch ~/.Xauthority for Xlib support, must happen before importing pyautogui +if os.getenv('CI') and sys.platform.startswith('linux'): + xauth = Path('~/.Xauthority').expanduser() + if not xauth.exists(): + xauth.touch() + @pytest.fixture def layer_data_and_types(): @@ -173,14 +179,14 @@ def skip_examples(request): """Skip examples test if .""" if request.node.get_closest_marker( 'examples' - ) and request.config.getoption("--skip_examples"): - pytest.skip("running with --skip_examples") + ) and request.config.getoption('--skip_examples'): + pytest.skip('running with --skip_examples') # _PYTEST_RAISE=1 will prevent pytest from handling exceptions. # Use with a debugger that's set to break on "unhandled exceptions". # https://github.com/pytest-dev/pytest/issues/7409 -if os.getenv('_PYTEST_RAISE', "0") != "0": +if os.getenv('_PYTEST_RAISE', '0') != '0': @pytest.hookimpl(tryfirst=True) def pytest_exception_interact(call): @@ -303,11 +309,11 @@ def check(instance, no_event=no_event_): if name in no_event: assert not hasattr( instance.events, name - ), f"event {name} defined" + ), f'event {name} defined' else: assert hasattr( instance.events, name - ), f"event {name} not defined" + ), f'event {name} not defined' return check @@ -330,36 +336,36 @@ def pytest_generate_tests(metafunc): for obj in metafunc.cls.get_objects(): for check, instance, name in _event_check(obj): res.append((check, instance)) - ids.append(f"{name}-{instance}") + ids.append(f'{name}-{instance}') metafunc.parametrize('event_define_check,obj', res, ids=ids) def pytest_collection_modifyitems(session, config, items): - test_subset = os.environ.get("NAPARI_TEST_SUBSET") + test_subset = os.environ.get('NAPARI_TEST_SUBSET') test_order_prefix = [ - os.path.join("napari", "utils"), - os.path.join("napari", "layers"), - os.path.join("napari", "components"), - os.path.join("napari", "settings"), - os.path.join("napari", "plugins"), - os.path.join("napari", "_vispy"), - os.path.join("napari", "_qt"), - os.path.join("napari", "qt"), - os.path.join("napari", "_tests"), - os.path.join("napari", "_tests", "test_examples.py"), + os.path.join('napari', 'utils'), + os.path.join('napari', 'layers'), + os.path.join('napari', 'components'), + os.path.join('napari', 'settings'), + os.path.join('napari', 'plugins'), + os.path.join('napari', '_vispy'), + os.path.join('napari', '_qt'), + os.path.join('napari', 'qt'), + os.path.join('napari', '_tests'), + os.path.join('napari', '_tests', 'test_examples.py'), ] test_order = [[] for _ in test_order_prefix] test_order.append([]) # for not matching tests for item in items: if test_subset: - if test_subset.lower() == "qt" and "qapp" not in item.fixturenames: + if test_subset.lower() == 'qt' and 'qapp' not in item.fixturenames: # Skip non Qt tests continue if ( - test_subset.lower() == "headless" - and "qapp" in item.fixturenames + test_subset.lower() == 'headless' + and 'qapp' in item.fixturenames ): # Skip Qt tests continue @@ -391,9 +397,9 @@ def disable_notification_dismiss_timer(monkeypatch): with suppress(ImportError): from napari._qt.dialogs.qt_notification import NapariQtNotification - monkeypatch.setattr(NapariQtNotification, "DISMISS_AFTER", 0) - monkeypatch.setattr(NapariQtNotification, "FADE_IN_RATE", 0) - monkeypatch.setattr(NapariQtNotification, "FADE_OUT_RATE", 0) + monkeypatch.setattr(NapariQtNotification, 'DISMISS_AFTER', 0) + monkeypatch.setattr(NapariQtNotification, 'FADE_IN_RATE', 0) + monkeypatch.setattr(NapariQtNotification, 'FADE_OUT_RATE', 0) @pytest.fixture() @@ -441,15 +447,15 @@ def _get_calling_stack(): # pragma: no cover frame = sys._getframe(i) except ValueError: break - stack.append(f"{frame.f_code.co_filename}:{frame.f_lineno}") - return "\n".join(stack) + stack.append(f'{frame.f_code.co_filename}:{frame.f_lineno}') + return '\n'.join(stack) def _get_calling_place(depth=1): # pragma: no cover - if not hasattr(sys, "_getframe"): - return "" + if not hasattr(sys, '_getframe'): + return '' frame = sys._getframe(1 + depth) - result = f"{frame.f_code.co_filename}:{frame.f_lineno}" + result = f'{frame.f_code.co_filename}:{frame.f_lineno}' if not frame.f_code.co_filename.startswith(ROOT_DIR): with suppress(ValueError): while not frame.f_code.co_filename.startswith(ROOT_DIR): @@ -457,7 +463,7 @@ def _get_calling_place(depth=1): # pragma: no cover if frame is None: break else: - result += f" called from\n{frame.f_code.co_filename}:{frame.f_lineno}" + result += f' called from\n{frame.f_code.co_filename}:{frame.f_lineno}' return result @@ -469,7 +475,7 @@ def dangling_qthreads(monkeypatch, qtbot, request): thread_dict = WeakKeyDictionary() # dict of threads that have been started but not yet terminated - if "disable_qthread_start" in request.keywords: + if 'disable_qthread_start' in request.keywords: def my_start(self, priority=QThread.InheritPriority): """dummy function to prevent thread start""" @@ -491,8 +497,8 @@ def my_start(self, priority=QThread.InheritPriority): dangling_threads_li.append((thread, calling)) except RuntimeError as e: if ( - "wrapped C/C++ object of type" not in e.args[0] - and "Internal C++ object" not in e.args[0] + 'wrapped C/C++ object of type' not in e.args[0] + and 'Internal C++ object' not in e.args[0] ): raise @@ -502,21 +508,21 @@ def my_start(self, priority=QThread.InheritPriority): qtbot.waitUntil(thread.isFinished, timeout=2000) long_desc = ( - "If you see this error, it means that a QThread was started in a test " - "but not terminated. This can cause segfaults in the test suite. " - "Please use the `qtbot` fixture to wait for the thread to finish. " - "If you think that the thread is obsolete for this test, you can " - "use the `@pytest.mark.disable_qthread_start` mark or `monkeypatch` " - "fixture to patch the `start` method of the " - "QThread class to do nothing.\n" + 'If you see this error, it means that a QThread was started in a test ' + 'but not terminated. This can cause segfaults in the test suite. ' + 'Please use the `qtbot` fixture to wait for the thread to finish. ' + 'If you think that the thread is obsolete for this test, you can ' + 'use the `@pytest.mark.disable_qthread_start` mark or `monkeypatch` ' + 'fixture to patch the `start` method of the ' + 'QThread class to do nothing.\n' ) if len(dangling_threads_li) > 1: - long_desc += " The QThreads were started in:\n" + long_desc += ' The QThreads were started in:\n' else: - long_desc += " The QThread was started in:\n" + long_desc += ' The QThread was started in:\n' - assert not dangling_threads_li, long_desc + "\n".join( + assert not dangling_threads_li, long_desc + '\n'.join( x[1] for x in dangling_threads_li ) @@ -529,7 +535,7 @@ def dangling_qthread_pool(monkeypatch, request): threadpool_dict = WeakKeyDictionary() # dict of threadpools that have been used to run QRunnables - if "disable_qthread_pool_start" in request.keywords: + if 'disable_qthread_pool_start' in request.keywords: def my_start(self, runnable, priority=0): """dummy function to prevent thread start""" @@ -559,21 +565,21 @@ def my_start(self, runnable, priority=0): thread_pool.waitForDone(2000) long_desc = ( - "If you see this error, it means that a QThreadPool was used to run " - "a QRunnable in a test but not terminated. This can cause segfaults " - "in the test suite. Please use the `qtbot` fixture to wait for the " - "thread to finish. If you think that the thread is obsolete for this " - "use the `@pytest.mark.disable_qthread_pool_start` mark or `monkeypatch` " - "fixture to patch the `start` " - "method of the QThreadPool class to do nothing.\n" + 'If you see this error, it means that a QThreadPool was used to run ' + 'a QRunnable in a test but not terminated. This can cause segfaults ' + 'in the test suite. Please use the `qtbot` fixture to wait for the ' + 'thread to finish. If you think that the thread is obsolete for this ' + 'use the `@pytest.mark.disable_qthread_pool_start` mark or `monkeypatch` ' + 'fixture to patch the `start` ' + 'method of the QThreadPool class to do nothing.\n' ) if len(dangling_threads_pools) > 1: - long_desc += " The QThreadPools were used in:\n" + long_desc += ' The QThreadPools were used in:\n' else: - long_desc += " The QThreadPool was used in:\n" + long_desc += ' The QThreadPool was used in:\n' - assert not dangling_threads_pools, long_desc + "\n".join( - "; ".join(x[1]) for x in dangling_threads_pools + assert not dangling_threads_pools, long_desc + '\n'.join( + '; '.join(x[1]) for x in dangling_threads_pools ) @@ -585,7 +591,7 @@ def dangling_qtimers(monkeypatch, request): timer_dkt = WeakKeyDictionary() single_shot_list = [] - if "disable_qtimer_start" in request.keywords: + if 'disable_qtimer_start' in request.keywords: from pytestqt.qt_compat import qt_api def my_start(self, msec=None): @@ -600,15 +606,15 @@ def start(self, time=None): else: base_start(self) - monkeypatch.setattr(qt_api.QtCore, "QTimer", OldTimer) + monkeypatch.setattr(qt_api.QtCore, 'QTimer', OldTimer) # This monkeypatch is require to keep `qtbot.waitUntil` working else: def my_start(self, msec=None): calling_place = _get_calling_place() - if "superqt" in calling_place and "throttler" in calling_place: - calling_place += f" - {_get_calling_place(2)}" + if 'superqt' in calling_place and 'throttler' in calling_place: + calling_place += f' - {_get_calling_place(2)}' timer_dkt[self] = calling_place if msec is not None: base_start(self, msec) @@ -623,7 +629,7 @@ def single_shot(msec, reciver, method=None): else: t.timeout.connect(getattr(reciver, method)) calling_place = _get_calling_place(2) - if "superqt" in calling_place and "throttler" in calling_place: + if 'superqt' in calling_place and 'throttler' in calling_place: calling_place += _get_calling_stack() single_shot_list.append((t, _get_calling_place(2))) base_start(t, msec) @@ -650,18 +656,18 @@ def _single_shot(self, *args): timer.stop() long_desc = ( - "If you see this error, it means that a QTimer was started but not stopped. " - "This can cause tests to fail, and can also cause segfaults. " - "If this test does not require a QTimer to pass you could monkeypatch it out. " - "If it does require a QTimer, you should stop or wait for it to finish before test ends. " + 'If you see this error, it means that a QTimer was started but not stopped. ' + 'This can cause tests to fail, and can also cause segfaults. ' + 'If this test does not require a QTimer to pass you could monkeypatch it out. ' + 'If it does require a QTimer, you should stop or wait for it to finish before test ends. ' ) if len(dangling_timers) > 1: - long_desc += "The QTimers were started in:\n" + long_desc += 'The QTimers were started in:\n' else: - long_desc += "The QTimer was started in:\n" + long_desc += 'The QTimer was started in:\n' def _check_throttle_info(path): - if "superqt" in path and "throttler" in path: + if 'superqt' in path and 'throttler' in path: return ( path + " it's possible that there was a problem with unfinished work by a " @@ -670,7 +676,7 @@ def _check_throttle_info(path): ) return path - assert not dangling_timers, long_desc + "\n".join( + assert not dangling_timers, long_desc + '\n'.join( _check_throttle_info(x[1]) for x in dangling_timers ) @@ -692,11 +698,11 @@ def disable_throttling(monkeypatch): """ # if this monkeypath fails then you should update path to GenericSignalThrottler monkeypatch.setattr( - "superqt.utils._throttler.GenericSignalThrottler.throttle", + 'superqt.utils._throttler.GenericSignalThrottler.throttle', _throttle_mock, ) monkeypatch.setattr( - "superqt.utils._throttler.GenericSignalThrottler.flush", _flush_mock + 'superqt.utils._throttler.GenericSignalThrottler.flush', _flush_mock ) @@ -707,7 +713,7 @@ def dangling_qanimations(monkeypatch, request): base_start = QPropertyAnimation.start animation_dkt = WeakKeyDictionary() - if "disable_qanimation_start" in request.keywords: + if 'disable_qanimation_start' in request.keywords: def my_start(self): """dummy function to prevent thread start""" @@ -732,33 +738,33 @@ def my_start(self): animation.stop() long_desc = ( - "If you see this error, it means that a QPropertyAnimation was started but not stopped. " - "This can cause tests to fail, and can also cause segfaults. " - "If this test does not require a QPropertyAnimation to pass you could monkeypatch it out. " - "If it does require a QPropertyAnimation, you should stop or wait for it to finish before test ends. " + 'If you see this error, it means that a QPropertyAnimation was started but not stopped. ' + 'This can cause tests to fail, and can also cause segfaults. ' + 'If this test does not require a QPropertyAnimation to pass you could monkeypatch it out. ' + 'If it does require a QPropertyAnimation, you should stop or wait for it to finish before test ends. ' ) if len(dangling_animations) > 1: - long_desc += " The QPropertyAnimations were started in:\n" + long_desc += ' The QPropertyAnimations were started in:\n' else: - long_desc += " The QPropertyAnimation was started in:\n" - assert not dangling_animations, long_desc + "\n".join( + long_desc += ' The QPropertyAnimation was started in:\n' + assert not dangling_animations, long_desc + '\n'.join( x[1] for x in dangling_animations ) def pytest_runtest_setup(item): - if "qapp" in item.fixturenames: + if 'qapp' in item.fixturenames: # here we do autouse for dangling fixtures only if qapp is used - if "qtbot" not in item.fixturenames: + if 'qtbot' not in item.fixturenames: # for proper waiting for threads to finish - item.fixturenames.append("qtbot") + item.fixturenames.append('qtbot') item.fixturenames.extend( [ - "dangling_qthread_pool", - "dangling_qanimations", - "dangling_qthreads", - "dangling_qtimers", + 'dangling_qthread_pool', + 'dangling_qanimations', + 'dangling_qthreads', + 'dangling_qtimers', ] ) @@ -774,20 +780,20 @@ class NapariTerminalReporter(CustomTerminalReporter): currentfspath: Optional[Path] def write_fspath_result(self, nodeid: str, res, **markup: bool) -> None: - if getattr(self, "_start_time", None) is None: + if getattr(self, '_start_time', None) is None: self._start_time = perf_counter() - fspath = self.config.rootpath / nodeid.split("::")[0] + fspath = self.config.rootpath / nodeid.split('::')[0] if self.currentfspath is None or fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: self._write_progress_information_filling_space() - if os.environ.get("CI", False): + if os.environ.get('CI', False): self.write( - f" [{timedelta(seconds=int(perf_counter() - self._start_time))}]" + f' [{timedelta(seconds=int(perf_counter() - self._start_time))}]' ) self.currentfspath = fspath relfspath = bestrelpath(self.startpath, fspath) self._tw.line() - self.write(relfspath + " ") + self.write(relfspath + ' ') self.write(res, flush=True, **markup) diff --git a/napari/errors/__init__.py b/napari/errors/__init__.py index b4050c511fe..28fb76b2ccc 100644 --- a/napari/errors/__init__.py +++ b/napari/errors/__init__.py @@ -5,7 +5,7 @@ ) __all__ = [ - "MultipleReaderError", - "NoAvailableReaderError", - "ReaderPluginError", + 'MultipleReaderError', + 'NoAvailableReaderError', + 'ReaderPluginError', ] diff --git a/napari/errors/reader_errors.py b/napari/errors/reader_errors.py index fdabb5be866..ba784be5193 100644 --- a/napari/errors/reader_errors.py +++ b/napari/errors/reader_errors.py @@ -1,5 +1,3 @@ -from typing import List - from napari.types import PathLike @@ -33,8 +31,8 @@ class MultipleReaderError(RuntimeError): def __init__( self, message: str, - available_readers: List[str], - paths: List[PathLike], + available_readers: list[str], + paths: list[PathLike], *args: object, ) -> None: super().__init__(message, *args) @@ -72,7 +70,7 @@ def __init__( self, message: str, reader_plugin: str, - paths: List[PathLike], + paths: list[PathLike], *args: object, ) -> None: super().__init__(message, *args) @@ -99,7 +97,7 @@ class NoAvailableReaderError(ValueError): """ def __init__( - self, message: str, paths: List[PathLike], *args: object + self, message: str, paths: list[PathLike], *args: object ) -> None: super().__init__(message, *args) self.paths = paths diff --git a/napari/layers/__init__.py b/napari/layers/__init__.py index 11ab3da7823..7885ca04b7d 100644 --- a/napari/layers/__init__.py +++ b/napari/layers/__init__.py @@ -6,7 +6,6 @@ """ import inspect as _inspect -from typing import Set from napari.layers.base import Layer from napari.layers.graph import Graph @@ -20,7 +19,7 @@ from napari.utils.misc import all_subclasses as _all_subcls # isabstact check is to exclude _ImageBase class -NAMES: Set[str] = { +NAMES: set[str] = { subclass.__name__.lower() for subclass in _all_subcls(Layer) if not _inspect.isabstract(subclass) diff --git a/napari/layers/_data_protocols.py b/napari/layers/_data_protocols.py index 77f200bab7b..9069240b021 100644 --- a/napari/layers/_data_protocols.py +++ b/napari/layers/_data_protocols.py @@ -7,7 +7,6 @@ TYPE_CHECKING, Any, Protocol, - Tuple, Union, runtime_checkable, ) @@ -24,7 +23,7 @@ # https://github.com/python/typing/issues/684#issuecomment-548203158 class ellipsis(Enum): - Ellipsis = "..." + Ellipsis = '...' Ellipsis = ellipsis.Ellipsis # noqa: A001 else: @@ -37,7 +36,7 @@ def _raise_protocol_error(obj: Any, protocol: type): needed = set(dir(protocol)).union(annotations) - _OBJ_NAMES missing = needed - set(dir(obj)) message = trans._( - "Object of type {type_name} does not implement {protocol_name} Protocol.\nMissing methods: {missing_methods}", + 'Object of type {type_name} does not implement {protocol_name} Protocol.\nMissing methods: {missing_methods}', deferred=True, type_name=repr(type(obj).__name__), protocol_name=repr(protocol.__name__), @@ -74,11 +73,11 @@ def dtype(self) -> DTypeLike: """Data type of the array elements.""" @property - def shape(self) -> Tuple[int, ...]: + def shape(self) -> tuple[int, ...]: """Array dimensions.""" def __getitem__( - self, key: Union[Index, Tuple[Index, ...], LayerDataProtocol] + self, key: Union[Index, tuple[Index, ...], LayerDataProtocol] ) -> LayerDataProtocol: """Returns self[key].""" diff --git a/napari/layers/_layer_actions.py b/napari/layers/_layer_actions.py index c63ee6b9112..93fb3fc89f7 100644 --- a/napari/layers/_layer_actions.py +++ b/napari/layers/_layer_actions.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, cast +from typing import TYPE_CHECKING, cast import numpy as np @@ -24,7 +24,7 @@ def _duplicate_layer(ll: LayerList, *, name: str = ''): for lay in list(ll.selection): data, state, type_str = lay.as_layer_data_tuple() - state["name"] = trans._('{name} copy', name=lay.name) + state['name'] = trans._('{name} copy', name=lay.name) with layer_source(parent=lay): new = Layer.create(deepcopy(data), state, type_str) ll.insert(ll.index(lay) + 1, new) @@ -81,7 +81,7 @@ def _convert_to_image(ll: LayerList): def _merge_stack(ll: LayerList, rgb=False): # force selection to follow LayerList ordering - imgs = cast(List[Image], [layer for layer in ll if layer in ll.selection]) + imgs = cast(list[Image], [layer for layer in ll if layer in ll.selection]) assert all(isinstance(layer, Image) for layer in imgs) merged = ( stack_utils.merge_rgb(imgs) @@ -147,7 +147,7 @@ def _convert_dtype(ll: LayerList, mode='int64'): if not isinstance(layer, Labels): raise NotImplementedError( trans._( - "Data type conversion only implemented for labels", + 'Data type conversion only implemented for labels', deferred=True, ) ) @@ -159,7 +159,7 @@ def _convert_dtype(ll: LayerList, mode='int64'): ): raise AssertionError( trans._( - "Labeling contains values outside of the target data type range.", + 'Labeling contains values outside of the target data type range.', deferred=True, ) ) @@ -174,7 +174,7 @@ def _project(ll: LayerList, axis: int = 0, mode='max'): if not isinstance(layer, Image): raise NotImplementedError( trans._( - "Projections are only implemented for images", deferred=True + 'Projections are only implemented for images', deferred=True ) ) diff --git a/napari/layers/_multiscale_data.py b/napari/layers/_multiscale_data.py index 489d98eba92..22e8007471c 100644 --- a/napari/layers/_multiscale_data.py +++ b/napari/layers/_multiscale_data.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import List, Sequence, Tuple, Union +from collections.abc import Sequence +from typing import Union import numpy as np @@ -35,10 +36,10 @@ def __init__( self, data: Sequence[LayerDataProtocol], ) -> None: - self._data: List[LayerDataProtocol] = list(data) + self._data: list[LayerDataProtocol] = list(data) if not self._data: raise ValueError( - trans._("Multiscale data must be a (non-empty) sequence") + trans._('Multiscale data must be a (non-empty) sequence') ) for d in self._data: assert_protocol(d) @@ -59,17 +60,17 @@ def dtype(self) -> np.dtype: return self._data[0].dtype @property - def shape(self) -> Tuple[int, ...]: + def shape(self) -> tuple[int, ...]: """Shape of multiscale is just the biggest shape.""" return self._data[0].shape @property - def shapes(self) -> Tuple[Tuple[int, ...], ...]: + def shapes(self) -> tuple[tuple[int, ...], ...]: """Tuple shapes for all scales.""" return tuple(im.shape for im in self._data) def __getitem__( # type: ignore [override] - self, key: Union[int, Tuple[slice, ...]] + self, key: Union[int, tuple[slice, ...]] ) -> LayerDataProtocol: """Multiscale indexing.""" return self._data[key] diff --git a/napari/_vendor/experimental/humanize/__init__.py b/napari/layers/_scalar_field/__init__.py similarity index 100% rename from napari/_vendor/experimental/humanize/__init__.py rename to napari/layers/_scalar_field/__init__.py diff --git a/napari/layers/_scalar_field/scalar_field.py b/napari/layers/_scalar_field/scalar_field.py new file mode 100644 index 00000000000..154a89a1536 --- /dev/null +++ b/napari/layers/_scalar_field/scalar_field.py @@ -0,0 +1,602 @@ +from __future__ import annotations + +import types +from abc import ABC +from collections.abc import Sequence +from contextlib import nullcontext +from typing import TYPE_CHECKING, Union + +import numpy as np +from numpy import typing as npt + +from napari.layers import Layer +from napari.layers._data_protocols import LayerDataProtocol +from napari.layers._multiscale_data import MultiScaleData +from napari.layers.image._image_constants import Interpolation, VolumeDepiction +from napari.layers.image._image_mouse_bindings import ( + move_plane_along_normal as plane_drag_callback, + set_plane_position as plane_double_click_callback, +) +from napari.layers.image._image_utils import guess_multiscale +from napari.layers.image._slice import _ImageSliceRequest, _ImageSliceResponse +from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice +from napari.layers.utils.plane import SlicingPlane +from napari.utils._dask_utils import DaskIndexer +from napari.utils.colormaps import AVAILABLE_COLORMAPS +from napari.utils.events import Event +from napari.utils.events.event import WarningEmitter +from napari.utils.events.event_utils import connect_no_arg +from napari.utils.naming import magic_name +from napari.utils.translations import trans + +if TYPE_CHECKING: + from napari.components import Dims + + +__all__ = ('ScalarFieldBase',) + + +# It is important to contain at least one abstractmethod to properly exclude this class +# in creating NAMES set inside of napari.layers.__init__ +# Mixin must come before Layer +class ScalarFieldBase(Layer, ABC): + """Base class for volumetric layers. + + Parameters + ---------- + data : array or list of array + Image data. Can be N >= 2 dimensional. If the last dimension has length + 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a + list and arrays are decreasing in shape then the data is treated as + a multiscale image. Please note multiscale rendering is only + supported in 2D. In 3D, only the lowest resolution scale is + displayed. + affine : n-D array or napari.utils.transforms.Affine + (N+1, N+1) affine transformation matrix in homogeneous coordinates. + The first (N, N) entries correspond to a linear transform and + the final column is a length N translation vector and a 1 or a napari + `Affine` transform object. Applied as an extra transform on top of the + provided scale, rotate, and shear values. + blending : str + One of a list of preset blending modes that determines how RGB and + alpha values of the layer visual get mixed. Allowed values are + {'opaque', 'translucent', and 'additive'}. + cache : bool + Whether slices of out-of-core datasets should be cached upon retrieval. + Currently, this only applies to dask arrays. + custom_interpolation_kernel_2d : np.ndarray + Convolution kernel used with the 'custom' interpolation mode in 2D rendering. + depiction : str + 3D Depiction mode. Must be one of {'volume', 'plane'}. + The default value is 'volume'. + experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList + Each dict defines a clipping plane in 3D in data coordinates. + Valid dictionary keys are {'position', 'normal', and 'enabled'}. + Values on the negative side of the normal are discarded if the plane is enabled. + metadata : dict + Layer metadata. + multiscale : bool + Whether the data is a multiscale image or not. Multiscale data is + represented by a list of array like image data. If not specified by + the user and if the data is a list of arrays that decrease in shape + then it will be taken to be multiscale. The first image in the list + should be the largest. Please note multiscale rendering is only + supported in 2D. In 3D, only the lowest resolution scale is + displayed. + name : str + Name of the layer. + ndim : int + Number of dimensions in the data. + opacity : float + Opacity of the layer visual, between 0.0 and 1.0. + plane : dict or SlicingPlane + Properties defining plane rendering in 3D. Properties are defined in + data coordinates. Valid dictionary keys are + {'position', 'normal', 'thickness', and 'enabled'}. + projection_mode : str + How data outside the viewed dimensions but inside the thick Dims slice will + be projected onto the viewed dimensions. Must fit to cls._projectionclass + rendering : str + Rendering mode used by vispy. Must be one of our supported + modes. + rotate : float, 3-tuple of float, or n-D array. + If a float convert into a 2D rotation matrix using that value as an + angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, + pitch, roll convention. Otherwise assume an nD rotation. Angles are + assumed to be in degrees. They can be converted from radians with + np.degrees if needed. + scale : tuple of float + Scale factors for the layer. + shear : 1-D array or n-D array + Either a vector of upper triangular values, or an nD shear matrix with + ones along the main diagonal. + translate : tuple of float + Translation values for the layer. + visible : bool + Whether the layer visual is currently being displayed. + + + Attributes + ---------- + data : array or list of array + Image data. Can be N dimensional. If the last dimension has length + 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a list + and arrays are decreasing in shape then the data is treated as a + multiscale image. Please note multiscale rendering is only + supported in 2D. In 3D, only the lowest resolution scale is + displayed. + metadata : dict + Image metadata. + multiscale : bool + Whether the data is a multiscale image or not. Multiscale data is + represented by a list of array like image data. The first image in the + list should be the largest. Please note multiscale rendering is only + supported in 2D. In 3D, only the lowest resolution scale is + displayed. + mode : str + Interactive mode. The normal, default mode is PAN_ZOOM, which + allows for normal interactivity with the canvas. + + In TRANSFORM mode the image can be transformed interactively. + rendering : str + Rendering mode used by vispy. Must be one of our supported + modes. + depiction : str + 3D Depiction mode used by vispy. Must be one of our supported modes. + plane : SlicingPlane or dict + Properties defining plane rendering in 3D. Valid dictionary keys are + {'position', 'normal', 'thickness'}. + experimental_clipping_planes : ClippingPlaneList + Clipping planes defined in data coordinates, used to clip the volume. + custom_interpolation_kernel_2d : np.ndarray + Convolution kernel used with the 'custom' interpolation mode in 2D rendering. + + Notes + ----- + _data_view : array (N, M), (N, M, 3), or (N, M, 4) + Image data for the currently viewed slice. Must be 2D image data, but + can be multidimensional for RGB or RGBA images if multidimensional is + `True`. + """ + + _colormaps = AVAILABLE_COLORMAPS + _interpolation2d: Interpolation + _interpolation3d: Interpolation + + def __init__( + self, + data, + *, + affine=None, + blending='translucent', + cache=True, + custom_interpolation_kernel_2d=None, + depiction='volume', + experimental_clipping_planes=None, + metadata=None, + multiscale=None, + name=None, + ndim=None, + opacity=1.0, + plane=None, + projection_mode='none', + rendering='mip', + rotate=None, + scale=None, + shear=None, + translate=None, + visible=True, + ): + if name is None and data is not None: + name = magic_name(data) + + if isinstance(data, types.GeneratorType): + data = list(data) + + if getattr(data, 'ndim', 2) < 2: + raise ValueError( + trans._('Image data must have at least 2 dimensions.') + ) + + # Determine if data is a multiscale + self._data_raw = data + if multiscale is None: + multiscale, data = guess_multiscale(data) + elif multiscale and not isinstance(data, MultiScaleData): + data = MultiScaleData(data) + + # Determine dimensionality of the data + if ndim is None: + ndim = len(data.shape) + + super().__init__( + data, + ndim, + name=name, + metadata=metadata, + scale=scale, + translate=translate, + rotate=rotate, + shear=shear, + affine=affine, + opacity=opacity, + blending=blending, + visible=visible, + multiscale=multiscale, + cache=cache, + experimental_clipping_planes=experimental_clipping_planes, + projection_mode=projection_mode, + ) + + self.events.add( + attenuation=Event, + custom_interpolation_kernel_2d=Event, + depiction=Event, + interpolation=WarningEmitter( + trans._( + "'layer.events.interpolation' is deprecated please use `interpolation2d` and `interpolation3d`", + deferred=True, + ), + type_name='select', + ), + interpolation2d=Event, + interpolation3d=Event, + iso_threshold=Event, + plane=Event, + rendering=Event, + ) + + self._array_like = True + + # Set data + self._data = data + if isinstance(data, MultiScaleData): + self._data_level = len(data) - 1 + # Determine which level of the multiscale to use for the thumbnail. + # Pick the smallest level with at least one axis >= 64. This is + # done to prevent the thumbnail from being from one of the very + # low resolution layers and therefore being very blurred. + big_enough_levels = [ + np.any(np.greater_equal(p.shape, 64)) for p in data + ] + if np.any(big_enough_levels): + self._thumbnail_level = np.where(big_enough_levels)[0][-1] + else: + self._thumbnail_level = 0 + else: + self._data_level = 0 + self._thumbnail_level = 0 + displayed_axes = self._slice_input.displayed + self.corner_pixels[1][displayed_axes] = ( + np.array(self.level_shapes)[self._data_level][displayed_axes] - 1 + ) + + self._slice = _ImageSliceResponse.make_empty( + slice_input=self._slice_input, + rgb=len(self.data.shape) != self.ndim, + ) + + self._plane = SlicingPlane(thickness=1) + # Whether to calculate clims on the next set_view_slice + self._should_calc_clims = False + # using self.colormap = colormap uses the setter in *derived* classes, + # where the intention here is to use the base setter, so we use the + # _set_colormap method. This is important for Labels layers, because + # we don't want to use get_color before set_view_slice has been + # triggered (self.refresh(), below). + self.rendering = rendering + self.depiction = depiction + if plane is not None: + self.plane = plane + connect_no_arg(self.plane.events, self.events, 'plane') + self.custom_interpolation_kernel_2d = custom_interpolation_kernel_2d + + def _post_init(self): + # Trigger generation of view slice and thumbnail + self.refresh() + + @property + def _data_view(self) -> np.ndarray: + """Viewable image for the current slice. (compatibility)""" + return self._slice.image.view + + @property + def dtype(self): + return self._data.dtype + + @property + def data_raw( + self, + ) -> Union[LayerDataProtocol, Sequence[LayerDataProtocol]]: + """Data, exactly as provided by the user.""" + return self._data_raw + + def _get_ndim(self) -> int: + """Determine number of dimensions of the layer.""" + return len(self.level_shapes[0]) + + @property + def _extent_data(self) -> np.ndarray: + """Extent of layer in data coordinates. + + Returns + ------- + extent_data : array, shape (2, D) + """ + shape = self.level_shapes[0] + return np.vstack([np.zeros(len(shape)), shape - 1]) + + @property + def _extent_data_augmented(self) -> np.ndarray: + extent = self._extent_data + return extent + [[-0.5], [+0.5]] + + @property + def _extent_level_data(self) -> np.ndarray: + """Extent of layer, accounting for current multiscale level, in data coordinates. + + Returns + ------- + extent_data : array, shape (2, D) + """ + shape = self.level_shapes[self.data_level] + return np.vstack([np.zeros(len(shape)), shape - 1]) + + @property + def _extent_level_data_augmented(self) -> np.ndarray: + extent = self._extent_level_data + return extent + [[-0.5], [+0.5]] + + @property + def data_level(self) -> int: + """int: Current level of multiscale, or 0 if image.""" + return self._data_level + + @data_level.setter + def data_level(self, level: int) -> None: + if self._data_level == level: + return + self._data_level = level + self.refresh() + + def _get_level_shapes(self): + data = self.data + if isinstance(data, MultiScaleData): + shapes = data.shapes + else: + shapes = [self.data.shape] + return shapes + + @property + def level_shapes(self) -> np.ndarray: + """array: Shapes of each level of the multiscale or just of image.""" + return np.array(self._get_level_shapes()) + + @property + def downsample_factors(self) -> np.ndarray: + """list: Downsample factors for each level of the multiscale.""" + return np.divide(self.level_shapes[0], self.level_shapes) + + @property + def depiction(self): + """The current 3D depiction mode. + + Selects a preset depiction mode in vispy + * volume: images are rendered as 3D volumes. + * plane: images are rendered as 2D planes embedded in 3D. + plane position, normal, and thickness are attributes of + layer.plane which can be modified directly. + """ + return str(self._depiction) + + @depiction.setter + def depiction(self, depiction: Union[str, VolumeDepiction]) -> None: + """Set the current 3D depiction mode.""" + self._depiction = VolumeDepiction(depiction) + self._update_plane_callbacks() + self.events.depiction() + + def _reset_plane_parameters(self): + """Set plane attributes to something valid.""" + self.plane.position = np.array(self.data.shape) / 2 + self.plane.normal = (1, 0, 0) + + def _update_plane_callbacks(self): + """Set plane callbacks depending on depiction mode.""" + plane_drag_callback_connected = ( + plane_drag_callback in self.mouse_drag_callbacks + ) + double_click_callback_connected = ( + plane_double_click_callback in self.mouse_double_click_callbacks + ) + if self.depiction == VolumeDepiction.VOLUME: + if plane_drag_callback_connected: + self.mouse_drag_callbacks.remove(plane_drag_callback) + if double_click_callback_connected: + self.mouse_double_click_callbacks.remove( + plane_double_click_callback + ) + elif self.depiction == VolumeDepiction.PLANE: + if not plane_drag_callback_connected: + self.mouse_drag_callbacks.append(plane_drag_callback) + if not double_click_callback_connected: + self.mouse_double_click_callbacks.append( + plane_double_click_callback + ) + + @property + def plane(self): + return self._plane + + @plane.setter + def plane(self, value: Union[dict, SlicingPlane]) -> None: + self._plane.update(value) + self.events.plane() + + @property + def custom_interpolation_kernel_2d(self): + return self._custom_interpolation_kernel_2d + + @custom_interpolation_kernel_2d.setter + def custom_interpolation_kernel_2d(self, value): + if value is None: + value = [[1]] + self._custom_interpolation_kernel_2d = np.array(value, np.float32) + self.events.custom_interpolation_kernel_2d() + + def _raw_to_displayed(self, raw: np.ndarray) -> np.ndarray: + """Determine displayed image from raw image. + + For normal image layers, just return the actual image. + + Parameters + ---------- + raw : array + Raw array. + + Returns + ------- + image : array + Displayed array. + """ + raise NotImplementedError + + def _set_view_slice(self) -> None: + """Set the slice output based on this layer's current state.""" + # The new slicing code makes a request from the existing state and + # executes the request on the calling thread directly. + # For async slicing, the calling thread will not be the main thread. + request = self._make_slice_request_internal( + slice_input=self._slice_input, + data_slice=self._data_slice, + dask_indexer=nullcontext, + ) + response = request() + self._update_slice_response(response) + + def _make_slice_request(self, dims: Dims) -> _ImageSliceRequest: + """Make an image slice request based on the given dims and this image.""" + slice_input = self._make_slice_input(dims) + # For the existing sync slicing, indices is passed through + # to avoid some performance issues related to the evaluation of the + # data-to-world transform and its inverse. Async slicing currently + # absorbs these performance issues here, but we can likely improve + # things either by caching the world-to-data transform on the layer + # or by lazily evaluating it in the slice task itself. + indices = slice_input.data_slice(self._data_to_world.inverse) + return self._make_slice_request_internal( + slice_input=slice_input, + data_slice=indices, + dask_indexer=self.dask_optimized_slicing, + ) + + def _make_slice_request_internal( + self, + *, + slice_input: _SliceInput, + data_slice: _ThickNDSlice, + dask_indexer: DaskIndexer, + ) -> _ImageSliceRequest: + """Needed to support old-style sync slicing through _slice_dims and + _set_view_slice. + + This is temporary scaffolding that should go away once we have completed + the async slicing project: https://github.com/napari/napari/issues/4795 + """ + return _ImageSliceRequest( + slice_input=slice_input, + data=self.data, + dask_indexer=dask_indexer, + data_slice=data_slice, + projection_mode=self.projection_mode, + multiscale=self.multiscale, + corner_pixels=self.corner_pixels, + rgb=len(self.data.shape) != self.ndim, + data_level=self.data_level, + thumbnail_level=self._thumbnail_level, + level_shapes=self.level_shapes, + downsample_factors=self.downsample_factors, + ) + + def _update_slice_response(self, response: _ImageSliceResponse) -> None: + """Update the slice output state currently on the layer. Currently used + for both sync and async slicing. + """ + response = response.to_displayed(self._raw_to_displayed) + # We call to_displayed here to ensure that if the contrast limits + # are outside the range of supported by vispy, then data view is + # rescaled to fit within the range. + self._slice_input = response.slice_input + self._transforms[0] = response.tile_to_data + self._slice = response + + def _get_value(self, position): + """Value of the data at a position in data coordinates. + + Parameters + ---------- + position : tuple + Position in data coordinates. + + Returns + ------- + value : tuple + Value of the data. + """ + if self.multiscale: + # for multiscale data map the coordinate from the data back to + # the tile + coord = self._transforms['tile2data'].inverse(position) + else: + coord = position + + coord = np.round(coord).astype(int) + + raw = self._slice.image.raw + shape = ( + raw.shape[:-1] if self.ndim != len(self._data.shape) else raw.shape + ) + + if self.ndim < len(coord): + # handle 3D views of 2D data by omitting extra coordinate + offset = len(coord) - len(shape) + coord = coord[[d + offset for d in self._slice_input.displayed]] + else: + coord = coord[self._slice_input.displayed] + + if all(0 <= c < s for c, s in zip(coord, shape)): + value = raw[tuple(coord)] + else: + value = None + + if self.multiscale: + value = (self.data_level, value) + + return value + + def _get_offset_data_position(self, position: npt.NDArray) -> npt.NDArray: + """Adjust position for offset between viewer and data coordinates. + + VisPy considers the coordinate system origin to be the canvas corner, + while napari considers the origin to be the **center** of the corner + pixel. To get the correct value under the mouse cursor, we need to + shift the position by 0.5 pixels on each axis. + """ + return position + 0.5 + + def _display_bounding_box_at_level( + self, dims_displayed: list[int], data_level: int + ) -> npt.NDArray: + """An axis aligned (ndisplay, 2) bounding box around the data at a given level""" + shape = self.level_shapes[data_level] + extent_at_level = np.vstack([np.zeros(len(shape)), shape - 1]) + return extent_at_level[:, dims_displayed].T + + def _display_bounding_box_augmented_data_level( + self, dims_displayed: list[int] + ) -> npt.NDArray: + """An augmented, axis-aligned (ndisplay, 2) bounding box. + If the layer is multiscale layer, then returns the + bounding box of the data at the current level + """ + return self._extent_level_data_augmented[:, dims_displayed].T diff --git a/napari/layers/_source.py b/napari/layers/_source.py index 8555b9954d3..f15ce9e693a 100644 --- a/napari/layers/_source.py +++ b/napari/layers/_source.py @@ -1,11 +1,14 @@ from __future__ import annotations import weakref +from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar -from typing import Optional, Tuple +from typing import Any, Optional +from weakref import ReferenceType from magicgui.widgets import FunctionGui +from typing_extensions import Self from napari._pydantic_compat import BaseModel, validator from napari.layers.base.base import Layer @@ -31,7 +34,7 @@ class Source(BaseModel): path: Optional[str] = None reader_plugin: Optional[str] = None - sample: Optional[Tuple[str, str]] = None + sample: Optional[tuple[str, str]] = None widget: Optional[FunctionGui] = None parent: Optional[Layer] = None @@ -40,10 +43,10 @@ class Config: frozen = True @validator('parent', allow_reuse=True) - def make_weakref(cls, layer: Layer): + def make_weakref(cls, layer: Layer) -> ReferenceType[Layer]: return weakref.ref(layer) - def __deepcopy__(self, memo): + def __deepcopy__(self, memo: Any) -> Self: """Custom deepcopy implementation. this prevents deep copy. `Source` doesn't really need to be copied @@ -59,7 +62,7 @@ def __deepcopy__(self, memo): @contextmanager -def layer_source(**source_kwargs): +def layer_source(**source_kwargs: Any) -> Generator[None, None, None]: """Creates context in which all layers will be given `source_kwargs`. The module-level variable `_LAYER_SOURCE` holds a set of key-value pairs @@ -101,7 +104,7 @@ def layer_source(**source_kwargs): _LAYER_SOURCE.reset(token) -def current_source(): +def current_source() -> Source: """Get the current layer :class:`Source` (inferred from context). The main place this function is used is in :meth:`Layer.__init__`. diff --git a/napari/layers/_tests/test_dask_layers.py b/napari/layers/_tests/test_dask_layers.py index 0600fb093e6..cb4f0210e9c 100644 --- a/napari/layers/_tests/test_dask_layers.py +++ b/napari/layers/_tests/test_dask_layers.py @@ -44,10 +44,10 @@ def test_dask_array_creates_cache(): resize_dask_cache(1) assert _dask_utils._DASK_CACHE.cache.available_bytes == 1 # by default we have no dask_cache and task fusion is active - original = dask.config.get("optimization.fuse.active", None) + original = dask.config.get('optimization.fuse.active', None) def mock_set_view_slice(): - assert dask.config.get("optimization.fuse.active") is False + assert dask.config.get('optimization.fuse.active') is False layer = layers.Image(da.ones((100, 100))) layer._set_view_slice = mock_set_view_slice @@ -57,7 +57,7 @@ def mock_set_view_slice(): # *but only* during slicing (see "mock_set_view_slice" above) assert _dask_utils._DASK_CACHE.cache.available_bytes > 100 assert not _dask_utils._DASK_CACHE.active - assert dask.config.get("optimization.fuse.active", None) == original + assert dask.config.get('optimization.fuse.active', None) == original # make sure we can resize the cache resize_dask_cache(10000) @@ -65,7 +65,7 @@ def mock_set_view_slice(): # This should only affect dask arrays, and not numpy data def mock_set_view_slice2(): - assert dask.config.get("optimization.fuse.active", None) == original + assert dask.config.get('optimization.fuse.active', None) == original layer2 = layers.Image(np.ones((100, 100))) layer2._set_view_slice = mock_set_view_slice2 @@ -76,11 +76,11 @@ def test_list_of_dask_arrays_doesnt_create_cache(): """Test that adding a list of dask array also creates a dask cache.""" resize_dask_cache(1) # in case other tests created it assert _dask_utils._DASK_CACHE.cache.available_bytes == 1 - original = dask.config.get("optimization.fuse.active", None) + original = dask.config.get('optimization.fuse.active', None) _ = layers.Image([da.ones((100, 100)), da.ones((20, 20))]) assert _dask_utils._DASK_CACHE.cache.available_bytes > 100 assert not _dask_utils._DASK_CACHE.active - assert dask.config.get("optimization.fuse.active", None) == original + assert dask.config.get('optimization.fuse.active', None) == original @pytest.fixture diff --git a/napari/layers/_tests/test_data_protocol.py b/napari/layers/_tests/test_data_protocol.py index e26836b80e3..b3f49eaf726 100644 --- a/napari/layers/_tests/test_data_protocol.py +++ b/napari/layers/_tests/test_data_protocol.py @@ -26,5 +26,5 @@ def test_layer_protocol(test_data): def test_layer_protocol_raises(): with pytest.raises(TypeError) as e: assert_protocol([]) # list doesn't provide the protocol - assert "Missing methods: " in str(e) + assert 'Missing methods: ' in str(e) assert "'shape'" in str(e) diff --git a/napari/layers/_tests/test_layer_actions.py b/napari/layers/_tests/test_layer_actions.py index 71260dce370..3fcd1a2ba68 100644 --- a/napari/layers/_tests/test_layer_actions.py +++ b/napari/layers/_tests/test_layer_actions.py @@ -63,15 +63,15 @@ def _dummy(): pass layer_list = LayerList() - layer_list.append(layer_type([], name="test")) + layer_list.append(layer_type([], name='test')) layer_list.selection.active = layer_list[0] layer_list[0].events.data.connect(_dummy) assert len(layer_list[0].events.data.callbacks) == 2 assert len(layer_list) == 1 _duplicate_layer(layer_list) assert len(layer_list) == 2 - assert layer_list[0].name == "test" - assert layer_list[1].name == "test copy" + assert layer_list[0].name == 'test' + assert layer_list[1].name == 'test copy' assert layer_list[1].events.source is layer_list[1] assert ( len(layer_list[1].events.data.callbacks) == 1 @@ -220,7 +220,7 @@ def test_convert_layer(layer, type_): assert np.array_equal(ll[0].scale, original_scale) if ( - type_ == "labels" + type_ == 'labels' and isinstance(layer, Image) and np.issubdtype(layer.data.dtype, np.integer) ): @@ -231,7 +231,7 @@ def test_convert_layer(layer, type_): def make_three_layer_layerlist(): layer_list = LayerList() - layer_list.append(Points([[0, 0]], name="test")) + layer_list.append(Points([[0, 0]], name='test')) layer_list.append(Image(np.random.rand(8, 8, 8))) layer_list.append(Image(np.random.rand(8, 8, 8))) diff --git a/napari/layers/_tests/test_utils.py b/napari/layers/_tests/test_utils.py index 4e310450af1..902ca5df399 100644 --- a/napari/layers/_tests/test_utils.py +++ b/napari/layers/_tests/test_utils.py @@ -6,8 +6,8 @@ from napari.layers.utils.layer_utils import convert_to_uint8 -@pytest.mark.filterwarnings("ignore:Downcasting uint:UserWarning:skimage") -@pytest.mark.parametrize("dtype", [np.uint8, np.uint16, np.uint32, np.uint64]) +@pytest.mark.filterwarnings('ignore:Downcasting uint:UserWarning:skimage') +@pytest.mark.parametrize('dtype', [np.uint8, np.uint16, np.uint32, np.uint64]) def test_uint(dtype): data = np.arange(50, dtype=dtype) data_scaled = data * 256 ** (data.dtype.itemsize - 1) @@ -19,8 +19,8 @@ def test_uint(dtype): ) -@pytest.mark.filterwarnings("ignore:Downcasting int:UserWarning:skimage") -@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.int64]) +@pytest.mark.filterwarnings('ignore:Downcasting int:UserWarning:skimage') +@pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32, np.int64]) def test_int(dtype): data = np.arange(50, dtype=dtype) data_scaled = data * 256 ** (data.dtype.itemsize - 1) @@ -37,7 +37,7 @@ def test_int(dtype): ) -@pytest.mark.parametrize("dtype", [np.float64, np.float32, float]) +@pytest.mark.parametrize('dtype', [np.float64, np.float32, float]) def test_float(dtype): data = np.linspace(0, 0.5, 128, dtype=dtype, endpoint=False) res = np.arange(128, dtype=np.uint8) diff --git a/napari/layers/base/_base_constants.py b/napari/layers/base/_base_constants.py index 86fc9369d71..0e3a861a0bb 100644 --- a/napari/layers/base/_base_constants.py +++ b/napari/layers/base/_base_constants.py @@ -46,11 +46,11 @@ class Blending(StringEnum): BLENDING_TRANSLATIONS = OrderedDict( [ - (Blending.TRANSLUCENT, trans._("translucent")), - (Blending.TRANSLUCENT_NO_DEPTH, trans._("translucent_no_depth")), - (Blending.ADDITIVE, trans._("additive")), - (Blending.MINIMUM, trans._("minimum")), - (Blending.OPAQUE, trans._("opaque")), + (Blending.TRANSLUCENT, trans._('translucent')), + (Blending.TRANSLUCENT_NO_DEPTH, trans._('translucent_no_depth')), + (Blending.ADDITIVE, trans._('additive')), + (Blending.MINIMUM, trans._('minimum')), + (Blending.OPAQUE, trans._('opaque')), ] ) @@ -95,7 +95,9 @@ class InteractionBoxHandle(IntEnum): INSIDE = 9 @classmethod - def opposite_handle(cls, handle): + def opposite_handle( + cls, handle: 'InteractionBoxHandle' + ) -> 'InteractionBoxHandle': opposites = { InteractionBoxHandle.TOP_LEFT: InteractionBoxHandle.BOTTOM_RIGHT, InteractionBoxHandle.TOP_CENTER: InteractionBoxHandle.BOTTOM_CENTER, @@ -104,12 +106,19 @@ def opposite_handle(cls, handle): } opposites.update({v: k for k, v in opposites.items()}) - if (opposite := opposites.get(handle, None)) is None: + if (opposite := opposites.get(handle)) is None: raise ValueError(f'{handle} has no opposite handle.') return opposite @classmethod - def corners(cls): + def corners( + cls, + ) -> tuple[ + 'InteractionBoxHandle', + 'InteractionBoxHandle', + 'InteractionBoxHandle', + 'InteractionBoxHandle', + ]: return ( cls.TOP_LEFT, cls.TOP_RIGHT, diff --git a/napari/layers/base/_base_mouse_bindings.py b/napari/layers/base/_base_mouse_bindings.py index 456d9021bc0..88d6b7be715 100644 --- a/napari/layers/base/_base_mouse_bindings.py +++ b/napari/layers/base/_base_mouse_bindings.py @@ -1,17 +1,24 @@ import warnings +from collections.abc import Generator +from typing import TYPE_CHECKING import numpy as np +import numpy.typing as npt from napari.layers.utils.interaction_box import ( InteractionBoxHandle, generate_transform_box_from_layer, get_nearby_handle, ) +from napari.utils.events import Event from napari.utils.transforms import Affine from napari.utils.translations import trans +if TYPE_CHECKING: + from napari.layers.base import Layer -def highlight_box_handles(layer, event): + +def highlight_box_handles(layer: 'Layer', event: Event) -> None: """ Highlight the hovered handle of a TransformBox. """ @@ -36,22 +43,26 @@ def highlight_box_handles(layer, event): def _translate_with_box( - layer, initial_affine, initial_mouse_pos, mouse_pos, event -): + layer: 'Layer', + initial_affine: Affine, + initial_mouse_pos: npt.NDArray, + mouse_pos: npt.NDArray, + event: Event, +) -> None: offset = mouse_pos - initial_mouse_pos new_affine = Affine(translate=offset).compose(initial_affine) layer.affine = layer.affine.replace_slice(event.dims_displayed, new_affine) def _rotate_with_box( - layer, - initial_affine, - initial_mouse_pos, - initial_handle_coords, - initial_center, - mouse_pos, - event, -): + layer: 'Layer', + initial_affine: Affine, + initial_mouse_pos: npt.NDArray, + initial_handle_coords: npt.NDArray, + initial_center: npt.NDArray, + mouse_pos: npt.NDArray, + event: Event, +) -> None: # calculate the angle between the center-handle vector and the center-mouse vector center_to_handle = ( initial_handle_coords[InteractionBoxHandle.ROTATION] - initial_center @@ -73,16 +84,16 @@ def _rotate_with_box( def _scale_with_box( - layer, - initial_affine, - initial_world_to_data, - initial_data2physical, - nearby_handle, - initial_center, - initial_handle_coords_data, - mouse_pos, - event, -): + layer: 'Layer', + initial_affine: Affine, + initial_world_to_data: Affine, + initial_data2physical: Affine, + nearby_handle: InteractionBoxHandle, + initial_center: npt.NDArray, + initial_handle_coords_data: npt.NDArray, + mouse_pos: npt.NDArray, + event: Event, +) -> None: locked_aspect_ratio = False if 'Shift' in event.modifiers: if nearby_handle in InteractionBoxHandle.corners(): @@ -121,7 +132,7 @@ def _scale_with_box( # (i.e: dragging the central handle of the TransformBox). # That's intended, because we get inf or nan, which we can then replace with 1s # and thus maintain the size along that axis. - warnings.simplefilter("ignore", RuntimeWarning) + warnings.simplefilter('ignore', RuntimeWarning) scale = center_to_mouse / center_to_handle scale = np.nan_to_num(scale, posinf=1, neginf=1) @@ -146,7 +157,9 @@ def _scale_with_box( layer.affine = layer.affine.replace_slice(event.dims_displayed, new_affine) -def transform_with_box(layer, event): +def transform_with_box( + layer: 'Layer', event: Event +) -> Generator[None, None, None]: """ Translate, rescale or rotate a layer by dragging a TransformBox handle. """ @@ -155,9 +168,8 @@ def transform_with_box(layer, event): # we work in data space so we're axis aligned which simplifies calculation # same as Layer.data_to_world - initial_data_to_world = layer._transforms[1:].simplified.set_slice( - event.dims_displayed - ) + simplified = layer._transforms[1:].simplified + initial_data_to_world = simplified.set_slice(event.dims_displayed) initial_world_to_data = initial_data_to_world.inverse initial_mouse_pos = np.array(event.position)[event.dims_displayed] initial_mouse_pos_data = initial_world_to_data(initial_mouse_pos) diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py index 6bd466e7507..cbb49ce0229 100644 --- a/napari/layers/base/base.py +++ b/napari/layers/base/base.py @@ -8,19 +8,16 @@ import warnings from abc import ABC, ABCMeta, abstractmethod from collections import defaultdict +from collections.abc import Generator from contextlib import contextmanager from functools import cached_property from typing import ( TYPE_CHECKING, + Any, Callable, ClassVar, - Dict, - List, Optional, - Tuple, - Type, Union, - cast, ) import magicgui as mgui @@ -75,9 +72,10 @@ from napari.components.dims import Dims from napari.components.overlays.base import Overlay + from napari.layers._source import Source -logger = logging.getLogger("napari.layers.base.base") +logger = logging.getLogger('napari.layers.base.base') def no_op(layer: Layer, event: Event) -> None: @@ -271,19 +269,23 @@ class Layer(KeymapProvider, MousemapProvider, ABC, metaclass=PostInit): * `_basename()`: base/default name of the layer """ - _modeclass: Type[StringEnum] = Mode - _projectionclass: Type[StringEnum] = BaseProjectionMode + _modeclass: type[StringEnum] = Mode + _projectionclass: type[StringEnum] = BaseProjectionMode - _drag_modes: ClassVar[Dict[StringEnum, Callable[[Layer, Event], None]]] = { + ModeCallable = Callable[ + ['Layer', Event], Union[None, Generator[None, None, None]] + ] + + _drag_modes: ClassVar[dict[StringEnum, ModeCallable]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, } - _move_modes: ClassVar[Dict[StringEnum, Callable[[Layer, Event], None]]] = { + _move_modes: ClassVar[dict[StringEnum, ModeCallable]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, } - _cursor_modes: ClassVar[Dict[StringEnum, str]] = { + _cursor_modes: ClassVar[dict[StringEnum, str]] = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', } @@ -309,7 +311,7 @@ def __init__( experimental_clipping_planes=None, mode='pan_zoom', projection_mode='none', - ) -> None: + ): super().__init__() if name is None and data is not None: @@ -432,7 +434,7 @@ def __init__( help=Event, interactive=WarningEmitter( trans._( - "layer.events.interactive is deprecated since 0.4.18 and will be removed in 0.6.0. Please use layer.events.mouse_pan and layer.events.mouse_zoom", + 'layer.events.interactive is deprecated since 0.4.18 and will be removed in 0.6.0. Please use layer.events.mouse_pan and layer.events.mouse_zoom', deferred=True, ), type_name='interactive', @@ -469,13 +471,13 @@ def __init__( def _post_init(self): """Post init hook for subclasses to use.""" - def __str__(self): + def __str__(self) -> str: """Return self.name.""" return self.name - def __repr__(self): + def __repr__(self) -> str: cls = type(self) - return f"<{cls.__name__} layer {self.name!r} at {hex(id(self))}>" + return f'<{cls.__name__} layer {self.name!r} at {hex(id(self))}>' def _mode_setter_helper(self, mode_in: Union[Mode, str]) -> StringEnum: """ @@ -513,7 +515,7 @@ def _mode_setter_helper(self, mode_in: Union[Mode, str]) -> StringEnum: if mode not in self._modeclass: raise ValueError( trans._( - "Mode not recognized: {mode}", deferred=True, mode=mode + 'Mode not recognized: {mode}', deferred=True, mode=mode ) ) @@ -556,13 +558,13 @@ def mode(self) -> str: return str(self._mode) @mode.setter - def mode(self, mode): - mode = self._mode_setter_helper(mode) - if mode == self._mode: + def mode(self, mode: Union[Mode, str]) -> None: + mode_enum = self._mode_setter_helper(mode) + if mode_enum == self._mode: return - self._mode = mode + self._mode = mode_enum - self.events.mode(mode=str(mode)) + self.events.mode(mode=str(mode_enum)) @property def projection_mode(self): @@ -583,16 +585,16 @@ def projection_mode(self, mode): self.refresh() @classmethod - def _basename(cls): + def _basename(cls) -> str: return f'{cls.__name__}' @property - def name(self): + def name(self) -> str: """str: Unique name of the layer.""" return self._name @name.setter - def name(self, name): + def name(self, name: Optional[str]) -> None: if name == self.name: return if not name: @@ -611,7 +613,7 @@ def metadata(self, value: dict) -> None: self._metadata.update(value) @property - def source(self): + def source(self) -> Source: return self._source @property @@ -649,12 +651,12 @@ def _update_loaded_slice_id(self, slice_id: int) -> None: self._set_loaded(True) @property - def opacity(self): + def opacity(self) -> float: """float: Opacity value between 0.0 and 1.0.""" return self._opacity @opacity.setter - def opacity(self, opacity): + def opacity(self, opacity: float) -> None: if not 0.0 <= opacity <= 1.0: raise ValueError( trans._( @@ -669,7 +671,7 @@ def opacity(self, opacity): self.events.opacity() @property - def blending(self): + def blending(self) -> str: """Blending mode: Determines how RGB and alpha values get mixed. Blending.OPAQUE @@ -710,7 +712,7 @@ def visible(self) -> bool: return self._visible @visible.setter - def visible(self, visible: bool): + def visible(self, visible: bool) -> None: self._visible = visible self.refresh() self.events.visible() @@ -721,7 +723,7 @@ def editable(self) -> bool: return self._editable @editable.setter - def editable(self, editable: bool): + def editable(self, editable: bool) -> None: if self._editable == editable: return self._editable = editable @@ -736,58 +738,58 @@ def _on_editable_changed(self) -> None: """Executes side-effects on this layer related to changes of the editable state.""" @property - def scale(self): - """list: Anisotropy factors to scale data into world coordinates.""" + def scale(self) -> npt.NDArray: + """array: Anisotropy factors to scale data into world coordinates.""" return self._transforms['data2physical'].scale @scale.setter - def scale(self, scale): + def scale(self, scale: Optional[npt.NDArray]) -> None: if scale is None: - scale = [1] * self.ndim + scale = np.array([1] * self.ndim) self._transforms['data2physical'].scale = np.array(scale) self._clear_extents_and_refresh() self.events.scale() @property - def translate(self): - """list: Factors to shift the layer by in units of world coordinates.""" + def translate(self) -> npt.NDArray: + """array: Factors to shift the layer by in units of world coordinates.""" return self._transforms['data2physical'].translate @translate.setter - def translate(self, translate): + def translate(self, translate: npt.ArrayLike) -> None: self._transforms['data2physical'].translate = np.array(translate) self._clear_extents_and_refresh() self.events.translate() @property - def rotate(self): + def rotate(self) -> npt.NDArray: """array: Rotation matrix in world coordinates.""" return self._transforms['data2physical'].rotate @rotate.setter - def rotate(self, rotate): + def rotate(self, rotate: npt.NDArray) -> None: self._transforms['data2physical'].rotate = rotate self._clear_extents_and_refresh() self.events.rotate() @property - def shear(self): + def shear(self) -> npt.NDArray: """array: Shear matrix in world coordinates.""" return self._transforms['data2physical'].shear @shear.setter - def shear(self, shear): + def shear(self, shear: npt.NDArray) -> None: self._transforms['data2physical'].shear = shear self._clear_extents_and_refresh() self.events.shear() @property - def affine(self): + def affine(self) -> Affine: """napari.utils.transforms.Affine: Extra affine transform to go from physical to world coordinates.""" return self._transforms['physical2world'] @affine.setter - def affine(self, affine): + def affine(self, affine: Union[npt.ArrayLike, Affine]) -> None: # Assignment by transform name is not supported by TransformChain and # EventedList, so use the integer index instead. For more details, see: # https://github.com/napari/napari/issues/3058 @@ -798,19 +800,19 @@ def affine(self, affine): self.events.affine() @property - def _translate_grid(self): - """list: Factors to shift the layer by.""" + def _translate_grid(self) -> npt.NDArray: + """array: Factors to shift the layer by.""" return self._transforms['world2grid'].translate @_translate_grid.setter - def _translate_grid(self, translate_grid): + def _translate_grid(self, translate_grid: npt.NDArray) -> None: if np.array_equal(self._translate_grid, translate_grid): return self._transforms['world2grid'].translate = np.array(translate_grid) self.events.translate() @property - def _is_moving(self): + def _is_moving(self) -> bool: return self._private_is_moving @_is_moving.setter @@ -820,7 +822,7 @@ def _is_moving(self, value): assert self._moving_coordinates is not None self._private_is_moving = value - def _update_dims(self): + def _update_dims(self) -> None: """Update the dimensionality of transforms and slices when data changes.""" ndim = self._get_ndim() @@ -942,19 +944,19 @@ def _extent_augmented(self) -> Extent: step=abs(data_to_world.scale), ) - def _clear_extent(self): + def _clear_extent(self) -> None: """Clear extent cache and emit extent event.""" if 'extent' in self.__dict__: del self.extent self.events.extent() - def _clear_extent_augmented(self): + def _clear_extent_augmented(self) -> None: """Clear extent_augmented cache and emit extent_augmented event.""" if '_extent_augmented' in self.__dict__: del self._extent_augmented self.events._extent_augmented() - def _clear_extents_and_refresh(self): + def _clear_extents_and_refresh(self) -> None: """Clears the cached extents, emits events and refreshes the layer. This should be called whenever this data or transform information @@ -978,10 +980,10 @@ def _data_slice(self) -> _ThickNDSlice: ) @abstractmethod - def _get_ndim(self): + def _get_ndim(self) -> int: raise NotImplementedError - def _get_base_state(self): + def _get_base_state(self) -> dict: """Get dictionary of attributes on base layer. Returns @@ -1012,7 +1014,7 @@ def _get_state(self): raise NotImplementedError @property - def _type_string(self): + def _type_string(self) -> str: return self.__class__.__name__.lower() def as_layer_data_tuple(self): @@ -1021,12 +1023,12 @@ def as_layer_data_tuple(self): return self.data, state, self._type_string @property - def thumbnail(self): + def thumbnail(self) -> npt.NDArray[np.uint8]: """array: Integer array of thumbnail for the layer""" return self._thumbnail @thumbnail.setter - def thumbnail(self, thumbnail): + def thumbnail(self, thumbnail: npt.NDArray) -> None: if 0 in thumbnail.shape: thumbnail = np.zeros(self._thumbnail_shape, dtype=np.uint8) if thumbnail.dtype != np.uint8: @@ -1048,17 +1050,17 @@ def thumbnail(self, thumbnail): self.events.thumbnail() @property - def ndim(self): + def ndim(self) -> int: """int: Number of dimensions in the data.""" return self._ndim @property - def help(self): + def help(self) -> str: """str: displayed in status bar bottom right.""" return self._help @help.setter - def help(self, help_text): + def help(self, help_text: str) -> None: if help_text == self.help: return self._help = help_text @@ -1068,7 +1070,7 @@ def help(self, help_text): def interactive(self) -> bool: warnings.warn( trans._( - "Layer.interactive is deprecated since napari 0.4.18 and will be removed in 0.6.0. Please use Layer.mouse_pan and Layer.mouse_zoom instead" + 'Layer.interactive is deprecated since napari 0.4.18 and will be removed in 0.6.0. Please use Layer.mouse_pan and Layer.mouse_zoom instead' ), FutureWarning, stacklevel=2, @@ -1076,10 +1078,10 @@ def interactive(self) -> bool: return self.mouse_pan or self.mouse_zoom @interactive.setter - def interactive(self, interactive: bool): + def interactive(self, interactive: bool) -> None: warnings.warn( trans._( - "Layer.interactive is deprecated since napari 0.4.18 and will be removed in 0.6.0. Please use Layer.mouse_pan and Layer.mouse_zoom instead" + 'Layer.interactive is deprecated since napari 0.4.18 and will be removed in 0.6.0. Please use Layer.mouse_pan and Layer.mouse_zoom instead' ), FutureWarning, stacklevel=2, @@ -1094,7 +1096,7 @@ def mouse_pan(self) -> bool: return self._mouse_pan @mouse_pan.setter - def mouse_pan(self, mouse_pan: bool): + def mouse_pan(self, mouse_pan: bool) -> None: if mouse_pan == self._mouse_pan: return self._mouse_pan = mouse_pan @@ -1109,7 +1111,7 @@ def mouse_zoom(self) -> bool: return self._mouse_zoom @mouse_zoom.setter - def mouse_zoom(self, mouse_zoom: bool): + def mouse_zoom(self, mouse_zoom: bool) -> None: if mouse_zoom == self._mouse_zoom: return self._mouse_zoom = mouse_zoom @@ -1119,31 +1121,31 @@ def mouse_zoom(self, mouse_zoom: bool): ) # Deprecated since 0.5.0 @property - def cursor(self): + def cursor(self) -> str: """str: String identifying cursor displayed over canvas.""" return self._cursor @cursor.setter - def cursor(self, cursor): + def cursor(self, cursor: str) -> None: if cursor == self.cursor: return self._cursor = cursor self.events.cursor(cursor=cursor) @property - def cursor_size(self): - """int | None: Size of cursor if custom. None yields default size.""" + def cursor_size(self) -> int: + """int: Size of cursor if custom. None yields default size.""" return self._cursor_size @cursor_size.setter - def cursor_size(self, cursor_size): + def cursor_size(self, cursor_size: int) -> None: if cursor_size == self.cursor_size: return self._cursor_size = cursor_size self.events.cursor_size(cursor_size=cursor_size) @property - def experimental_clipping_planes(self): + def experimental_clipping_planes(self) -> ClippingPlaneList: return self._experimental_clipping_planes @experimental_clipping_planes.setter @@ -1152,10 +1154,10 @@ def experimental_clipping_planes( value: Union[ dict, ClippingPlane, - List[Union[ClippingPlane, dict]], + list[Union[ClippingPlane, dict]], ClippingPlaneList, ], - ): + ) -> None: self._experimental_clipping_planes.clear() if value is None: return @@ -1168,10 +1170,10 @@ def experimental_clipping_planes( self._experimental_clipping_planes.append(plane) @property - def bounding_box(self): + def bounding_box(self) -> Overlay: return self._overlays['bounding_box'] - def set_view_slice(self): + def set_view_slice(self) -> None: with self.dask_optimized_slicing(): self._set_view_slice() @@ -1183,7 +1185,7 @@ def _slice_dims( self, dims: Dims, force: bool = False, - ): + ) -> None: """Slice data with values from a global dims model. Note this will likely be moved off the base layer soon. @@ -1262,9 +1264,9 @@ def get_value( position: npt.ArrayLike, *, view_direction: Optional[npt.ArrayLike] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, - ): + ) -> Optional[tuple]: """Value of the data at a position. If the layer is not visible, return None. @@ -1335,9 +1337,9 @@ def _get_value_3d( self, start_point: Optional[np.ndarray], end_point: Optional[np.ndarray], - dims_displayed: List[int], + dims_displayed: list[int], ) -> Union[ - float, int, None, Tuple[Union[float, int, None], Optional[int]] + float, int, None, tuple[Union[float, int, None], Optional[int]] ]: """Get the layer data value along a ray @@ -1362,8 +1364,8 @@ def projected_distance_from_mouse_drag( end_position: npt.ArrayLike, view_direction: npt.ArrayLike, vector: np.ndarray, - dims_displayed: List[int], - ): + dims_displayed: list[int], + ) -> npt.NDArray: """Calculate the length of the projection of a line between two mouse clicks onto a vector (or array of vectors) in data coordinates. @@ -1405,7 +1407,7 @@ def projected_distance_from_mouse_drag( ) @contextmanager - def block_update_properties(self): + def block_update_properties(self) -> Generator[None, None, None]: previous = self._update_properties self._update_properties = False try: @@ -1413,7 +1415,7 @@ def block_update_properties(self): finally: self._update_properties = previous - def _set_highlight(self, force=False): + def _set_highlight(self, force: bool = False) -> None: """Render layer highlights when appropriate. Parameters @@ -1432,7 +1434,7 @@ def _block_refresh(self): finally: self._refresh_blocked = previous - def refresh(self, event=None): + def refresh(self, event: Optional[Event] = None) -> None: """Refresh all layer data based on current view slice.""" if self._refresh_blocked: logger.debug('Layer.refresh blocked: %s', self) @@ -1445,7 +1447,7 @@ def refresh(self, event=None): else: self._refresh_sync() - def _refresh_sync(self, event=None): + def _refresh_sync(self, event: Optional[Event] = None) -> None: logger.debug('Layer._refresh_sync: %s', self) if self.visible: self.set_view_slice() @@ -1474,7 +1476,7 @@ def world_to_data(self, position: npt.ArrayLike) -> npt.NDArray: else: coords = [0] * (self.ndim - len(position)) + list(position) - simplified = cast(Affine, self._transforms[1:].simplified) + simplified = self._transforms[1:].simplified return simplified.inverse(coords) def data_to_world(self, position): @@ -1500,7 +1502,7 @@ def data_to_world(self, position): return tuple(self._transforms[1:].simplified(coords)) def _world_to_displayed_data( - self, position: np.ndarray, dims_displayed: List[int] + self, position: np.ndarray, dims_displayed: list[int] ) -> npt.NDArray: """Convert world to data coordinates for displayed dimensions only. @@ -1531,8 +1533,7 @@ def _data_to_world(self) -> Affine: affine * (rotate * shear * scale + translate) """ - t = self._transforms[1:3].simplified - return cast(Affine, t) + return self._transforms[1:3].simplified def _world_to_data_ray(self, vector: npt.ArrayLike) -> npt.NDArray: """Convert a vector defining an orientation from world coordinates to data coordinates. @@ -1555,7 +1556,7 @@ def _world_to_data_ray(self, vector: npt.ArrayLike) -> npt.NDArray: return normalized_vector def _world_to_displayed_data_ray( - self, vector_world: npt.ArrayLike, dims_displayed: List[int] + self, vector_world: npt.ArrayLike, dims_displayed: list[int] ) -> np.ndarray: """Convert an orientation from world to displayed data coordinates. @@ -1643,7 +1644,7 @@ def _world_to_layer_dims( @staticmethod def _world_to_layer_dims_impl( world_dims: npt.NDArray, ndim_world: int, ndim: int - ): + ) -> npt.NDArray: """ Static for ease of testing """ @@ -1656,12 +1657,12 @@ def _world_to_layer_dims_impl( order = order[order >= 0] return order - order.min() - def _display_bounding_box(self, dims_displayed: List[int]) -> npt.NDArray: + def _display_bounding_box(self, dims_displayed: list[int]) -> npt.NDArray: """An axis aligned (ndisplay, 2) bounding box around the data""" return self._extent_data[:, dims_displayed].T def _display_bounding_box_augmented( - self, dims_displayed: List[int] + self, dims_displayed: list[int] ) -> npt.NDArray: """An augmented, axis-aligned (ndisplay, 2) bounding box. @@ -1670,7 +1671,7 @@ def _display_bounding_box_augmented( return self._extent_data_augmented[:, dims_displayed].T def _display_bounding_box_augmented_data_level( - self, dims_displayed: List[int] + self, dims_displayed: list[int] ) -> npt.NDArray: """An augmented, axis-aligned (ndisplay, 2) bounding box. @@ -1683,8 +1684,8 @@ def click_plane_from_click_data( self, click_position: npt.ArrayLike, view_direction: npt.ArrayLike, - dims_displayed: List[int], - ) -> Tuple[np.ndarray, np.ndarray]: + dims_displayed: list[int], + ) -> tuple[np.ndarray, np.ndarray]: """Calculate a (point, normal) plane parallel to the canvas in data coordinates, centered on the centre of rotation of the camera. @@ -1712,9 +1713,9 @@ def get_ray_intersections( self, position: npt.ArrayLike, view_direction: npt.ArrayLike, - dims_displayed: List[int], + dims_displayed: list[int], world: bool = True, - ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: """Get the start and end point for the ray extending from a point through the data bounding box. @@ -1774,10 +1775,10 @@ def _get_ray_intersections( self, position: npt.NDArray, view_direction: np.ndarray, - dims_displayed: List[int], + dims_displayed: list[int], bounding_box: npt.NDArray, world: bool = True, - ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: """Get the start and end point for the ray extending from a point through the data bounding box. @@ -1939,9 +1940,10 @@ def _update_draw( corners[:, displayed_axes] = data_bbox_clipped self.corner_pixels = corners - def _get_source_info(self): + def _get_source_info(self) -> dict: components = {} if self.source.reader_plugin: + components['layer_name'] = self.name components['layer_base'] = os.path.basename(self.source.path or '') components['source_type'] = 'plugin' try: @@ -1953,6 +1955,7 @@ def _get_source_info(self): return components if self.source.sample: + components['layer_name'] = self.name components['layer_base'] = self.name components['source_type'] = 'sample' try: @@ -1964,35 +1967,41 @@ def _get_source_info(self): return components if self.source.widget: + components['layer_name'] = self.name components['layer_base'] = self.name components['source_type'] = 'widget' components['plugin'] = self.source.widget._function.__name__ return components + components['layer_name'] = self.name components['layer_base'] = self.name components['source_type'] = '' components['plugin'] = '' return components - def get_source_str(self): + def get_source_str(self) -> str: source_info = self._get_source_info() + source_str = source_info['layer_name'] + if source_info['layer_base'] != source_info['layer_name']: + source_str += '\n' + source_info['layer_base'] + if source_info['source_type']: + source_str += ( + '\n' + + source_info['source_type'] + + ' : ' + + source_info['plugin'] + ) - return ( - source_info['layer_base'] - + ', ' - + source_info['source_type'] - + ' : ' - + source_info['plugin'] - ) + return source_str def get_status( self, position: Optional[npt.ArrayLike] = None, *, view_direction: Optional[npt.ArrayLike] = None, - dims_displayed: Optional[List[int]] = None, - world=False, - ): + dims_displayed: Optional[list[int]] = None, + world: bool = False, + ) -> dict: """ Status message information of the data at a coordinate position. @@ -2042,7 +2051,7 @@ def _get_tooltip_text( position: npt.NDArray, *, view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> str: """ @@ -2067,9 +2076,9 @@ def _get_tooltip_text( msg : string String containing a message that can be used as a tooltip. """ - return "" + return '' - def save(self, path: str, plugin: Optional[str] = None) -> List[str]: + def save(self, path: str, plugin: Optional[str] = None) -> list[str]: """Save this layer to ``path`` with default (or specified) plugin. Parameters @@ -2118,7 +2127,7 @@ def __copy__(self): @classmethod def create( cls, - data, + data: Any, meta: Optional[dict] = None, layer_type: Optional[str] = None, ) -> Layer: @@ -2192,7 +2201,7 @@ def create( bad_key = str(exc).split('keyword argument ')[-1] raise TypeError( trans._( - "_add_layer_from_data received an unexpected keyword argument ({bad_key}) for layer type {layer_type}", + '_add_layer_from_data received an unexpected keyword argument ({bad_key}) for layer type {layer_type}', deferred=True, bad_key=bad_key, layer_type=layer_type, @@ -2200,4 +2209,4 @@ def create( ) from exc -mgui.register_type(type_=List[Layer], return_callback=add_layers_to_viewer) +mgui.register_type(type_=list[Layer], return_callback=add_layers_to_viewer) diff --git a/napari/layers/image/_image_constants.py b/napari/layers/image/_image_constants.py index 68b08939061..512f09f87a8 100644 --- a/napari/layers/image/_image_constants.py +++ b/napari/layers/image/_image_constants.py @@ -1,27 +1,27 @@ from collections import OrderedDict from enum import auto -from typing import Literal, Tuple +from typing import Literal from napari.utils.misc import StringEnum from napari.utils.translations import trans InterpolationStr = Literal[ - "bessel", - "cubic", - "linear", - "blackman", - "catrom", - "gaussian", - "hamming", - "hanning", - "hermite", - "kaiser", - "lanczos", - "mitchell", - "nearest", - "spline16", - "spline36", - "custom", + 'bessel', + 'cubic', + 'linear', + 'blackman', + 'catrom', + 'gaussian', + 'hamming', + 'hanning', + 'hermite', + 'kaiser', + 'lanczos', + 'mitchell', + 'nearest', + 'spline16', + 'spline36', + 'custom', ] @@ -56,12 +56,12 @@ class Interpolation(StringEnum): @classmethod def view_subset( cls, - ) -> Tuple[ - "Interpolation", - "Interpolation", - "Interpolation", - "Interpolation", - "Interpolation", + ) -> tuple[ + 'Interpolation', + 'Interpolation', + 'Interpolation', + 'Interpolation', + 'Interpolation', ]: return ( cls.CUBIC, @@ -109,13 +109,13 @@ class ImageRendering(StringEnum): ImageRenderingStr = Literal[ - "translucent", - "additive", - "iso", - "mip", - "minip", - "attenuated_mip", - "average", + 'translucent', + 'additive', + 'iso', + 'mip', + 'minip', + 'attenuated_mip', + 'average', ] diff --git a/napari/layers/image/_image_key_bindings.py b/napari/layers/image/_image_key_bindings.py index a8c283316fc..68eb1fb15ca 100644 --- a/napari/layers/image/_image_key_bindings.py +++ b/napari/layers/image/_image_key_bindings.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Callable, Generator, Union +from collections.abc import Generator +from typing import Callable, Union from app_model.types import KeyCode diff --git a/napari/layers/image/_image_mouse_bindings.py b/napari/layers/image/_image_mouse_bindings.py index 5766ce1c5d7..31eef60f2b6 100644 --- a/napari/layers/image/_image_mouse_bindings.py +++ b/napari/layers/image/_image_mouse_bindings.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Generator, Union +from collections.abc import Generator +from typing import TYPE_CHECKING, Union import numpy as np diff --git a/napari/layers/image/_image_utils.py b/napari/layers/image/_image_utils.py index 39fa49611e4..8b5c20bf893 100644 --- a/napari/layers/image/_image_utils.py +++ b/napari/layers/image/_image_utils.py @@ -1,7 +1,8 @@ """guess_rgb, guess_multiscale, guess_labels. """ -from typing import Any, Callable, Literal, Sequence, Tuple, Union +from collections.abc import Sequence +from typing import Any, Callable, Literal, Union import numpy as np import numpy.typing as npt @@ -12,7 +13,7 @@ from napari.utils.translations import trans -def guess_rgb(shape: Tuple[int, ...]) -> bool: +def guess_rgb(shape: tuple[int, ...]) -> bool: """Guess if the passed shape comes from rgb data. If last dim is 3 or 4 assume the data is rgb, including rgba. @@ -35,7 +36,7 @@ def guess_rgb(shape: Tuple[int, ...]) -> bool: def guess_multiscale( data: Union[MultiScaleData, list, tuple], -) -> Tuple[bool, Union[LayerDataProtocol, Sequence[LayerDataProtocol]]]: +) -> tuple[bool, Union[LayerDataProtocol, Sequence[LayerDataProtocol]]]: """Guess whether the passed data is multiscale, process it accordingly. If shape of arrays along first axis is strictly decreasing, the data is @@ -96,7 +97,7 @@ def guess_multiscale( return True, MultiScaleData(data) -def guess_labels(data: Any) -> Literal["labels", "image"]: +def guess_labels(data: Any) -> Literal['labels', 'image']: """Guess if array contains labels data.""" if hasattr(data, 'dtype') and data.dtype in ( @@ -111,8 +112,8 @@ def guess_labels(data: Any) -> Literal["labels", "image"]: def project_slice( - data: npt.NDArray, axis: Tuple[int, ...], mode: ImageProjectionMode -) -> float: + data: npt.NDArray, axis: tuple[int, ...], mode: ImageProjectionMode +) -> npt.NDArray: """Project a thick slice along axis based on mode.""" func: Callable if mode == ImageProjectionMode.SUM: diff --git a/napari/layers/image/_slice.py b/napari/layers/image/_slice.py index 2617bb72ac1..77385323049 100644 --- a/napari/layers/image/_slice.py +++ b/napari/layers/image/_slice.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Any, Callable, List, Tuple, Union +from typing import Any, Callable, Union import numpy as np @@ -254,7 +254,8 @@ def _call_multi_scale(self) -> _ImageSliceResponse: ) # slice displayed dimensions to get the right tile data - data = np.asarray(data[tuple(disp_slice)]) + data = data[tuple(disp_slice)] + # project the thick slice data_slice = self._thick_slice_at_level(level) data = self._project_thick_slice(data, data_slice) @@ -290,9 +291,12 @@ def _thick_slice_at_level(self, level: int) -> _ThickNDSlice: def _project_thick_slice( self, data: ArrayLike, data_slice: _ThickNDSlice - ) -> ArrayLike: + ) -> np.ndarray: """ Slice the given data with the given data slice and project the extra dims. + + This is also responsible for materializing the data if it is backed + by a lazy store or compute graph (e.g. dask). """ if self.projection_mode == 'none': @@ -310,7 +314,7 @@ def _project_thick_slice( mode=self.projection_mode, ) - def _get_order(self) -> Tuple[int, ...]: + def _get_order(self) -> tuple[int, ...]: """Return the ordered displayed dimensions, but reduced to fit in the slice space.""" order = reorder_after_dim_reduction(self.slice_input.displayed) if self.rgb: @@ -339,8 +343,8 @@ def _slice_out_of_bounds(self) -> bool: @staticmethod def _point_to_slices( - point: Tuple[float, ...] - ) -> Tuple[Union[slice, int], ...]: + point: tuple[float, ...] + ) -> tuple[Union[slice, int], ...]: # no need to check out of bounds here cause it's guaranteed # values in point and margins are np.nan if no slicing should happen along that dimension @@ -353,8 +357,8 @@ def _point_to_slices( @staticmethod def _data_slice_to_slices( - data_slice: _ThickNDSlice, dims_displayed: List[int] - ) -> Tuple[slice, ...]: + data_slice: _ThickNDSlice, dims_displayed: list[int] + ) -> tuple[slice, ...]: slices = [slice(None) for _ in range(data_slice.ndim)] for dim, (point, m_left, m_right) in enumerate(data_slice): diff --git a/napari/layers/image/_tests/test_big_image_timing.py b/napari/layers/image/_tests/test_big_image_timing.py index 9389c32d589..cc61ca001d0 100644 --- a/napari/layers/image/_tests/test_big_image_timing.py +++ b/napari/layers/image/_tests/test_big_image_timing.py @@ -15,9 +15,9 @@ @pytest.mark.parametrize( 'kwargs', [ - {"multiscale": False, "contrast_limits": [0, 1]}, - {"multiscale": False}, - {"contrast_limits": [0, 1]}, + {'multiscale': False, 'contrast_limits': [0, 1]}, + {'multiscale': False}, + {'contrast_limits': [0, 1]}, {}, ], ids=('all', 'multiscale', 'clims', 'nothing'), @@ -29,7 +29,7 @@ def test_timing_fast_big_dask(data, kwargs): elapsed = time.monotonic() - now assert ( elapsed < 2 - ), "Test took to long some computation are likely not lazy" + ), 'Test took to long some computation are likely not lazy' def test_non_visible_images(): diff --git a/napari/layers/image/_tests/test_image.py b/napari/layers/image/_tests/test_image.py index 66dd2d458b3..359dc2364c6 100644 --- a/napari/layers/image/_tests/test_image.py +++ b/napari/layers/image/_tests/test_image.py @@ -209,7 +209,7 @@ def test_non_rgb_image(): assert layer._data_view.shape == shape[-2:] -@pytest.mark.parametrize("shape", [(10, 15, 6), (10, 10)]) +@pytest.mark.parametrize('shape', [(10, 15, 6), (10, 10)]) def test_error_non_rgb_image(shape): """Test error on trying non rgb as rgb.""" # If rgb is set to be True in constructor but the last dim has a @@ -644,7 +644,7 @@ def test_out_of_range_no_contrast(dtype): @pytest.mark.parametrize( - "scale", + 'scale', [ (None), ([1, 1]), @@ -662,7 +662,7 @@ def test_image_scale(scale): @pytest.mark.parametrize( - "translate", + 'translate', [ (None), ([1, 1]), @@ -839,7 +839,7 @@ def test_tensorstore_image(): @pytest.mark.parametrize( - "start_position, end_position, view_direction, vector, expected_value", + 'start_position, end_position, view_direction, vector, expected_value', [ # drag vector parallel to view direction # projected onto perpendicular vector diff --git a/napari/layers/image/_tests/test_image_utils.py b/napari/layers/image/_tests/test_image_utils.py index 1a69a2e5a73..1e558aa63ea 100644 --- a/napari/layers/image/_tests/test_image_utils.py +++ b/napari/layers/image/_tests/test_image_utils.py @@ -114,7 +114,7 @@ def test_timing_multiscale_big(): now = time.monotonic() assert not guess_multiscale(data_dask)[0] elapsed = time.monotonic() - now - assert elapsed < 2, "test was too slow, computation was likely not lazy" + assert elapsed < 2, 'test was too slow, computation was likely not lazy' def test_create_data_indexing(): diff --git a/napari/layers/image/image.py b/napari/layers/image/image.py index fcfd9bd69b7..dc4f841c8fa 100644 --- a/napari/layers/image/image.py +++ b/napari/layers/image/image.py @@ -3,619 +3,35 @@ from __future__ import annotations -import types import warnings -from abc import ABC -from contextlib import nullcontext -from typing import TYPE_CHECKING, List, Sequence, Tuple, Union, cast +from typing import Literal, Union, cast import numpy as np from scipy import ndimage as ndi from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData -from napari.layers.base import Layer +from napari.layers._scalar_field.scalar_field import ScalarFieldBase from napari.layers.image._image_constants import ( ImageProjectionMode, ImageRendering, Interpolation, InterpolationStr, - VolumeDepiction, ) -from napari.layers.image._image_mouse_bindings import ( - move_plane_along_normal as plane_drag_callback, - set_plane_position as plane_double_click_callback, -) -from napari.layers.image._image_utils import guess_multiscale, guess_rgb -from napari.layers.image._slice import _ImageSliceRequest, _ImageSliceResponse +from napari.layers.image._image_utils import guess_rgb +from napari.layers.image._slice import _ImageSliceResponse from napari.layers.intensity_mixin import IntensityVisualizationMixin -from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice from napari.layers.utils.layer_utils import calc_data_range -from napari.layers.utils.plane import SlicingPlane -from napari.utils._dask_utils import DaskIndexer from napari.utils._dtype import get_dtype_limits, normalize_dtype -from napari.utils.colormaps import AVAILABLE_COLORMAPS, ensure_colormap +from napari.utils.colormaps import ensure_colormap from napari.utils.colormaps.colormap_utils import _coerce_contrast_limits -from napari.utils.events import Event -from napari.utils.events.event import WarningEmitter -from napari.utils.events.event_utils import connect_no_arg from napari.utils.migrations import rename_argument -from napari.utils.naming import magic_name from napari.utils.translations import trans -if TYPE_CHECKING: - import numpy.typing as npt - - from napari.components import Dims - - -# It is important to contain at least one abstractmethod to properly exclude this class -# in creating NAMES set inside of napari.layers.__init__ -# Mixin must come before Layer -class _ImageBase(Layer, ABC): - """Base class for volumetric layers. - - Parameters - ---------- - data : array or list of array - Image data. Can be N >= 2 dimensional. If the last dimension has length - 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a - list and arrays are decreasing in shape then the data is treated as - a multiscale image. Please note multiscale rendering is only - supported in 2D. In 3D, only the lowest resolution scale is - displayed. - affine : n-D array or napari.utils.transforms.Affine - (N+1, N+1) affine transformation matrix in homogeneous coordinates. - The first (N, N) entries correspond to a linear transform and - the final column is a length N translation vector and a 1 or a napari - `Affine` transform object. Applied as an extra transform on top of the - provided scale, rotate, and shear values. - blending : str - One of a list of preset blending modes that determines how RGB and - alpha values of the layer visual get mixed. Allowed values are - {'opaque', 'translucent', and 'additive'}. - cache : bool - Whether slices of out-of-core datasets should be cached upon retrieval. - Currently, this only applies to dask arrays. - custom_interpolation_kernel_2d : np.ndarray - Convolution kernel used with the 'custom' interpolation mode in 2D rendering. - depiction : str - 3D Depiction mode. Must be one of {'volume', 'plane'}. - The default value is 'volume'. - experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList - Each dict defines a clipping plane in 3D in data coordinates. - Valid dictionary keys are {'position', 'normal', and 'enabled'}. - Values on the negative side of the normal are discarded if the plane is enabled. - metadata : dict - Layer metadata. - multiscale : bool - Whether the data is a multiscale image or not. Multiscale data is - represented by a list of array like image data. If not specified by - the user and if the data is a list of arrays that decrease in shape - then it will be taken to be multiscale. The first image in the list - should be the largest. Please note multiscale rendering is only - supported in 2D. In 3D, only the lowest resolution scale is - displayed. - name : str - Name of the layer. - ndim : int - Number of dimensions in the data. - opacity : float - Opacity of the layer visual, between 0.0 and 1.0. - plane : dict or SlicingPlane - Properties defining plane rendering in 3D. Properties are defined in - data coordinates. Valid dictionary keys are - {'position', 'normal', 'thickness', and 'enabled'}. - projection_mode : str - How data outside the viewed dimensions but inside the thick Dims slice will - be projected onto the viewed dimensions. Must fit to cls._projectionclass - rendering : str - Rendering mode used by vispy. Must be one of our supported - modes. - rotate : float, 3-tuple of float, or n-D array. - If a float convert into a 2D rotation matrix using that value as an - angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, - pitch, roll convention. Otherwise assume an nD rotation. Angles are - assumed to be in degrees. They can be converted from radians with - np.degrees if needed. - scale : tuple of float - Scale factors for the layer. - shear : 1-D array or n-D array - Either a vector of upper triangular values, or an nD shear matrix with - ones along the main diagonal. - translate : tuple of float - Translation values for the layer. - visible : bool - Whether the layer visual is currently being displayed. - - - Attributes - ---------- - data : array or list of array - Image data. Can be N dimensional. If the last dimension has length - 3 or 4 can be interpreted as RGB or RGBA if rgb is `True`. If a list - and arrays are decreasing in shape then the data is treated as a - multiscale image. Please note multiscale rendering is only - supported in 2D. In 3D, only the lowest resolution scale is - displayed. - metadata : dict - Image metadata. - multiscale : bool - Whether the data is a multiscale image or not. Multiscale data is - represented by a list of array like image data. The first image in the - list should be the largest. Please note multiscale rendering is only - supported in 2D. In 3D, only the lowest resolution scale is - displayed. - mode : str - Interactive mode. The normal, default mode is PAN_ZOOM, which - allows for normal interactivity with the canvas. - - In TRANSFORM mode the image can be transformed interactively. - rendering : str - Rendering mode used by vispy. Must be one of our supported - modes. - depiction : str - 3D Depiction mode used by vispy. Must be one of our supported modes. - plane : SlicingPlane or dict - Properties defining plane rendering in 3D. Valid dictionary keys are - {'position', 'normal', 'thickness'}. - experimental_clipping_planes : ClippingPlaneList - Clipping planes defined in data coordinates, used to clip the volume. - custom_interpolation_kernel_2d : np.ndarray - Convolution kernel used with the 'custom' interpolation mode in 2D rendering. - - Notes - ----- - _data_view : array (N, M), (N, M, 3), or (N, M, 4) - Image data for the currently viewed slice. Must be 2D image data, but - can be multidimensional for RGB or RGBA images if multidimensional is - `True`. - """ - - _colormaps = AVAILABLE_COLORMAPS - _interpolation2d: Interpolation - _interpolation3d: Interpolation - - def __init__( - self, - data, - *, - affine=None, - blending='translucent', - cache=True, - custom_interpolation_kernel_2d=None, - depiction='volume', - experimental_clipping_planes=None, - metadata=None, - multiscale=None, - name=None, - ndim=None, - opacity=1.0, - plane=None, - projection_mode='none', - rendering='mip', - rotate=None, - scale=None, - shear=None, - translate=None, - visible=True, - ) -> None: - if name is None and data is not None: - name = magic_name(data) - - if isinstance(data, types.GeneratorType): - data = list(data) +__all__ = ('Image',) - if getattr(data, 'ndim', 2) < 2: - raise ValueError( - trans._('Image data must have at least 2 dimensions.') - ) - - # Determine if data is a multiscale - self._data_raw = data - if multiscale is None: - multiscale, data = guess_multiscale(data) - elif multiscale and not isinstance(data, MultiScaleData): - data = MultiScaleData(data) - # Determine dimensionality of the data - if ndim is None: - ndim = len(data.shape) - - super().__init__( - data, - ndim, - name=name, - metadata=metadata, - scale=scale, - translate=translate, - rotate=rotate, - shear=shear, - affine=affine, - opacity=opacity, - blending=blending, - visible=visible, - multiscale=multiscale, - cache=cache, - experimental_clipping_planes=experimental_clipping_planes, - projection_mode=projection_mode, - ) - - self.events.add( - attenuation=Event, - custom_interpolation_kernel_2d=Event, - depiction=Event, - interpolation=WarningEmitter( - trans._( - "'layer.events.interpolation' is deprecated please use `interpolation2d` and `interpolation3d`", - deferred=True, - ), - type_name='select', - ), - interpolation2d=Event, - interpolation3d=Event, - iso_threshold=Event, - plane=Event, - rendering=Event, - ) - - self._array_like = True - - # Set data - self._data = data - if isinstance(data, MultiScaleData): - self._data_level = len(data) - 1 - # Determine which level of the multiscale to use for the thumbnail. - # Pick the smallest level with at least one axis >= 64. This is - # done to prevent the thumbnail from being from one of the very - # low resolution layers and therefore being very blurred. - big_enough_levels = [ - np.any(np.greater_equal(p.shape, 64)) for p in data - ] - if np.any(big_enough_levels): - self._thumbnail_level = np.where(big_enough_levels)[0][-1] - else: - self._thumbnail_level = 0 - else: - self._data_level = 0 - self._thumbnail_level = 0 - displayed_axes = self._slice_input.displayed - self.corner_pixels[1][displayed_axes] = ( - np.array(self.level_shapes)[self._data_level][displayed_axes] - 1 - ) - - self._slice = _ImageSliceResponse.make_empty( - slice_input=self._slice_input, - rgb=len(self.data.shape) != self.ndim, - ) - - self._plane = SlicingPlane(thickness=1) - # Whether to calculate clims on the next set_view_slice - self._should_calc_clims = False - # using self.colormap = colormap uses the setter in *derived* classes, - # where the intention here is to use the base setter, so we use the - # _set_colormap method. This is important for Labels layers, because - # we don't want to use get_color before set_view_slice has been - # triggered (self.refresh(), below). - self.rendering = rendering - self.depiction = depiction - if plane is not None: - self.plane = plane - connect_no_arg(self.plane.events, self.events, 'plane') - self.custom_interpolation_kernel_2d = custom_interpolation_kernel_2d - - def _post_init(self): - # Trigger generation of view slice and thumbnail - self.refresh() - - @property - def _data_view(self) -> np.ndarray: - """Viewable image for the current slice. (compatibility)""" - return self._slice.image.view - - @property - def dtype(self): - return self._data.dtype - - @property - def data_raw( - self, - ) -> Union[LayerDataProtocol, Sequence[LayerDataProtocol]]: - """Data, exactly as provided by the user.""" - return self._data_raw - - def _get_ndim(self) -> int: - """Determine number of dimensions of the layer.""" - return len(self.level_shapes[0]) - - @property - def _extent_data(self) -> np.ndarray: - """Extent of layer in data coordinates. - - Returns - ------- - extent_data : array, shape (2, D) - """ - shape = self.level_shapes[0] - return np.vstack([np.zeros(len(shape)), shape - 1]) - - @property - def _extent_data_augmented(self) -> np.ndarray: - extent = self._extent_data - return extent + [[-0.5], [+0.5]] - - @property - def _extent_level_data(self) -> np.ndarray: - """Extent of layer, accounting for current multiscale level, in data coordinates. - - Returns - ------- - extent_data : array, shape (2, D) - """ - shape = self.level_shapes[self.data_level] - return np.vstack([np.zeros(len(shape)), shape - 1]) - - @property - def _extent_level_data_augmented(self) -> np.ndarray: - extent = self._extent_level_data - return extent + [[-0.5], [+0.5]] - - @property - def data_level(self) -> int: - """int: Current level of multiscale, or 0 if image.""" - return self._data_level - - @data_level.setter - def data_level(self, level: int): - if self._data_level == level: - return - self._data_level = level - self.refresh() - - def _get_level_shapes(self): - data = self.data - if isinstance(data, MultiScaleData): - shapes = data.shapes - else: - shapes = [self.data.shape] - return shapes - - @property - def level_shapes(self) -> np.ndarray: - """array: Shapes of each level of the multiscale or just of image.""" - return np.array(self._get_level_shapes()) - - @property - def downsample_factors(self) -> np.ndarray: - """list: Downsample factors for each level of the multiscale.""" - return np.divide(self.level_shapes[0], self.level_shapes) - - @property - def depiction(self): - """The current 3D depiction mode. - - Selects a preset depiction mode in vispy - * volume: images are rendered as 3D volumes. - * plane: images are rendered as 2D planes embedded in 3D. - plane position, normal, and thickness are attributes of - layer.plane which can be modified directly. - """ - return str(self._depiction) - - @depiction.setter - def depiction(self, depiction: Union[str, VolumeDepiction]): - """Set the current 3D depiction mode.""" - self._depiction = VolumeDepiction(depiction) - self._update_plane_callbacks() - self.events.depiction() - - def _reset_plane_parameters(self): - """Set plane attributes to something valid.""" - self.plane.position = np.array(self.data.shape) / 2 - self.plane.normal = (1, 0, 0) - - def _update_plane_callbacks(self): - """Set plane callbacks depending on depiction mode.""" - plane_drag_callback_connected = ( - plane_drag_callback in self.mouse_drag_callbacks - ) - double_click_callback_connected = ( - plane_double_click_callback in self.mouse_double_click_callbacks - ) - if self.depiction == VolumeDepiction.VOLUME: - if plane_drag_callback_connected: - self.mouse_drag_callbacks.remove(plane_drag_callback) - if double_click_callback_connected: - self.mouse_double_click_callbacks.remove( - plane_double_click_callback - ) - elif self.depiction == VolumeDepiction.PLANE: - if not plane_drag_callback_connected: - self.mouse_drag_callbacks.append(plane_drag_callback) - if not double_click_callback_connected: - self.mouse_double_click_callbacks.append( - plane_double_click_callback - ) - - @property - def plane(self): - return self._plane - - @plane.setter - def plane(self, value: Union[dict, SlicingPlane]): - self._plane.update(value) - self.events.plane() - - @property - def custom_interpolation_kernel_2d(self): - return self._custom_interpolation_kernel_2d - - @custom_interpolation_kernel_2d.setter - def custom_interpolation_kernel_2d(self, value): - if value is None: - value = [[1]] - self._custom_interpolation_kernel_2d = np.array(value, np.float32) - self.events.custom_interpolation_kernel_2d() - - def _raw_to_displayed(self, raw: np.ndarray) -> np.ndarray: - """Determine displayed image from raw image. - - For normal image layers, just return the actual image. - - Parameters - ---------- - raw : array - Raw array. - - Returns - ------- - image : array - Displayed array. - """ - raise NotImplementedError - - def _set_view_slice(self) -> None: - """Set the slice output based on this layer's current state.""" - # The new slicing code makes a request from the existing state and - # executes the request on the calling thread directly. - # For async slicing, the calling thread will not be the main thread. - request = self._make_slice_request_internal( - slice_input=self._slice_input, - data_slice=self._data_slice, - dask_indexer=nullcontext, - ) - response = request() - self._update_slice_response(response) - - def _make_slice_request(self, dims: Dims) -> _ImageSliceRequest: - """Make an image slice request based on the given dims and this image.""" - slice_input = self._make_slice_input(dims) - # For the existing sync slicing, indices is passed through - # to avoid some performance issues related to the evaluation of the - # data-to-world transform and its inverse. Async slicing currently - # absorbs these performance issues here, but we can likely improve - # things either by caching the world-to-data transform on the layer - # or by lazily evaluating it in the slice task itself. - indices = slice_input.data_slice(self._data_to_world.inverse) - return self._make_slice_request_internal( - slice_input=slice_input, - data_slice=indices, - dask_indexer=self.dask_optimized_slicing, - ) - - def _make_slice_request_internal( - self, - *, - slice_input: _SliceInput, - data_slice: _ThickNDSlice, - dask_indexer: DaskIndexer, - ) -> _ImageSliceRequest: - """Needed to support old-style sync slicing through _slice_dims and - _set_view_slice. - - This is temporary scaffolding that should go away once we have completed - the async slicing project: https://github.com/napari/napari/issues/4795 - """ - return _ImageSliceRequest( - slice_input=slice_input, - data=self.data, - dask_indexer=dask_indexer, - data_slice=data_slice, - projection_mode=self.projection_mode, - multiscale=self.multiscale, - corner_pixels=self.corner_pixels, - rgb=len(self.data.shape) != self.ndim, - data_level=self.data_level, - thumbnail_level=self._thumbnail_level, - level_shapes=self.level_shapes, - downsample_factors=self.downsample_factors, - ) - - def _update_slice_response(self, response: _ImageSliceResponse) -> None: - """Update the slice output state currently on the layer. Currently used - for both sync and async slicing. - """ - response = response.to_displayed(self._raw_to_displayed) - # We call to_displayed here to ensure that if the contrast limits - # are outside the range of supported by vispy, then data view is - # rescaled to fit within the range. - self._slice_input = response.slice_input - self._transforms[0] = response.tile_to_data - self._slice = response - - def _get_value(self, position): - """Value of the data at a position in data coordinates. - - Parameters - ---------- - position : tuple - Position in data coordinates. - - Returns - ------- - value : tuple - Value of the data. - """ - if self.multiscale: - # for multiscale data map the coordinate from the data back to - # the tile - coord = self._transforms['tile2data'].inverse(position) - else: - coord = position - - coord = np.round(coord).astype(int) - - raw = self._slice.image.raw - shape = ( - raw.shape[:-1] if self.ndim != len(self._data.shape) else raw.shape - ) - - if self.ndim < len(coord): - # handle 3D views of 2D data by omitting extra coordinate - offset = len(coord) - len(shape) - coord = coord[[d + offset for d in self._slice_input.displayed]] - else: - coord = coord[self._slice_input.displayed] - - if all(0 <= c < s for c, s in zip(coord, shape)): - value = raw[tuple(coord)] - else: - value = None - - if self.multiscale: - value = (self.data_level, value) - - return value - - def _get_offset_data_position(self, position: npt.NDArray) -> npt.NDArray: - """Adjust position for offset between viewer and data coordinates. - - VisPy considers the coordinate system origin to be the canvas corner, - while napari considers the origin to be the **center** of the corner - pixel. To get the correct value under the mouse cursor, we need to - shift the position by 0.5 pixels on each axis. - """ - return position + 0.5 - - def _display_bounding_box_at_level( - self, dims_displayed: List[int], data_level: int - ) -> npt.NDArray: - """An axis aligned (ndisplay, 2) bounding box around the data at a given level""" - shape = self.level_shapes[data_level] - extent_at_level = np.vstack([np.zeros(len(shape)), shape - 1]) - return extent_at_level[:, dims_displayed].T - - def _display_bounding_box_augmented_data_level( - self, dims_displayed: List[int] - ) -> npt.NDArray: - """An augmented, axis-aligned (ndisplay, 2) bounding box. - If the layer is multiscale layer, then returns the - bounding box of the data at the current level - """ - return self._extent_level_data_augmented[:, dims_displayed].T - - -class Image(IntensityVisualizationMixin, _ImageBase): +class Image(IntensityVisualizationMixin, ScalarFieldBase): """Image layer. Parameters @@ -638,20 +54,21 @@ class Image(IntensityVisualizationMixin, _ImageBase): blending : str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are - {'opaque', 'translucent', and 'additive'}. + {'translucent', 'translucent_no_depth', 'additive', 'minimum', 'opaque'}. cache : bool Whether slices of out-of-core datasets should be cached upon retrieval. Currently, this only applies to dask arrays. colormap : str, napari.utils.Colormap, tuple, dict - Colormap to use for luminance images. If a string must be the name - of a supported colormap from vispy or matplotlib. If a tuple the - first value must be a string to assign as a name to a colormap and - the second item must be a Colormap. If a dict the key must be a - string to assign as a name to a colormap and the value must be a + Colormaps to use for luminance images. If a string, it can be the name + of a supported colormap from vispy or matplotlib or the name of + a vispy color or a hexadecimal RGB color representation. + If a tuple, the first value must be a string to assign as a name to a + colormap and the second item must be a Colormap. If a dict, the key must + be a string to assign as a name to a colormap and the value must be a Colormap. contrast_limits : list (2,) - Color limits to be used for determining the colormap bounds for - luminance images. If not passed is calculated as the min and max of + Intensity value limits to be used for determining the minimum and maximum colormap bounds for + luminance images. If not passed, they will be calculated as the min and max intensity value of the image. custom_interpolation_kernel_2d : np.ndarray Convolution kernel used with the 'custom' interpolation mode in 2D rendering. @@ -663,13 +80,13 @@ class Image(IntensityVisualizationMixin, _ImageBase): Valid dictionary keys are {'position', 'normal', and 'enabled'}. Values on the negative side of the normal are discarded if the plane is enabled. gamma : float - Gamma correction for determining colormap linearity. Defaults to 1. + Gamma correction for determining colormap linearity; defaults to 1. interpolation2d : str Interpolation mode used by vispy for rendering 2d data. Must be one of our supported modes. (for list of supported modes see Interpolation enum) 'custom' is a special mode for 2D interpolation in which a regular grid - of samples are taken from the texture around a position using 'linear' + of samples is taken from the texture around a position using 'linear' interpolation before being multiplied with a custom interpolation kernel (provided with 'custom_interpolation_kernel_2d'). interpolation3d : str @@ -680,8 +97,8 @@ class Image(IntensityVisualizationMixin, _ImageBase): Layer metadata. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is - represented by a list of array like image data. If not specified by - the user and if the data is a list of arrays that decrease in shape + represented by a list of array-like image data. If not specified by + the user and if the data is a list of arrays that decrease in shape, then it will be taken to be multiscale. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is @@ -695,21 +112,22 @@ class Image(IntensityVisualizationMixin, _ImageBase): data coordinates. Valid dictionary keys are {'position', 'normal', 'thickness', and 'enabled'}. projection_mode : str - How data outside the viewed dimensions but inside the thick Dims slice will + How data outside the viewed dimensions, but inside the thick Dims slice will be projected onto the viewed dimensions. Must fit to ImageProjectionMode rendering : str Rendering mode used by vispy. Must be one of our supported modes. - rgb : bool - Whether the image is rgb RGB or RGBA. If not specified by user and - the last dimension of the data has length 3 or 4 it will be set as - `True`. If `False` the image is interpreted as a luminance image. + rgb : bool, optional + Whether the image is RGB or RGBA if rgb. If not + specified by user, but the last dimension of the data has length 3 or 4, + it will be set as `True`. If `False`, the image is interpreted as a + luminance image. rotate : float, 3-tuple of float, or n-D array. - If a float convert into a 2D rotation matrix using that value as an - angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, - pitch, roll convention. Otherwise assume an nD rotation. Angles are + If a float, convert into a 2D rotation matrix using that value as an + angle. If 3-tuple, convert into a 3D rotation matrix, using a yaw, + pitch, roll convention. Otherwise, assume an nD rotation. Angles are assumed to be in degrees. They can be converted from radians with - np.degrees if needed. + 'np.degrees' if needed. scale : tuple of float Scale factors for the layer. shear : 1-D array or n-D array @@ -797,10 +215,10 @@ class Image(IntensityVisualizationMixin, _ImageBase): _projectionclass = ImageProjectionMode @rename_argument( - from_name="interpolation", - to_name="interpolation2d", - version="0.6.0", - since_version="0.4.17", + from_name='interpolation', + to_name='interpolation2d', + version='0.6.0', + since_version='0.4.17', ) def __init__( self, @@ -832,7 +250,7 @@ def __init__( shear=None, translate=None, visible=True, - ) -> None: + ): # Determine if rgb data_shape = data.shape if hasattr(data, 'shape') else data[0].shape rgb_guess = guess_rgb(data_shape) @@ -891,7 +309,7 @@ def __init__( self.contrast_limits_range = self._calc_data_range() else: self.contrast_limits_range = contrast_limits - self._contrast_limits: Tuple[float, float] = self.contrast_limits_range + self._contrast_limits: tuple[float, float] = self.contrast_limits_range self.contrast_limits = self._contrast_limits if iso_threshold is None: @@ -988,18 +406,18 @@ def attenuation(self) -> float: return self._attenuation @attenuation.setter - def attenuation(self, value: float): + def attenuation(self, value: float) -> None: self._attenuation = value self._update_thumbnail() self.events.attenuation() @property - def data(self) -> LayerDataProtocol: + def data(self) -> Union[LayerDataProtocol, MultiScaleData]: """Data, possibly in multiscale wrapper. Obeys LayerDataProtocol.""" return self._data @data.setter - def data(self, data: Union[LayerDataProtocol, MultiScaleData]): + def data(self, data: Union[LayerDataProtocol, MultiScaleData]) -> None: self._data_raw = data # note, we don't support changing multiscale in an Image instance self._data = MultiScaleData(data) if self.multiscale else data # type: ignore @@ -1030,7 +448,7 @@ def interpolation(self): """ warnings.warn( trans._( - "Interpolation attribute is deprecated since 0.4.17. Please use interpolation2d or interpolation3d", + 'Interpolation attribute is deprecated since 0.4.17. Please use interpolation2d or interpolation3d', ), category=DeprecationWarning, stacklevel=2, @@ -1046,7 +464,7 @@ def interpolation(self, interpolation): """Set current interpolation mode.""" warnings.warn( trans._( - "Interpolation setting is deprecated since 0.4.17. Please use interpolation2d or interpolation3d", + 'Interpolation setting is deprecated since 0.4.17. Please use interpolation2d or interpolation3d', ), category=DeprecationWarning, stacklevel=2, @@ -1071,7 +489,9 @@ def interpolation2d(self) -> InterpolationStr: return cast(InterpolationStr, str(self._interpolation2d)) @interpolation2d.setter - def interpolation2d(self, value: Union[InterpolationStr, Interpolation]): + def interpolation2d( + self, value: Union[InterpolationStr, Interpolation] + ) -> None: if value == 'bilinear': raise ValueError( trans._( @@ -1094,7 +514,9 @@ def interpolation3d(self) -> InterpolationStr: return cast(InterpolationStr, str(self._interpolation3d)) @interpolation3d.setter - def interpolation3d(self, value: Union[InterpolationStr, Interpolation]): + def interpolation3d( + self, value: Union[InterpolationStr, Interpolation] + ) -> None: if value == 'custom': raise NotImplementedError( 'custom interpolation is not implemented yet for 3D rendering' @@ -1116,7 +538,7 @@ def iso_threshold(self) -> float: return self._iso_threshold @iso_threshold.setter - def iso_threshold(self, value: float): + def iso_threshold(self, value: float) -> None: self._iso_threshold = value self._update_thumbnail() self.events.iso_threshold() @@ -1182,7 +604,9 @@ def _update_thumbnail(self): colormapped[..., 3] *= self.opacity self.thumbnail = colormapped - def _calc_data_range(self, mode='data') -> Tuple[float, float]: + def _calc_data_range( + self, mode: Literal['data', 'slice'] = 'data' + ) -> tuple[float, float]: """ Calculate the range of the data values in the currently viewed slice or full data array @@ -1200,7 +624,9 @@ def _calc_data_range(self, mode='data') -> Tuple[float, float]: mode=mode, ) ) - return calc_data_range(input_data, rgb=self.rgb) + return calc_data_range( + cast(LayerDataProtocol, input_data), rgb=self.rgb + ) def _raw_to_displayed(self, raw: np.ndarray) -> np.ndarray: """Determine displayed image from raw image. diff --git a/napari/layers/intensity_mixin.py b/napari/layers/intensity_mixin.py index a025268c753..1dd35c32e62 100644 --- a/napari/layers/intensity_mixin.py +++ b/napari/layers/intensity_mixin.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional import numpy as np @@ -11,7 +11,7 @@ validate_2_tuple = validate_n_seq(2) if TYPE_CHECKING: - from napari.layers.image.image import _ImageBase + from napari.layers._scalar_field.scalar_field import ScalarFieldBase class IntensityVisualizationMixin: @@ -39,17 +39,17 @@ def __init__(self, *args, **kwargs) -> None: self._gamma = 1 self._colormap_name = '' self._contrast_limits_msg = '' - self._contrast_limits: Tuple[Optional[float], Optional[float]] = ( + self._contrast_limits: tuple[Optional[float], Optional[float]] = ( None, None, ) - self._contrast_limits_range: Tuple[ + self._contrast_limits_range: tuple[ Optional[float], Optional[float] ] = (None, None) self._auto_contrast_source = 'slice' self._keep_auto_contrast = False - def reset_contrast_limits(self: '_ImageBase', mode=None): + def reset_contrast_limits(self: 'ScalarFieldBase', mode=None): """Scale contrast limits to data range""" mode = mode or self._auto_contrast_source self.contrast_limits = self._calc_data_range(mode) diff --git a/napari/layers/labels/_labels_constants.py b/napari/layers/labels/_labels_constants.py index 2b2a53822c7..6f24c1d8eb2 100644 --- a/napari/layers/labels/_labels_constants.py +++ b/napari/layers/labels/_labels_constants.py @@ -66,8 +66,8 @@ class LabelColorMode(StringEnum): LABEL_COLOR_MODE_TRANSLATIONS = OrderedDict( [ - (LabelColorMode.AUTO, trans._("auto")), - (LabelColorMode.DIRECT, trans._("direct")), + (LabelColorMode.AUTO, trans._('auto')), + (LabelColorMode.DIRECT, trans._('direct')), ] ) diff --git a/napari/layers/labels/_labels_key_bindings.py b/napari/layers/labels/_labels_key_bindings.py index 10a84677ec2..f2f8e5515ca 100644 --- a/napari/layers/labels/_labels_key_bindings.py +++ b/napari/layers/labels/_labels_key_bindings.py @@ -31,17 +31,17 @@ def activate_labels_pan_zoom_mode(layer: Labels): layer.mode = Mode.PAN_ZOOM -@register_label_mode_action(trans._("Activate the paint brush")) +@register_label_mode_action(trans._('Activate the paint brush')) def activate_labels_paint_mode(layer: Labels): layer.mode = Mode.PAINT -@register_label_mode_action(trans._("Activate the polygon tool")) +@register_label_mode_action(trans._('Activate the polygon tool')) def activate_labels_polygon_mode(layer: Labels): layer.mode = Mode.POLYGON -@register_label_mode_action(trans._("Activate the fill bucket")) +@register_label_mode_action(trans._('Activate the fill bucket')) def activate_labels_fill_mode(layer: Labels): layer.mode = Mode.FILL @@ -52,7 +52,7 @@ def activate_labels_picker_mode(layer: Labels): layer.mode = Mode.PICK -@register_label_mode_action(trans._("Activate the label eraser")) +@register_label_mode_action(trans._('Activate the label eraser')) def activate_labels_erase_mode(layer: Labels): layer.mode = Mode.ERASE @@ -70,7 +70,7 @@ def activate_labels_erase_mode(layer: Labels): @register_label_action( trans._( - "Set the currently selected label to the largest used label plus one." + 'Set the currently selected label to the largest used label plus one.' ), ) def new_label(layer: Labels): @@ -80,8 +80,8 @@ def new_label(layer: Labels): if layer.selected_label == new_selected_label: show_info( trans._( - "Current selected label is not being used. You will need to use it first " - "to be able to set the current select label to the next one available", + 'Current selected label is not being used. You will need to use it first ' + 'to be able to set the current select label to the next one available', ) ) else: @@ -89,13 +89,13 @@ def new_label(layer: Labels): else: show_info( trans._( - "Calculating empty label on non-numpy array is not supported" + 'Calculating empty label on non-numpy array is not supported' ) ) @register_label_action( - trans._("Swap between the selected label and the background label."), + trans._('Swap between the selected label and the background label.'), ) def swap_selected_and_background_labels(layer: Labels): """Swap between the selected label and the background label.""" @@ -103,21 +103,21 @@ def swap_selected_and_background_labels(layer: Labels): @register_label_action( - trans._("Decrease the currently selected label by one."), + trans._('Decrease the currently selected label by one.'), ) def decrease_label_id(layer: Labels): layer.selected_label -= 1 @register_label_action( - trans._("Increase the currently selected label by one."), + trans._('Increase the currently selected label by one.'), ) def increase_label_id(layer: Labels): layer.selected_label += 1 @register_label_action( - trans._("Decrease the paint brush size by one."), + trans._('Decrease the paint brush size by one.'), repeatable=True, ) def decrease_brush_size(layer: Labels): @@ -130,7 +130,7 @@ def decrease_brush_size(layer: Labels): @register_label_action( - trans._("Increase the paint brush size by one."), + trans._('Increase the paint brush size by one.'), repeatable=True, ) def increase_brush_size(layer: Labels): @@ -139,7 +139,7 @@ def increase_brush_size(layer: Labels): @register_layer_attr_action( - Labels, trans._("Toggle preserve labels"), "preserve_labels" + Labels, trans._('Toggle preserve labels'), 'preserve_labels' ) def toggle_preserve_labels(layer: Labels): layer.preserve_labels = not layer.preserve_labels @@ -158,19 +158,19 @@ def redo(layer: Labels): @register_label_action( - trans._("Reset the current polygon"), + trans._('Reset the current polygon'), ) def reset_polygon(layer: Labels): """Reset the drawing of the current polygon.""" - layer._overlays["polygon"].points = [] + layer._overlays['polygon'].points = [] @register_label_action( - trans._("Complete the current polygon"), + trans._('Complete the current polygon'), ) def complete_polygon(layer: Labels): """Complete the drawing of the current polygon.""" # Because layer._overlays has type Overlay, mypy doesn't know that # ._overlays["polygon"] has type LabelsPolygonOverlay, so type ignore for now # TODO: Improve typing of layer._overlays to fix this - layer._overlays["polygon"].add_polygon_to_labels(layer) + layer._overlays['polygon'].add_polygon_to_labels(layer) diff --git a/napari/layers/labels/_labels_utils.py b/napari/layers/labels/_labels_utils.py index f6b7b07859d..0de99e5a8f7 100644 --- a/napari/layers/labels/_labels_utils.py +++ b/napari/layers/labels/_labels_utils.py @@ -1,5 +1,4 @@ from functools import lru_cache -from typing import Tuple import numpy as np from scipy import ndimage as ndi @@ -239,8 +238,8 @@ def get_contours(labels: np.ndarray, thickness: int, background_label: int): def expand_slice( - axes_slice: Tuple[slice, ...], shape: tuple, offset: int -) -> Tuple[slice, ...]: + axes_slice: tuple[slice, ...], shape: tuple, offset: int +) -> tuple[slice, ...]: """Expands or shrinks a provided multi-axis slice by a given offset""" return tuple( slice( diff --git a/napari/layers/labels/_tests/test_labels.py b/napari/layers/labels/_tests/test_labels.py index e72bd971ab1..281e8f22a89 100644 --- a/napari/layers/labels/_tests/test_labels.py +++ b/napari/layers/labels/_tests/test_labels.py @@ -1,9 +1,9 @@ import copy import itertools import time +from collections import defaultdict from dataclasses import dataclass from tempfile import TemporaryDirectory -from typing import List import numpy as np import numpy.testing as npt @@ -12,7 +12,6 @@ import xarray as xr import zarr from numpy.core.numerictypes import issubdtype -from numpy.testing import assert_array_almost_equal, assert_raises from skimage import data as sk_data from napari._tests.utils import check_layer_world_data_extent @@ -260,57 +259,7 @@ def test_blending(): assert layer.blending == 'opaque' -@pytest.mark.filterwarnings("ignore:.*seed is deprecated.*") -def test_seed(): - """Test setting seed.""" - np.random.seed(0) - data = np.random.randint(20, size=(10, 15)) - layer = Labels(data) - assert layer.seed == 0.5 - - layer.seed = 0.9 - assert layer.seed == 0.9 - - layer = Labels(data, seed=0.7) - assert layer.seed == 0.7 - - # ensure setting seed updates the random colormap - mapped_07 = layer.colormap.map(layer.data) - layer.seed = 0.4 - mapped_04 = layer.colormap.map(layer.data) - assert_raises( - AssertionError, assert_array_almost_equal, mapped_07, mapped_04 - ) - - -def test_num_colors(): - """Test setting number of colors in colormap with deprecated API.""" - np.random.seed(0) - data = np.random.randint(20, size=(10, 15)) - layer = Labels(data) - - with pytest.warns(FutureWarning, match='num_colors is deprecated'): - assert layer.num_colors == 50 - - with pytest.warns(FutureWarning, match='num_colors is deprecated'): - layer.num_colors = 80 - - assert len(layer.colormap) == 80 - - with pytest.warns(FutureWarning, match='num_colors is deprecated'): - layer = Labels(data, num_colors=60) - - assert len(layer.colormap) == 60 - - with pytest.raises(ValueError, match=r".*Only up to 2\*\*16=65535 colors"): - with pytest.warns(FutureWarning, match='num_colors is deprecated'): - layer.num_colors = 2**17 - - with pytest.raises(ValueError, match=r".*Only up to 2\*\*16=65535 colors"): - with pytest.warns(FutureWarning, match='num_colors is deprecated'): - Labels(data, num_colors=2**17) - - +@pytest.mark.filterwarnings('ignore:.*seed is deprecated.*') def test_properties(): """Test adding labels with properties.""" np.random.seed(0) @@ -341,7 +290,7 @@ def test_properties(): properties = {'class': ['Background']} layer = Labels(data, properties=properties) layer_message = layer.get_status((0, 0)) - assert layer_message['coordinates'].endswith("[No Properties]") + assert layer_message['coordinates'].endswith('[No Properties]') properties = {'class': ['Background', 'Class 12'], 'index': [0, 12]} label_index = {0: 0, 12: 1} @@ -402,7 +351,7 @@ def test_multiscale_properties(): properties = {'class': ['Background']} layer = Labels(data, properties=properties) layer_message = layer.get_status((0, 0)) - assert layer_message['coordinates'].endswith("[No Properties]") + assert layer_message['coordinates'].endswith('[No Properties]') properties = {'class': ['Background', 'Class 12'], 'index': [0, 12]} label_index = {0: 0, 12: 1} @@ -447,11 +396,13 @@ def test_custom_color_dict(): """Test custom color dict.""" np.random.seed(0) data = np.random.randint(20, size=(10, 15)) - with pytest.warns(FutureWarning, match='Labels.color is deprecated'): - layer = Labels( - data, - color={2: 'white', 4: 'red', 8: 'blue', 16: 'red', 32: 'blue'}, + cmap = DirectLabelColormap( + color_dict=defaultdict( + lambda: 'black', + {2: 'white', 4: 'red', 8: 'blue', 16: 'red', 32: 'blue'}, ) + ) + layer = Labels(data, colormap=cmap) # test with custom color dict assert isinstance(layer.get_color(2), np.ndarray) @@ -462,8 +413,6 @@ def test_custom_color_dict(): # test disable custom color dict # should not initialize as white since we are using random.seed - with pytest.warns(FutureWarning, match='Labels.color_mode is deprecated'): - layer.color_mode = 'auto' assert not (layer.get_color(1) == np.array([1.0, 1.0, 1.0, 1.0])).all() @@ -510,7 +459,7 @@ def test_n_edit_dimensions(): @pytest.mark.parametrize( - "input_data, expected_data_view", + 'input_data, expected_data_view', [ ( np.array( @@ -625,7 +574,7 @@ def test_contour(input_data, expected_data_view): layer.contour = -1 -@pytest.mark.parametrize("background_num", [0, 1, 2, -1]) +@pytest.mark.parametrize('background_num', [0, 1, 2, -1]) def test_background_label(background_num): data = np.zeros((10, 10), dtype=np.int32) data[1:-1, 1:-1] = 1 @@ -852,7 +801,7 @@ def test_paint_2d_xarray(): assert isinstance(layer.data, xr.DataArray) assert layer.data.sum() == 411 elapsed = time.monotonic() - now - assert elapsed < 1, "test was too slow, computation was likely not lazy" + assert elapsed < 1, 'test was too slow, computation was likely not lazy' def test_paint_3d(): @@ -1004,8 +953,8 @@ def test_thumbnail(): assert layer.thumbnail.shape == layer._thumbnail_shape -@pytest.mark.parametrize("value", [1, 10, 50, -2, -10]) -@pytest.mark.parametrize("dtype", [np.int8, np.int32]) +@pytest.mark.parametrize('value', [1, 10, 50, -2, -10]) +@pytest.mark.parametrize('dtype', [np.int8, np.int32]) def test_thumbnail_single_color(value, dtype): labels = Labels(np.full((10, 10), value, dtype=dtype), opacity=1) labels._update_thumbnail() @@ -1124,7 +1073,7 @@ def test_cursor_size_with_negative_scale(): @pytest.mark.xfail( - reason="labels are converted to float32 before being mapped" + reason='labels are converted to float32 before being mapped' ) def test_large_label_values(): label_array = 2**23 + np.arange(4, dtype=np.uint64).reshape((2, 2)) @@ -1222,11 +1171,11 @@ def test_3d_video_and_3d_scale_translate_then_scale_translate_padded(): @dataclass class MouseEvent: # mock mouse event class - pos: List[int] - position: List[int] - dims_point: List[int] - dims_displayed: List[int] - view_direction: List[int] + pos: list[int] + position: list[int] + dims_point: list[int] + dims_displayed: list[int] + view_direction: list[int] def test_get_value_ray_3d(): @@ -1511,8 +1460,6 @@ def test_is_default_color(): # setting color to default colors doesn't update color mode layer.colormap = DirectLabelColormap(color_dict=current_color) assert isinstance(layer.colormap, CyclicLabelColormap) - with pytest.warns(FutureWarning, match='Labels.color_mode is deprecated'): - assert layer.color_mode == 'auto' # new colors are not default new_color = {0: 'white', 1: 'red', 3: 'green', None: 'blue'} @@ -1520,8 +1467,6 @@ def test_is_default_color(): # setting the color with non-default colors updates color mode layer.colormap = DirectLabelColormap(color_dict=new_color) assert isinstance(layer.colormap, DirectLabelColormap) - with pytest.warns(FutureWarning, match='Labels.color_mode is deprecated'): - assert layer.color_mode == 'direct' def test_large_labels_direct_color(): @@ -1529,12 +1474,12 @@ def test_large_labels_direct_color(): pytest.importorskip('numba') data = np.array([[0, 1], [2**16, 2**20]], dtype=np.uint32) colors = {1: 'white', 2**16: 'green', 2**20: 'magenta'} - layer = Labels(data) - with pytest.warns(FutureWarning, match='Labels.color is deprecated'): - layer.color = colors - - with pytest.warns(FutureWarning, match='Labels.color_mode is deprecated'): - assert layer.color_mode == 'direct' + layer = Labels( + data, + colormap=DirectLabelColormap( + color_dict=defaultdict(lambda: 'black', colors) + ), + ) np.testing.assert_allclose(layer.get_color(2**20), [1.0, 0.0, 1.0, 1.0]) @@ -1737,7 +1682,7 @@ def test_copy(): @pytest.mark.parametrize( - "colormap,expected", + 'colormap,expected', [ (label_colormap(49, 0.5), [0, 1]), ( @@ -1751,11 +1696,11 @@ def test_copy(): [1, 2], ), ], - ids=["auto", "direct"], + ids=['auto', 'direct'], ) def test_draw(colormap, expected): labels = Labels(np.zeros((30, 30), dtype=np.uint32)) - labels.mode = "paint" + labels.mode = 'paint' labels.colormap = colormap labels.selected_label = 1 npt.assert_array_equal(np.unique(labels._slice.image.raw), [0]) @@ -1773,5 +1718,5 @@ def get_objects(): def test_events_defined(self, event_define_check, obj): event_define_check( obj, - {"seed", "num_colors", "color", "seed_rng"}, + {'seed', 'num_colors', 'color', 'seed_rng'}, ) diff --git a/napari/layers/labels/labels.py b/napari/layers/labels/labels.py index b619633c5ce..512e45025af 100644 --- a/napari/layers/labels/labels.py +++ b/napari/layers/labels/labels.py @@ -1,14 +1,11 @@ import warnings from collections import deque +from collections.abc import Sequence from contextlib import contextmanager from typing import ( Callable, ClassVar, - Dict, - List, Optional, - Sequence, - Tuple, Union, cast, ) @@ -21,6 +18,7 @@ from napari.layers._data_protocols import LayerDataProtocol from napari.layers._multiscale_data import MultiScaleData +from napari.layers._scalar_field.scalar_field import ScalarFieldBase from napari.layers.base import Layer, no_op from napari.layers.base._base_mouse_bindings import ( highlight_box_handles, @@ -28,7 +26,6 @@ ) from napari.layers.image._image_utils import guess_multiscale from napari.layers.image._slice import _ImageSliceResponse -from napari.layers.image.image import _ImageBase from napari.layers.labels._labels_constants import ( LabelColorMode, LabelsRendering, @@ -55,22 +52,21 @@ ) from napari.utils.colormaps.colormap import ( CyclicLabelColormap, - DirectLabelColormap, LabelColormapBase, ) from napari.utils.colormaps.colormap_utils import shuffle_and_extend_colormap from napari.utils.events import EmitterGroup, Event from napari.utils.events.custom_types import Array -from napari.utils.events.event import WarningEmitter from napari.utils.geometry import clamp_point_to_bounding_box -from napari.utils.migrations import deprecated_constructor_arg_by_attr from napari.utils.misc import StringEnum, _is_array_type from napari.utils.naming import magic_name from napari.utils.status_messages import generate_layer_coords_status from napari.utils.translations import trans +__all__ = ('Labels',) -class Labels(_ImageBase): + +class Labels(ScalarFieldBase): """Labels (or segmentation) layer. An image-like layer where every pixel contains an integer ID @@ -242,7 +238,7 @@ class Labels(_ImageBase): _modeclass = Mode - _drag_modes: ClassVar[Dict[Mode, Callable[["Labels", Event], None]]] = { # type: ignore[assignment] + _drag_modes: ClassVar[dict[Mode, Callable[['Labels', Event], None]]] = { # type: ignore[assignment] Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.PICK: pick, @@ -254,7 +250,7 @@ class Labels(_ImageBase): brush_size_on_mouse_move = BrushSizeOnMouseMove(min_brush_size=1) - _move_modes: ClassVar[Dict[StringEnum, Callable[["Labels", Event], None]]] = { # type: ignore[assignment] + _move_modes: ClassVar[dict[StringEnum, Callable[['Labels', Event], None]]] = { # type: ignore[assignment] Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.PICK: no_op, @@ -264,7 +260,7 @@ class Labels(_ImageBase): Mode.POLYGON: no_op, # the overlay handles mouse events in this mode } - _cursor_modes: ClassVar[Dict[Mode, str]] = { # type: ignore[assignment] + _cursor_modes: ClassVar[dict[Mode, str]] = { # type: ignore[assignment] Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.PICK: 'cross', @@ -276,9 +272,6 @@ class Labels(_ImageBase): _history_limit = 100 - @deprecated_constructor_arg_by_attr("color") - @deprecated_constructor_arg_by_attr("num_colors") - @deprecated_constructor_arg_by_attr("seed") def __init__( self, data, @@ -348,15 +341,6 @@ def __init__( self.events.add( brush_shape=Event, brush_size=Event, - color_mode=WarningEmitter( - trans._( - 'Labels.events.color_mode is deprecated since 0.4.19 and ' - 'will be removed in 0.5.0, please use ' - 'Labels.events.colormap.', - deferred=True, - ), - type_name='color_mode', - ), colormap=Event, contiguous=Event, contour=Event, @@ -374,7 +358,7 @@ def __init__( LabelsPolygonOverlay, ) - self._overlays.update({"polygon": LabelsPolygonOverlay()}) + self._overlays.update({'polygon': LabelsPolygonOverlay()}) self._feature_table = _FeatureTable.from_layer( features=features, properties=properties @@ -456,7 +440,7 @@ def contour(self) -> int: @contour.setter def contour(self, contour: int) -> None: if contour < 0: - raise ValueError("contour value must be >= 0") + raise ValueError('contour value must be >= 0') self._contour = int(contour) self.events.contour() self.refresh() @@ -481,33 +465,6 @@ def _calculate_cursor_size(self): ) return abs(self.brush_size * min_scale) - @property - def seed(self): - """float: Seed for colormap random generator.""" - warnings.warn( - "seed is deprecated since 0.4.19 and will be removed in 0.5.0, " - "please check Labels.colormap directly.", - FutureWarning, - stacklevel=2, - ) - return self._random_colormap.seed - - @seed.setter - def seed(self, seed): - warnings.warn( - "seed is deprecated since 0.4.19 and will be removed in 0.5.0, " - "please use the new_colormap method instead, or set the colormap " - "directly.", - FutureWarning, - stacklevel=2, - ) - - self.colormap = label_colormap( - len(self.colormap) - 1, - seed=seed, - background_value=self.colormap.background_value, - ) - def new_colormap(self, seed: Optional[int] = None): if seed is None: seed = np.random.default_rng().integers(2**32 - 1) @@ -556,40 +513,7 @@ def _set_colormap(self, colormap): self.refresh() @property - def num_colors(self): - """int: Number of unique colors to use in colormap.""" - warnings.warn( - trans._( - 'Labels.num_colors is deprecated since 0.4.19 and will be ' - 'removed in 0.5.0, please use len(Labels.colormap) ' - 'instead.', - deferred=True, - ), - FutureWarning, - stacklevel=2, - ) - return len(self.colormap) - - @num_colors.setter - def num_colors(self, num_colors): - warnings.warn( - trans._( - 'Setting Labels.num_colors is deprecated since 0.4.19 and ' - 'will be removed in 0.5.0, please set Labels.colormap ' - 'instead.', - deferred=True, - ), - FutureWarning, - stacklevel=2, - ) - self.colormap = label_colormap( - num_colors - 1, - seed=self._random_colormap.seed, - background_value=self.colormap.background_value, - ) - - @property - def data(self) -> LayerDataProtocol: + def data(self) -> Union[LayerDataProtocol, MultiScaleData]: """array: Image data.""" return self._data @@ -623,7 +547,7 @@ def features(self): @features.setter def features( self, - features: Union[Dict[str, np.ndarray], pd.DataFrame], + features: Union[dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features) self._label_index = self._make_label_index() @@ -631,15 +555,15 @@ def features( self.events.features() @property - def properties(self) -> Dict[str, np.ndarray]: + def properties(self) -> dict[str, np.ndarray]: """dict {str: array (N,)}, DataFrame: Properties for each label.""" return self._feature_table.properties() @properties.setter - def properties(self, properties: Dict[str, Array]): + def properties(self, properties: dict[str, Array]): self.features = properties - def _make_label_index(self) -> Dict[int, int]: + def _make_label_index(self) -> dict[int, int]: features = self._feature_table.values label_index = {} if 'index' in features: @@ -648,35 +572,6 @@ def _make_label_index(self) -> Dict[int, int]: label_index = {i: i for i in range(features.shape[0])} return label_index - @property - def color(self) -> dict: - """dict: custom color dict for label coloring""" - warnings.warn( - "Labels.color is deprecated since 0.4.19 and will be removed in " - "0.5.0, please use Labels.colormap.color_dict instead. Note: this" - "will only work when the colormap is a DirectLabelsColormap.", - FutureWarning, - stacklevel=2, - ) - return {**self._direct_colormap.color_dict} - - @color.setter - def color(self, color: Dict[Optional[int], Union[str, np.ndarray]]): - warnings.warn( - "Labels.color is deprecated since 0.4.19 and will be removed in " - "0.5.0, please set Labels.colormap directly with an instance " - "of napari.utils.colormaps.DirectLabelColormap instead.", - FutureWarning, - stacklevel=2, - ) - color = dict(color) if color else {} - - color[self.colormap.background_value] = color.get( - self.colormap.background_value, 'transparent' - ) - color[None] = color.get(None, 'black') - self.colormap = DirectLabelColormap(color_dict=color) - def _is_default_colors(self, color): """Returns True if color contains only default colors, otherwise False. @@ -717,7 +612,7 @@ def _ensure_int_labels(self, data): if np.issubdtype(normalize_dtype(data_level.dtype), np.floating): raise TypeError( trans._( - "Only integer types are supported for Labels layers, but data contains {data_level_type}.", + 'Only integer types are supported for Labels layers, but data contains {data_level_type}.', data_level_type=data_level.dtype, ) ) @@ -783,55 +678,6 @@ def swap_selected_and_background_labels(self): else: self.selected_label = self._prev_selected_label - @property - def color_mode(self): - """Color mode to change how color is represented. - - AUTO (default) allows color to be set via a hash function with a seed. - - DIRECT allows color of each label to be set directly by a color dict. - """ - warnings.warn( - trans._( - 'Labels.color_mode is deprecated since 0.4.19 and will be ' - 'removed in 0.5.0. Please check type(Labels.colormap) ' - 'instead. napari.utils.colormaps.CyclicLabelColormap ' - 'corresponds to AUTO color mode, and ' - 'napari.utils.colormaps.DirectLabelColormap' - ' corresponds to DIRECT color mode.', - deferred=True, - ), - FutureWarning, - stacklevel=2, - ) - return str(self._color_mode) - - @color_mode.setter - def color_mode(self, color_mode: Union[str, LabelColorMode]): - warnings.warn( - trans._( - 'Labels.color_mode is deprecated since 0.4.19 and will be ' - 'removed in 0.5.0. Please set Labels.colormap instead, to an' - 'instance of napari.utils.colormaps.CyclicLabelColormap for ' - '"auto" mode, or napari.utils.colormaps.DirectLabelColormap ' - 'for "direct" mode.', - deferred=True, - ), - FutureWarning, - stacklevel=2, - ) - color_mode = LabelColorMode(color_mode) - self._color_mode = color_mode - if color_mode == LabelColorMode.AUTO: - self._colormap = self._random_colormap - else: - self._colormap = self._direct_colormap - self._selected_color = self.get_color(self.selected_label) - self.events.color_mode() - self.events.colormap() # If remove this emitting, connect shader update to color_mode - self.events.selected_label() - self.refresh() - @property def show_selected_label(self): """Whether to filter displayed labels to only the selected label or not""" @@ -883,7 +729,7 @@ def _mode_setter_helper(self, mode): if mode == self._mode: return mode - self._overlays["polygon"].enabled = mode == Mode.POLYGON + self._overlays['polygon'].enabled = mode == Mode.POLYGON if mode in {Mode.PAINT, Mode.ERASE}: self.cursor_size = self._calculate_cursor_size() @@ -961,7 +807,7 @@ def _partial_labels_refresh(self): self._updated_slice = None def _calculate_contour( - self, labels: np.ndarray, data_slice: Tuple[slice, ...] + self, labels: np.ndarray, data_slice: tuple[slice, ...] ) -> Optional[np.ndarray]: """Calculate the contour of a given label array within the specified data slice. @@ -984,7 +830,7 @@ def _calculate_contour( if labels.ndim > 2: warnings.warn( trans._( - "Contours are not displayed during 3D rendering", + 'Contours are not displayed during 3D rendering', deferred=True, ) ) @@ -1005,7 +851,7 @@ def _calculate_contour( return sliced_labels[delta_slice] def _raw_to_displayed( - self, raw, data_slice: Optional[Tuple[slice, ...]] = None + self, raw, data_slice: Optional[tuple[slice, ...]] = None ) -> np.ndarray: """Determine displayed image from a saved raw image and a saved seed. @@ -1092,7 +938,7 @@ def _get_value_ray( self, start_point: Optional[np.ndarray], end_point: Optional[np.ndarray], - dims_displayed: List[int], + dims_displayed: list[int], ) -> Optional[int]: """Get the first non-background value encountered along a ray. @@ -1162,7 +1008,7 @@ def _get_value_3d( self, start_point: Optional[np.ndarray], end_point: Optional[np.ndarray], - dims_displayed: List[int], + dims_displayed: list[int], ) -> Optional[int]: """Get the first non-background value encountered along a ray. @@ -1451,7 +1297,7 @@ def paint_polygon(self, points, new_label): if len(dims_to_paint) != 2: raise NotImplementedError( - "Polygon painting is implemented only in 2D." + 'Polygon painting is implemented only in 2D.' ) points = np.array(points, dtype=int) @@ -1530,7 +1376,7 @@ def _paint_indices( self.data_setitem(slice_coord, new_label, refresh) - def _get_shape_and_dims_to_paint(self) -> Tuple[list, list]: + def _get_shape_and_dims_to_paint(self) -> tuple[list, list]: dims_to_paint = sorted(self._get_dims_to_paint()) shape = list(self.data.shape) @@ -1542,7 +1388,7 @@ def _get_shape_and_dims_to_paint(self) -> Tuple[list, list]: def _get_dims_to_paint(self) -> list: return list(self._slice_input.order[-self.n_edit_dimensions :]) - def _get_pt_not_disp(self) -> Dict[int, int]: + def _get_pt_not_disp(self) -> dict[int, int]: """ Get indices of current visible slice. """ @@ -1657,7 +1503,7 @@ def get_status( position: Optional[npt.ArrayLike] = None, *, view_direction: Optional[npt.ArrayLike] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> dict: """Status message information of the data at a coordinate position. @@ -1706,7 +1552,7 @@ def get_status( world=world, ) if properties: - source_info['coordinates'] += "; " + ", ".join(properties) + source_info['coordinates'] += '; ' + ', '.join(properties) return source_info @@ -1715,7 +1561,7 @@ def _get_tooltip_text( position, *, view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, ): """ @@ -1740,7 +1586,7 @@ def _get_tooltip_text( msg : string String containing a message that can be used as a tooltip. """ - return "\n".join( + return '\n'.join( self._get_properties( position, view_direction=view_direction, @@ -1754,7 +1600,7 @@ def _get_properties( position, *, view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> list: if len(self._label_index) == 0 or self.features.shape[1] == 0: diff --git a/napari/layers/points/_points_constants.py b/napari/layers/points/_points_constants.py index 5f43389eea5..e84358b2c5d 100644 --- a/napari/layers/points/_points_constants.py +++ b/napari/layers/points/_points_constants.py @@ -104,8 +104,8 @@ class Shading(StringEnum): SHADING_TRANSLATION = { - trans._("none"): Shading.NONE, - trans._("spherical"): Shading.SPHERICAL, + trans._('none'): Shading.NONE, + trans._('spherical'): Shading.SPHERICAL, } diff --git a/napari/layers/points/_points_key_bindings.py b/napari/layers/points/_points_key_bindings.py index d24a115590d..63a51120ef2 100644 --- a/napari/layers/points/_points_key_bindings.py +++ b/napari/layers/points/_points_key_bindings.py @@ -61,7 +61,7 @@ def paste(layer: Points): @register_points_action( - trans._("Select all points in the current view slice."), + trans._('Select all points in the current view slice.'), ) def select_all_in_slice(layer: Points): new_selected = set(layer._indices_view[: len(layer._view_data)]) @@ -71,7 +71,7 @@ def select_all_in_slice(layer: Points): layer.selected_data = layer.selected_data - new_selected show_info( trans._( - "Deselected all points in this slice, use Shift-A to deselect all points on the layer. ({n_total} selected)", + 'Deselected all points in this slice, use Shift-A to deselect all points on the layer. ({n_total} selected)', n_total=len(layer.selected_data), deferred=True, ) @@ -82,7 +82,7 @@ def select_all_in_slice(layer: Points): layer.selected_data = layer.selected_data | new_selected show_info( trans._( - "Selected {n_new} points in this slice, use Shift-A to select all points on the layer. ({n_total} selected)", + 'Selected {n_new} points in this slice, use Shift-A to select all points on the layer. ({n_total} selected)', n_new=len(new_selected), n_total=len(layer.selected_data), deferred=True, @@ -92,13 +92,13 @@ def select_all_in_slice(layer: Points): @register_points_action( - trans._("Select all points in the layer."), + trans._('Select all points in the layer.'), ) def select_all_data(layer: Points): # If all points are already selected, deselect all points if len(layer.selected_data) == len(layer.data): layer.selected_data = set() - show_info(trans._("Cleared all selections.", deferred=True)) + show_info(trans._('Cleared all selections.', deferred=True)) # Select all points else: @@ -108,7 +108,7 @@ def select_all_data(layer: Points): layer.selected_data = new_selected show_info( trans._( - "Selected {n_new} points across all slices, including {n_invis} points not currently visible. ({n_total})", + 'Selected {n_new} points across all slices, including {n_invis} points not currently visible. ({n_total})', n_new=len(new_selected), n_invis=len(new_selected - view_selected), n_total=len(layer.selected_data), diff --git a/napari/layers/points/_points_mouse_bindings.py b/napari/layers/points/_points_mouse_bindings.py index 174905a5a00..589b2df5839 100644 --- a/napari/layers/points/_points_mouse_bindings.py +++ b/napari/layers/points/_points_mouse_bindings.py @@ -1,4 +1,4 @@ -from typing import Set, TypeVar +from typing import TypeVar import numpy as np @@ -146,10 +146,10 @@ def highlight(layer, event): layer._set_highlight() -_T = TypeVar("_T") +_T = TypeVar('_T') -def _toggle_selected(selection: Set[_T], value: _T) -> Set[_T]: +def _toggle_selected(selection: set[_T], value: _T) -> set[_T]: """Add or remove value from the selection set. This function returns a copy of the existing selection. diff --git a/napari/layers/points/_points_utils.py b/napari/layers/points/_points_utils.py index b828ca77426..53a8b688803 100644 --- a/napari/layers/points/_points_utils.py +++ b/napari/layers/points/_points_utils.py @@ -1,4 +1,5 @@ -from typing import Iterable, List, Optional, Tuple +from collections.abc import Iterable +from typing import Optional import numpy as np @@ -106,7 +107,7 @@ def _points_in_box_3d( sizes: np.ndarray, box_normal: np.ndarray, up_direction: np.ndarray, -) -> List[int]: +) -> list[int]: """Determine which points are inside of 2D bounding box. The 2D bounding box extends infinitely in both directions along its normal @@ -179,7 +180,7 @@ def _points_in_box_3d( def points_in_box( corners: np.ndarray, points: np.ndarray, sizes: np.ndarray -) -> List[int]: +) -> list[int]: """Find which points are in an axis aligned box defined by its corners. Parameters @@ -210,7 +211,7 @@ def points_in_box( def fix_data_points( points: Optional[np.ndarray], ndim: Optional[int] -) -> Tuple[np.ndarray, int]: +) -> tuple[np.ndarray, int]: """ Ensure that points array is 2d and have second dimension of size ndim (default 2 for empty arrays) @@ -243,7 +244,7 @@ def fix_data_points( if ndim is not None and ndim != data_ndim: raise ValueError( trans._( - "Points dimensions must be equal to ndim", + 'Points dimensions must be equal to ndim', deferred=True, ) ) diff --git a/napari/layers/points/_tests/test_points.py b/napari/layers/points/_tests/test_points.py index d5a1d34cfbb..485f5e9dfc3 100644 --- a/napari/layers/points/_tests/test_points.py +++ b/napari/layers/points/_tests/test_points.py @@ -475,16 +475,16 @@ def test_remove_selected_updates_value(): layer.selected_data = selection layer.remove_selected() assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.REMOVING, - "data_indices": tuple(selection), - "vertex_indices": ((),), + 'value': old_data, + 'action': ActionType.REMOVING, + 'data_indices': tuple(selection), + 'vertex_indices': ((),), } assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.REMOVED, - "data_indices": tuple(selection), - "vertex_indices": ((),), + 'value': layer.data, + 'action': ActionType.REMOVED, + 'data_indices': tuple(selection), + 'vertex_indices': ((),), } assert layer._value == 2 @@ -552,20 +552,20 @@ def test_move(): assert np.array_equal(layer.data[0], unmoved[0] + [10, 10]) assert np.array_equal(layer.data[1:], unmoved[1:]) assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.CHANGED, - "data_indices": (0,), - "vertex_indices": ((),), + 'value': layer.data, + 'action': ActionType.CHANGED, + 'data_indices': (0,), + 'vertex_indices': ((),), } # Move two points relative to an initial drag start location layer._move([1, 2], [2, 2]) layer._move([1, 2], np.add([2, 2], [-3, 4])) assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.CHANGED, - "data_indices": (1, 2), - "vertex_indices": ((),), + 'value': layer.data, + 'action': ActionType.CHANGED, + 'data_indices': (1, 2), + 'vertex_indices': ((),), } assert np.array_equal(layer.data[1:2], unmoved[1:2] + [-3, 4]) @@ -683,7 +683,7 @@ def test_symbol(): properties_list = {'point_type': list(_make_cycled_properties(['A', 'B'], 10))} -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_properties(properties): shape = (10, 2) np.random.seed(0) @@ -723,11 +723,11 @@ def test_properties(properties): paste_annotations = np.concatenate((add_annotations, ['A', 'B']), axis=0) assert np.array_equal(layer.properties['point_type'], paste_annotations) - assert layer.get_status(data[0])['coordinates'].endswith("point_type: B") - assert layer.get_status(data[1])['coordinates'].endswith("point_type: A") + assert layer.get_status(data[0])['coordinates'].endswith('point_type: B') + assert layer.get_status(data[1])['coordinates'].endswith('point_type: A') -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_adding_properties(attribute): """Test adding properties to an existing layer""" shape = (10, 2) @@ -848,7 +848,7 @@ def test_setting_current_properties(): properties_list = {'point_type': list(_make_cycled_properties(['A', 'B'], 10))} -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_text_from_property_value(properties): """Test setting text from a property value""" shape = (10, 2) @@ -859,7 +859,7 @@ def test_text_from_property_value(properties): np.testing.assert_equal(layer.text.values, properties['point_type']) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_text_from_property_fstring(properties): """Test setting text with an f-string from the property value""" shape = (10, 2) @@ -881,18 +881,18 @@ def test_text_from_property_fstring(properties): layer.selected_data = {0} layer._copy_data() layer._paste_data() - expected_text_3 = [*expected_text_2, "type-ish: A"] + expected_text_3 = [*expected_text_2, 'type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_3) # add point layer.selected_data = {0} new_shape = np.random.random((1, 2)) layer.add(new_shape) - expected_text_4 = [*expected_text_3, "type-ish: A"] + expected_text_4 = [*expected_text_3, 'type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_4) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_set_text_with_kwarg_dict(properties): text_kwargs = { 'string': 'type: {point_type}', @@ -918,7 +918,7 @@ def test_set_text_with_kwarg_dict(properties): np.testing.assert_equal(layer_value, value) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_text_error(properties): """creating a layer with text as the wrong type should raise an error""" shape = (10, 2) @@ -1007,7 +1007,7 @@ def test_border_width(): @pytest.mark.parametrize( - "border_width", + 'border_width', [1, float(1), np.array([1, 2, 3, 4, 5]), [1, 2, 3, 4, 5]], ) def test_border_width_types(border_width): @@ -1022,8 +1022,8 @@ def test_border_width_types(border_width): @pytest.mark.parametrize( - "border_width", - [int(-1), float(-1), np.array([-1, 2, 3, 4, 5]), [-1, 2, 3, 4, 5]], + 'border_width', + [-1, float(-1), np.array([-1, 2, 3, 4, 5]), [-1, 2, 3, 4, 5]], ) def test_border_width_types_negative(border_width): """Test negative values in all border_width dtypes""" @@ -1060,7 +1060,7 @@ def test_out_of_slice_display(): assert layer.out_of_slice_display is True -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_switch_color_mode(attribute): """Test switching between color modes""" shape = (10, 2) @@ -1121,7 +1121,7 @@ def test_switch_color_mode(attribute): np.testing.assert_allclose(new_border_color, color) -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_colormap_without_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 2) @@ -1133,7 +1133,7 @@ def test_colormap_without_properties(attribute): setattr(layer, f'{attribute}_color_mode', 'colormap') -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_colormap_with_categorical_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 2) @@ -1146,7 +1146,7 @@ def test_colormap_with_categorical_properties(attribute): setattr(layer, f'{attribute}_color_mode', 'colormap') -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_add_colormap(attribute): """Test directly adding a vispy Colormap object""" shape = (10, 2) @@ -1163,7 +1163,7 @@ def test_add_colormap(attribute): assert 'unnamed colormap' in layer_colormap.name -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_add_point_direct(attribute: str): """Test adding points to layer directly""" layer = Points() @@ -1176,23 +1176,23 @@ def test_add_point_direct(attribute: str): layer.add(coord) assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.ADDING, - "data_indices": (-1,), - "vertex_indices": ((),), + 'value': old_data, + 'action': ActionType.ADDING, + 'data_indices': (-1,), + 'vertex_indices': ((),), } assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.ADDED, - "data_indices": (-1,), - "vertex_indices": ((),), + 'value': layer.data, + 'action': ActionType.ADDED, + 'data_indices': (-1,), + 'vertex_indices': ((),), } np.testing.assert_allclose( [[1, 0, 0, 1]], getattr(layer, f'{attribute}_color') ) -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_color_direct(attribute: str): """Test setting colors directly""" shape = (10, 2) @@ -1252,9 +1252,9 @@ def test_color_direct(attribute: str): color_cycle_rgba = [[1, 0, 0, 1], [0, 0, 1, 1]] -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) @pytest.mark.parametrize( - "color_cycle", + 'color_cycle', [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(attribute, color_cycle): @@ -1317,7 +1317,7 @@ def test_color_cycle(attribute, color_cycle): ) -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_color_cycle_dict(attribute): """Test setting border/face color with a color cycle dict""" data = np.array([[0, 0], [100, 0], [0, 100]]) @@ -1336,7 +1336,7 @@ def test_color_cycle_dict(attribute): np.testing.assert_allclose(color_cycle_map[6], [1, 1, 1, 1]) # 6 is white -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_add_color_cycle_to_empty_layer(attribute): """Test adding a point to an empty layer when border/face color is a color cycle @@ -1377,7 +1377,7 @@ def test_add_color_cycle_to_empty_layer(attribute): np.testing.assert_equal(layer.properties, new_properties) -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_adding_value_color_cycle(attribute): """Test that adding values to properties used to set a color cycle and then calling Points.refresh_colors() performs the update and adds the @@ -1410,7 +1410,7 @@ def test_adding_value_color_cycle(attribute): assert 'C' in color_map_keys -@pytest.mark.parametrize("attribute", ['border', 'face']) +@pytest.mark.parametrize('attribute', ['border', 'face']) def test_color_colormap(attribute): """Test setting border/face color with a colormap""" # create Points using with a colormap @@ -2407,7 +2407,7 @@ def test_shown_view_size_and_view_data_have_the_same_dimension(): def test_empty_data_from_tuple(): """Test that empty data raises an error.""" - layer = Points(name="points") + layer = Points(name='points') layer2 = Points.create(*layer.as_layer_data_tuple()) assert layer2.data.size == 0 @@ -2415,10 +2415,10 @@ def test_empty_data_from_tuple(): @pytest.mark.parametrize( 'attribute, new_value', [ - ("size", 20), - ("face_color", np.asarray([0.0, 0.0, 1.0, 1.0])), - ("border_color", np.asarray([0.0, 0.0, 1.0, 1.0])), - ("border_width", np.asarray([0.2])), + ('size', 20), + ('face_color', np.asarray([0.0, 0.0, 1.0, 1.0])), + ('border_color', np.asarray([0.0, 0.0, 1.0, 1.0])), + ('border_width', np.asarray([0.2])), ], ) def test_new_point_size_editable(attribute, new_value): @@ -2427,7 +2427,7 @@ def test_new_point_size_editable(attribute, new_value): layer.mode = Mode.ADD layer.add((0, 0)) - setattr(layer, f"current_{attribute}", new_value) + setattr(layer, f'current_{attribute}', new_value) np.testing.assert_allclose(getattr(layer, attribute)[0], new_value) @@ -2463,7 +2463,7 @@ def test_set_drag_start(): @pytest.mark.parametrize( - "dims_indices,target_indices", + 'dims_indices,target_indices', [ ((8, np.nan, np.nan), [2]), ((10, np.nan, np.nan), [0, 1, 3, 4]), @@ -2548,53 +2548,53 @@ def test_data_setter_events(): layer.data = [] assert layer.events.data.call_args_list[0][1] == { - "value": data, - "action": ActionType.REMOVING, - "data_indices": tuple(i for i in range(len(data))), - "vertex_indices": ((),), + 'value': data, + 'action': ActionType.REMOVING, + 'data_indices': tuple(i for i in range(len(data))), + 'vertex_indices': ((),), } # Avoid truth value of empty array error assert np.array_equal( - layer.events.data.call_args_list[1][1]["value"], np.empty((0, 2)) + layer.events.data.call_args_list[1][1]['value'], np.empty((0, 2)) ) assert ( - layer.events.data.call_args_list[1][1]["action"] == ActionType.REMOVED + layer.events.data.call_args_list[1][1]['action'] == ActionType.REMOVED ) - assert layer.events.data.call_args_list[1][1]["data_indices"] == () - assert layer.events.data.call_args_list[1][1]["vertex_indices"] == ((),) + assert layer.events.data.call_args_list[1][1]['data_indices'] == () + assert layer.events.data.call_args_list[1][1]['vertex_indices'] == ((),) layer.data = data assert np.array_equal( - layer.events.data.call_args_list[2][1]["value"], np.empty((0, 2)) + layer.events.data.call_args_list[2][1]['value'], np.empty((0, 2)) ) assert ( - layer.events.data.call_args_list[2][1]["action"] == ActionType.ADDING + layer.events.data.call_args_list[2][1]['action'] == ActionType.ADDING ) - assert layer.events.data.call_args_list[2][1]["data_indices"] == tuple( + assert layer.events.data.call_args_list[2][1]['data_indices'] == tuple( i for i in range(len(data)) ) - assert layer.events.data.call_args_list[2][1]["vertex_indices"] == ((),) + assert layer.events.data.call_args_list[2][1]['vertex_indices'] == ((),) assert layer.events.data.call_args_list[3][1] == { - "value": data, - "action": ActionType.ADDED, - "data_indices": tuple(i for i in range(len(data))), - "vertex_indices": ((),), + 'value': data, + 'action': ActionType.ADDED, + 'data_indices': tuple(i for i in range(len(data))), + 'vertex_indices': ((),), } layer.data = data assert layer.events.data.call_args_list[4][1] == { - "value": data, - "action": ActionType.CHANGING, - "data_indices": tuple(i for i in range(len(layer.data))), - "vertex_indices": ((),), + 'value': data, + 'action': ActionType.CHANGING, + 'data_indices': tuple(i for i in range(len(layer.data))), + 'vertex_indices': ((),), } assert layer.events.data.call_args_list[5][1] == { - "value": data, - "action": ActionType.CHANGED, - "data_indices": tuple(i for i in range(len(layer.data))), - "vertex_indices": ((),), + 'value': data, + 'action': ActionType.CHANGED, + 'data_indices': tuple(i for i in range(len(layer.data))), + 'vertex_indices': ((),), } @@ -2613,3 +2613,28 @@ def test_thick_slice(): # it will take in the other point layer._slice_dims(Dims(ndim=3, point=(0, 0, 0), margin_right=(10, 0, 0))) np.testing.assert_array_equal(layer._view_data, data[:, -2:]) + + +@pytest.mark.parametrize( + 'old_name, new_name, value', + [ + ('edge_width', 'border_width', 0.9), + ('edge_width_is_relative', 'border_width_is_relative', False), + ('current_edge_width', 'current_border_width', 0.9), + ('edge_color', 'border_color', 'blue'), + ('current_edge_color', 'current_border_color', 'pink'), + ], +) +def test_events_callback(old_name, new_name, value): + data = np.array([[0, 0, 0], [10, 10, 10]]) + layer = Points(data) + old_name_callback = Mock() + new_name_callback = Mock() + with pytest.warns(FutureWarning): + getattr(layer.events, old_name).connect(old_name_callback) + getattr(layer.events, new_name).connect(new_name_callback) + + setattr(layer, new_name, value) + + new_name_callback.assert_called_once() + old_name_callback.assert_called_once() diff --git a/napari/layers/points/_tests/test_points_mouse_bindings.py b/napari/layers/points/_tests/test_points_mouse_bindings.py index 978dc0f6170..208e462926d 100644 --- a/napari/layers/points/_tests/test_points_mouse_bindings.py +++ b/napari/layers/points/_tests/test_points_mouse_bindings.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from unittest.mock import MagicMock import numpy as np @@ -23,17 +23,17 @@ class Event: type: str is_dragging: bool = False - modifiers: List[str] = field(default_factory=list) - position: Union[Tuple[int, int], Tuple[int, int, int]] = ( + modifiers: list[str] = field(default_factory=list) + position: Union[tuple[int, int], tuple[int, int, int]] = ( 0, 0, ) # world coords pos: np.ndarray = field( default_factory=lambda: np.zeros(2) ) # canvas coords - view_direction: Optional[List[float]] = None - up_direction: Optional[List[float]] = None - dims_displayed: List[int] = field(default_factory=lambda: [0, 1]) + view_direction: Optional[list[float]] = None + up_direction: Optional[list[float]] = None + dims_displayed: list[int] = field(default_factory=lambda: [0, 1]) def read_only_event(*args, **kwargs): @@ -771,7 +771,7 @@ def test_drag_start_selection( layer.data[0], [offset_position[0], offset_position[1]] ) else: - raise AssertionError("Unreachable code") # pragma: no cover + raise AssertionError('Unreachable code') # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] @@ -808,7 +808,7 @@ def test_drag_start_selection( layer.data[0], [offset_position[0], offset_position[1]] ) else: - raise AssertionError("Unreachable code") # pragma: no cover + raise AssertionError('Unreachable code') # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] @@ -866,14 +866,14 @@ def test_drag_point_with_mouse(create_known_points_layer_2d): # Required to assert before the changing event as otherwise layer.data for changing is updated in place. changing_event = { - "value": old_data, - "action": ActionType.CHANGING, - "data_indices": (1,), - "vertex_indices": ((),), + 'value': old_data, + 'action': ActionType.CHANGING, + 'data_indices': (1,), + 'vertex_indices': ((),), } def side_effect(*args, **kwargs): - if kwargs["action"] == ActionType.CHANGING: + if kwargs['action'] == ActionType.CHANGING: assert compare_dicts(kwargs, changing_event) layer.events.data.side_effect = side_effect @@ -891,10 +891,10 @@ def side_effect(*args, **kwargs): mouse_release_callbacks(layer, event) changed_event = { - "value": layer.data, - "action": ActionType.CHANGED, - "data_indices": (1,), - "vertex_indices": ((),), + 'value': layer.data, + 'action': ActionType.CHANGED, + 'data_indices': (1,), + 'vertex_indices': ((),), } assert not np.array_equal(layer.data, old_data) assert compare_dicts(layer.events.data.call_args[1], changed_event) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 960cb6761f2..85297685eae 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -1,17 +1,15 @@ import numbers import warnings from abc import abstractmethod +from collections.abc import Sequence from copy import copy, deepcopy from itertools import cycle from typing import ( Any, Callable, ClassVar, - Dict, - List, + Literal, Optional, - Sequence, - Tuple, Union, ) @@ -79,20 +77,20 @@ class _BasePoints(Layer): _modeclass = Mode _projectionclass = PointsProjectionMode - _drag_modes: ClassVar[Dict[Mode, Callable[["Points", Event], Any]]] = { + _drag_modes: ClassVar[dict[Mode, Callable[['Points', Event], Any]]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.ADD: add, Mode.SELECT: select, } - _move_modes: ClassVar[Dict[Mode, Callable[["Points", Event], Any]]] = { + _move_modes: ClassVar[dict[Mode, Callable[['Points', Event], Any]]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.ADD: no_op, Mode.SELECT: highlight, } - _cursor_modes: ClassVar[Dict[Mode, str]] = { + _cursor_modes: ClassVar[dict[Mode, str]] = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.ADD: 'crosshair', @@ -105,6 +103,36 @@ class _BasePoints(Layer): # If more points are present then they are randomly subsampled _max_points_thumbnail = 1024 + @rename_argument( + 'edge_width', 'border_width', since_version='0.5.0', version='0.6.0' + ) + @rename_argument( + 'edge_width_is_relative', + 'border_width_is_relative', + since_version='0.5.0', + version='0.6.0', + ) + @rename_argument( + 'edge_color', 'border_color', since_version='0.5.0', version='0.6.0' + ) + @rename_argument( + 'edge_color_cycle', + 'border_color_cycle', + since_version='0.5.0', + version='0.6.0', + ) + @rename_argument( + 'edge_colormap', + 'border_colormap', + since_version='0.5.0', + version='0.6.0', + ) + @rename_argument( + 'edge_contrast_limits', + 'border_contrast_limits', + since_version='0.5.0', + version='0.6.0', + ) def __init__( self, data=None, @@ -213,6 +241,31 @@ def __init__( feature_defaults=Event, ) + deprecated_events = {} + for attr in [ + '{}_width', + 'current_{}_width', + '{}_width_is_relative', + '{}_color', + 'current_{}_color', + ]: + old_attr = attr.format('edge') + new_attr = attr.format('border') + old_emitter = deprecation_warning_event( + 'layer.events', + old_attr, + new_attr, + since_version='0.5.0', + version='0.6.0', + ) + getattr(self.events, new_attr).connect(old_emitter) + deprecated_events[old_attr] = old_emitter + + self.events.add(**deprecated_events) + + # Save the point coordinates + self._data = np.asarray(data) + self._feature_table = _FeatureTable.from_layer( features=features, feature_defaults=feature_defaults, @@ -295,6 +348,30 @@ def __init__( # Trigger generation of view slice and thumbnail self.refresh() + @classmethod + def _add_deprecated_properties(cls) -> None: + """Adds deprecated properties to class.""" + deprecated_properties = [ + 'edge_width', + 'edge_width_is_relative', + 'current_edge_width', + 'edge_color', + 'edge_color_cycle', + 'edge_colormap', + 'edge_contrast_limits', + 'current_edge_color', + 'edge_color_mode', + ] + for old_property in deprecated_properties: + new_property = old_property.replace('edge', 'border') + add_deprecated_property( + cls, + old_property, + new_property, + since_version='0.5.0', + version='0.6.0', + ) + @property def _points_data(self) -> np.ndarray: """Spatially distributed coordinates.""" @@ -307,42 +384,6 @@ def data(self) -> Any: def _set_data(self, data: Any) -> None: raise NotImplementedError - @data.setter - def data(self, data: Optional[np.ndarray]) -> None: - """Set the data array and emit a corresponding event.""" - prior_data = len(self.data) > 0 - data_not_empty = ( - data is not None - and (isinstance(data, np.ndarray) and data.size > 0) - or (isinstance(data, list) and len(data) > 0) - ) - kwargs = { - "value": self.data, - "vertex_indices": ((),), - "data_indices": tuple(i for i in range(len(self.data))), - } - if prior_data and data_not_empty: - kwargs["action"] = ActionType.CHANGING - elif data_not_empty: - kwargs["action"] = ActionType.ADDING - kwargs["data_indices"] = tuple(i for i in range(len(data))) - else: - kwargs["action"] = ActionType.REMOVING - - self.events.data(**kwargs) - self._set_data(data) - kwargs["data_indices"] = tuple(i for i in range(len(self.data))) - kwargs["value"] = self.data - - if prior_data and data_not_empty: - kwargs["action"] = ActionType.CHANGED - elif data_not_empty: - kwargs["data_indices"] = tuple(i for i in range(len(data))) - kwargs["action"] = ActionType.ADDED - else: - kwargs["action"] = ActionType.REMOVED - self.events.data(**kwargs) - def _on_selection(self, selected): if selected: self._set_highlight() @@ -372,16 +413,16 @@ def features(self): @features.setter def features( self, - features: Union[Dict[str, np.ndarray], pd.DataFrame], + features: Union[dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values( features, num_data=len(self._points_data) ) self._update_color_manager( - self._face, self._feature_table, "face_color" + self._face, self._feature_table, 'face_color' ) self._update_color_manager( - self._border, self._feature_table, "border_color" + self._border, self._feature_table, 'border_color' ) self.text.refresh(self.features) self.events.properties() @@ -397,7 +438,7 @@ def feature_defaults(self): @feature_defaults.setter def feature_defaults( - self, defaults: Union[Dict[str, Any], pd.DataFrame] + self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) current_properties = self.current_properties @@ -407,11 +448,11 @@ def feature_defaults( self.events.feature_defaults() @property - def property_choices(self) -> Dict[str, np.ndarray]: + def property_choices(self) -> dict[str, np.ndarray]: return self._feature_table.choices() @property - def properties(self) -> Dict[str, np.ndarray]: + def properties(self) -> dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}, DataFrame: Annotations for each point""" return self._feature_table.properties() @@ -439,12 +480,12 @@ def _update_color_manager(color_manager, feature_table, name): @properties.setter def properties( - self, properties: Union[Dict[str, Array], pd.DataFrame, None] + self, properties: Union[dict[str, Array], pd.DataFrame, None] ): self.features = properties @property - def current_properties(self) -> Dict[str, np.ndarray]: + def current_properties(self) -> dict[str, np.ndarray]: """dict{str: np.ndarray(1,)}: properties for the next added point.""" return self._feature_table.currents() @@ -586,8 +627,8 @@ def size(self, size: Union[float, np.ndarray, list]) -> None: except ValueError: raise ValueError( trans._( - "Size of shape {size_shape} is not compatible for broadcasting " - "with shape {points_shape}", + 'Size of shape {size_shape} is not compatible for broadcasting ' + 'with shape {points_shape}', size_shape=size.shape, points_shape=self._points_data.shape, deferred=True, @@ -597,8 +638,8 @@ def size(self, size: Union[float, np.ndarray, list]) -> None: self._size = np.mean(size, axis=1) warnings.warn( trans._( - "Since 0.4.18 point sizes must be isotropic; the average from each dimension will be" - " used instead. This will become an error in version 0.6.0.", + 'Since 0.4.18 point sizes must be isotropic; the average from each dimension will be' + ' used instead. This will become an error in version 0.6.0.', deferred=True, ), category=DeprecationWarning, @@ -617,8 +658,8 @@ def current_size(self, size: Union[None, float]) -> None: if isinstance(size, (list, tuple, np.ndarray)): warnings.warn( trans._( - "Since 0.4.18 point sizes must be isotropic; the average from each dimension will be used instead. " - "This will become an error in version 0.6.0.", + 'Since 0.4.18 point sizes must be isotropic; the average from each dimension will be used instead. ' + 'This will become an error in version 0.6.0.', deferred=True, ), category=DeprecationWarning, @@ -682,7 +723,7 @@ def shading(self, value): self.events.shading() @property - def canvas_size_limits(self) -> Tuple[float, float]: + def canvas_size_limits(self) -> tuple[float, float]: """Limit the canvas size of points""" return self._canvas_size_limits @@ -737,6 +778,7 @@ def border_width( ) self._border_width: np.ndarray = border_width + self.events.border_width(value=border_width) self.refresh() @property @@ -815,7 +857,7 @@ def border_colormap(self, colormap: ValidColormapArg): self._border.continuous_colormap = colormap @property - def border_contrast_limits(self) -> Tuple[float, float]: + def border_contrast_limits(self) -> tuple[float, float]: """None, (float, float): contrast limits for mapping the border_color colormap property to 0 and 1 """ @@ -823,7 +865,7 @@ def border_contrast_limits(self) -> Tuple[float, float]: @border_contrast_limits.setter def border_contrast_limits( - self, contrast_limits: Union[None, Tuple[float, float]] + self, contrast_limits: Union[None, tuple[float, float]] ): self._border.contrast_limits = contrast_limits @@ -902,7 +944,7 @@ def face_colormap(self, colormap: ValidColormapArg): self._face.continuous_colormap = colormap @property - def face_contrast_limits(self) -> Union[None, Tuple[float, float]]: + def face_contrast_limits(self) -> Union[None, tuple[float, float]]: """None, (float, float) : clims for mapping the face_color colormap property to 0 and 1 """ @@ -910,7 +952,7 @@ def face_contrast_limits(self) -> Union[None, Tuple[float, float]]: @face_contrast_limits.setter def face_contrast_limits( - self, contrast_limits: Union[None, Tuple[float, float]] + self, contrast_limits: Union[None, tuple[float, float]] ): self._face.contrast_limits = contrast_limits @@ -948,7 +990,9 @@ def face_color_mode(self, face_color_mode): self._set_color_mode(face_color_mode, 'face') def _set_color_mode( - self, color_mode: Union[ColorMode, str], attribute: str + self, + color_mode: Union[ColorMode, str], + attribute: Literal['border', 'face'], ): """Set the face_color_mode or border_color_mode property @@ -1233,7 +1277,7 @@ def _view_text(self) -> np.ndarray: return self.text.view_text(self._indices_view) @property - def _view_text_coords(self) -> Tuple[np.ndarray, str, str]: + def _view_text_coords(self) -> tuple[np.ndarray, str, str]: """Get the coordinates of the text elements in view Returns @@ -1386,7 +1430,7 @@ def _get_value_3d( self, start_point: np.ndarray, end_point: np.ndarray, - dims_displayed: List[int], + dims_displayed: list[int], ) -> Optional[int]: """Get the layer data value along a ray @@ -1449,11 +1493,11 @@ def _get_value_3d( def get_ray_intersections( self, - position: List[float], + position: list[float], view_direction: np.ndarray, - dims_displayed: List[int], + dims_displayed: list[int], world: bool = True, - ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[None, None]]: + ) -> Union[tuple[np.ndarray, np.ndarray], tuple[None, None]]: """Get the start and end point for the ray extending from a point through the displayed bounding box. @@ -1723,7 +1767,7 @@ def _move( @abstractmethod def _move_points( - self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray + self, ixgrid: tuple[np.ndarray, np.ndarray], shift: np.ndarray ) -> None: """Move points along a set a coordinates given a shift. @@ -1766,33 +1810,33 @@ def _set_drag_start( def get_status( self, - position: Optional[Tuple] = None, + position: Optional[tuple] = None, *, view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> dict: """Status message information of the data at a coordinate position. - Parameters - ---------- - position : tuple - Position in either data or world coordinates. - view_direction : Optional[np.ndarray] - A unit vector giving the direction of the ray in nD world coordinates. - The default value is None. - dims_displayed : Optional[List[int]] - A list of the dimensions currently being displayed in the viewer. - The default value is None. - world : bool - If True the position is taken to be in world coordinates - and converted into data coordinates. False by default. - - Returns - ------- - source_info : dict - Dict containing information that can be used in a status update. - """ + # Parameters + # ---------- + # position : tuple + # Position in either data or world coordinates. + # view_direction : Optional[np.ndarray] + # A unit vector giving the direction of the ray in nD world coordinates. + # The default value is None. + # dims_displayed : Optional[List[int]] + # A list of the dimensions currently being displayed in the viewer. + # The default value is None. + # world : bool + # If True the position is taken to be in world coordinates + # and converted into data coordinates. False by default. + + # Returns + # ------- + # source_info : dict + # Dict containing information that can be used in a status update. + #""" if position is not None: value = self.get_value( position, @@ -1816,7 +1860,7 @@ def get_status( world=world, ) if properties: - source_info['coordinates'] += "; " + ", ".join(properties) + source_info['coordinates'] += '; ' + ', '.join(properties) return source_info @@ -1825,10 +1869,11 @@ def _get_tooltip_text( position, *, view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, ): - """Tooltip message of the data at a coordinate position. + """ + tooltip message of the data at a coordinate position. Parameters ---------- @@ -1849,7 +1894,7 @@ def _get_tooltip_text( msg : string String containing a message that can be used as a tooltip. """ - return "\n".join( + return '\n'.join( self._get_properties( position, view_direction=view_direction, @@ -1863,7 +1908,7 @@ def _get_properties( position, *, view_direction: Optional[np.ndarray] = None, - dims_displayed: Optional[List[int]] = None, + dims_displayed: Optional[list[int]] = None, world: bool = False, ) -> list: if self.features.shape[1] == 0: @@ -1876,7 +1921,7 @@ def _get_properties( world=world, ) # if the cursor is not outside the image or on the background - if value is None or value > len(self._points_data): + if value is None or value > self.data.shape[0]: return [] return [ @@ -2027,6 +2072,8 @@ class Points(_BasePoints): Array of symbols for each point. size : array (N,) Array of sizes for each point. Must have the same shape as the layer `data`. + border_width : array (N,) + Width of the marker borders in pixels for all points border_width : array (N,) Width of the marker borders for all points as a fraction of their size. border_color : Nx4 numpy array @@ -2059,10 +2106,10 @@ class Points(_BasePoints): Size of the marker for the next point to be added or the currently selected point. current_border_width : float - border width of the marker for the next point to be added or the currently + Border width of the marker for the next point to be added or the currently selected point. current_border_color : str - border color of the marker border for the next point to be added or the currently + Border color of the marker border for the next point to be added or the currently selected point. current_face_color : str Face color of the marker border for the next point to be added or the currently @@ -2090,7 +2137,7 @@ class Points(_BasePoints): COLORMAP allows color to be set via a color map over an attribute border_color_mode : str - border color setting mode. + Border color setting mode. DIRECT (default mode) allows each point to be set arbitrarily @@ -2115,7 +2162,7 @@ class Points(_BasePoints): _view_symbol : array (M, ) Symbols of the point markers in the currently viewed slice. _view_border_width : array (M, ) - border width of the point markers in the currently viewed slice. + Border width of the point markers in the currently viewed slice. _indices_view : array (M, ) Integer indices of the points in the currently viewed slice and are shown. _selected_view : @@ -2131,34 +2178,34 @@ class Points(_BasePoints): """ @rename_argument( - "edge_width", "border_width", since_version="0.5.0", version="0.6.0" + 'edge_width', 'border_width', since_version='0.5.0', version='0.6.0' ) @rename_argument( - "edge_width_is_relative", - "border_width_is_relative", - since_version="0.5.0", - version="0.6.0", + 'edge_width_is_relative', + 'border_width_is_relative', + since_version='0.5.0', + version='0.6.0', ) @rename_argument( - "edge_color", "border_color", since_version="0.5.0", version="0.6.0" + 'edge_color', 'border_color', since_version='0.5.0', version='0.6.0' ) @rename_argument( - "edge_color_cycle", - "border_color_cycle", - since_version="0.5.0", - version="0.6.0", + 'edge_color_cycle', + 'border_color_cycle', + since_version='0.5.0', + version='0.6.0', ) @rename_argument( - "edge_colormap", - "border_colormap", - since_version="0.5.0", - version="0.6.0", + 'edge_colormap', + 'border_colormap', + since_version='0.5.0', + version='0.6.0', ) @rename_argument( - "edge_contrast_limits", - "border_contrast_limits", - since_version="0.5.0", - version="0.6.0", + 'edge_contrast_limits', + 'border_contrast_limits', + since_version='0.5.0', + version='0.6.0', ) def __init__( self, @@ -2260,20 +2307,20 @@ def __init__( deprecated_events = {} for attr in [ - "{}_width", - "current_{}_width", - "{}_width_is_relative", - "{}_color", - "current_{}_color", + '{}_width', + 'current_{}_width', + '{}_width_is_relative', + '{}_color', + 'current_{}_color', ]: - old_attr = attr.format("edge") - new_attr = attr.format("border") + old_attr = attr.format('edge') + new_attr = attr.format('border') old_emitter = deprecation_warning_event( - "layer.events", + 'layer.events', old_attr, new_attr, - since_version="0.5.0", - version="0.6.0", + since_version='0.5.0', + version='0.6.0', ) getattr(self.events, new_attr).connect(old_emitter) deprecated_events[old_attr] = old_emitter @@ -2284,24 +2331,24 @@ def __init__( def _add_deprecated_properties(cls) -> None: """Adds deprecated properties to class.""" deprecated_properties = [ - "edge_width", - "edge_width_is_relative", - "current_edge_width", - "edge_color", - "edge_color_cycle", - "edge_colormap", - "edge_contrast_limits", - "current_edge_color", - "edge_color_mode", + 'edge_width', + 'edge_width_is_relative', + 'current_edge_width', + 'edge_color', + 'edge_color_cycle', + 'edge_colormap', + 'edge_contrast_limits', + 'current_edge_color', + 'edge_color_mode', ] for old_property in deprecated_properties: - new_property = old_property.replace("edge", "border") + new_property = old_property.replace('edge', 'border') add_deprecated_property( cls, old_property, new_property, - since_version="0.5.0", - version="0.6.0", + since_version='0.5.0', + version='0.6.0', ) @property @@ -2327,7 +2374,11 @@ def _set_data(self, data: Optional[np.ndarray]): self._data = data # Add/remove property and style values based on the number of new points. - with self.events.blocker_all(), self._border.events.blocker_all(), self._face.events.blocker_all(): + with ( + self.events.blocker_all(), + self._border.events.blocker_all(), + self._face.events.blocker_all(), + ): self._feature_table.resize(len(data)) self.text.apply(self.features) if len(data) < cur_npoints: @@ -2471,7 +2522,7 @@ def remove_selected(self): self.selected_data = set() def _move_points( - self, ixgrid: Tuple[np.ndarray, np.ndarray], shift: np.ndarray + self, ixgrid: tuple[np.ndarray, np.ndarray], shift: np.ndarray ) -> None: """Move points along a set a coordinates given a shift. diff --git a/napari/layers/shapes/_mesh.py b/napari/layers/shapes/_mesh.py index ee4b38b9a94..b98acc462fe 100644 --- a/napari/layers/shapes/_mesh.py +++ b/napari/layers/shapes/_mesh.py @@ -51,11 +51,11 @@ class Mesh: _types = ('face', 'edge') - def __init__(self, ndisplay=2) -> None: + def __init__(self, ndisplay: int = 2) -> None: self._ndisplay = ndisplay self.clear() - def clear(self): + def clear(self) -> None: """Resets mesh data""" self.vertices = np.empty((0, self.ndisplay)) self.vertices_centers = np.empty((0, self.ndisplay)) @@ -71,12 +71,12 @@ def clear(self): self.displayed_triangles_colors = np.empty((0, 4)) @property - def ndisplay(self): + def ndisplay(self) -> int: """int: Number of displayed dimensions.""" return self._ndisplay @ndisplay.setter - def ndisplay(self, ndisplay): + def ndisplay(self, ndisplay: int) -> None: if self.ndisplay == ndisplay: return diff --git a/napari/layers/shapes/_shape_list.py b/napari/layers/shapes/_shape_list.py index aa047b3487a..cabec6900f0 100644 --- a/napari/layers/shapes/_shape_list.py +++ b/napari/layers/shapes/_shape_list.py @@ -1,9 +1,11 @@ -from collections.abc import Iterable +import typing +from collections.abc import Generator, Iterable, Sequence from contextlib import contextmanager from functools import wraps -from typing import List, Sequence, Union +from typing import Literal, Union import numpy as np +import numpy.typing as npt from napari.layers.shapes._mesh import Mesh from napari.layers.shapes._shapes_constants import ShapeType, shape_classes @@ -82,9 +84,11 @@ class ShapeList: be rendered. """ - def __init__(self, data=(), ndisplay=2) -> None: + def __init__( + self, data: typing.Iterable[Shape] = (), ndisplay: int = 2 + ) -> None: self._ndisplay = ndisplay - self.shapes: List[Shape] = [] + self.shapes: list[Shape] = [] self._displayed = np.array([]) self._slice_key = np.array([]) self.displayed_vertices = np.array([]) @@ -110,7 +114,7 @@ def __init__(self, data=(), ndisplay=2) -> None: self.add(d) @contextmanager - def batched_updates(self): + def batched_updates(self) -> Generator[None, None, None]: """ Reentrant context manager to batch the display update @@ -146,17 +150,17 @@ def batched_updates(self): assert self.__batched_level >= 0 @property - def data(self): + def data(self) -> list[npt.NDArray]: """list of (M, D) array: data arrays for each shape.""" return [s.data for s in self.shapes] @property - def ndisplay(self): + def ndisplay(self) -> int: """int: Number of displayed dimensions.""" return self._ndisplay @ndisplay.setter - def ndisplay(self, ndisplay): + def ndisplay(self, ndisplay: int) -> None: if self.ndisplay == ndisplay: return @@ -172,35 +176,37 @@ def ndisplay(self, ndisplay): self._update_z_order() @property - def slice_keys(self): + def slice_keys(self) -> npt.NDArray: """(N, 2, P) array: slice key for each shape.""" return np.array([s.slice_key for s in self.shapes]) @property - def shape_types(self): + def shape_types(self) -> list[str]: """list of str: shape types for each shape.""" return [s.name for s in self.shapes] @property - def edge_color(self): + def edge_color(self) -> npt.NDArray: """(N x 4) np.ndarray: Array of RGBA edge colors for each shape""" return self._edge_color @edge_color.setter - def edge_color(self, edge_color): + def edge_color(self, edge_color: npt.NDArray) -> None: self._set_color(edge_color, 'edge') @property - def face_color(self): + def face_color(self) -> npt.NDArray: """(N x 4) np.ndarray: Array of RGBA face colors for each shape""" return self._face_color @face_color.setter - def face_color(self, face_color): + def face_color(self, face_color: npt.NDArray) -> None: self._set_color(face_color, 'face') @_batch_dec - def _set_color(self, colors, attribute): + def _set_color( + self, colors: npt.NDArray, attribute: Literal['edge', 'face'] + ) -> None: """Set the face_color or edge_color property Parameters @@ -229,12 +235,12 @@ def _set_color(self, colors, attribute): self._update_displayed() @property - def edge_widths(self): + def edge_widths(self) -> list[float]: """list of float: edge width for each shape.""" return [s.edge_width for s in self.shapes] @property - def z_indices(self): + def z_indices(self) -> list[int]: """list of int: z-index for each shape.""" return [s.z_index for s in self.shapes] @@ -251,7 +257,7 @@ def slice_key(self, slice_key): self._slice_key = slice_key self._update_displayed() - def _update_displayed(self): + def _update_displayed(self) -> None: """Update the displayed data based on the slice key. This method must be called from within the `batched_updates` context @@ -259,7 +265,7 @@ def _update_displayed(self): """ assert ( self.__batched_level >= 1 - ), "call _update_displayed from within self.batched_updates context manager" + ), 'call _update_displayed from within self.batched_updates context manager' if not self.__batch_force_call: self.__update_displayed_called += 1 return diff --git a/napari/layers/shapes/_shapes_constants.py b/napari/layers/shapes/_shapes_constants.py index 24d76d594c4..ab984411824 100644 --- a/napari/layers/shapes/_shapes_constants.py +++ b/napari/layers/shapes/_shapes_constants.py @@ -1,6 +1,6 @@ import sys from enum import auto -from typing import ClassVar, List +from typing import ClassVar from napari.layers.shapes._shapes_models import ( Ellipse, @@ -63,9 +63,9 @@ class ColorMode(StringEnum): class Box: """Box: Constants associated with the vertices of the interaction box""" - WITH_HANDLE: ClassVar[List[int]] = [0, 1, 2, 3, 4, 5, 6, 7, 9] - LINE_HANDLE: ClassVar[List[int]] = [7, 6, 4, 2, 0, 7, 8] - LINE: ClassVar[List[int]] = [0, 2, 4, 6, 0] + WITH_HANDLE: ClassVar[list[int]] = [0, 1, 2, 3, 4, 5, 6, 7, 9] + LINE_HANDLE: ClassVar[list[int]] = [7, 6, 4, 2, 0, 7, 8] + LINE: ClassVar[list[int]] = [0, 2, 4, 6, 0] TOP_LEFT = 0 TOP_CENTER = 7 LEFT_CENTER = 1 diff --git a/napari/layers/shapes/_shapes_key_bindings.py b/napari/layers/shapes/_shapes_key_bindings.py index 90867159d19..654c63d049d 100644 --- a/napari/layers/shapes/_shapes_key_bindings.py +++ b/napari/layers/shapes/_shapes_key_bindings.py @@ -1,3 +1,6 @@ +from collections.abc import Generator +from typing import Callable + import numpy as np from app_model.types import KeyCode @@ -14,7 +17,7 @@ @Shapes.bind_key(KeyCode.Shift, overwrite=True) -def hold_to_lock_aspect_ratio(layer: Shapes): +def hold_to_lock_aspect_ratio(layer: Shapes) -> Generator[None, None, None]: """Hold to lock aspect ratio when resizing a shape.""" # on key press layer._fixed_aspect = True @@ -37,80 +40,84 @@ def hold_to_lock_aspect_ratio(layer: Shapes): layer._fixed_aspect = False -def register_shapes_action(description: str, repeatable: bool = False): +def register_shapes_action( + description: str, repeatable: bool = False +) -> Callable[[Callable], Callable]: return register_layer_action(Shapes, description, repeatable) -def register_shapes_mode_action(description): +def register_shapes_mode_action( + description: str, +) -> Callable[[Callable], Callable]: return register_layer_attr_action(Shapes, description, 'mode') @register_shapes_mode_action(trans._('Transform')) -def activate_shapes_transform_mode(layer): +def activate_shapes_transform_mode(layer: Shapes) -> None: layer.mode = Mode.TRANSFORM @register_shapes_mode_action(trans._('Pan/zoom')) -def activate_shapes_pan_zoom_mode(layer: Shapes): +def activate_shapes_pan_zoom_mode(layer: Shapes) -> None: layer.mode = Mode.PAN_ZOOM @register_shapes_mode_action(trans._('Add rectangles')) -def activate_add_rectangle_mode(layer: Shapes): +def activate_add_rectangle_mode(layer: Shapes) -> None: """Activate add rectangle tool.""" layer.mode = Mode.ADD_RECTANGLE @register_shapes_mode_action(trans._('Add ellipses')) -def activate_add_ellipse_mode(layer: Shapes): +def activate_add_ellipse_mode(layer: Shapes) -> None: """Activate add ellipse tool.""" layer.mode = Mode.ADD_ELLIPSE @register_shapes_mode_action(trans._('Add lines')) -def activate_add_line_mode(layer: Shapes): +def activate_add_line_mode(layer: Shapes) -> None: """Activate add line tool.""" layer.mode = Mode.ADD_LINE @register_shapes_mode_action(trans._('Add path')) -def activate_add_path_mode(layer: Shapes): +def activate_add_path_mode(layer: Shapes) -> None: """Activate add path tool.""" layer.mode = Mode.ADD_PATH @register_shapes_mode_action(trans._('Add polygons')) -def activate_add_polygon_mode(layer: Shapes): +def activate_add_polygon_mode(layer: Shapes) -> None: """Activate add polygon tool.""" layer.mode = Mode.ADD_POLYGON @register_shapes_mode_action(trans._('Add polygons lasso')) -def activate_add_polygon_lasso_mode(layer: Shapes): +def activate_add_polygon_lasso_mode(layer: Shapes) -> None: """Activate add polygon tool.""" layer.mode = Mode.ADD_POLYGON_LASSO @register_shapes_mode_action(trans._('Select vertices')) -def activate_direct_mode(layer: Shapes): +def activate_direct_mode(layer: Shapes) -> None: """Activate vertex selection tool.""" layer.mode = Mode.DIRECT @register_shapes_mode_action(trans._('Select shapes')) -def activate_select_mode(layer: Shapes): +def activate_select_mode(layer: Shapes) -> None: """Activate shape selection tool.""" layer.mode = Mode.SELECT @register_shapes_mode_action(trans._('Insert vertex')) -def activate_vertex_insert_mode(layer: Shapes): +def activate_vertex_insert_mode(layer: Shapes) -> None: """Activate vertex insertion tool.""" layer.mode = Mode.VERTEX_INSERT @register_shapes_mode_action(trans._('Remove vertex')) -def activate_vertex_remove_mode(layer: Shapes): +def activate_vertex_remove_mode(layer: Shapes) -> None: """Activate vertex deletion tool.""" layer.mode = Mode.VERTEX_REMOVE @@ -132,21 +139,21 @@ def activate_vertex_remove_mode(layer: Shapes): @register_shapes_action(trans._('Copy any selected shapes')) -def copy_selected_shapes(layer: Shapes): +def copy_selected_shapes(layer: Shapes) -> None: """Copy any selected shapes.""" if layer._mode in (Mode.DIRECT, Mode.SELECT): layer._copy_data() @register_shapes_action(trans._('Paste any copied shapes')) -def paste_shape(layer: Shapes): +def paste_shape(layer: Shapes) -> None: """Paste any copied shapes.""" if layer._mode in (Mode.DIRECT, Mode.SELECT): layer._paste_data() @register_shapes_action(trans._('Select all shapes in the current view slice')) -def select_all_shapes(layer: Shapes): +def select_all_shapes(layer: Shapes) -> None: """Select all shapes in the current view slice.""" if layer._mode in (Mode.DIRECT, Mode.SELECT): layer.selected_data = set(np.nonzero(layer._data_view._displayed)[0]) @@ -154,7 +161,7 @@ def select_all_shapes(layer: Shapes): @register_shapes_action(trans._('Delete any selected shapes')) -def delete_selected_shapes(layer: Shapes): +def delete_selected_shapes(layer: Shapes) -> None: """.""" if not layer._is_creating: @@ -162,12 +169,12 @@ def delete_selected_shapes(layer: Shapes): @register_shapes_action(trans._('Move to front')) -def move_shapes_selection_to_front(layer: Shapes): +def move_shapes_selection_to_front(layer: Shapes) -> None: layer.move_to_front() @register_shapes_action(trans._('Move to back')) -def move_shapes_selection_to_back(layer: Shapes): +def move_shapes_selection_to_back(layer: Shapes) -> None: layer.move_to_back() @@ -176,6 +183,6 @@ def move_shapes_selection_to_back(layer: Shapes): 'Finish any drawing, for example when using the path or polygon tool.' ), ) -def finish_drawing_shape(layer: Shapes): +def finish_drawing_shape(layer: Shapes) -> None: """Finish any drawing, for example when using the path or polygon tool.""" layer._finish_drawing() diff --git a/napari/layers/shapes/_shapes_models/__init__.py b/napari/layers/shapes/_shapes_models/__init__.py index 98d8d5f6d71..f1f8b54ab2d 100644 --- a/napari/layers/shapes/_shapes_models/__init__.py +++ b/napari/layers/shapes/_shapes_models/__init__.py @@ -5,4 +5,4 @@ from napari.layers.shapes._shapes_models.rectangle import Rectangle from napari.layers.shapes._shapes_models.shape import Shape -__all__ = ["Ellipse", "Line", "Path", "Polygon", "Rectangle", "Shape"] +__all__ = ['Ellipse', 'Line', 'Path', 'Polygon', 'Rectangle', 'Shape'] diff --git a/napari/layers/shapes/_shapes_models/_polgyon_base.py b/napari/layers/shapes/_shapes_models/_polgyon_base.py index cd56917d571..ca94ebe4270 100644 --- a/napari/layers/shapes/_shapes_models/_polgyon_base.py +++ b/napari/layers/shapes/_shapes_models/_polgyon_base.py @@ -73,7 +73,7 @@ def data(self, data): if len(data) < 2: raise ValueError( trans._( - "Shape needs at least two vertices, {number} provided.", + 'Shape needs at least two vertices, {number} provided.', deferred=True, number=len(data), ) @@ -82,7 +82,7 @@ def data(self, data): self._data = data self._update_displayed_data() - def _update_displayed_data(self): + def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" # Raw vertices data = self.data_displayed diff --git a/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py b/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py index 40b663544fa..460d6958ef2 100644 --- a/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py +++ b/napari/layers/shapes/_shapes_models/_tests/test_shapes_models.py @@ -64,7 +64,7 @@ def test_polygon_data_triangle(): def test_polygon_data_triangle_module(): - pytest.importorskip("triangle") + pytest.importorskip('triangle') data = np.array( [ [10.97627008, 14.30378733], @@ -98,7 +98,7 @@ def test_polygon(): assert shape.data_displayed.shape == (6, 2) assert shape.slice_key.shape == (2, 0) # should get few triangles - expected_face = (6, 2) if "triangle" in sys.modules else (8, 2) + expected_face = (6, 2) if 'triangle' in sys.modules else (8, 2) assert shape._edge_vertices.shape == (16, 2) assert shape._face_vertices.shape == expected_face @@ -108,7 +108,7 @@ def test_polygon2(): shape = Polygon(data, interpolation_order=3) # should get many triangles - expected_face = (249, 2) if "triangle" in sys.modules else (251, 2) + expected_face = (249, 2) if 'triangle' in sys.modules else (251, 2) assert shape._edge_vertices.shape == (500, 2) assert shape._face_vertices.shape == expected_face diff --git a/napari/layers/shapes/_shapes_models/ellipse.py b/napari/layers/shapes/_shapes_models/ellipse.py index 43fcc0ad419..2c21ecb308c 100644 --- a/napari/layers/shapes/_shapes_models/ellipse.py +++ b/napari/layers/shapes/_shapes_models/ellipse.py @@ -70,7 +70,7 @@ def data(self, data): if len(data) != 4: raise ValueError( trans._( - "Data shape does not match a ellipse. Ellipse expects four corner vertices, {number} provided.", + 'Data shape does not match a ellipse. Ellipse expects four corner vertices, {number} provided.', deferred=True, number=len(data), ) @@ -79,7 +79,7 @@ def data(self, data): self._data = data self._update_displayed_data() - def _update_displayed_data(self): + def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" # Build boundary vertices with num_segments vertices, triangles = triangulate_ellipse(self.data_displayed) diff --git a/napari/layers/shapes/_shapes_models/line.py b/napari/layers/shapes/_shapes_models/line.py index e8915fa1f09..a7626f1ec34 100644 --- a/napari/layers/shapes/_shapes_models/line.py +++ b/napari/layers/shapes/_shapes_models/line.py @@ -55,7 +55,7 @@ def data(self, data): if len(data) != 2: raise ValueError( trans._( - "Data shape does not match a line. A line expects two end vertices, {number} provided.", + 'Data shape does not match a line. A line expects two end vertices, {number} provided.', deferred=True, number=len(data), ) @@ -64,7 +64,7 @@ def data(self, data): self._data = data self._update_displayed_data() - def _update_displayed_data(self): + def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" # For path connect every all data self._set_meshes(self.data_displayed, face=False, closed=False) diff --git a/napari/layers/shapes/_shapes_models/rectangle.py b/napari/layers/shapes/_shapes_models/rectangle.py index dd78961e609..baee56a56c2 100644 --- a/napari/layers/shapes/_shapes_models/rectangle.py +++ b/napari/layers/shapes/_shapes_models/rectangle.py @@ -61,7 +61,7 @@ def data(self, data): if len(data) != 4: raise ValueError( trans._( - "Data shape does not match a rectangle. Rectangle expects four corner vertices, {number} provided.", + 'Data shape does not match a rectangle. Rectangle expects four corner vertices, {number} provided.', deferred=True, number=len(data), ) @@ -70,7 +70,7 @@ def data(self, data): self._data = data self._update_displayed_data() - def _update_displayed_data(self): + def _update_displayed_data(self) -> None: """Update the data that is to be displayed.""" # Add four boundary lines and then two triangles for each self._set_meshes(self.data_displayed, face=False) diff --git a/napari/layers/shapes/_shapes_models/shape.py b/napari/layers/shapes/_shapes_models/shape.py index 002fa5108a3..f40f2dfdded 100644 --- a/napari/layers/shapes/_shapes_models/shape.py +++ b/napari/layers/shapes/_shapes_models/shape.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod +from typing import Optional import numpy as np +import numpy.typing as npt from napari.layers.shapes._shapes_utils import ( is_collinear, @@ -98,7 +100,7 @@ def __init__( ) -> None: self._dims_order = dims_order or list(range(2)) self._ndisplay = ndisplay - self.slice_key = None + self.slice_key: Optional[npt.NDArray] = None self._face_vertices = np.empty((0, self.ndisplay)) self._face_triangles = np.empty((0, 3), dtype=np.uint32) @@ -114,6 +116,8 @@ def __init__( self.z_index = z_index self.name = '' + self._data: npt.NDArray + @property @abstractmethod def data(self): @@ -126,7 +130,7 @@ def data(self, data): raise NotImplementedError @abstractmethod - def _update_displayed_data(self): + def _update_displayed_data(self) -> None: raise NotImplementedError @property @@ -188,7 +192,13 @@ def z_index(self): def z_index(self, z_index): self._z_index = z_index - def _set_meshes(self, data, closed=True, face=True, edge=True): + def _set_meshes( + self, + data: npt.NDArray, + closed: bool = True, + face: bool = True, + edge: bool = True, + ) -> None: """Sets the face and edge meshes from a set of points. Parameters @@ -227,8 +237,8 @@ def _set_meshes(self, data, closed=True, face=True, edge=True): exp = np.expand_dims(np.repeat(val, len(vertices)), axis=1) vertices = np.concatenate([exp, vertices], axis=1) else: - triangles = [] - vertices = [] + triangles = np.array([]) + vertices = np.array([]) if len(triangles) > 0: self._face_vertices = vertices self._face_triangles = triangles @@ -242,7 +252,7 @@ def _set_meshes(self, data, closed=True, face=True, edge=True): self._face_vertices = np.empty((0, self.ndisplay)) self._face_triangles = np.empty((0, 3), dtype=np.uint32) - def transform(self, transform): + def transform(self, transform: npt.NDArray) -> None: """Performs a linear transform on the shape Parameters @@ -265,7 +275,7 @@ def transform(self, transform): self._edge_offsets = offsets self._edge_triangles = triangles - def shift(self, shift): + def shift(self, shift: npt.NDArray) -> None: """Performs a 2D shift on the shape Parameters @@ -390,7 +400,7 @@ def to_mask(self, mask_shape=None, zoom_factor=1, offset=(0, 0)): else: raise ValueError( trans._( - "mask shape length must either be 2 or the same as the dimensionality of the shape, expected {expected} got {received}.", + 'mask shape length must either be 2 or the same as the dimensionality of the shape, expected {expected} got {received}.', deferred=True, expected=self.data.shape[1], received=len(mask_shape), @@ -413,16 +423,18 @@ def to_mask(self, mask_shape=None, zoom_factor=1, offset=(0, 0)): # and embed as a slice. if embedded: mask = np.zeros(mask_shape, dtype=bool) - slice_key = [0] * len(mask_shape) - j = 0 + slice_key: list[int | slice] = [0] * len(mask_shape) for i in range(len(mask_shape)): if i in self.dims_displayed: slice_key[i] = slice(None) - else: + elif self.slice_key is not None: slice_key[i] = slice( - self.slice_key[0, j], self.slice_key[1, j] + 1 + self.slice_key[0, i], self.slice_key[1, i] + 1 + ) + else: + raise RuntimeError( + 'Internal error: self.slice_key is None' ) - j += 1 displayed_order = argsort(self.dims_displayed) mask[tuple(slice_key)] = mask_p.transpose(displayed_order) else: diff --git a/napari/layers/shapes/_shapes_mouse_bindings.py b/napari/layers/shapes/_shapes_mouse_bindings.py index 0e582cf4381..99b4bcc1291 100644 --- a/napari/layers/shapes/_shapes_mouse_bindings.py +++ b/napari/layers/shapes/_shapes_mouse_bindings.py @@ -18,7 +18,8 @@ from napari.settings import get_settings if TYPE_CHECKING: - from typing import Generator, List, Optional, Tuple + from collections.abc import Generator + from typing import Optional import numpy.typing as npt from vispy.app.canvas import MouseEvent @@ -294,7 +295,7 @@ def finish_drawing_shape(layer: Shapes, event: MouseEvent) -> None: def initiate_polygon_draw( - layer: Shapes, coordinates: Tuple[float, ...] + layer: Shapes, coordinates: tuple[float, ...] ) -> None: """Start drawing of polygon. @@ -359,7 +360,7 @@ def add_vertex_to_path( layer: Shapes, event: MouseEvent, index: int, - coordinates: Tuple[float, ...], + coordinates: tuple[float, ...], new_type: Optional[str], ) -> None: """Add a vertex to an existing path or polygon and edit the layer view. @@ -446,7 +447,7 @@ def add_path_polygon(layer: Shapes, event: MouseEvent) -> None: def move_active_vertex_under_cursor( - layer: Shapes, coordinates: Tuple[float, ...] + layer: Shapes, coordinates: tuple[float, ...] ) -> None: """While a path or polygon is being created, move next vertex to be added. @@ -616,7 +617,7 @@ def vertex_remove(layer: Shapes, event: MouseEvent) -> None: layer.refresh() -def _drag_selection_box(layer: Shapes, coordinates: Tuple[float, ...]) -> None: +def _drag_selection_box(layer: Shapes, coordinates: tuple[float, ...]) -> None: """Drag a selection box. Parameters @@ -641,8 +642,8 @@ def _drag_selection_box(layer: Shapes, coordinates: Tuple[float, ...]) -> None: def _set_drag_start( - layer: Shapes, coordinates: Tuple[float, ...] -) -> List[float]: + layer: Shapes, coordinates: tuple[float, ...] +) -> list[float]: """Indicate where in data space a drag event started. Sets the coordinates relative to the center of the bounding box of a shape and returns the position @@ -668,7 +669,7 @@ def _set_drag_start( def _move_active_element_under_cursor( - layer: Shapes, coordinates: Tuple[float, ...] + layer: Shapes, coordinates: tuple[float, ...] ) -> None: """Moves object at given mouse position and set of indices. diff --git a/napari/layers/shapes/_shapes_utils.py b/napari/layers/shapes/_shapes_utils.py index 1bce8070641..324448d5228 100644 --- a/napari/layers/shapes/_shapes_utils.py +++ b/napari/layers/shapes/_shapes_utils.py @@ -247,7 +247,7 @@ def orientation(p, q, r): return val -def is_collinear(points): +def is_collinear(points: npt.NDArray) -> bool: """Determines if a list of 2D points are collinear. Parameters @@ -325,7 +325,7 @@ def point_to_lines(point, lines): return index, location -def create_box(data): +def create_box(data: npt.NDArray) -> npt.NDArray: """Creates the axis aligned interaction box of a list of points Parameters @@ -362,7 +362,7 @@ def create_box(data): return box -def rectangle_to_box(data): +def rectangle_to_box(data: npt.NDArray) -> npt.NDArray: """Converts the four corners of a rectangle into a interaction box like representation. If the rectangle is not axis aligned the resulting box representation will not be axis aligned either @@ -382,7 +382,7 @@ def rectangle_to_box(data): if not data.shape[0] == 4: raise ValueError( trans._( - "Data shape does not match expected `[4, D]` shape specifying corners for the rectangle", + 'Data shape does not match expected `[4, D]` shape specifying corners for the rectangle', deferred=True, ) ) @@ -402,7 +402,7 @@ def rectangle_to_box(data): return box -def find_corners(data): +def find_corners(data: npt.NDArray) -> npt.NDArray: """Finds the four corners of the interaction box defined by an array of points @@ -426,27 +426,31 @@ def find_corners(data): return corners -def center_radii_to_corners(center, radii): +def center_radii_to_corners( + center: npt.NDArray, radii: npt.NDArray +) -> npt.NDArray: """Expands a center and radii into a four corner rectangle Parameters ---------- - center : np.ndarray | list - Length 2 array or list of the center coordinates - radii : np.ndarray | list - Length 2 array or list of the two radii + center : np.ndarray + Length 2 array of the center coordinates. + radii : np.ndarray + Length 2 array of the two radii. Returns ------- corners : np.ndarray - 4x2 array of corners of the bounding box + 4x2 array of corners of the bounding box. """ data = np.array([center + radii, center - radii]) corners = find_corners(data) return corners -def triangulate_ellipse(corners, num_segments=100): +def triangulate_ellipse( + corners: npt.NDArray, num_segments: int = 100 +) -> tuple[npt.NDArray, npt.NDArray]: """Determines the triangulation of a path. The resulting `offsets` can multiplied by a `width` scalar and be added to the resulting `centers` to generate the vertices of the triangles for the triangulation, i.e. @@ -488,7 +492,7 @@ def triangulate_ellipse(corners, num_segments=100): if not corners.shape[0] == 4: raise ValueError( trans._( - "Data shape does not match expected `[4, D]` shape specifying corners for the ellipse", + 'Data shape does not match expected `[4, D]` shape specifying corners for the ellipse', deferred=True, ) ) @@ -531,7 +535,7 @@ def triangulate_ellipse(corners, num_segments=100): return vertices, triangles -def triangulate_face(data): +def triangulate_face(data: npt.NDArray) -> tuple[npt.NDArray, npt.NDArray]: """Determines the triangulation of the face of a shape. Parameters @@ -557,7 +561,7 @@ def triangulate_face(data): # connect last with first vertex edges[-1, 1] = 0 - res = triangulate({"vertices": data, "segments": edges}, "p") + res = triangulate({'vertices': data, 'segments': edges}, 'p') vertices, triangles = res['vertices'], res['triangles'] else: vertices, triangles = PolygonData(vertices=data).triangulate() @@ -567,7 +571,9 @@ def triangulate_face(data): return vertices, triangles -def triangulate_edge(path, closed=False): +def triangulate_edge( + path: npt.NDArray, closed: bool = False +) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]: """Determines the triangulation of a path. The resulting `offsets` can multiplied by a `width` scalar and be added to the resulting `centers` to generate the vertices of the triangles for the triangulation, i.e. @@ -730,22 +736,29 @@ def generate_2D_edge_meshes(path, closed=False, limit=3, bevel=False): )[0] if len(idx_bevel) > 0: - # only the 'outwards sticking' offsets should be changed - # TODO: This is not entirely true as in extreme cases both can go to infinity idx_offset = (miter_signs[idx_bevel] < 0).astype(int) - idx_bevel_full = 2 * idx_bevel + idx_offset + + # outside and inside offsets are treated differently (only outside offsets get beveled) + # See drawing at: + # https://github.com/napari/napari/pull/6706#discussion_r1528790407 + idx_bevel_outside = 2 * idx_bevel + idx_offset + idx_bevel_inside = 2 * idx_bevel + (1 - idx_offset) sign_bevel = np.expand_dims(miter_signs[idx_bevel], -1) - # adjust offset of outer "left" vertex - offsets[idx_bevel_full] = ( + # adjust offset of outer offset + offsets[idx_bevel_outside] = ( -0.5 * full_normals[:-1][idx_bevel] * sign_bevel ) + # adjust/normalize length of inner offset + offsets[idx_bevel_inside] /= np.sqrt( + miter_lengths_squared[idx_bevel, np.newaxis] + ) # special cases for the last vertex _nonspecial = idx_bevel != len(path) - 1 idx_bevel = idx_bevel[_nonspecial] - idx_bevel_full = idx_bevel_full[_nonspecial] + idx_bevel_outside = idx_bevel_outside[_nonspecial] sign_bevel = sign_bevel[_nonspecial] idx_offset = idx_offset[_nonspecial] @@ -762,9 +775,8 @@ def generate_2D_edge_meshes(path, closed=False, limit=3, bevel=False): n_centers + np.arange(len(idx_bevel)) ) - # add center triangle + # add a new center/bevel triangle triangles0 = np.tile(np.array([[0, 1, 2]]), (len(idx_bevel), 1)) - triangles_bevel = np.array( [ 2 * idx_bevel + idx_offset, @@ -772,7 +784,6 @@ def generate_2D_edge_meshes(path, closed=False, limit=3, bevel=False): n_centers + np.arange(len(idx_bevel)), ] ).T - # add all new centers, offsets, and triangles centers = np.concatenate([centers, centers_bevel]) offsets = np.concatenate([offsets, offsets_bevel]) @@ -858,7 +869,9 @@ def generate_tube_meshes(path, closed=False, tube_points=10): return centers, offsets, triangles -def path_to_mask(mask_shape, vertices): +def path_to_mask( + mask_shape: npt.NDArray, vertices: npt.NDArray +) -> npt.NDArray[np.bool_]: """Converts a path to a boolean mask with `True` for points lying along each edge. @@ -896,7 +909,9 @@ def path_to_mask(mask_shape, vertices): return mask -def poly_to_mask(mask_shape, vertices): +def poly_to_mask( + mask_shape: npt.ArrayLike, vertices: npt.ArrayLike +) -> npt.NDArray[np.bool_]: """Converts a polygon to a boolean mask with `True` for points lying inside the shape. Uses the bounding box of the vertices to reduce computation time. @@ -1032,7 +1047,7 @@ def get_default_shape_type(current_type): default_type : str default shape type """ - default = "polygon" + default = 'polygon' if not current_type: return default first_type = current_type[0] @@ -1138,7 +1153,7 @@ def validate_num_vertices( ): raise ValueError( trans._( - "{shape_type} {shape} has invalid number of vertices: {shape_length}.", + '{shape_type} {shape} has invalid number of vertices: {shape_length}.', deferred=True, shape_type=shape_type, shape=shape, diff --git a/napari/layers/shapes/_tests/test_shape_list.py b/napari/layers/shapes/_tests/test_shape_list.py index 59e6ce97e13..e8ab3aa1eec 100644 --- a/napari/layers/shapes/_tests/test_shape_list.py +++ b/napari/layers/shapes/_tests/test_shape_list.py @@ -83,7 +83,7 @@ def test_nD_shapes(): assert shape_list._mesh.vertices.shape[1] == 3 -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_bad_color_array(attribute): """Test adding shapes to ShapeList.""" np.random.seed(0) diff --git a/napari/layers/shapes/_tests/test_shapes.py b/napari/layers/shapes/_tests/test_shapes.py index 26c00253c3f..72332c3a514 100644 --- a/napari/layers/shapes/_tests/test_shapes.py +++ b/napari/layers/shapes/_tests/test_shapes.py @@ -76,7 +76,7 @@ def test_empty_shapes_with_features(): properties_list = {'shape_type': list(_make_cycled_properties(['A', 'B'], 10))} -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_properties(properties): shape = (10, 4, 2) np.random.seed(0) @@ -127,7 +127,7 @@ def test_properties(properties): assert updated_properties['shape_type'][0] == 'B' -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_adding_properties(attribute): """Test adding properties to an existing layer""" shape = (10, 4, 2) @@ -283,7 +283,7 @@ def test_empty_layer_with_text_formatted(): np.testing.assert_equal(layer.text.values, ['shape_type: 1.50']) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_text_from_property_value(properties): """Test setting text from a property value""" shape = (10, 4, 2) @@ -294,7 +294,7 @@ def test_text_from_property_value(properties): np.testing.assert_equal(layer.text.values, properties['shape_type']) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_text_from_property_fstring(properties): """Test setting text with an f-string from the property value""" shape = (10, 4, 2) @@ -316,18 +316,18 @@ def test_text_from_property_fstring(properties): layer.selected_data = {0} layer._copy_data() layer._paste_data() - expected_text_3 = [*expected_text_2, "type-ish: A"] + expected_text_3 = [*expected_text_2, 'type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_3) # add shape layer.selected_data = {0} new_shape = np.random.random((1, 4, 2)) layer.add(new_shape) - expected_text_4 = [*expected_text_3, "type-ish: A"] + expected_text_4 = [*expected_text_3, 'type-ish: A'] np.testing.assert_equal(layer.text.values, expected_text_4) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_set_text_with_kwarg_dict(properties): text_kwargs = { 'string': 'type: {shape_type}', @@ -353,7 +353,7 @@ def test_set_text_with_kwarg_dict(properties): np.testing.assert_equal(layer_value, value) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_text_error(properties): """creating a layer with text as the wrong type should raise an error""" shape = (10, 4, 2) @@ -435,7 +435,7 @@ def test_nd_text(prepend): np.testing.assert_equal(layer._view_text_coords[0], [[20, 40, 40]]) -@pytest.mark.parametrize("properties", [properties_array, properties_list]) +@pytest.mark.parametrize('properties', [properties_array, properties_list]) def test_data_setter_with_text(properties): """Test layer data on a layer with text via the data setter""" shape = (10, 4, 2) @@ -462,7 +462,7 @@ def test_data_setter_with_text(properties): @pytest.mark.parametrize( - "shape", + 'shape', [ # single & multiple four corner rectangles (1, 4, 2), @@ -517,7 +517,7 @@ def test_rectangles_with_shape_type(): shape = (1, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) - data = (vertices, "rectangle") + data = (vertices, 'rectangle') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.array_equiv(layer.data[0], data[0]) @@ -527,7 +527,7 @@ def test_rectangles_with_shape_type(): # Test (list of rectangles, shape_type) tuple shape = (10, 4, 2) vertices = 20 * np.random.random(shape) - data = (vertices, "rectangle") + data = (vertices, 'rectangle') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all( @@ -537,7 +537,7 @@ def test_rectangles_with_shape_type(): assert np.all([s == 'rectangle' for s in layer.shape_type]) # Test list of (rectangle, shape_type) tuples - data = [(vertices[i], "rectangle") for i in range(shape[0])] + data = [(vertices[i], 'rectangle') for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all( @@ -617,7 +617,7 @@ def test_3D_rectangles(): @pytest.mark.parametrize( - "shape", + 'shape', [ # single & multiple four corner ellipses (1, 4, 2), @@ -673,7 +673,7 @@ def test_ellipses_with_shape_type(): shape = (1, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) - data = (vertices, "ellipse") + data = (vertices, 'ellipse') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.array_equiv(layer.data[0], data[0]) @@ -684,7 +684,7 @@ def test_ellipses_with_shape_type(): shape = (10, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) - data = (vertices, "ellipse") + data = (vertices, 'ellipse') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all( @@ -697,7 +697,7 @@ def test_ellipses_with_shape_type(): shape = (10, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) - data = [(vertices[i], "ellipse") for i in range(shape[0])] + data = [(vertices[i], 'ellipse') for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all( @@ -709,7 +709,7 @@ def test_ellipses_with_shape_type(): # Test single (center-radii, shape_type) ellipse shape = (1, 2, 2) np.random.seed(0) - data = (20 * np.random.random(shape), "ellipse") + data = (20 * np.random.random(shape), 'ellipse') layer = Shapes(data) assert layer.nshapes == 1 assert len(layer.data[0]) == 4 @@ -720,7 +720,7 @@ def test_ellipses_with_shape_type(): shape = (10, 2, 2) np.random.seed(0) center_radii = 20 * np.random.random(shape) - data = (center_radii, "ellipse") + data = (center_radii, 'ellipse') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([len(ld) == 4 for ld in layer.data]) @@ -731,7 +731,7 @@ def test_ellipses_with_shape_type(): shape = (10, 2, 2) np.random.seed(0) center_radii = 20 * np.random.random(shape) - data = [(center_radii[i], "ellipse") for i in range(shape[0])] + data = [(center_radii[i], 'ellipse') for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all([len(ld) == 4 for ld in layer.data]) @@ -834,7 +834,7 @@ def test_lines_with_shape_type(): shape = (10, 2, 2) np.random.seed(0) end_points = 20 * np.random.random(shape) - data = (end_points, "line") + data = (end_points, 'line') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all( @@ -847,7 +847,7 @@ def test_lines_with_shape_type(): shape = (10, 2, 2) np.random.seed(0) end_points = 20 * np.random.random(shape) - data = [(end_points[i], "line") for i in range(shape[0])] + data = [(end_points[i], 'line') for i in range(shape[0])] layer = Shapes(data) assert layer.nshapes == shape[0] assert np.all( @@ -868,7 +868,7 @@ def test_lines_roundtrip(): @pytest.mark.parametrize( - "shape", + 'shape', [ # single path, six points (6, 2), @@ -920,7 +920,7 @@ def test_paths_with_shape_type(): shape = (1, 6, 2) np.random.seed(0) path_points = 20 * np.random.random(shape) - data = (path_points, "path") + data = (path_points, 'path') layer = Shapes(data) assert layer.nshapes == shape[0] assert np.array_equal(layer.data[0], path_points[0]) @@ -931,7 +931,7 @@ def test_paths_with_shape_type(): path_points = [ 20 * np.random.random((np.random.randint(2, 12), 2)) for i in range(10) ] - data = (path_points, "path") + data = (path_points, 'path') layer = Shapes(data) assert layer.nshapes == len(path_points) assert np.all( @@ -941,7 +941,7 @@ def test_paths_with_shape_type(): assert np.all([s == 'path' for s in layer.shape_type]) # Test list of (path, shape_type) tuples - data = [(path_points[i], "path") for i in range(len(path_points))] + data = [(path_points[i], 'path') for i in range(len(path_points))] layer = Shapes(data) assert layer.nshapes == len(data) assert np.all( @@ -965,7 +965,7 @@ def test_paths_roundtrip(): @pytest.mark.parametrize( - "shape", + 'shape', [ # single 2D polygon, six points (6, 2), @@ -998,20 +998,20 @@ def test_polygons(shape): # Avoid a.any(), a.all() assert layer2.events.data.call_args_list[0][1] == { - "value": [], - "action": ActionType.ADDING, - "data_indices": (-1,), - "vertex_indices": ((),), + 'value': [], + 'action': ActionType.ADDING, + 'data_indices': (-1,), + 'vertex_indices': ((),), } assert np.array_equal( - layer2.events.data.call_args_list[1][1]["value"], layer.data + layer2.events.data.call_args_list[1][1]['value'], layer.data ) assert ( - layer2.events.data.call_args_list[0][1]["action"] == ActionType.ADDING + layer2.events.data.call_args_list[0][1]['action'] == ActionType.ADDING ) - assert layer2.events.data.call_args_list[0][1]["data_indices"] == (-1,) - assert layer2.events.data.call_args_list[0][1]["vertex_indices"] == ((),) + assert layer2.events.data.call_args_list[0][1]['data_indices'] == (-1,) + assert layer2.events.data.call_args_list[0][1]['vertex_indices'] == ((),) def test_add_polygons_raises_error(): @@ -1132,11 +1132,11 @@ def test_data_shape_type_overwrites_meta(): shape = (10, 4, 2) np.random.seed(0) vertices = 20 * np.random.random(shape) - data = (vertices, "ellipse") + data = (vertices, 'ellipse') layer = Shapes(data, shape_type='rectangle') assert np.all([s == 'ellipse' for s in layer.shape_type]) - data = [(vertices[i], "ellipse") for i in range(shape[0])] + data = [(vertices[i], 'ellipse') for i in range(shape[0])] layer = Shapes(data, shape_type='rectangle') assert np.all([s == 'ellipse' for s in layer.shape_type]) @@ -1159,7 +1159,7 @@ def test_changing_shapes(): assert np.all([s == 'rectangle' for s in layer.shape_type]) # setting data with shape type - data_a = (vertices_a, "ellipse") + data_a = (vertices_a, 'ellipse') layer.data = data_a assert layer.nshapes == shape_a[0] assert np.all( @@ -1202,7 +1202,7 @@ def test_changing_shape_type(): np.random.seed(0) rectangles = 20 * np.random.random((10, 4, 2)) layer = Shapes(rectangles, shape_type='rectangle') - layer.shape_type = "ellipse" + layer.shape_type = 'ellipse' assert np.all([s == 'ellipse' for s in layer.shape_type]) @@ -1288,16 +1288,16 @@ def test_removing_all_shapes_empty_list(): layer.data = [] assert layer.nshapes == 0 assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.REMOVING, - "data_indices": tuple(i for i in range(len(old_data))), - "vertex_indices": ((),), + 'value': old_data, + 'action': ActionType.REMOVING, + 'data_indices': tuple(i for i in range(len(old_data))), + 'vertex_indices': ((),), } assert layer.events.data.call_args_list[1][1] == { - "value": layer.data, - "action": ActionType.REMOVED, - "data_indices": (), - "vertex_indices": ((),), + 'value': layer.data, + 'action': ActionType.REMOVED, + 'data_indices': (), + 'vertex_indices': ((),), } @@ -1313,16 +1313,16 @@ def test_removing_all_shapes_empty_array(): layer.data = np.empty((0, 2)) assert layer.nshapes == 0 assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.REMOVING, - "data_indices": tuple(i for i in range(len(old_data))), - "vertex_indices": ((),), + 'value': old_data, + 'action': ActionType.REMOVING, + 'data_indices': tuple(i for i in range(len(old_data))), + 'vertex_indices': ((),), } assert layer.events.data.call_args_list[1][1] == { - "value": layer.data, - "action": ActionType.REMOVED, - "data_indices": (), - "vertex_indices": ((),), + 'value': layer.data, + 'action': ActionType.REMOVED, + 'data_indices': (), + 'vertex_indices': ((),), } @@ -1346,20 +1346,20 @@ def test_removing_selected_shapes(): layer.selected_data = selection layer.remove_selected() assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.REMOVING, - "data_indices": tuple( + 'value': old_data, + 'action': ActionType.REMOVING, + 'data_indices': tuple( selection, ), - "vertex_indices": ((),), + 'vertex_indices': ((),), } assert layer.events.data.call_args_list[1][1] == { - "value": layer.data, - "action": ActionType.REMOVED, - "data_indices": tuple( + 'value': layer.data, + 'action': ActionType.REMOVED, + 'data_indices': tuple( selection, ), - "vertex_indices": ((),), + 'vertex_indices': ((),), } keep = [0, *range(2, 7)] + [9] @@ -1505,7 +1505,7 @@ def test_blending(): assert layer.blending == 'opaque' -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_switch_color_mode(attribute): """Test switching between color modes""" shape = (10, 4, 2) @@ -1564,7 +1564,7 @@ def test_switch_color_mode(attribute): np.testing.assert_allclose(new_edge_color, color) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_color_direct(attribute: str): """Test setting face/edge color directly.""" shape = (10, 4, 2) @@ -1625,7 +1625,7 @@ def test_color_direct(attribute: str): np.testing.assert_allclose(color_array, layer_color) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_single_shape_properties(attribute): """Test creating single shape with properties""" shape = (4, 2) @@ -1643,9 +1643,9 @@ def test_single_shape_properties(attribute): color_cycle_rgba = [[1, 0, 0, 1], [0, 0, 1, 1]] -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) @pytest.mark.parametrize( - "color_cycle", + 'color_cycle', [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(attribute, color_cycle): @@ -1710,7 +1710,7 @@ def test_color_cycle(attribute, color_cycle): ) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_add_color_cycle_to_empty_layer(attribute): """Test adding a shape to an empty layer when edge/face color is a color cycle @@ -1753,7 +1753,7 @@ def test_add_color_cycle_to_empty_layer(attribute): np.testing.assert_equal(layer.properties, new_properties) -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_adding_value_color_cycle(attribute): """Test that adding values to properties used to set a color cycle and then calling Shapes.refresh_colors() performs the update and adds the @@ -1782,7 +1782,7 @@ def test_adding_value_color_cycle(attribute): assert 'C' in color_map_keys -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_color_colormap(attribute): """Test setting edge/face color with a colormap""" # create Shapes using with a colormap @@ -1849,7 +1849,7 @@ def test_color_colormap(attribute): assert attribute_colormap.name == new_colormap -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_colormap_without_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 4, 2) @@ -1861,7 +1861,7 @@ def test_colormap_without_properties(attribute): setattr(layer, f'{attribute}_color_mode', 'colormap') -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_colormap_with_categorical_properties(attribute): """Setting the colormode to colormap should raise an exception""" shape = (10, 4, 2) @@ -1874,7 +1874,7 @@ def test_colormap_with_categorical_properties(attribute): setattr(layer, f'{attribute}_color_mode', 'colormap') -@pytest.mark.parametrize("attribute", ['edge', 'face']) +@pytest.mark.parametrize('attribute', ['edge', 'face']) def test_add_colormap(attribute): """Test directly adding a vispy Colormap object""" shape = (10, 4, 2) diff --git a/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py b/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py index ade9643d6ba..a8fe13d9446 100644 --- a/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py +++ b/napari/layers/shapes/_tests/test_shapes_mouse_bindings.py @@ -294,16 +294,16 @@ def test_vertex_insert(create_known_shapes_layer): assert len(layer.data) == n_shapes assert len(layer.data[0]) == n_coord + 1 assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.CHANGING, - "data_indices": tuple(layer.selected_data), - "vertex_indices": ((2,),), + 'value': old_data, + 'action': ActionType.CHANGING, + 'data_indices': tuple(layer.selected_data), + 'vertex_indices': ((2,),), } assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.CHANGED, - "data_indices": tuple(layer.selected_data), - "vertex_indices": ((2,),), + 'value': layer.data, + 'action': ActionType.CHANGED, + 'data_indices': tuple(layer.selected_data), + 'vertex_indices': ((2,),), } np.testing.assert_allclose( np.min(abs(layer.data[0] - known_non_shape), axis=0), [0, 0] @@ -328,20 +328,20 @@ def test_vertex_remove(create_known_shapes_layer): ) mouse_press_callbacks(layer, event) assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.CHANGING, - "data_indices": tuple( + 'value': old_data, + 'action': ActionType.CHANGING, + 'data_indices': tuple( select, ), - "vertex_indices": ((0,),), + 'vertex_indices': ((0,),), } assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.CHANGED, - "data_indices": tuple( + 'value': layer.data, + 'action': ActionType.CHANGED, + 'data_indices': tuple( select, ), - "vertex_indices": ((0,),), + 'vertex_indices': ((0,),), } assert len(layer.data) == n_shapes assert len(layer.data[0]) == n_coord - 1 @@ -445,16 +445,16 @@ def test_drag_shape(create_known_shapes_layer): assert len(layer.selected_data) == 1 assert layer.selected_data == {0} assert layer.events.data.call_args_list[0][1] == { - "value": old_data, - "action": ActionType.CHANGING, - "data_indices": (0,), - "vertex_indices": vertex_indices, + 'value': old_data, + 'action': ActionType.CHANGING, + 'data_indices': (0,), + 'vertex_indices': vertex_indices, } assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.CHANGED, - "data_indices": (0,), - "vertex_indices": vertex_indices, + 'value': layer.data, + 'action': ActionType.CHANGED, + 'data_indices': (0,), + 'vertex_indices': vertex_indices, } np.testing.assert_allclose(layer.data[0], orig_data + np.array([10, 5])) @@ -545,10 +545,10 @@ def test_drag_vertex(create_known_shapes_layer): assert len(layer.selected_data) == 1 assert layer.selected_data == {0} assert layer.events.data.call_args[1] == { - "value": layer.data, - "action": ActionType.CHANGED, - "data_indices": (0,), - "vertex_indices": vertex_indices, + 'value': layer.data, + 'action': ActionType.CHANGED, + 'data_indices': (0,), + 'vertex_indices': vertex_indices, } np.testing.assert_allclose(layer.data[0][0], [0, 0]) @@ -856,7 +856,7 @@ def test_drag_start_selection( [offset_position[0], offset_position[1]], ) else: - raise AssertionError("Unreachable code") # pragma: no cover + raise AssertionError('Unreachable code') # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] @@ -883,7 +883,7 @@ def test_drag_start_selection( [offset_position[0], offset_position[1]], ) else: - raise AssertionError("Unreachable code") # pragma: no cover + raise AssertionError('Unreachable code') # pragma: no cover else: np.testing.assert_array_equal( layer._drag_box, [initial_position, offset_position] diff --git a/napari/layers/shapes/_tests/test_shapes_utils.py b/napari/layers/shapes/_tests/test_shapes_utils.py index e23b90ba8b1..af25a5226ab 100644 --- a/napari/layers/shapes/_tests/test_shapes_utils.py +++ b/napari/layers/shapes/_tests/test_shapes_utils.py @@ -83,11 +83,11 @@ def _regen_testcases(): [ [0.47434165, 0.15811388], [-0.47434165, -0.15811388], - [-0.0, 1.58113883], + [-0.0, 0.5], [-0.47434165, -0.15811388], [-0.21850801, 0.92561479], [0.21850801, -0.92561479], - [-1.82514077, 2.53224755], + [-0.29235514, 0.40562109], [-0.35355339, -0.35355339], [-0.4472136, -0.2236068], [0.4472136, 0.2236068], @@ -139,11 +139,11 @@ def _regen_testcases(): [ [0.58459244, -0.17263848], [-0.58459244, 0.17263848], - [-0.0, 1.58113883], + [-0.0, 0.5], [-0.47434165, -0.15811388], [-0.21850801, 0.92561479], [0.21850801, -0.92561479], - [-1.82514077, 2.53224755], + [-0.29235514, 0.40562109], [-0.35355339, -0.35355339], [-0.17061484, -0.7768043], [0.17061484, 0.7768043], @@ -199,11 +199,11 @@ def _regen_testcases(): [ [0.47434165, 0.15811388], [-0.47434165, -0.15811388], - [-0.0, 1.58113883], + [-0.0, 0.5], [-0.47434165, -0.15811388], [-0.47434165, 0.15811388], - [0.21850801, -0.92561479], - [-1.82514077, 2.53224755], + [0.11487646, -0.48662449], + [-0.29235514, 0.40562109], [-0.35355339, -0.35355339], [-0.4472136, -0.2236068], [0.4472136, 0.2236068], @@ -260,17 +260,17 @@ def _regen_testcases(): ), array( [ - [0.58459244, -0.17263848], + [0.47952713, -0.14161119], [-0.31234752, 0.3904344], - [-0.0, 1.58113883], + [-0.0, 0.5], [-0.47434165, -0.15811388], [-0.47434165, 0.15811388], - [0.21850801, -0.92561479], - [-1.82514077, 2.53224755], + [0.11487646, -0.48662449], + [-0.29235514, 0.40562109], [-0.35355339, -0.35355339], - [-0.17061484, -0.7768043], + [-0.10726172, -0.48835942], [0.4472136, 0.2236068], - [0.58459244, -0.17263848], + [0.47952713, -0.14161119], [-0.31234752, 0.3904344], [-0.47434165, -0.15811388], [0.47434165, -0.15811388], diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py index a9f3aa25eff..a477307bdbf 100644 --- a/napari/layers/shapes/shapes.py +++ b/napari/layers/shapes/shapes.py @@ -6,11 +6,7 @@ Any, Callable, ClassVar, - Dict, - List, Optional, - Set, - Tuple, Union, ) @@ -348,7 +344,7 @@ class Shapes(Layer): # in the thumbnail _max_shapes_thumbnail = 100 - _drag_modes: ClassVar[Dict[Mode, Callable[["Shapes", Event], Any]]] = { + _drag_modes: ClassVar[dict[Mode, Callable[['Shapes', Event], Any]]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: transform_with_box, Mode.SELECT: select, @@ -363,7 +359,7 @@ class Shapes(Layer): Mode.ADD_POLYGON_LASSO: add_path_polygon_lasso, } - _move_modes: ClassVar[Dict[Mode, Callable[["Shapes", Event], Any]]] = { + _move_modes: ClassVar[dict[Mode, Callable[['Shapes', Event], Any]]] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: highlight_box_handles, Mode.SELECT: highlight, @@ -379,7 +375,7 @@ class Shapes(Layer): } _double_click_modes: ClassVar[ - Dict[Mode, Callable[["Shapes", Event], Any]] + dict[Mode, Callable[['Shapes', Event], Any]] ] = { Mode.PAN_ZOOM: no_op, Mode.TRANSFORM: no_op, @@ -395,7 +391,7 @@ class Shapes(Layer): Mode.ADD_POLYGON_LASSO: no_op, } - _cursor_modes: ClassVar[Dict[Mode, str]] = { + _cursor_modes: ClassVar[dict[Mode, str]] = { Mode.PAN_ZOOM: 'standard', Mode.TRANSFORM: 'standard', Mode.SELECT: 'pointing', @@ -410,7 +406,7 @@ class Shapes(Layer): Mode.ADD_POLYGON_LASSO: 'cross', } - _interactive_modes: ClassVar[Set[Mode]] = { + _interactive_modes: ClassVar[set[Mode]] = { Mode.PAN_ZOOM, } @@ -459,7 +455,7 @@ def __init__( if ndim is not None and ndim != data_ndim: raise ValueError( trans._( - "Shape dimensions must be equal to ndim", + 'Shape dimensions must be equal to ndim', deferred=True, ) ) @@ -525,7 +521,7 @@ def __init__( self._value = (None, None) self._value_stored = (None, None) - self._moving_value: Tuple[Optional[int], Optional[int]] = (None, None) + self._moving_value: tuple[Optional[int], Optional[int]] = (None, None) self._selected_data = set() self._selected_data_stored = set() self._selected_data_history = set() @@ -549,7 +545,7 @@ def __init__( self._drag_box = None self._drag_box_stored = None self._is_creating = False - self._clipboard: Dict[str, Shapes] = {} + self._clipboard: dict[str, Shapes] = {} self._status = self.mode @@ -579,14 +575,14 @@ def __init__( self._current_edge_color = transform_color_with_defaults( num_entries=1, colors=edge_color, - elem_name="edge_color", - default="black", + elem_name='edge_color', + default='black', ) self._current_face_color = transform_color_with_defaults( num_entries=1, colors=face_color, - elem_name="face_color", - default="black", + elem_name='face_color', + default='black', ) self._text = TextManager._from_layer( @@ -616,7 +612,7 @@ def _initialize_current_color_for_empty_layer( num_entries=1, colors=color, elem_name=f'{attribute}_color', - default="white", + default='white', ) elif color_mode == ColorMode.CYCLE: @@ -696,17 +692,17 @@ def data(self, data): or (isinstance(data, list) and len(data) > 0) ) kwargs = { - "value": self.data, - "vertex_indices": ((),), - "data_indices": tuple(i for i in range(len(self.data))), + 'value': self.data, + 'vertex_indices': ((),), + 'data_indices': tuple(i for i in range(len(self.data))), } if prior_data and data_not_empty: - kwargs["action"] = ActionType.CHANGING + kwargs['action'] = ActionType.CHANGING elif data_not_empty: - kwargs["action"] = ActionType.ADDING - kwargs["data_indices"] = tuple(i for i in range(len(data))) + kwargs['action'] = ActionType.ADDING + kwargs['data_indices'] = tuple(i for i in range(len(data))) else: - kwargs["action"] = ActionType.REMOVING + kwargs['action'] = ActionType.REMOVING self.events.data(**kwargs) self._data_view = ShapeList(ndisplay=self._slice_input.ndisplay) @@ -724,14 +720,14 @@ def data(self, data): ) self._update_dims() - kwargs["data_indices"] = tuple(i for i in range(len(data))) - kwargs["value"] = self.data + kwargs['data_indices'] = tuple(i for i in range(len(data))) + kwargs['value'] = self.data if prior_data and data_not_empty: - kwargs["action"] = ActionType.CHANGED + kwargs['action'] = ActionType.CHANGED elif data_not_empty: - kwargs["action"] = ActionType.ADDED + kwargs['action'] = ActionType.ADDED else: - kwargs["action"] = ActionType.REMOVED + kwargs['action'] = ActionType.REMOVED self.events.data(**kwargs) self._reset_editable() @@ -762,7 +758,7 @@ def features(self): @features.setter def features( self, - features: Union[Dict[str, np.ndarray], pd.DataFrame], + features: Union[dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=self.nshapes) if self._face_color_property and ( @@ -804,23 +800,23 @@ def feature_defaults(self): @feature_defaults.setter def feature_defaults( - self, defaults: Union[Dict[str, Any], pd.DataFrame] + self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) self.events.current_properties() self.events.feature_defaults() @property - def properties(self) -> Dict[str, np.ndarray]: + def properties(self) -> dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}, DataFrame: Annotations for each shape""" return self._feature_table.properties() @properties.setter - def properties(self, properties: Dict[str, Array]): + def properties(self, properties: dict[str, Array]): self.features = properties @property - def property_choices(self) -> Dict[str, np.ndarray]: + def property_choices(self) -> dict[str, np.ndarray]: return self._feature_table.choices() def _get_ndim(self): @@ -895,7 +891,7 @@ def current_face_color(self, face_color): self.events.current_face_color() @property - def current_properties(self) -> Dict[str, np.ndarray]: + def current_properties(self) -> dict[str, np.ndarray]: """dict{str: np.ndarray(1,)}: properties for the next added shape.""" return self._feature_table.currents() @@ -981,7 +977,7 @@ def edge_colormap(self, colormap: ValidColormapArg): self._edge_colormap = ensure_colormap(colormap) @property - def edge_contrast_limits(self) -> Union[Tuple[float, float], None]: + def edge_contrast_limits(self) -> Union[tuple[float, float], None]: """None, (float, float): contrast limits for mapping the edge_color colormap property to 0 and 1 """ @@ -989,7 +985,7 @@ def edge_contrast_limits(self) -> Union[Tuple[float, float], None]: @edge_contrast_limits.setter def edge_contrast_limits( - self, contrast_limits: Union[None, Tuple[float, float]] + self, contrast_limits: Union[None, tuple[float, float]] ): self._edge_contrast_limits = contrast_limits @@ -1047,7 +1043,7 @@ def face_colormap(self, colormap: ValidColormapArg): self._face_colormap = ensure_colormap(colormap) @property - def face_contrast_limits(self) -> Union[None, Tuple[float, float]]: + def face_contrast_limits(self) -> Union[None, tuple[float, float]]: """None, (float, float) : clims for mapping the face_color colormap property to 0 and 1 """ @@ -1055,7 +1051,7 @@ def face_contrast_limits(self) -> Union[None, Tuple[float, float]]: @face_contrast_limits.setter def face_contrast_limits( - self, contrast_limits: Union[None, Tuple[float, float]] + self, contrast_limits: Union[None, tuple[float, float]] ): self._face_contrast_limits = contrast_limits @@ -1150,7 +1146,7 @@ def _set_color_cycle( transformed_color_cycle, transformed_colors = transform_color_cycle( color_cycle=color_cycle, elem_name=f'{attribute}_color_cycle', - default="white", + default='white', ) setattr(self, f'_{attribute}_color_cycle_values', transformed_colors) setattr(self, f'_{attribute}_color_cycle', transformed_color_cycle) @@ -1295,8 +1291,8 @@ def _set_color(self, color, attribute: str): transformed_color = transform_color_with_defaults( num_entries=len(self.data), colors=color, - elem_name="face_color", - default="white", + elem_name='face_color', + default='white', ) colors = normalize_and_broadcast_colors( len(self.data), transformed_color @@ -1385,8 +1381,8 @@ def _initialize_color(self, color, attribute: str, n_shapes: int): transformed_color = transform_color_with_defaults( num_entries=n_shapes, colors=color, - elem_name="face_color", - default="white", + elem_name='face_color', + default='white', ) init_colors = normalize_and_broadcast_colors( n_shapes, transformed_color @@ -1610,7 +1606,7 @@ def _view_text(self) -> np.ndarray: return self.text.view_text(self._indices_view) @property - def _view_text_coords(self) -> Tuple[np.ndarray, str, str]: + def _view_text_coords(self) -> tuple[np.ndarray, str, str]: """Get the coordinates of the text elements in view Returns @@ -1713,8 +1709,8 @@ def add_rectangles( ---------- data : Array | List[Array] List of rectangle data where each element is a (4, D) array of 4 vertices - in D dimensions, or a (2, D) array of 2 vertices in D dimensions, where - the vertices are top-left and bottom-right corners. + in D dimensions, or in 2D a (2, 2) array of 2 vertices that are + the top-left and bottom-right corners. Can be a 3-dimensional array for multiple shapes, or list of 2 or 4 vertices for a single shape. edge_width : float | list @@ -1771,8 +1767,8 @@ def add_ellipses( ---------- data : Array | List[Array] List of ellipse data where each element is a (4, D) array of 4 vertices - in D dimensions representing a bounding box, or a (2, D) array of - center position and radii magnitudes in D dimensions. + in D dimensions representing a bounding box, or in 2D a (2, 2) array of + center position and radii magnitudes. Can be a 3-dimensional array for multiple shapes, or list of 2 or 4 vertices for a single shape. edge_width : float | list @@ -2212,7 +2208,7 @@ def _add_shapes( if n_new_shapes > 0: total_shapes = n_new_shapes + self.nshapes self._feature_table.resize(total_shapes) - if hasattr(self, "text"): + if hasattr(self, 'text'): self.text.apply(self.features) if edge_color is None: @@ -2244,8 +2240,8 @@ def _add_shapes( transformed_ec = transform_color_with_defaults( num_entries=len(data), colors=edge_color, - elem_name="edge_color", - default="white", + elem_name='edge_color', + default='white', ) transformed_edge_color = normalize_and_broadcast_colors( len(data), transformed_ec @@ -2253,8 +2249,8 @@ def _add_shapes( transformed_fc = transform_color_with_defaults( num_entries=len(data), colors=face_color, - elem_name="face_color", - default="white", + elem_name='face_color', + default='white', ) transformed_face_color = normalize_and_broadcast_colors( len(data), transformed_fc @@ -2566,7 +2562,7 @@ def _set_highlight(self, force=False) -> None: self._drag_box_stored = copy(self._drag_box) self.events.highlight() - def _finish_drawing(self, event=None): + def _finish_drawing(self, event=None) -> None: """Reset properties used in shape drawing.""" index = copy(self._moving_value[0]) self._is_moving = False @@ -2656,7 +2652,7 @@ def _update_thumbnail(self, event=None): self.thumbnail = colormapped - def remove_selected(self): + def remove_selected(self) -> None: """Remove any selected shapes.""" index = list(self.selected_data) to_remove = sorted(index, reverse=True) @@ -2838,8 +2834,8 @@ def _get_value_3d( self, start_point: np.ndarray, end_point: np.ndarray, - dims_displayed: List[int], - ) -> Tuple[Union[float, int, None], None]: + dims_displayed: list[int], + ) -> tuple[Union[float, int, None], None]: """Get the layer data value along a ray Parameters @@ -2870,8 +2866,8 @@ def _get_index_and_intersection( self, start_point: np.ndarray, end_point: np.ndarray, - dims_displayed: List[int], - ) -> Tuple[Union[None, float, int], Union[None, np.ndarray]]: + dims_displayed: list[int], + ) -> tuple[Union[None, float, int], Union[None, np.ndarray]]: """Get the shape index and intersection point of the first shape (i.e., closest to start_point) along the specified 3D line segment. @@ -2925,8 +2921,8 @@ def get_index_and_intersection( self, position: np.ndarray, view_direction: np.ndarray, - dims_displayed: List[int], - ) -> Tuple[Union[float, int, None], Union[npt.NDArray, None]]: + dims_displayed: list[int], + ) -> tuple[Union[float, int, None], Union[npt.NDArray, None]]: """Get the shape index and intersection point of the first shape (i.e., closest to start_point) "under" a mouse click. @@ -2966,7 +2962,7 @@ def get_index_and_intersection( intersection_point = None return shape_index, intersection_point - def move_to_front(self): + def move_to_front(self) -> None: """Moves selected objects to be displayed in front of all others.""" if len(self.selected_data) == 0: return @@ -2975,7 +2971,7 @@ def move_to_front(self): self._data_view.update_z_index(index, new_z_index) self.refresh() - def move_to_back(self): + def move_to_back(self) -> None: """Moves selected objects to be displayed behind all others.""" if len(self.selected_data) == 0: return @@ -2984,7 +2980,7 @@ def move_to_back(self): self._data_view.update_z_index(index, new_z_index) self.refresh() - def _copy_data(self): + def _copy_data(self) -> None: """Copy selected shapes to clipboard.""" if len(self.selected_data) > 0: index = list(self.selected_data) @@ -3002,7 +2998,7 @@ def _copy_data(self): else: self._clipboard = {} - def _paste_data(self): + def _paste_data(self) -> None: """Paste any shapes from clipboard and then selects them.""" cur_shapes = self.nshapes if len(self._clipboard.keys()) > 0: diff --git a/napari/layers/surface/_surface_constants.py b/napari/layers/surface/_surface_constants.py index a23c9247016..a62c9dd93be 100644 --- a/napari/layers/surface/_surface_constants.py +++ b/napari/layers/surface/_surface_constants.py @@ -27,7 +27,7 @@ class Shading(StringEnum): SHADING_TRANSLATION = { - trans._("none"): Shading.NONE, - trans._("flat"): Shading.FLAT, - trans._("smooth"): Shading.SMOOTH, + trans._('none'): Shading.NONE, + trans._('flat'): Shading.FLAT, + trans._('smooth'): Shading.SMOOTH, } diff --git a/napari/layers/surface/_tests/test_surface.py b/napari/layers/surface/_tests/test_surface.py index d436afcb730..abaacdf353f 100644 --- a/napari/layers/surface/_tests/test_surface.py +++ b/napari/layers/surface/_tests/test_surface.py @@ -322,7 +322,7 @@ def test_vertex_colors(): @pytest.mark.parametrize( - "ray_start,ray_direction,expected_value,expected_index", + 'ray_start,ray_direction,expected_value,expected_index', [ ([0, 1, 1], [1, 0, 0], 2, 0), ([10, 1, 1], [-1, 0, 0], 2, 1), @@ -360,7 +360,7 @@ def test_get_value_3d( @pytest.mark.parametrize( - "ray_start,ray_direction,expected_value,expected_index", + 'ray_start,ray_direction,expected_value,expected_index', [ ([0, 0, 1, 1], [0, 1, 0, 0], 2, 0), ([0, 10, 1, 1], [0, -1, 0, 0], 2, 1), @@ -418,7 +418,7 @@ def test_surface_normals(): faces = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) values = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3]) - normals = {"face": {"visible": True, "color": 'red'}} + normals = {'face': {'visible': True, 'color': 'red'}} surface_layer = Surface((vertices, faces, values), normals=normals) assert isinstance(surface_layer.normals, SurfaceNormals) assert surface_layer.normals.face.visible is True @@ -453,7 +453,7 @@ def test_surface_wireframe(): faces = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) values = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3]) - wireframe = {"visible": True, "color": 'red'} + wireframe = {'visible': True, 'color': 'red'} surface_layer = Surface((vertices, faces, values), wireframe=wireframe) assert isinstance(surface_layer.wireframe, SurfaceWireframe) assert surface_layer.wireframe.visible is True diff --git a/napari/layers/surface/_tests/test_surface_utils.py b/napari/layers/surface/_tests/test_surface_utils.py index 157cec1d2da..7fe8490a9f0 100644 --- a/napari/layers/surface/_tests/test_surface_utils.py +++ b/napari/layers/surface/_tests/test_surface_utils.py @@ -7,7 +7,7 @@ @pytest.mark.parametrize( - "point,expected_barycentric_coordinates", + 'point,expected_barycentric_coordinates', [ ([5, 1, 1], [1 / 3, 1 / 3, 1 / 3]), ([5, 0, 0], [1, 0, 0]), diff --git a/napari/layers/surface/surface.py b/napari/layers/surface/surface.py index 84511961131..1953950e1de 100644 --- a/napari/layers/surface/surface.py +++ b/napari/layers/surface/surface.py @@ -1,6 +1,6 @@ import copy import warnings -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Optional, Union import numpy as np import pandas as pd @@ -287,8 +287,8 @@ def __init__( # Data containing vectors in the currently viewed slice self._data_view = np.zeros((0, self._slice_input.ndisplay)) self._view_faces = np.zeros((0, 3)) - self._view_vertex_values: Union[List[Any], np.ndarray] = [] - self._view_vertex_colors: Union[List[Any], np.ndarray] = [] + self._view_vertex_values: Union[list[Any], np.ndarray] = [] + self._view_vertex_colors: Union[list[Any], np.ndarray] = [] # Trigger generation of view slice and thumbnail. # Use _update_dims instead of refresh here because _get_ndim is @@ -389,7 +389,7 @@ def vertex_colors(self, vertex_colors: Optional[np.ndarray]): vertex_colors, np.ndarray ): msg = ( - f"texture should be None or ndarray; got {type(vertex_colors)}" + f'texture should be None or ndarray; got {type(vertex_colors)}' ) raise ValueError(msg) self._vertex_colors = vertex_colors @@ -461,7 +461,7 @@ def features(self): @features.setter def features( self, - features: Union[Dict[str, np.ndarray], pd.DataFrame], + features: Union[dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=len(self.data[0])) self.events.features() @@ -476,7 +476,7 @@ def feature_defaults(self): @feature_defaults.setter def feature_defaults( - self, defaults: Union[Dict[str, Any], pd.DataFrame] + self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) self.events.feature_defaults() @@ -537,7 +537,7 @@ def texture(self) -> Optional[np.ndarray]: @texture.setter def texture(self, texture: np.ndarray): if texture is not None and not isinstance(texture, np.ndarray): - msg = f"texture should be None or ndarray; got {type(texture)}" + msg = f'texture should be None or ndarray; got {type(texture)}' raise ValueError(msg) self._texture = texture self.events.texture(value=self._texture) @@ -549,7 +549,7 @@ def texcoords(self) -> Optional[np.ndarray]: @texcoords.setter def texcoords(self, texcoords: np.ndarray): if texcoords is not None and not isinstance(texcoords, np.ndarray): - msg = f"texcoords should be None or ndarray; got {type(texcoords)}" + msg = f'texcoords should be None or ndarray; got {type(texcoords)}' raise ValueError(msg) self._texcoords = texcoords self.events.texcoords(value=self._texcoords) @@ -595,7 +595,7 @@ def _slice_associated_data( data: np.ndarray, vertex_ndim: int, dims: int = 1, - ) -> Union[List[Any], np.ndarray]: + ) -> Union[list[Any], np.ndarray]: """Return associated layer data (e.g. vertex values, colors) within the current slice. """ @@ -605,7 +605,7 @@ def _slice_associated_data( data_ndim = data.ndim - 1 if data_ndim >= dims: # Get indices for axes corresponding to data dimensions - data_indices: Tuple[Union[int, slice], ...] = tuple( + data_indices: tuple[Union[int, slice], ...] = tuple( slice(None) if np.isnan(idx) else int(np.round(idx)) for idx in self._data_slice.point[:-vertex_ndim] ) @@ -613,10 +613,10 @@ def _slice_associated_data( if data.ndim > dims: warnings.warn( trans._( - "Assigning multiple data per vertex after slicing " - "is not allowed. All dimensions corresponding to " - "vertex data must be non-displayed dimensions. Data " - "may not be visible.", + 'Assigning multiple data per vertex after slicing ' + 'is not allowed. All dimensions corresponding to ' + 'vertex data must be non-displayed dimensions. Data ' + 'may not be visible.', deferred=True, ), category=UserWarning, @@ -705,8 +705,8 @@ def _get_value_3d( self, start_point: Optional[np.ndarray], end_point: Optional[np.ndarray], - dims_displayed: List[int], - ) -> Tuple[Union[None, float, int], Optional[int]]: + dims_displayed: list[int], + ) -> tuple[Union[None, float, int], Optional[int]]: """Get the layer data value along a ray Parameters diff --git a/napari/layers/tracks/_tests/test_tracks.py b/napari/layers/tracks/_tests/test_tracks.py index b604a85d841..f43768b402d 100644 --- a/napari/layers/tracks/_tests/test_tracks.py +++ b/napari/layers/tracks/_tests/test_tracks.py @@ -19,7 +19,7 @@ @pytest.mark.parametrize( - "data", [data_array_2dt, data_list_2dt, dataframe_2dt] + 'data', [data_array_2dt, data_list_2dt, dataframe_2dt] ) def test_tracks_layer_2dt_ndim(data): """Test instantiating Tracks layer, check 2D+t dimensionality.""" @@ -35,7 +35,7 @@ def test_tracks_layer_2dt_ndim(data): @pytest.mark.parametrize( - "data", [data_array_3dt, data_list_3dt, dataframe_3dt] + 'data', [data_array_3dt, data_list_3dt, dataframe_3dt] ) def test_tracks_layer_3dt_ndim(data): """Test instantiating Tracks layer, check 3D+t dimensionality.""" @@ -59,7 +59,7 @@ def test_track_layer_data(): @pytest.mark.parametrize( - "timestamps", [np.arange(100, 200), np.arange(100, 300, 2)] + 'timestamps', [np.arange(100, 200), np.arange(100, 300, 2)] ) def test_track_layer_data_nonzero_starting_time(timestamps): """Test data with sparse timestamps or not starting at zero.""" @@ -83,7 +83,7 @@ def test_track_layer_data_flipped(): properties_df = pd.DataFrame(properties_dict) -@pytest.mark.parametrize("properties", [{}, properties_dict, properties_df]) +@pytest.mark.parametrize('properties', [{}, properties_dict, properties_df]) def test_track_layer_properties(properties): """Test properties.""" data = np.zeros((100, 4)) @@ -93,7 +93,7 @@ def test_track_layer_properties(properties): np.testing.assert_equal(layer.properties[k], v) -@pytest.mark.parametrize("properties", [{}, properties_dict, properties_df]) +@pytest.mark.parametrize('properties', [{}, properties_dict, properties_df]) def test_track_layer_properties_flipped(properties): """Test properties.""" data = np.zeros((100, 4)) @@ -105,7 +105,7 @@ def test_track_layer_properties_flipped(properties): np.testing.assert_equal(layer.properties[k], np.flip(v)) -@pytest.mark.filterwarnings("ignore:.*track_id.*:UserWarning") +@pytest.mark.filterwarnings('ignore:.*track_id.*:UserWarning') def test_track_layer_colorby_nonexistent(): """Test error handling for non-existent properties with color_by""" data = np.zeros((100, 4)) @@ -118,7 +118,7 @@ def test_track_layer_colorby_nonexistent(): ) -@pytest.mark.filterwarnings("ignore:.*track_id.*:UserWarning") +@pytest.mark.filterwarnings('ignore:.*track_id.*:UserWarning') def test_track_layer_properties_changed_colorby(): """Test behaviour when changes to properties invalidate current color_by""" properties_dict_1 = {'time': np.arange(100), 'prop1': np.arange(100)} @@ -243,4 +243,50 @@ def test_track_ids_ordering() -> None: sorted_track_ids = [0, 0, 1, 1, 2] # track_ids after sorting layer = Tracks(unsorted_data) - np.testing.assert_array_equal(sorted_track_ids, layer.features["track_id"]) + np.testing.assert_array_equal(sorted_track_ids, layer.features['track_id']) + + +def test_changing_data_inplace() -> None: + """Test if layer can be refreshed after changing data in place.""" + + data = np.ones((100, 4)) + data[:, 1] = np.arange(100) + + layer = Tracks(data) + + # Change data in place + # coordinates + layer.data[50:, -1] = 2 + layer.refresh() + + # time + layer.data[50:, 1] = np.arange(100, 150) + layer.refresh() + + # track_id + layer.data[50:, 0] = 2 + layer.refresh() + + +def test_track_connex_validity() -> None: + """Test if track_connex is valid (i.e if the value False appears as many + times as there are tracks.""" + + data = np.zeros((11, 4)) + + # Track ids + data[:-1, 0] = np.repeat(np.arange(1, 6), 2) + # create edge case where a track has length one + data[-1, 0] = 6 + + # Time + data[:-1, 1] = np.array([0, 1] * 5) + data[-1, 1] = 0 + + layer = Tracks(data) + + # number of tracks + n_tracks = 6 + + # the number of 'False' in the track_connex array should be equal to the number of tracks + assert np.sum(~layer._manager.track_connex) == n_tracks diff --git a/napari/layers/tracks/_track_utils.py b/napari/layers/tracks/_track_utils.py index e9e70389ad5..d937470c8c0 100644 --- a/napari/layers/tracks/_track_utils.py +++ b/napari/layers/tracks/_track_utils.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Optional, Union import numpy as np import pandas as pd @@ -13,18 +13,6 @@ import numpy.typing as npt -def connex(vertices: np.ndarray) -> list: - """Connection array to build vertex edges for vispy LineVisual. - - Notes - ----- - See - http://api.vispy.org/en/latest/visuals.html#vispy.visuals.LineVisual - - """ - return [True] * (vertices.shape[0] - 1) + [False] - - class TrackManager: """Manage track data and simplify interactions with the Tracks layer. @@ -80,22 +68,22 @@ def __init__(self, data: np.ndarray) -> None: self._feature_table = _FeatureTable() self._data: npt.NDArray - self._order: List[int] + self._order: list[int] self._kdtree: cKDTree self._points: npt.NDArray self._points_id: npt.NDArray - self._points_lookup: Dict[int, slice] + self._points_lookup: dict[int, slice] self._ordered_points_idx: npt.NDArray self._track_vertices = None self._track_connex = None - self._graph: Optional[Dict[int, List[int]]] = None + self._graph: Optional[dict[int, list[int]]] = None self._graph_vertices = None self._graph_connex = None @staticmethod - def _fast_points_lookup(sorted_time: np.ndarray) -> Dict[int, slice]: + def _fast_points_lookup(sorted_time: np.ndarray) -> dict[int, slice]: """Computes a fast lookup table from time to their respective points slicing.""" # finds where t transitions to t + 1 @@ -175,7 +163,7 @@ def features(self): @features.setter def features( self, - features: Union[Dict[str, np.ndarray], pd.DataFrame], + features: Union[dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=len(self.data)) self._feature_table.reorder(self._order) @@ -183,22 +171,22 @@ def features( self._feature_table.values['track_id'] = self.track_ids @property - def properties(self) -> Dict[str, np.ndarray]: + def properties(self) -> dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}: Properties for each track.""" return self._feature_table.properties() @properties.setter - def properties(self, properties: Dict[str, Array]): + def properties(self, properties: dict[str, Array]): """set track properties""" self.features = properties @property - def graph(self) -> Optional[Dict[int, List[int]]]: + def graph(self) -> Optional[dict[int, list[int]]]: """dict {int: list}: Graph representing associations between tracks.""" return self._graph @graph.setter - def graph(self, graph: Dict[int, Union[int, List[int]]]): + def graph(self, graph: dict[int, Union[int, list[int]]]): """set the track graph""" self._graph = self._normalize_track_graph(graph) @@ -253,10 +241,10 @@ def _validate_track_data(self, data: np.ndarray) -> np.ndarray: return data def _normalize_track_graph( - self, graph: Dict[int, Union[int, List[int]]] - ) -> Dict[int, List[int]]: + self, graph: dict[int, Union[int, list[int]]] + ) -> dict[int, list[int]]: """validate the track graph""" - new_graph: Dict[int, List[int]] = {} + new_graph: dict[int, list[int]] = {} # check that graph nodes are of correct format for node_idx, parents_idx in graph.items(): @@ -286,25 +274,24 @@ def _normalize_track_graph( def build_tracks(self): """build the tracks""" - points_id = [] - track_vertices = [] - track_connex = [] - - # NOTE(arl): this takes some time when the number of tracks is large - for idx in self.unique_track_ids: - indices = self._vertex_indices_from_id(idx) + # Track ids associated to all vertices, sorted by time + points_id = self.data[:, 0][self._ordered_points_idx] + # Coordinates of all vertices + track_vertices = self.data[:, 1:] - # grab the correct vertices and sort by time - vertices = self.data[indices, 1:] + # Indices in the data array just before the track id changes + indices_new_id = np.where(np.diff(self.data[:, 0]))[0] - # coordinates of the text identifiers, vertices and connections - points_id += [idx] * vertices.shape[0] - track_vertices.append(vertices) - track_connex.append(connex(vertices)) + # Define track_connex as an array full of 'True', then set to 'False' + # at the indices just before the track id changes + track_connex = np.ones(self.data.shape[0], dtype=bool) + track_connex[indices_new_id] = False + # Add 'False' for the last entry too (end of the last track) + track_connex[-1] = False - self._points_id = np.array(points_id)[self._ordered_points_idx] - self._track_vertices = np.concatenate(track_vertices, axis=0) - self._track_connex = np.concatenate(track_connex, axis=0) + self._points_id = points_id + self._track_vertices = track_vertices + self._track_connex = track_connex def build_graph(self): """build the track graph""" @@ -413,7 +400,7 @@ def graph_times(self) -> Optional[np.ndarray]: def track_labels( self, current_time: int - ) -> Union[Tuple[None, None], Tuple[List[str], np.ndarray]]: + ) -> Union[tuple[None, None], tuple[list[str], np.ndarray]]: """return track labels at the current time""" if self._points_id is None: return None, None diff --git a/napari/layers/tracks/tracks.py b/napari/layers/tracks/tracks.py index a1d878ea0ae..d82ef1662d2 100644 --- a/napari/layers/tracks/tracks.py +++ b/napari/layers/tracks/tracks.py @@ -2,7 +2,7 @@ # from napari.utils.events import Event # from napari.utils.colormaps import AVAILABLE_COLORMAPS -from typing import Dict, List, Optional, Union +from typing import Optional, Union from warnings import warn import numpy as np @@ -410,34 +410,34 @@ def features(self): @features.setter def features( self, - features: Union[Dict[str, np.ndarray], pd.DataFrame], + features: Union[dict[str, np.ndarray], pd.DataFrame], ) -> None: self._manager.features = features self._check_color_by_in_features() self.events.properties() @property - def properties(self) -> Dict[str, np.ndarray]: + def properties(self) -> dict[str, np.ndarray]: """dict {str: np.ndarray (N,)}: Properties for each track.""" return self._manager.properties @properties.setter - def properties(self, properties: Dict[str, np.ndarray]): + def properties(self, properties: dict[str, np.ndarray]): """set track properties""" self.features = properties @property - def properties_to_color_by(self) -> List[str]: + def properties_to_color_by(self) -> list[str]: """track properties that can be used for coloring etc...""" return list(self.properties.keys()) @property - def graph(self) -> Optional[Dict[int, List[int]]]: + def graph(self) -> Optional[dict[int, list[int]]]: """dict {int: list}: Graph representing associations between tracks.""" return self._manager.graph @graph.setter - def graph(self, graph: Dict[int, Union[int, List[int]]]): + def graph(self, graph: dict[int, Union[int, list[int]]]): """Set the track graph.""" # Ignored type, because mypy can't handle different signatures # on getters and setters; see https://github.com/python/mypy/issues/3004 @@ -548,13 +548,13 @@ def colormap(self, colormap: str): self.events.colormap() @property - def colormaps_dict(self) -> Dict[str, Colormap]: + def colormaps_dict(self) -> dict[str, Colormap]: return self._colormaps_dict # Ignored type because mypy doesn't recognise colormaps_dict as a property # TODO: investigate and fix this - not sure why this is the case? @colormaps_dict.setter # type: ignore[attr-defined] - def colomaps_dict(self, colormaps_dict: Dict[str, Colormap]): + def colomaps_dict(self, colormaps_dict: dict[str, Colormap]): # validate the dictionary entries? self._colormaps_dict = colormaps_dict @@ -627,7 +627,7 @@ def _check_color_by_in_features(self): warn( ( trans._( - "Previous color_by key {key!r} not present in features. Falling back to track_id", + 'Previous color_by key {key!r} not present in features. Falling back to track_id', deferred=True, key=self._color_by, ) diff --git a/napari/layers/utils/_link_layers.py b/napari/layers/utils/_link_layers.py index a4140b90093..e50e1b419b5 100644 --- a/napari/layers/utils/_link_layers.py +++ b/napari/layers/utils/_link_layers.py @@ -1,27 +1,35 @@ from __future__ import annotations +from collections.abc import Generator, Iterable from contextlib import contextmanager from functools import partial from itertools import combinations, permutations, product -from typing import TYPE_CHECKING, Callable, DefaultDict, Iterable, Set, Tuple +from typing import ( + TYPE_CHECKING, + Callable, + Optional, +) from weakref import ReferenceType, ref if TYPE_CHECKING: from collections import abc from napari.layers import Layer + from napari.utils.events import Event + +from collections import defaultdict from napari.utils.events.event import WarningEmitter from napari.utils.translations import trans #: Record of already linked layers... to avoid duplicating callbacks # in the form of {(id(layer1), id(layer2), attribute_name) -> callback} -LinkKey = Tuple['ReferenceType[Layer]', 'ReferenceType[Layer]', str] +LinkKey = tuple['ReferenceType[Layer]', 'ReferenceType[Layer]', str] Unlinker = Callable[[], None] _UNLINKERS: dict[LinkKey, Unlinker] = {} -_LINKED_LAYERS: DefaultDict[ - ReferenceType[Layer], Set[ReferenceType[Layer]] -] = DefaultDict(set) +_LINKED_LAYERS: defaultdict[ + ReferenceType[Layer], set[ReferenceType[Layer]] +] = defaultdict(set) def layer_is_linked(layer: Layer) -> bool: @@ -29,7 +37,7 @@ def layer_is_linked(layer: Layer) -> bool: return ref(layer) in _LINKED_LAYERS -def get_linked_layers(*layers: Layer) -> Set[Layer]: +def get_linked_layers(*layers: Layer) -> set[Layer]: """Return layers that are linked to any layer in `*layers`. Note, if multiple layers are provided, the returned set will represent any @@ -98,7 +106,7 @@ def link_layers( if extra: raise ValueError( trans._( - "Cannot link attributes that are not shared by all layers: {extra}. Allowable attrs include:\n{valid_attrs}", + 'Cannot link attributes that are not shared by all layers: {extra}. Allowable attrs include:\n{valid_attrs}', deferred=True, extra=extra, valid_attrs=valid_attrs, @@ -116,18 +124,20 @@ def link_layers( if key in _UNLINKERS: continue - def _make_l2_setter(l1=lay1, l2=lay2, attr=attribute): + def _make_l2_setter( + l1: Layer = lay1, l2: Layer = lay2, attr: str = attribute + ) -> Callable: # get a suitable equality operator for this attribute type eq_op = pick_equality_operator(getattr(l1, attr)) - def setter(event=None): + def setter(event: Optional[Event] = None) -> None: new_val = getattr(l1, attr) # this line is the important part for avoiding recursion if not eq_op(getattr(l2, attr), new_val): setattr(l2, attr, new_val) - setter.__doc__ = f"Set {attr!r} on {l1} to that of {l2}" - setter.__qualname__ = f"set_{attr}_on_layer_{id(l2)}" + setter.__doc__ = f'Set {attr!r} on {l1} to that of {l2}' + setter.__qualname__ = f'set_{attr}_on_layer_{id(l2)}' return setter # actually make the connection @@ -144,7 +154,9 @@ def setter(event=None): return links -def unlink_layers(layers: Iterable[Layer], attributes: Iterable[str] = ()): +def unlink_layers( + layers: Iterable[Layer], attributes: Iterable[str] = () +) -> None: """Unlink previously linked ``attributes`` between all layers in ``layers``. Parameters @@ -159,7 +171,7 @@ def unlink_layers(layers: Iterable[Layer], attributes: Iterable[str] = ()): """ if not layers: raise ValueError( - trans._("Must provide at least one layer to unlink", deferred=True) + trans._('Must provide at least one layer to unlink', deferred=True) ) layer_refs = [ref(layer) for layer in layers] if len(layer_refs) == 1: @@ -179,7 +191,9 @@ def unlink_layers(layers: Iterable[Layer], attributes: Iterable[str] = ()): @contextmanager -def layers_linked(layers: Iterable[Layer], attributes: Iterable[str] = ()): +def layers_linked( + layers: Iterable[Layer], attributes: Iterable[str] = () +) -> Generator[None, None, None]: """Context manager that temporarily links ``attributes`` on ``layers``.""" links = link_layers(layers, attributes) try: @@ -199,14 +213,18 @@ def _get_common_evented_attributes( 'data', 'features', 'properties', + 'size', + 'symbol', 'edge_width', + 'border_width', 'edge_color', 'face_color', + 'border_color', 'extent', 'loaded', ) ), - with_private=False, + with_private: bool = False, ) -> set[str]: """Get the set of common, non-private evented attributes in ``layers``. @@ -220,7 +238,6 @@ def _get_common_evented_attributes( A set of layers to evaluate for attribute linking. exclude : set, optional Layer attributes that make no sense to link, or may error on changing. - {'thumbnail', 'status', 'name', 'mode', 'data', 'features', 'properties', 'extent', 'loaded'} with_private : bool, optional include private attributes @@ -236,7 +253,7 @@ def _get_common_evented_attributes( except StopIteration: raise ValueError( trans._( - "``layers`` iterable must have at least one layer", + '``layers`` iterable must have at least one layer', deferred=True, ) ) from None @@ -252,7 +269,7 @@ def _get_common_evented_attributes( common_events = set.intersection(*layer_events) common_attrs = set.intersection(*(set(dir(lay)) for lay in layers)) if not with_private: - common_attrs = {x for x in common_attrs if not x.startswith("_")} + common_attrs = {x for x in common_attrs if not x.startswith('_')} common = common_events & common_attrs - exclude # lastly, discard any method-only events (we just want attrs) @@ -269,7 +286,7 @@ def _link_key(lay1: Layer, lay2: Layer, attr: str) -> LinkKey: return (ref(lay1), ref(lay2), attr) -def _unlink_keys(keys: Iterable[LinkKey]): +def _unlink_keys(keys: Iterable[LinkKey]) -> None: """Disconnect layer linkages by keys.""" for key in keys: disconnecter = _UNLINKERS.pop(key, None) @@ -279,8 +296,10 @@ def _unlink_keys(keys: Iterable[LinkKey]): _LINKED_LAYERS = _rebuild_link_index() -def _rebuild_link_index(): - links = DefaultDict(set) +def _rebuild_link_index() -> ( + defaultdict[ReferenceType[Layer], set[ReferenceType[Layer]]] +): + links = defaultdict(set) for l1, l2, _attr in _UNLINKERS: links[l1].add(l2) return links diff --git a/napari/layers/utils/_slice_input.py b/napari/layers/utils/_slice_input.py index d333b8f0c19..5ade4e8b52e 100644 --- a/napari/layers/utils/_slice_input.py +++ b/napari/layers/utils/_slice_input.py @@ -2,7 +2,7 @@ import warnings from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic, List, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Generic, TypeVar, Union import numpy as np @@ -22,9 +22,9 @@ class _ThickNDSlice(Generic[_T]): """Holds the point and the left and right margins of a thick nD slice.""" - point: Tuple[_T, ...] - margin_left: Tuple[_T, ...] - margin_right: Tuple[_T, ...] + point: tuple[_T, ...] + margin_left: tuple[_T, ...] + margin_right: tuple[_T, ...] @property def ndim(self): @@ -142,7 +142,7 @@ class _SliceInput: # The layer dimension indices in the order they are displayed. # A permutation of the ``range(self.ndim)``. # The last ``self.ndisplay`` dimensions are displayed in the canvas. - order: Tuple[int, ...] + order: tuple[int, ...] @property def ndim(self) -> int: @@ -150,12 +150,12 @@ def ndim(self) -> int: return len(self.order) @property - def displayed(self) -> List[int]: + def displayed(self) -> list[int]: """The layer dimension indices displayed in this slice.""" return list(self.order[-self.ndisplay :]) @property - def not_displayed(self) -> List[int]: + def not_displayed(self) -> list[int]: """The layer dimension indices not displayed in this slice.""" return list(self.order[: -self.ndisplay]) diff --git a/napari/layers/utils/_tests/test_color_manager.py b/napari/layers/utils/_tests/test_color_manager.py index ac238bc4115..82ad4400c7e 100644 --- a/napari/layers/utils/_tests/test_color_manager.py +++ b/napari/layers/utils/_tests/test_color_manager.py @@ -281,7 +281,7 @@ def test_set_color_colormap(): @pytest.mark.parametrize( - "color_cycle", + 'color_cycle', [color_cycle_str, color_cycle_rgb, color_cycle_rgba], ) def test_color_cycle(color_cycle): diff --git a/napari/layers/utils/_tests/test_interactivity_utils.py b/napari/layers/utils/_tests/test_interactivity_utils.py index 9d3ffc7e5c0..02be2564b27 100644 --- a/napari/layers/utils/_tests/test_interactivity_utils.py +++ b/napari/layers/utils/_tests/test_interactivity_utils.py @@ -7,7 +7,7 @@ @pytest.mark.parametrize( - "start_position, end_position, view_direction, vector, expected_value", + 'start_position, end_position, view_direction, vector, expected_value', [ # drag vector parallel to view direction # projected onto perpendicular vector diff --git a/napari/layers/utils/_tests/test_layer_utils.py b/napari/layers/utils/_tests/test_layer_utils.py index dfa09352672..88d99857116 100644 --- a/napari/layers/utils/_tests/test_layer_utils.py +++ b/napari/layers/utils/_tests/test_layer_utils.py @@ -81,7 +81,7 @@ def test_calc_data_range_fast(data): val = calc_data_range(data) assert len(val) > 0 elapsed = time.monotonic() - now - assert elapsed < 5, "test took too long, computation was likely not lazy" + assert elapsed < 5, 'test took too long, computation was likely not lazy' def test_segment_normal_2d(): @@ -110,26 +110,26 @@ def test_dataframe_to_properties(): def test_get_current_properties_with_properties_then_last_values(): properties = { - "face_color": np.array(["cyan", "red", "red"]), - "angle": np.array([0.5, 1.5, 1.5]), + 'face_color': np.array(['cyan', 'red', 'red']), + 'angle': np.array([0.5, 1.5, 1.5]), } current_properties = get_current_properties(properties, {}, 3) assert current_properties == { - "face_color": "red", - "angle": 1.5, + 'face_color': 'red', + 'angle': 1.5, } def test_get_current_properties_with_property_choices_then_first_values(): properties = { - "face_color": np.empty(0, dtype=str), - "angle": np.empty(0, dtype=float), + 'face_color': np.empty(0, dtype=str), + 'angle': np.empty(0, dtype=float), } property_choices = { - "face_color": np.array(["cyan", "red"]), - "angle": np.array([0.5, 1.5]), + 'face_color': np.array(['cyan', 'red']), + 'angle': np.array([0.5, 1.5]), } current_properties = get_current_properties( @@ -138,8 +138,8 @@ def test_get_current_properties_with_property_choices_then_first_values(): ) assert current_properties == { - "face_color": "cyan", - "angle": 0.5, + 'face_color': 'cyan', + 'angle': 0.5, } @@ -177,7 +177,7 @@ def test_coerce_current_properties_invalid_values(): @pytest.mark.parametrize( - "dims_displayed,ndim_world,ndim_layer,expected", + 'dims_displayed,ndim_world,ndim_layer,expected', [ ([1, 2, 3], 4, 4, [1, 2, 3]), ([0, 1, 2], 4, 4, [0, 1, 2]), @@ -461,7 +461,7 @@ def test_feature_table_set_defaults_with_missing_column(feature_table): def test_register_label_attr_action(monkeypatch): - monkeypatch.setattr(time, "time", lambda: 1) + monkeypatch.setattr(time, 'time', lambda: 1) class Foo(KeymapProvider): def __init__(self) -> None: @@ -473,18 +473,18 @@ def __init__(self) -> None: handler = KeymapHandler() handler.keymap_providers = [foo] - @register_layer_attr_action(Foo, "value desc", "value", "K") + @register_layer_attr_action(Foo, 'value desc', 'value', 'K') def set_value_1(x): x.value = 1 - handler.press_key("K") + handler.press_key('K') assert foo.value == 1 - handler.release_key("K") + handler.release_key('K') assert foo.value == 1 foo.value = 0 - handler.press_key("K") + handler.press_key('K') assert foo.value == 1 - monkeypatch.setattr(time, "time", lambda: 2) - handler.release_key("K") + monkeypatch.setattr(time, 'time', lambda: 2) + handler.release_key('K') assert foo.value == 0 diff --git a/napari/layers/utils/_tests/test_link_layers.py b/napari/layers/utils/_tests/test_link_layers.py index 58543108d5a..feedda986c8 100644 --- a/napari/layers/utils/_tests/test_link_layers.py +++ b/napari/layers/utils/_tests/test_link_layers.py @@ -60,7 +60,7 @@ def test_link_invalid_param(): l2 = layers.Points(None) with pytest.raises(ValueError) as e: link_layers([l1, l2], ('rendering',)) - assert "Cannot link attributes that are not shared by all layers" in str(e) + assert 'Cannot link attributes that are not shared by all layers' in str(e) def test_adding_points_to_linked_layer(): diff --git a/napari/layers/utils/_tests/test_string_encoding.py b/napari/layers/utils/_tests/test_string_encoding.py index 684ceb33940..1c456fc9ebf 100644 --- a/napari/layers/utils/_tests/test_string_encoding.py +++ b/napari/layers/utils/_tests/test_string_encoding.py @@ -142,6 +142,19 @@ def test_validate_from_format_string(): assert actual == expected +def test_format_with_index(features): + encoding = FormatStringEncoding(format='{index}: {confidence:.2f}') + values = encoding(features) + np.testing.assert_array_equal(values, ['0: 0.50', '1: 1.00', '2: 0.25']) + + +def test_format_with_index_column(features): + features['index'] = features['class'] + encoding = FormatStringEncoding(format='{index}: {confidence:.2f}') + values = encoding(features) + np.testing.assert_array_equal(values, ['a: 0.50', 'b: 1.00', 'c: 0.25']) + + def test_validate_from_non_format_string(): argument = 'abc' expected = DirectStringEncoding(feature=argument) diff --git a/napari/layers/utils/_tests/test_text_utils.py b/napari/layers/utils/_tests/test_text_utils.py index daac3756a6b..5ecc7bc029d 100644 --- a/napari/layers/utils/_tests/test_text_utils.py +++ b/napari/layers/utils/_tests/test_text_utils.py @@ -19,7 +19,7 @@ @pytest.mark.parametrize( - "view_data,expected_coords", + 'view_data,expected_coords', [(view_data_list, [[5, 5]]), (view_data_ndarray, coords)], ) def test_bbox_center(view_data, expected_coords): @@ -30,7 +30,7 @@ def test_bbox_center(view_data, expected_coords): @pytest.mark.parametrize( - "view_data,expected_coords", + 'view_data,expected_coords', [(view_data_list, [[0, 0]]), (view_data_ndarray, coords)], ) def test_bbox_upper_left(view_data, expected_coords): @@ -41,7 +41,7 @@ def test_bbox_upper_left(view_data, expected_coords): @pytest.mark.parametrize( - "view_data,expected_coords", + 'view_data,expected_coords', [(view_data_list, [[0, 10]]), (view_data_ndarray, coords)], ) def test_bbox_upper_right(view_data, expected_coords): @@ -52,7 +52,7 @@ def test_bbox_upper_right(view_data, expected_coords): @pytest.mark.parametrize( - "view_data,expected_coords", + 'view_data,expected_coords', [(view_data_list, [[10, 0]]), (view_data_ndarray, coords)], ) def test_bbox_lower_left(view_data, expected_coords): @@ -63,7 +63,7 @@ def test_bbox_lower_left(view_data, expected_coords): @pytest.mark.parametrize( - "view_data,expected_coords", + 'view_data,expected_coords', [(view_data_list, [[10, 10]]), (view_data_ndarray, coords)], ) def test_bbox_lower_right(view_data, expected_coords): @@ -74,7 +74,7 @@ def test_bbox_lower_right(view_data, expected_coords): @pytest.mark.parametrize( - "anchor_type,ndisplay,expected_coords", + 'anchor_type,ndisplay,expected_coords', [ (Anchor.CENTER, 2, [[5, 5]]), (Anchor.UPPER_LEFT, 2, [[0, 0]]), diff --git a/napari/layers/utils/_text_utils.py b/napari/layers/utils/_text_utils.py index 045dc4f69f7..f8e85d60825 100644 --- a/napari/layers/utils/_text_utils.py +++ b/napari/layers/utils/_text_utils.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union +from typing import Union import numpy as np import numpy.typing as npt @@ -11,7 +11,7 @@ def get_text_anchors( view_data: Union[np.ndarray, list], ndisplay: int, anchor: Anchor = Anchor.CENTER, -) -> Tuple[np.ndarray, str, str]: +) -> tuple[np.ndarray, str, str]: # Explicitly convert to an Anchor so that string values can be used. text_anchor_func = TEXT_ANCHOR_CALCULATION[Anchor(anchor)] text_coords, anchor_x, anchor_y = text_anchor_func(view_data, ndisplay) @@ -20,7 +20,7 @@ def get_text_anchors( def _calculate_anchor_center( view_data: Union[np.ndarray, list], ndisplay: int -) -> Tuple[np.ndarray, str, str]: +) -> tuple[np.ndarray, str, str]: text_coords = _calculate_bbox_centers(view_data) anchor_x = 'center' @@ -73,7 +73,7 @@ def _calculate_bbox_centers(view_data: Union[np.ndarray, list]) -> np.ndarray: def _calculate_anchor_upper_left( view_data: Union[np.ndarray, list], ndisplay: int -) -> Tuple[np.ndarray, str, str]: +) -> tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_min[:, 0], bbox_min[:, 1]]).T @@ -91,7 +91,7 @@ def _calculate_anchor_upper_left( def _calculate_anchor_upper_right( view_data: Union[np.ndarray, list], ndisplay: int -) -> Tuple[np.ndarray, str, str]: +) -> tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_min[:, 0], bbox_max[:, 1]]).T @@ -109,7 +109,7 @@ def _calculate_anchor_upper_right( def _calculate_anchor_lower_left( view_data: Union[np.ndarray, list], ndisplay: int -) -> Tuple[np.ndarray, str, str]: +) -> tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_max[:, 0], bbox_min[:, 1]]).T @@ -127,7 +127,7 @@ def _calculate_anchor_lower_left( def _calculate_anchor_lower_right( view_data: Union[np.ndarray, list], ndisplay: int -) -> Tuple[np.ndarray, str, str]: +) -> tuple[np.ndarray, str, str]: if ndisplay == 2: bbox_min, bbox_max = _calculate_bbox_extents(view_data) text_anchors = np.array([bbox_max[:, 0], bbox_max[:, 1]]).T @@ -145,7 +145,7 @@ def _calculate_anchor_lower_right( def _calculate_bbox_extents( view_data: Union[np.ndarray, list] -) -> Tuple[npt.NDArray, npt.NDArray]: +) -> tuple[npt.NDArray, npt.NDArray]: """Calculate the extents of the bounding box""" if isinstance(view_data, np.ndarray): if view_data.ndim == 2: diff --git a/napari/layers/utils/color_encoding.py b/napari/layers/utils/color_encoding.py index 101232f824c..9b52e931a8f 100644 --- a/napari/layers/utils/color_encoding.py +++ b/napari/layers/utils/color_encoding.py @@ -3,7 +3,6 @@ Literal, Optional, Protocol, - Tuple, Union, runtime_checkable, ) @@ -192,7 +191,7 @@ class QuantitativeColorEncoding(_DerivedStyleEncoding[ColorValue, ColorArray]): ) feature: str colormap: Colormap - contrast_limits: Optional[Tuple[float, float]] = None + contrast_limits: Optional[tuple[float, float]] = None fallback: ColorValue = Field(default_factory=lambda: DEFAULT_COLOR) def __call__(self, features: Any) -> ColorArray: @@ -211,7 +210,7 @@ def _check_colormap(cls, colormap: ValidColormapArg) -> Colormap: @validator('contrast_limits', pre=True, always=True, allow_reuse=True) def _check_contrast_limits( cls, contrast_limits - ) -> Optional[Tuple[float, float]]: + ) -> Optional[tuple[float, float]]: if (contrast_limits is not None) and ( contrast_limits[0] >= contrast_limits[1] ): @@ -226,7 +225,7 @@ def _check_contrast_limits( def _calculate_contrast_limits( values: np.ndarray, -) -> Optional[Tuple[float, float]]: +) -> Optional[tuple[float, float]]: contrast_limits = None if values.size > 0: min_value = np.min(values) diff --git a/napari/layers/utils/color_manager.py b/napari/layers/utils/color_manager.py index 32e79aef984..8740373fdc2 100644 --- a/napari/layers/utils/color_manager.py +++ b/napari/layers/utils/color_manager.py @@ -1,6 +1,6 @@ from copy import deepcopy from dataclasses import dataclass -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Optional, Union import numpy as np @@ -147,7 +147,7 @@ class ColorManager(EventedModel): color_mode: ColorMode = ColorMode.DIRECT color_properties: Optional[ColorProperties] = None continuous_colormap: Colormap = ensure_colormap('viridis') - contrast_limits: Optional[Tuple[float, float]] = None + contrast_limits: Optional[tuple[float, float]] = None categorical_colormap: CategoricalColormap = CategoricalColormap.from_array( [0, 0, 0, 1] ) @@ -188,7 +188,7 @@ def _validate_colors(cls, values): # set the current color to the last color/property value # if it wasn't already set - if values.get("current_color") is None and len(colors) > 0: + if values.get('current_color') is None and len(colors) > 0: values['current_color'] = colors[-1] if color_mode in [ColorMode.CYCLE, ColorMode.COLORMAP]: property_values = values['color_properties'] @@ -202,8 +202,8 @@ def _set_color( self, color: ColorType, n_colors: int, - properties: Dict[str, np.ndarray], - current_properties: Dict[str, np.ndarray], + properties: dict[str, np.ndarray], + current_properties: dict[str, np.ndarray], ): """Set a color property. This is convenience function @@ -238,8 +238,8 @@ def _set_color( transformed_color = transform_color_with_defaults( num_entries=n_colors, colors=color, - elem_name="color", - default="white", + elem_name='color', + default='white', ) colors = normalize_and_broadcast_colors( n_colors, transformed_color @@ -249,7 +249,7 @@ def _set_color( def _refresh_colors( self, - properties: Dict[str, np.ndarray], + properties: dict[str, np.ndarray], update_color_mapping: bool = False, ): """Calculate and update colors if using a cycle or color map @@ -303,8 +303,8 @@ def _add( transformed_color = transform_color_with_defaults( num_entries=n_colors, colors=new_color, - elem_name="color", - default="white", + elem_name='color', + default='white', ) broadcasted_colors = normalize_and_broadcast_colors( n_colors, transformed_color @@ -353,7 +353,7 @@ def _remove(self, indices_to_remove: Union[set, list, np.ndarray]): current_value=current_value, ) - def _paste(self, colors: np.ndarray, properties: Dict[str, np.ndarray]): + def _paste(self, colors: np.ndarray, properties: dict[str, np.ndarray]): """Append colors to the ColorManager. Uses the color values if in direct mode and the properties in colormap or cycle mode. @@ -390,7 +390,7 @@ def _paste(self, colors: np.ndarray, properties: Dict[str, np.ndarray]): ) def _update_current_properties( - self, current_properties: Dict[str, np.ndarray] + self, current_properties: dict[str, np.ndarray] ): """This is updates the current_value of the color_properties when the layer current_properties is updated. @@ -451,10 +451,10 @@ def _update_current_color( def _from_layer_kwargs( cls, colors: Union[dict, str, np.ndarray], - properties: Dict[str, np.ndarray], + properties: dict[str, np.ndarray], n_colors: Optional[int] = None, continuous_colormap: Optional[Union[str, Colormap]] = None, - contrast_limits: Optional[Tuple[float, float]] = None, + contrast_limits: Optional[tuple[float, float]] = None, categorical_colormap: Optional[ Union[CategoricalColormap, list, np.ndarray] ] = None, @@ -557,8 +557,8 @@ def _from_layer_kwargs( transformed_color = transform_color_with_defaults( num_entries=n_colors, colors=color_values, - elem_name="colors", - default="white", + elem_name='colors', + default='white', ) colors = normalize_and_broadcast_colors( n_colors, transformed_color diff --git a/napari/layers/utils/color_manager_utils.py b/napari/layers/utils/color_manager_utils.py index fdd828c9878..5ef95dfd505 100644 --- a/napari/layers/utils/color_manager_utils.py +++ b/napari/layers/utils/color_manager_utils.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Tuple, Union +from typing import Any, Union import numpy as np @@ -48,8 +48,8 @@ def is_color_mapped(color, properties): def map_property( prop: np.ndarray, colormap: Colormap, - contrast_limits: Union[None, Tuple[float, float]] = None, -) -> Tuple[np.ndarray, Tuple[float, float]]: + contrast_limits: Union[None, tuple[float, float]] = None, +) -> tuple[np.ndarray, tuple[float, float]]: """Apply a colormap to a property Parameters @@ -74,8 +74,8 @@ def map_property( def _validate_colormap_mode( - values: Dict[str, Any] -) -> Tuple[np.ndarray, Dict[str, Any]]: + values: dict[str, Any] +) -> tuple[np.ndarray, dict[str, Any]]: """Validate the ColorManager field values specific for colormap mode This is called by the root_validator in ColorManager @@ -118,8 +118,8 @@ def _validate_colormap_mode( def _validate_cycle_mode( - values: Dict[str, Any] -) -> Tuple[np.ndarray, Dict[str, Any]]: + values: dict[str, Any] +) -> tuple[np.ndarray, dict[str, Any]]: """Validate the ColorManager field values specific for color cycle mode This is called by the root_validator in ColorManager diff --git a/napari/layers/utils/color_transformations.py b/napari/layers/utils/color_transformations.py index 4f280f70050..6d25fdc46da 100644 --- a/napari/layers/utils/color_transformations.py +++ b/napari/layers/utils/color_transformations.py @@ -6,7 +6,6 @@ import warnings from itertools import cycle -from typing import Tuple import numpy as np @@ -42,7 +41,7 @@ def transform_color_with_defaults( except (AttributeError, ValueError, KeyError): warnings.warn( trans._( - "The provided {elem_name} parameter contained illegal values, resetting all {elem_name} values to {default}.", + 'The provided {elem_name} parameter contained illegal values, resetting all {elem_name} values to {default}.', deferred=True, elem_name=elem_name, default=default, @@ -53,7 +52,7 @@ def transform_color_with_defaults( if (len(transformed) != 1) and (len(transformed) != num_entries): warnings.warn( trans._( - "The provided {elem_name} parameter has {length} entries, while the data contains {num_entries} entries. Setting {elem_name} to {default}.", + 'The provided {elem_name} parameter has {length} entries, while the data contains {num_entries} entries. Setting {elem_name} to {default}.', deferred=True, elem_name=elem_name, length=len(colors), @@ -67,7 +66,7 @@ def transform_color_with_defaults( def transform_color_cycle( color_cycle: ColorType, elem_name: str, default: str -) -> Tuple["cycle[np.ndarray]", np.ndarray]: +) -> tuple['cycle[np.ndarray]', np.ndarray]: """Helper method to return an Nx4 np.array from an arbitrary user input. Parameters @@ -130,7 +129,7 @@ def normalize_and_broadcast_colors( if len(colors) != 1: warnings.warn( trans._( - "The number of supplied colors mismatch the number of given data points. Length of data is {num_entries}, while the number of colors is {length}. Color for all points is reset to white.", + 'The number of supplied colors mismatch the number of given data points. Length of data is {num_entries}, while the number of colors is {length}. Color for all points is reset to white.', deferred=True, num_entries=num_entries, length=len(colors), diff --git a/napari/layers/utils/interaction_box.py b/napari/layers/utils/interaction_box.py index ae749004748..6dd591db36d 100644 --- a/napari/layers/utils/interaction_box.py +++ b/napari/layers/utils/interaction_box.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import lru_cache -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional import numpy as np @@ -13,8 +13,8 @@ @lru_cache def generate_interaction_box_vertices( - top_left: Tuple[float, float], - bot_right: Tuple[float, float], + top_left: tuple[float, float], + bot_right: tuple[float, float], handles: bool = True, ) -> np.ndarray: """ @@ -61,7 +61,7 @@ def generate_interaction_box_vertices( def generate_transform_box_from_layer( - layer: Layer, dims_displayed: Tuple[int, int] + layer: Layer, dims_displayed: tuple[int, int] ) -> np.ndarray: """ Generate coordinates for the handles of a layer's transform box. @@ -88,7 +88,7 @@ def generate_transform_box_from_layer( def calculate_bounds_from_contained_points( points: np.ndarray, -) -> Tuple[Tuple[float, float], Tuple[float, float]]: +) -> tuple[tuple[float, float], tuple[float, float]]: """ Calculate the top-left and bottom-right corners of an axis-aligned bounding box. diff --git a/napari/layers/utils/interactivity_utils.py b/napari/layers/utils/interactivity_utils.py index 2841313fe9c..0653b1d8122 100644 --- a/napari/layers/utils/interactivity_utils.py +++ b/napari/layers/utils/interactivity_utils.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Tuple, Union +from typing import TYPE_CHECKING, Union import numpy as np +import numpy.typing as npt from napari.utils.geometry import ( point_in_bounding_box, @@ -14,10 +15,10 @@ def displayed_plane_from_nd_line_segment( - start_point: np.ndarray, - end_point: np.ndarray, - dims_displayed: Union[List[int], np.ndarray], -) -> Tuple[np.ndarray, np.ndarray]: + start_point: npt.NDArray, + end_point: npt.NDArray, + dims_displayed: Union[list[int], npt.NDArray], +) -> tuple[npt.NDArray, npt.NDArray]: """Get the plane defined by start_point and the normal vector that goes from start_point to end_point. @@ -50,8 +51,11 @@ def displayed_plane_from_nd_line_segment( def drag_data_to_projected_distance( - start_position, end_position, view_direction, vector -): + start_position: npt.NDArray, + end_position: npt.NDArray, + view_direction: npt.NDArray, + vector: npt.NDArray, +) -> npt.NDArray: """Calculate the projected distance between two mouse events. Project the drag vector between two mouse events onto a 3D vector @@ -98,7 +102,9 @@ def drag_data_to_projected_distance( return np.einsum('j, ij -> i', drag_vector_canvas, vector).squeeze() -def orient_plane_normal_around_cursor(layer: Image, plane_normal: tuple): +def orient_plane_normal_around_cursor( + layer: Image, plane_normal: tuple +) -> None: """Orient a rendering plane by rotating it around the cursor. If the cursor ray does not intersect the plane, the position will remain @@ -151,8 +157,8 @@ def orient_plane_normal_around_cursor(layer: Image, plane_normal: tuple): def nd_line_segment_to_displayed_data_ray( start_point: np.ndarray, end_point: np.ndarray, - dims_displayed: Union[List[int], np.ndarray], -) -> Tuple[np.ndarray, np.ndarray]: + dims_displayed: Union[list[int], np.ndarray], +) -> tuple[np.ndarray, np.ndarray]: """Convert the start and end point of the line segment of a mouse click ray intersecting a data cube to a ray (i.e., start position and direction) in displayed data coordinates diff --git a/napari/layers/utils/layer_utils.py b/napari/layers/utils/layer_utils.py index ab93930a017..90bc082d23a 100644 --- a/napari/layers/utils/layer_utils.py +++ b/napari/layers/utils/layer_utils.py @@ -3,16 +3,13 @@ import functools import inspect import warnings +from collections.abc import Sequence from typing import ( TYPE_CHECKING, Any, Callable, - Dict, - List, NamedTuple, Optional, - Sequence, - Tuple, Union, ) @@ -26,7 +23,7 @@ from napari.utils.translations import trans if TYPE_CHECKING: - from typing import Mapping + from collections.abc import Mapping import numpy.typing as npt @@ -63,7 +60,7 @@ def register_layer_action( keymapprovider, description: str, repeatable: bool = False, - shortcuts: Optional[Union[str, List[str]]] = None, + shortcuts: Optional[Union[str, list[str]]] = None, ) -> Callable[[Callable], Callable]: """ Convenient decorator to register an action with the current Layers @@ -120,7 +117,7 @@ def register_layer_attr_action( description: str, attribute_name: str, shortcuts=None, -): +) -> Callable[[Callable], Callable]: """ Convenient decorator to register an action with the current Layers. This will get and restore attribute from function first argument. @@ -149,14 +146,14 @@ class on which to register the keybindings - this will typically be """ - def _handle(func): + def _handle(func: Callable) -> Callable: sig = inspect.signature(func) try: first_variable_name = next(iter(sig.parameters)) except StopIteration as e: raise RuntimeError( trans._( - "If actions has no arguments there is no way to know what to set the attribute to.", + 'If actions has no arguments there is no way to know what to set the attribute to.', deferred=True, ), ) from e @@ -209,7 +206,7 @@ def _nanmax(array): def calc_data_range( data: LayerDataProtocol, rgb: bool = False -) -> Tuple[float, float]: +) -> tuple[float, float]: """Calculate range of data values. If all values are equal return [0, 1]. Parameters @@ -232,8 +229,8 @@ def calc_data_range( if data.dtype == np.uint8: return (0, 255) - center: Union[int, List[int]] - reduced_data: Union[List, LayerDataProtocol] + center: Union[int, list[int]] + reduced_data: Union[list, LayerDataProtocol] if data.size > 1e7 and (data.ndim == 1 or (rgb and data.ndim == 2)): # If data is very large take the average of start, middle and end. center = int(data.shape[0] // 2) @@ -325,7 +322,7 @@ def segment_normal(a, b, p=(0, 0, 1)) -> np.ndarray: return normal / norm -def convert_to_uint8(data: np.ndarray) -> Optional[np.ndarray]: +def convert_to_uint8(data: np.ndarray) -> np.ndarray: """ Convert array content to uint8, always returning a copy. @@ -347,17 +344,17 @@ def convert_to_uint8(data: np.ndarray) -> Optional[np.ndarray]: if data.dtype == out_dtype: return data in_kind = data.dtype.kind - if in_kind == "b": + if in_kind == 'b': return data.astype(out_dtype) * 255 - if in_kind == "f": + if in_kind == 'f': image_out = np.multiply(data, out_max, dtype=data.dtype) np.rint(image_out, out=image_out) np.clip(image_out, 0, out_max, out=image_out) image_out = np.nan_to_num(image_out, copy=False) return image_out.astype(out_dtype) - if in_kind in "ui": - if in_kind == "u": + if in_kind in 'ui': + if in_kind == 'u': if data.max() < out_max: return data.astype(out_dtype) return np.right_shift(data, (data.dtype.itemsize - 1) * 8).astype( @@ -376,10 +373,10 @@ def convert_to_uint8(data: np.ndarray) -> Optional[np.ndarray]: def get_current_properties( - properties: Dict[str, np.ndarray], - choices: Dict[str, np.ndarray], + properties: dict[str, np.ndarray], + choices: dict[str, np.ndarray], num_data: int = 0, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get the current property values from the properties or choices. Parameters @@ -411,7 +408,7 @@ def get_current_properties( def dataframe_to_properties( dataframe: pd.DataFrame, -) -> Dict[str, np.ndarray]: +) -> dict[str, np.ndarray]: """Convert a dataframe to a properties dictionary. Parameters ---------- @@ -427,9 +424,9 @@ def dataframe_to_properties( def validate_properties( - properties: Optional[Union[Dict[str, Array], pd.DataFrame]], + properties: Optional[Union[dict[str, Array], pd.DataFrame]], expected_len: Optional[int] = None, -) -> Dict[str, np.ndarray]: +) -> dict[str, np.ndarray]: """Validate the type and size of properties and coerce values to numpy arrays. Parameters ---------- @@ -454,7 +451,7 @@ def validate_properties( if any(v != expected_len for v in lens): raise ValueError( trans._( - "the number of items must be equal for all properties", + 'the number of items must be equal for all properties', deferred=True, ) ) @@ -502,7 +499,7 @@ def coerce_current_properties( current_properties: Mapping[ str, Union[float, str, int, bool, list, tuple, npt.NDArray] ] -) -> Dict[str, np.ndarray]: +) -> dict[str, np.ndarray]: """Coerce a current_properties dictionary into the correct type. @@ -598,7 +595,12 @@ def compute_multiscale_level_and_corners( return level, corners -def coerce_affine(affine, *, ndim, name=None): +def coerce_affine( + affine: Union[npt.ArrayLike, Affine], + *, + ndim: int, + name: Optional[str] = None, +) -> Affine: """Coerce a user input into an affine transform object. If the input is already an affine transform object, that same object is returned @@ -639,10 +641,10 @@ def coerce_affine(affine, *, ndim, name=None): def dims_displayed_world_to_layer( - dims_displayed_world: List[int], + dims_displayed_world: list[int], ndim_world: int, ndim_layer: int, -) -> List[int]: +) -> list[int]: """Convert the dims_displayed from world dims to the layer dims. This accounts differences in the number of dimensions in the world @@ -683,7 +685,11 @@ def dims_displayed_world_to_layer( return dims_displayed -def get_extent_world(data_extent, data_to_world, centered=None): +def get_extent_world( + data_extent: npt.NDArray, + data_to_world: Affine, + centered: Optional[Any] = None, +) -> npt.NDArray: """Range of layer in world coordinates base on provided data_extent Parameters @@ -760,10 +766,10 @@ class _FeatureTable: def __init__( self, - values: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] = None, + values: Optional[Union[dict[str, np.ndarray], pd.DataFrame]] = None, *, num_data: Optional[int] = None, - defaults: Optional[Union[Dict[str, Any], pd.DataFrame]] = None, + defaults: Optional[Union[dict[str, Any], pd.DataFrame]] = None, ) -> None: self._values = _validate_features(values, num_data=num_data) self._defaults = _validate_feature_defaults(defaults, self._values) @@ -784,12 +790,12 @@ def defaults(self) -> pd.DataFrame: return self._defaults def set_defaults( - self, defaults: Union[Dict[str, Any], pd.DataFrame] + self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: """Sets the feature default values.""" self._defaults = _validate_feature_defaults(defaults, self._values) - def properties(self) -> Dict[str, np.ndarray]: + def properties(self) -> dict[str, np.ndarray]: """Converts this to a deprecated properties dictionary. This will reference the features data when possible, but in general the @@ -802,7 +808,7 @@ def properties(self) -> Dict[str, np.ndarray]: """ return _features_to_properties(self._values) - def choices(self) -> Dict[str, np.ndarray]: + def choices(self) -> dict[str, np.ndarray]: """Converts this to a deprecated property choices dictionary. Only categorical features will have corresponding entries in the dictionary. @@ -818,15 +824,15 @@ def choices(self) -> Dict[str, np.ndarray]: if isinstance(series.dtype, pd.CategoricalDtype) } - def currents(self) -> Dict[str, np.ndarray]: + def currents(self) -> dict[str, np.ndarray]: """Converts the defaults table to a deprecated current properties dictionary.""" return _features_to_properties(self._defaults) def set_currents( self, - currents: Dict[str, npt.NDArray], + currents: dict[str, npt.NDArray], *, - update_indices: Optional[List[int]] = None, + update_indices: Optional[list[int]] = None, ) -> None: """Sets the default values using the deprecated current properties dictionary. @@ -895,12 +901,12 @@ def reorder(self, order: Sequence[int]) -> None: def from_layer( cls, *, - features: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] = None, - feature_defaults: Optional[Union[Dict[str, Any], pd.DataFrame]] = None, + features: Optional[Union[dict[str, np.ndarray], pd.DataFrame]] = None, + feature_defaults: Optional[Union[dict[str, Any], pd.DataFrame]] = None, properties: Optional[ - Union[Dict[str, np.ndarray], pd.DataFrame] + Union[dict[str, np.ndarray], pd.DataFrame] ] = None, - property_choices: Optional[Dict[str, np.ndarray]] = None, + property_choices: Optional[dict[str, np.ndarray]] = None, num_data: Optional[int] = None, ) -> _FeatureTable: """Coerces a layer's keyword arguments to a feature manager. @@ -957,7 +963,7 @@ def _get_default_column(column: pd.Series) -> pd.Series: def _validate_features( - features: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]], + features: Optional[Union[dict[str, np.ndarray], pd.DataFrame]], *, num_data: Optional[int] = None, ) -> pd.DataFrame: @@ -983,7 +989,7 @@ def _validate_features( def _validate_feature_defaults( - defaults: Optional[Union[Dict[str, Any], pd.DataFrame]], + defaults: Optional[Union[dict[str, Any], pd.DataFrame]], values: pd.DataFrame, ) -> pd.DataFrame: """Validates and coerces feature default values into a pandas DataFrame. @@ -1031,8 +1037,8 @@ def _validate_feature_defaults( def _features_from_properties( *, - properties: Optional[Union[Dict[str, np.ndarray], pd.DataFrame]] = None, - property_choices: Optional[Dict[str, np.ndarray]] = None, + properties: Optional[Union[dict[str, np.ndarray], pd.DataFrame]] = None, + property_choices: Optional[dict[str, np.ndarray]] = None, num_data: Optional[int] = None, ) -> pd.DataFrame: """Validates and coerces deprecated properties input into a features DataFrame. @@ -1054,7 +1060,7 @@ def _features_from_properties( return _validate_features(properties, num_data=num_data) -def _features_to_properties(features: pd.DataFrame) -> Dict[str, np.ndarray]: +def _features_to_properties(features: pd.DataFrame) -> dict[str, np.ndarray]: """Converts a features DataFrame to a deprecated properties dictionary. See Also diff --git a/napari/layers/utils/plane.py b/napari/layers/utils/plane.py index 40de2e84ffd..36e30ae14fb 100644 --- a/napari/layers/utils/plane.py +++ b/napari/layers/utils/plane.py @@ -1,5 +1,5 @@ import sys -from typing import Any, Tuple, cast +from typing import Any, cast import numpy as np import numpy.typing as npt @@ -17,7 +17,7 @@ from typing import TypeAlias -Point3D: TypeAlias = Tuple[float, float, float] +Point3D: TypeAlias = tuple[float, float, float] class Plane(EventedModel): diff --git a/napari/layers/utils/stack_utils.py b/napari/layers/utils/stack_utils.py index 3b3cba466d3..a7a030e6a34 100644 --- a/napari/layers/utils/stack_utils.py +++ b/napari/layers/utils/stack_utils.py @@ -1,7 +1,7 @@ from __future__ import annotations import itertools -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING import numpy as np @@ -44,7 +44,7 @@ def split_channels( data: np.ndarray, channel_axis: int, **kwargs, -) -> List[FullLayerData]: +) -> list[FullLayerData]: """Split the data array into separate arrays along an axis. Keyword arguments will override any parameters altered or set in this @@ -157,7 +157,7 @@ def split_channels( return layerdata_list -def stack_to_images(stack: Image, axis: int, **kwargs) -> List[Image]: +def stack_to_images(stack: Image, axis: int, **kwargs) -> list[Image]: """Splits a single Image layer into a list layers along axis. Some image layer properties will be changed unless specified as an item in @@ -185,7 +185,7 @@ def stack_to_images(stack: Image, axis: int, **kwargs) -> List[Image]: data, meta, _ = stack.as_layer_data_tuple() - for key in ("contrast_limits", "colormap", "blending"): + for key in ('contrast_limits', 'colormap', 'blending'): del meta[key] name = stack.name @@ -194,7 +194,7 @@ def stack_to_images(stack: Image, axis: int, **kwargs) -> List[Image]: if num_dim < 3: raise ValueError( trans._( - "The image needs more than 2 dimensions for splitting", + 'The image needs more than 2 dimensions for splitting', deferred=True, ) ) @@ -209,7 +209,7 @@ def stack_to_images(stack: Image, axis: int, **kwargs) -> List[Image]: ) ) - if kwargs.get("colormap"): + if kwargs.get('colormap'): kwargs['colormap'] = itertools.cycle(kwargs['colormap']) if meta['rgb']: @@ -241,7 +241,7 @@ def stack_to_images(stack: Image, axis: int, **kwargs) -> List[Image]: return imagelist -def split_rgb(stack: Image, with_alpha=False) -> List[Image]: +def split_rgb(stack: Image, with_alpha=False) -> list[Image]: """Variant of stack_to_images that splits an RGB with predefined cmap.""" if not stack.rgb: raise ValueError( @@ -252,7 +252,7 @@ def split_rgb(stack: Image, with_alpha=False) -> List[Image]: return images if with_alpha else images[:3] -def images_to_stack(images: List[Image], axis: int = 0, **kwargs) -> Image: +def images_to_stack(images: list[Image], axis: int = 0, **kwargs) -> Image: """Combines a list of Image layers into one layer stacked along axis The new image layer will get the meta properties of the first @@ -275,22 +275,22 @@ def images_to_stack(images: List[Image], axis: int = 0, **kwargs) -> Image: """ if not images: - raise IndexError(trans._("images list is empty", deferred=True)) + raise IndexError(trans._('images list is empty', deferred=True)) data, meta, _ = images[0].as_layer_data_tuple() - kwargs.setdefault("scale", np.insert(meta['scale'], axis, 1)) - kwargs.setdefault("translate", np.insert(meta['translate'], axis, 0)) + kwargs.setdefault('scale', np.insert(meta['scale'], axis, 1)) + kwargs.setdefault('translate', np.insert(meta['translate'], axis, 0)) meta.update(kwargs) new_data = np.stack([image.data for image in images], axis=axis) return Image(new_data, **meta) -def merge_rgb(images: List[Image]) -> Image: +def merge_rgb(images: list[Image]) -> Image: """Variant of images_to_stack that makes an RGB from 3 images.""" if not (len(images) == 3 and all(isinstance(x, Image) for x in images)): raise ValueError( - trans._("merge_rgb requires 3 images layers", deferred=True) + trans._('merge_rgb requires 3 images layers', deferred=True) ) return images_to_stack(images, axis=-1, rgb=True) diff --git a/napari/layers/utils/string_encoding.py b/napari/layers/utils/string_encoding.py index f71eeba57d8..f479b3f8a1a 100644 --- a/napari/layers/utils/string_encoding.py +++ b/napari/layers/utils/string_encoding.py @@ -1,5 +1,6 @@ +from collections.abc import Sequence from string import Formatter -from typing import Any, Literal, Protocol, Sequence, Union, runtime_checkable +from typing import Any, Literal, Protocol, Union, runtime_checkable import numpy as np @@ -166,9 +167,15 @@ class FormatStringEncoding(_DerivedStyleEncoding[StringValue, StringArray]): def __call__(self, features: Any) -> StringArray: feature_names = features.columns.to_list() + # Expose the dataframe index to the format string keys + # unless a column exists with the name "index", which takes precedence. + with_index = False + if 'index' not in feature_names: + feature_names = ['index'] + feature_names + with_index = True values = [ self.format.format(**dict(zip(feature_names, row))) - for row in features.itertuples(index=False, name=None) + for row in features.itertuples(index=with_index, name=None) ] return np.array(values, dtype=str) diff --git a/napari/layers/utils/style_encoding.py b/napari/layers/utils/style_encoding.py index aa659a27e69..6d00291a754 100644 --- a/napari/layers/utils/style_encoding.py +++ b/napari/layers/utils/style_encoding.py @@ -3,7 +3,6 @@ from typing import ( Any, Generic, - List, Protocol, TypeVar, Union, @@ -15,7 +14,7 @@ from napari.utils.events import EventedModel from napari.utils.translations import trans -IndicesType = Union[range, List[int], np.ndarray] +IndicesType = Union[range, list[int], np.ndarray] """The variable type of a single style value.""" StyleValue = TypeVar('StyleValue', bound=np.ndarray) diff --git a/napari/layers/utils/text_manager.py b/napari/layers/utils/text_manager.py index 5cbc3d7f8f7..c5542e44a44 100644 --- a/napari/layers/utils/text_manager.py +++ b/napari/layers/utils/text_manager.py @@ -1,6 +1,7 @@ import warnings +from collections.abc import Sequence from copy import deepcopy -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Optional, Union import numpy as np import pandas as pd @@ -137,7 +138,7 @@ def refresh(self, features: Any) -> None: # Trigger the main event for vispy layers. self.events(Event(type_name='refresh')) - def refresh_text(self, properties: Dict[str, np.ndarray]): + def refresh_text(self, properties: dict[str, np.ndarray]): """Refresh all of the current text elements using updated properties values Parameters @@ -211,7 +212,7 @@ def apply(self, features: Any): self.events.values() self.color._apply(features) - def _copy(self, indices: List[int]) -> dict: + def _copy(self, indices: list[int]) -> dict: """Copies all encoded values at the given indices.""" return { 'string': _get_style_values(self.string, indices), @@ -228,8 +229,8 @@ def compute_text_coords( self, view_data: np.ndarray, ndisplay: int, - order: Optional[Tuple[int, ...]] = None, - ) -> Tuple[np.ndarray, str, str]: + order: Optional[tuple[int, ...]] = None, + ) -> tuple[np.ndarray, str, str]: """Calculate the coordinates for each text element in view Parameters diff --git a/napari/layers/vectors/_slice.py b/napari/layers/vectors/_slice.py index e4b72bc3849..6a294738cb4 100644 --- a/napari/layers/vectors/_slice.py +++ b/napari/layers/vectors/_slice.py @@ -2,6 +2,7 @@ from typing import Any, Union import numpy as np +import numpy.typing as npt from napari.layers.base._slice import _next_request_id from napari.layers.utils._slice_input import _SliceInput, _ThickNDSlice @@ -92,23 +93,7 @@ def __call__(self) -> _VectorSliceResponse: request_id=self.id, ) - def _get_out_of_display_slice_data(self, not_disp, not_disp_indices): - """This method slices in the out-of-display case.""" - data = self.data[:, 0, not_disp] - distances = abs(data - not_disp_indices) - # get the scaled projected vectors - projected_lengths = abs(self.data[:, 1, not_disp] * self.length) - # find where the distance to plane is less than the scaled vector - matches = np.all(distances <= projected_lengths, axis=1) - alpha_match = projected_lengths[matches] - alpha_match[alpha_match == 0] = 1 - alpha_per_dim = (alpha_match - distances[matches]) / alpha_match - alpha_per_dim[alpha_match == 0] = 1 - alpha = np.prod(alpha_per_dim, axis=1).astype(float) - slice_indices = np.where(matches)[0].astype(int) - return slice_indices, alpha - - def _get_slice_data(self, not_disp): + def _get_slice_data(self, not_disp: list[int]) -> tuple[npt.NDArray, int]: data = self.data[:, 0, not_disp] alphas = 1 diff --git a/napari/layers/vectors/_tests/test_vectors.py b/napari/layers/vectors/_tests/test_vectors.py index 3c4cc18caa6..47e56172bc7 100644 --- a/napari/layers/vectors/_tests/test_vectors.py +++ b/napari/layers/vectors/_tests/test_vectors.py @@ -170,7 +170,7 @@ def test_no_data_3D_vectors_with_ndim(): assert layer.data.shape[-1] == 3 -@pytest.mark.filterwarnings("ignore:Passing `np.nan`:DeprecationWarning:numpy") +@pytest.mark.filterwarnings('ignore:Passing `np.nan`:DeprecationWarning:numpy') def test_empty_3D_vectors(): """Test instantiating Vectors layer with empty coordinate-like 3D data.""" shape = (0, 2, 3) @@ -691,6 +691,6 @@ def test_out_of_slice_display(): def test_empty_data_from_tuple(): """Test that empty data raises an error.""" - layer = Vectors(name="vector", ndim=3) + layer = Vectors(name='vector', ndim=3) layer2 = Vectors.create(*layer.as_layer_data_tuple()) assert layer2.data.size == 0 diff --git a/napari/layers/vectors/_vector_utils.py b/napari/layers/vectors/_vector_utils.py index cc578469ef8..2f660293ecd 100644 --- a/napari/layers/vectors/_vector_utils.py +++ b/napari/layers/vectors/_vector_utils.py @@ -1,11 +1,12 @@ -from typing import Optional, Tuple +from typing import Optional import numpy as np +import numpy.typing as npt from napari.utils.translations import trans -def convert_image_to_coordinates(vectors) -> np.ndarray: +def convert_image_to_coordinates(vectors: npt.NDArray) -> npt.NDArray: """To convert an image-like array with elements (y-proj, x-proj) into a position list of coordinates Every pixel position (n, m) results in two output coordinates of (N,2) @@ -40,7 +41,7 @@ def convert_image_to_coordinates(vectors) -> np.ndarray: def fix_data_vectors( vectors: Optional[np.ndarray], ndim: Optional[int] -) -> Tuple[np.ndarray, int]: +) -> tuple[np.ndarray, int]: """ Ensure that vectors array is 3d and have second dimension of size 2 and third dimension of size ndim (default 2 for empty arrays) @@ -90,7 +91,7 @@ def fix_data_vectors( if vectors.ndim != 3 or vectors.shape[1] != 2: raise ValueError( trans._( - "could not reshape Vector data from {vectors_shape} to (N, 2, {dimensions})", + 'could not reshape Vector data from {vectors_shape} to (N, 2, {dimensions})', deferred=True, vectors_shape=vectors.shape, dimensions=ndim or 'D', @@ -101,7 +102,7 @@ def fix_data_vectors( if ndim is not None and ndim != data_ndim: raise ValueError( trans._( - "Vectors dimensions ({data_ndim}) must be equal to ndim ({ndim})", + 'Vectors dimensions ({data_ndim}) must be equal to ndim ({ndim})', deferred=True, data_ndim=data_ndim, ndim=ndim, diff --git a/napari/layers/vectors/_vectors_constants.py b/napari/layers/vectors/_vectors_constants.py index a750ab9a946..2d70bd94795 100644 --- a/napari/layers/vectors/_vectors_constants.py +++ b/napari/layers/vectors/_vectors_constants.py @@ -25,9 +25,9 @@ class VectorStyle(StringEnum): VECTORSTYLE_TRANSLATIONS = OrderedDict( [ - (VectorStyle.LINE, trans._("line")), - (VectorStyle.TRIANGLE, trans._("triangle")), - (VectorStyle.ARROW, trans._("arrow")), + (VectorStyle.LINE, trans._('line')), + (VectorStyle.TRIANGLE, trans._('triangle')), + (VectorStyle.ARROW, trans._('arrow')), ] ) diff --git a/napari/layers/vectors/_vectors_key_bindings.py b/napari/layers/vectors/_vectors_key_bindings.py index fb2fe720f4d..9971b0edc05 100644 --- a/napari/layers/vectors/_vectors_key_bindings.py +++ b/napari/layers/vectors/_vectors_key_bindings.py @@ -1,3 +1,5 @@ +from typing import Callable + from napari.layers.base._base_constants import Mode from napari.layers.utils.layer_utils import ( register_layer_action, @@ -7,21 +9,25 @@ from napari.utils.translations import trans -def register_vectors_action(description: str, repeatable: bool = False): +def register_vectors_action( + description: str, repeatable: bool = False +) -> Callable[[Callable], Callable]: return register_layer_action(Vectors, description, repeatable) -def register_vectors_mode_action(description): +def register_vectors_mode_action( + description: str, +) -> Callable[[Callable], Callable]: return register_layer_attr_action(Vectors, description, 'mode') @register_vectors_mode_action(trans._('Transform')) -def activate_vectors_transform_mode(layer): - layer.mode = Mode.TRANSFORM +def activate_vectors_transform_mode(layer: Vectors) -> None: + layer.mode = str(Mode.TRANSFORM) @register_vectors_mode_action(trans._('Pan/zoom')) -def activate_vectors_pan_zoom_mode(layer: Vectors): +def activate_vectors_pan_zoom_mode(layer: Vectors) -> None: layer.mode = str(Mode.PAN_ZOOM) diff --git a/napari/layers/vectors/vectors.py b/napari/layers/vectors/vectors.py index f3ebb1c2e69..14d41ded10b 100644 --- a/napari/layers/vectors/vectors.py +++ b/napari/layers/vectors/vectors.py @@ -1,6 +1,6 @@ import warnings from copy import copy -from typing import Any, Dict, Tuple, Union +from typing import Any, Union import numpy as np import pandas as pd @@ -335,7 +335,7 @@ def features(self): @features.setter def features( self, - features: Union[Dict[str, np.ndarray], pd.DataFrame], + features: Union[dict[str, np.ndarray], pd.DataFrame], ) -> None: self._feature_table.set_values(features, num_data=len(self.data)) if self._edge.color_properties is not None: @@ -361,12 +361,12 @@ def features( self.events.features() @property - def properties(self) -> Dict[str, np.ndarray]: + def properties(self) -> dict[str, np.ndarray]: """dict {str: array (N,)}, DataFrame: Annotations for each point""" return self._feature_table.properties() @properties.setter - def properties(self, properties: Dict[str, Array]): + def properties(self, properties: dict[str, Array]): self.features = properties @property @@ -379,13 +379,13 @@ def feature_defaults(self): @feature_defaults.setter def feature_defaults( - self, defaults: Union[Dict[str, Any], pd.DataFrame] + self, defaults: Union[dict[str, Any], pd.DataFrame] ) -> None: self._feature_table.set_defaults(defaults) self.events.feature_defaults() @property - def property_choices(self) -> Dict[str, np.ndarray]: + def property_choices(self) -> dict[str, np.ndarray]: return self._feature_table.choices() def _get_state(self): @@ -608,7 +608,7 @@ def edge_color_cycle(self, edge_color_cycle: Union[list, np.ndarray]): self._edge.categorical_colormap = edge_color_cycle @property - def edge_colormap(self) -> Tuple[str, Colormap]: + def edge_colormap(self) -> Colormap: """Return the colormap to be applied to a property to get the edge color. Returns @@ -623,7 +623,7 @@ def edge_colormap(self, colormap: ValidColormapArg): self._edge.continuous_colormap = colormap @property - def edge_contrast_limits(self) -> Tuple[float, float]: + def edge_contrast_limits(self) -> tuple[float, float]: """None, (float, float): contrast limits for mapping the edge_color colormap property to 0 and 1 """ @@ -631,7 +631,7 @@ def edge_contrast_limits(self) -> Tuple[float, float]: @edge_contrast_limits.setter def edge_contrast_limits( - self, contrast_limits: Union[None, Tuple[float, float]] + self, contrast_limits: Union[None, tuple[float, float]] ): self._edge.contrast_limits = contrast_limits @@ -717,7 +717,7 @@ def _update_slice_response(self, response: _VectorSliceResponse): def _update_thumbnail(self): """Update thumbnail with current vectors and colors.""" # Set the default thumbnail to black, opacity 1 - colormapped = np.zeros(self._thumbnail_shape) + colormapped = np.zeros(self._thumbnail_shape, dtype=np.uint8) colormapped[..., 3] = 1 if len(self.data) == 0: self.thumbnail = colormapped @@ -762,7 +762,9 @@ def _update_thumbnail(self): y_vals = np.linspace(start[1], stop[1], step) for x, y in zip(x_vals, y_vals): colormapped[int(x), int(y), :] = ec - colormapped[..., 3] *= self.opacity + colormapped[..., 3] = (colormapped[..., 3] * self.opacity).astype( + np.uint8 + ) self.thumbnail = colormapped def _get_value(self, position): diff --git a/napari/plugins/__init__.py b/napari/plugins/__init__.py index a85f7478434..31eafdb4716 100644 --- a/napari/plugins/__init__.py +++ b/napari/plugins/__init__.py @@ -8,7 +8,7 @@ from napari.plugins._plugin_manager import NapariPluginManager from napari.settings import get_settings -__all__ = ("plugin_manager", "menu_item_template") +__all__ = ('plugin_manager', 'menu_item_template') from napari.utils.theme import _install_npe2_themes @@ -22,7 +22,7 @@ @lru_cache # only call once -def _initialize_plugins(): +def _initialize_plugins() -> None: _npe2pm = _PluginManager.instance() settings = get_settings() diff --git a/napari/plugins/_npe2.py b/napari/plugins/_npe2.py index 8b932ee5fdf..8490bf210c2 100644 --- a/napari/plugins/_npe2.py +++ b/napari/plugins/_npe2.py @@ -1,18 +1,10 @@ from __future__ import annotations from collections import defaultdict -from functools import partial +from collections.abc import Iterator, Sequence from typing import ( TYPE_CHECKING, - Any, - DefaultDict, - Dict, - Iterator, - List, Optional, - Sequence, - Set, - Tuple, cast, ) @@ -21,7 +13,6 @@ from npe2 import io_utils, plugin_manager as pm from npe2.manifest import contributions -from napari.errors.reader_errors import MultipleReaderError from napari.utils.translations import trans if TYPE_CHECKING: @@ -31,7 +22,6 @@ from npe2.types import LayerData, SampleDataCreator, WidgetCreator from qtpy.QtWidgets import QMenu - from napari._qt.qt_viewer import QtViewer from napari.layers import Layer from napari.types import SampleDict @@ -43,7 +33,7 @@ def __init__(self, name) -> None: def read( paths: Sequence[str], plugin: Optional[str] = None, *, stack: bool -) -> Optional[Tuple[List[LayerData], _FakeHookimpl]]: +) -> Optional[tuple[list[LayerData], _FakeHookimpl]]: """Try to return data for `path`, from reader plugins using a manifest.""" # do nothing if `plugin` is not an npe2 reader @@ -78,10 +68,10 @@ def read( def write_layers( path: str, - layers: List[Layer], + layers: list[Layer], plugin_name: Optional[str] = None, writer: Optional[WriterContribution] = None, -) -> Tuple[List[str], str]: +) -> tuple[list[str], str]: """ Write layers to a file using an NPE2 plugin. @@ -132,7 +122,7 @@ def write_layers( def get_widget_contribution( plugin_name: str, widget_name: Optional[str] = None -) -> Optional[Tuple[WidgetCreator, str]]: +) -> Optional[tuple[WidgetCreator, str]]: widgets_seen = set() for contrib in pm.iter_widgets(): if contrib.plugin_name == plugin_name: @@ -176,7 +166,7 @@ def _wrapped(*args): def file_extensions_string_for_layers( layers: Sequence[Layer], -) -> Tuple[Optional[str], List[WriterContribution]]: +) -> tuple[Optional[str], list[WriterContribution]]: """Create extensions string using npe2. When npe2 can be imported, returns an extension string and the list @@ -201,7 +191,7 @@ def _items(): for writer in writers: name = pm.get_manifest(writer.command).display_name title = ( - f"{name} {writer.display_name}" + f'{name} {writer.display_name}' if writer.display_name else name ) @@ -211,15 +201,15 @@ def _items(): # " (* * *);;+" def _fmt_exts(es): - return " ".join(f"*{e}" for e in es if e) if es else "*.*" + return ' '.join(f'*{e}' for e in es if e) if es else '*.*' return ( - ";;".join(f"{name} ({_fmt_exts(exts)})" for name, exts in _items()), + ';;'.join(f'{name} ({_fmt_exts(exts)})' for name, exts in _items()), writers, ) -def get_readers(path: Optional[str] = None) -> Dict[str, str]: +def get_readers(path: Optional[str] = None) -> dict[str, str]: """Get valid reader plugin_name:display_name mapping given path. Iterate through compatible readers for the given path and return @@ -255,15 +245,15 @@ def iter_manifests( yield from pm.iter_manifests(disabled=disabled) -def widget_iterator() -> Iterator[Tuple[str, Tuple[str, Sequence[str]]]]: +def widget_iterator() -> Iterator[tuple[str, tuple[str, Sequence[str]]]]: # eg ('dock', ('my_plugin', ('My widget', MyWidget))) - wdgs: DefaultDict[str, List[str]] = defaultdict(list) + wdgs: defaultdict[str, list[str]] = defaultdict(list) for wdg_contrib in pm.iter_widgets(): wdgs[wdg_contrib.plugin_name].append(wdg_contrib.display_name) return (('dock', x) for x in wdgs.items()) -def sample_iterator() -> Iterator[Tuple[str, Dict[str, SampleDict]]]: +def sample_iterator() -> Iterator[tuple[str, dict[str, SampleDict]]]: return ( ( # use display_name for user facing display @@ -279,7 +269,7 @@ def sample_iterator() -> Iterator[Tuple[str, Dict[str, SampleDict]]]: def get_sample_data( plugin: str, sample: str -) -> Tuple[Optional[SampleDataCreator], List[Tuple[str, str]]]: +) -> tuple[Optional[SampleDataCreator], list[tuple[str, str]]]: """Get sample data opener from npe2. Parameters @@ -297,7 +287,7 @@ def get_sample_data( - second item is a list of available samples (plugin_name, sample_name) if no data opener is found. """ - avail: List[Tuple[str, str]] = [] + avail: list[tuple[str, str]] = [] for plugin_name, contribs in pm.iter_sample_data(): for contrib in contribs: if plugin_name == plugin and contrib.key == sample: @@ -311,13 +301,12 @@ def index_npe1_adapters(): pm.index_npe1_adapters() -def on_plugin_enablement_change(enabled: Set[str], disabled: Set[str]): +def on_plugin_enablement_change(enabled: set[str], disabled: set[str]): """Callback when any npe2 plugins are enabled or disabled. 'Disabled' means the plugin remains installed, but it cannot be activated, and its contributions will not be indexed """ - from napari import Viewer from napari.settings import get_settings plugin_settings = get_settings().plugins @@ -332,16 +321,10 @@ def on_plugin_enablement_change(enabled: Set[str], disabled: Set[str]): # actually a registered plugin. if plugin_name in pm.instance(): _register_manifest_actions(pm.get_manifest(plugin_name)) + _safe_register_qt_actions(pm.get_manifest(plugin_name)) - # TODO: after app-model, these QMenus will be evented and self-updating - # and we can remove this... but `_register_manifest_actions` will need to - # add the actions file and plugins menus (since we don't require plugins to - # list them explicitly) - for v in Viewer._instances: - v.window.plugins_menu._build() - -def on_plugins_registered(manifests: Set[PluginManifest]): +def on_plugins_registered(manifests: set[PluginManifest]): """Callback when any npe2 plugins are registered. 'Registered' means that a manifest has been provided or discovered. @@ -349,147 +332,7 @@ def on_plugins_registered(manifests: Set[PluginManifest]): for mf in manifests: if not pm.is_disabled(mf.name): _register_manifest_actions(mf) - - -# TODO: This is a separate function from `_get_samples_submenu_actions` so it -# can be easily deleted once npe1 is no longer supported. -def _rebuild_npe1_samples_menu() -> None: - """Register submenu and actions for all npe1 plugins, clearing all first.""" - from napari._app_model import get_app - from napari._app_model.constants import MenuGroup, MenuId - from napari.plugins import menu_item_template, plugin_manager - - app = get_app() - # Unregister all existing npe1 sample menu actions and submenus - if unreg := plugin_manager._unreg_sample_submenus: - unreg() - if unreg := plugin_manager._unreg_sample_actions: - unreg() - - sample_actions: List[Action] = [] - for plugin_name, samples in plugin_manager._sample_data.items(): - multiprovider = len(samples) > 1 - if multiprovider: - submenu_id = f'napari/file/samples/{plugin_name}' - submenu = [ - ( - MenuId.FILE_SAMPLES, - SubmenuItem( - submenu=submenu_id, title=trans._(plugin_name) - ), - ), - ] - else: - submenu_id = MenuId.FILE_SAMPLES - submenu = [] - - for sample_name, sample_dict in samples.items(): - - def _add_sample( - qt_viewer: QtViewer, - plugin=plugin_name, - sample=sample_name, - ): - from napari._qt.dialogs.qt_reader_dialog import ( - handle_gui_reading, - ) - - try: - qt_viewer.viewer.open_sample(plugin, sample) - except MultipleReaderError as e: - handle_gui_reading( - [str(p) for p in e.paths], - qt_viewer, - stack=False, - ) - - display_name = sample_dict['display_name'].replace("&", "&&") - if multiprovider: - title = display_name - else: - title = menu_item_template.format(plugin_name, display_name) - - action: Action = Action( - id=f"{plugin_name}:{display_name}", - title=title, - menus=[{'id': submenu_id, 'group': MenuGroup.NAVIGATION}], - callback=_add_sample, - ) - sample_actions.append(action) - - unreg_sample_submenus = app.menus.append_menu_items(submenu) - plugin_manager._unreg_sample_submenus = unreg_sample_submenus - unreg_sample_actions = app.register_actions(sample_actions) - plugin_manager._unreg_sample_actions = unreg_sample_actions - - -# Note `QtViewer` gets added to `injection_store.namespace` during -# `init_qactions` so does not need to be imported for type annotation resolution -def _add_sample(qt_viewer: QtViewer, plugin=str, sample=str) -> None: - from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading - - try: - qt_viewer.viewer.open_sample(plugin, sample) - except MultipleReaderError as e: - handle_gui_reading( - [str(p) for p in e.paths], - qt_viewer, - stack=False, - ) - - -def _get_samples_submenu_actions( - mf: PluginManifest, -) -> Tuple[List[Any], List[Any]]: - """Get sample data submenu and actions for a single npe2 plugin manifest.""" - from napari._app_model.constants import MenuGroup, MenuId - from napari.plugins import menu_item_template - - # If no sample data, return - if not mf.contributions.sample_data: - return [], [] - - sample_data = mf.contributions.sample_data - multiprovider = len(sample_data) > 1 - if multiprovider: - submenu_id = f'napari/file/samples/{mf.name}' - submenu = [ - ( - MenuId.FILE_SAMPLES, - SubmenuItem( - submenu=submenu_id, title=trans._(mf.display_name) - ), - ), - ] - else: - submenu_id = MenuId.FILE_SAMPLES - submenu = [] - - sample_actions: List[Action] = [] - for sample in sample_data: - _add_sample_partial = partial( - _add_sample, - plugin=mf.name, - sample=sample.key, - ) - - if multiprovider: - title = sample.display_name - else: - title = menu_item_template.format( - mf.display_name, sample.display_name - ) - # To display '&' instead of creating a shortcut - title = title.replace("&", "&&") - - action: Action = Action( - id=f'{mf.name}:{sample.key}', - title=title, - menus=[{'id': submenu_id, 'group': MenuGroup.NAVIGATION}], - callback=_add_sample_partial, - ) - sample_actions.append(action) - return submenu, sample_actions + _safe_register_qt_actions(mf) def _register_manifest_actions(mf: PluginManifest) -> None: @@ -502,52 +345,62 @@ def _register_manifest_actions(mf: PluginManifest) -> None: app = get_app() actions, submenus = _npe2_manifest_to_actions(mf) - samples_submenu, sample_actions = _get_samples_submenu_actions(mf) + context = pm.get_context(cast('PluginName', mf.name)) - # Connect 'unregister' callback to plugin deactivate ('unregistered') event + + # Register and connect dispose callback to plugin deactivate ('unregistered') event if actions: context.register_disposable(app.register_actions(actions)) if submenus: context.register_disposable(app.menus.append_menu_items(submenus)) - if samples_submenu: - context.register_disposable( - app.menus.append_menu_items(samples_submenu) - ) - if sample_actions: - context.register_disposable(app.register_actions(sample_actions)) + + +def _safe_register_qt_actions(mf: PluginManifest) -> None: + """Register samples and widget `Actions` if Qt available.""" + try: + from napari._qt._qplugins import _register_qt_actions + except ImportError: # pragma: no cover + # if no Qt bindings are installed (PyQt/PySide), then trying to import + # qtpy will raise an ImportError, *not* a ModuleNotFoundError + pass + else: + _register_qt_actions(mf) def _npe2_manifest_to_actions( mf: PluginManifest, -) -> Tuple[List[Action], List[Tuple[str, SubmenuItem]]]: +) -> tuple[list[Action], list[tuple[str, SubmenuItem]]]: """Gather actions and submenus from a npe2 manifest, export app_model types.""" from app_model.types import Action, MenuRule from napari._app_model.constants._menus import is_menu_contributable - cmds: DefaultDict[str, List[MenuRule]] = DefaultDict(list) - submenus: List[Tuple[str, SubmenuItem]] = [] + menu_cmds: defaultdict[str, list[MenuRule]] = defaultdict(list) + submenus: list[tuple[str, SubmenuItem]] = [] for menu_id, items in mf.contributions.menus.items(): if is_menu_contributable(menu_id): for item in items: if isinstance(item, contributions.MenuCommand): rule = MenuRule(id=menu_id, **_when_group_order(item)) - cmds[item.command].append(rule) + menu_cmds[item.command].append(rule) else: subitem = _npe2_submenu_to_app_model(item) submenus.append((menu_id, subitem)) # Filter sample data commands (not URIs) as they are registered via - # `_get_samples_submenu_actions` - sample_data_commands = { + # `_safe_register_qt_actions` + sample_data_ids = { contrib.command for contrib in mf.contributions.sample_data or () if hasattr(contrib, 'command') } + # Filter widgets as are registered via `_safe_register_qt_actions` + widget_ids = {widget.command for widget in mf.contributions.widgets or ()} - actions: List[Action] = [] + # We want to register all `Actions` so they appear in the command pallete + actions: list[Action] = [] for cmd in mf.contributions.commands or (): - if cmd.id not in sample_data_commands: + if cmd.id not in sample_data_ids | widget_ids: actions.append( Action( id=cmd.id, @@ -557,7 +410,7 @@ def _npe2_manifest_to_actions( icon=cmd.icon, enablement=cmd.enablement, callback=cmd.python_name or '', - menus=cmds.get(cmd.id), + menus=menu_cmds.get(cmd.id), keybindings=[], ) ) @@ -569,7 +422,7 @@ def _when_group_order( menu_item: contributions.MenuItem, ) -> dict: """Extract when/group/order from an npe2 Submenu or MenuCommand.""" - group, _, _order = (menu_item.group or '').partition("@") + group, _, _order = (menu_item.group or '').partition('@') try: order: Optional[float] = float(_order) except ValueError: diff --git a/napari/plugins/_plugin_manager.py b/napari/plugins/_plugin_manager.py index f5b8946d039..b1ecc78a9ed 100644 --- a/napari/plugins/_plugin_manager.py +++ b/napari/plugins/_plugin_manager.py @@ -1,18 +1,13 @@ import contextlib import warnings +from collections.abc import Iterable, Iterator from functools import partial from pathlib import Path from types import FunctionType from typing import ( Any, Callable, - Dict, - Iterable, - Iterator, - List, Optional, - Set, - Tuple, Union, ) from warnings import warn @@ -41,7 +36,7 @@ class PluginHookOption(TypedDict): enabled: bool -CallOrderDict = Dict[str, List[PluginHookOption]] +CallOrderDict = dict[str, list[PluginHookOption]] class NapariPluginManager(PluginManager): @@ -79,28 +74,33 @@ def __init__(self) -> None: # set of package names to skip when discovering, used for skipping # npe2 stuff - self._skip_packages: Set[str] = set() + self._skip_packages: set[str] = set() with self.discovery_blocked(): self.add_hookspecs(hook_specifications) # dicts to store maps from extension -> plugin_name - self._extension2reader: Dict[str, str] = {} - self._extension2writer: Dict[str, str] = {} + self._extension2reader: dict[str, str] = {} + self._extension2writer: dict[str, str] = {} - self._sample_data: Dict[str, Dict[str, SampleDict]] = {} - self._dock_widgets: Dict[ - str, Dict[str, Tuple[WidgetCallable, Dict[str, Any]]] + self._sample_data: dict[str, dict[str, SampleDict]] = {} + self._dock_widgets: dict[ + str, dict[str, tuple[WidgetCallable, dict[str, Any]]] ] = {} - self._function_widgets: Dict[str, Dict[str, Callable[..., Any]]] = {} - self._theme_data: Dict[str, Dict[str, Theme]] = {} + self._function_widgets: dict[str, dict[str, Callable[..., Any]]] = {} + self._theme_data: dict[str, dict[str, Theme]] = {} + # TODO: remove once npe1 deprecated # appmodel sample menu actions/submenu unregister functions used in - # `napari.plugins._npe2._build_npe1_samples_menu` + # `_rebuild_npe1_samples_menu` self._unreg_sample_submenus = None self._unreg_sample_actions = None + # appmodel plugins menu actions/submenu unregister functions used in + # `_rebuild_npe1_plugins_menu` + self._unreg_plugin_submenus = None + self._unreg_plugin_actions = None - def _initialize(self): + def _initialize(self) -> None: with self.discovery_blocked(): from napari.settings import get_settings @@ -122,7 +122,7 @@ def iter_available( path: Optional[str] = None, entry_point: Optional[str] = None, prefix: Optional[str] = None, - ) -> Iterator[Tuple[str, str, Optional[str]]]: + ) -> Iterator[tuple[str, str, Optional[str]]]: # overriding to skip npe2 plugins for item in super().iter_available(path, entry_point, prefix): if item[-1] not in self._skip_packages: @@ -156,7 +156,7 @@ def unregister( return plugin - def _on_blocked_change(self, event): + def _on_blocked_change(self, event) -> None: # things that are "added to the blocked list" become disabled for item in event.added: self.events.disabled(value=item) @@ -251,7 +251,7 @@ def set_call_order(self, new_order: CallOrderDict): def register_sample_data( self, - data: Dict[str, Union[str, Callable[..., Iterable[LayerData]]]], + data: dict[str, Union[str, Callable[..., Iterable[LayerData]]]], hookimpl: HookImplementation, ): """Register sample data dict returned by `napari_provide_sample_data`. @@ -281,7 +281,7 @@ def register_sample_data( warn(message=warn_message) return - _data: Dict[str, SampleDict] = {} + _data: dict[str, SampleDict] = {} for name, _datum in list(data.items()): if isinstance(_datum, dict): datum: SampleDict = _datum @@ -308,7 +308,7 @@ def register_sample_data( plugin_name=plugin_name, name=name, hook_name=hook_name, - dtype=type(datum["data"]), + dtype=type(datum['data']), ) warn(message=warn_message) continue @@ -319,7 +319,7 @@ def register_sample_data( self._sample_data[plugin_name].update(_data) - def available_samples(self) -> Tuple[Tuple[str, str], ...]: + def available_samples(self) -> tuple[tuple[str, str], ...]: """Return a tuple of sample data keys provided by plugins. Returns @@ -349,7 +349,7 @@ def available_samples(self) -> Tuple[Tuple[str, str], ...]: def register_theme_colors( self, - data: Dict[str, Dict[str, Union[str, Tuple, List]]], + data: dict[str, dict[str, Union[str, tuple, list]]], hookimpl: HookImplementation, ): """Register theme data dict returned by `napari_experimental_provide_theme`. @@ -359,7 +359,7 @@ def register_theme_colors( """ plugin_name = hookimpl.plugin_name hook_name = '`napari_experimental_provide_theme`' - if not isinstance(data, Dict): + if not isinstance(data, dict): warn_message = trans._( 'Plugin {plugin_name!r} provided a non-dict object to {hook_name!r}: data ignored', deferred=True, @@ -377,7 +377,7 @@ def register_theme_colors( _data[theme_id] = theme except (KeyError, ValidationError) as err: warn_msg = trans._( - "In {hook_name!r}, plugin {plugin_name!r} provided an invalid dict object for creating themes. {err!r}", + 'In {hook_name!r}, plugin {plugin_name!r} provided an invalid dict object for creating themes. {err!r}', deferred=True, hook_name=hook_name, plugin_name=plugin_name, @@ -405,17 +405,17 @@ def unregister_theme_colors(self, plugin_name: str): settings = get_settings() current_theme = settings.appearance.theme if current_theme in self._theme_data[plugin_name]: - settings.appearance.theme = "dark" # type: ignore + settings.appearance.theme = 'dark' # type: ignore warnings.warn( message=trans._( - "The current theme {current_theme!r} was provided by the plugin {plugin_name!r} which was disabled or removed. Switched theme to the default.", + 'The current theme {current_theme!r} was provided by the plugin {plugin_name!r} which was disabled or removed. Switched theme to the default.', deferred=True, plugin_name=plugin_name, current_theme=current_theme, ) ) - def discover_themes(self): + def discover_themes(self) -> None: """Trigger discovery of theme plugins. As a "historic" hook, this should only need to be called once. @@ -430,7 +430,7 @@ def discover_themes(self): # FUNCTION & DOCK WIDGETS ----------------------- - def iter_widgets(self) -> Iterator[Tuple[str, Tuple[str, Dict[str, Any]]]]: + def iter_widgets(self) -> Iterator[tuple[str, tuple[str, dict[str, Any]]]]: from itertools import chain, repeat # The content of contribution dictionaries is name of plugin and @@ -439,14 +439,14 @@ def iter_widgets(self) -> Iterator[Tuple[str, Tuple[str, Dict[str, Any]]]]: # we sort it to make it easier searchable. dock_widgets = zip( - repeat("dock"), + repeat('dock'), ( (name, sorted(cont)) for name, cont in self._dock_widgets.items() ), ) func_widgets = zip( - repeat("func"), + repeat('func'), ( (name, sorted(cont)) for name, cont in self._function_widgets.items() @@ -456,7 +456,7 @@ def iter_widgets(self) -> Iterator[Tuple[str, Tuple[str, Dict[str, Any]]]]: def register_dock_widget( self, - args: Union[AugmentedWidget, List[AugmentedWidget]], + args: Union[AugmentedWidget, list[AugmentedWidget]], hookimpl: HookImplementation, ): plugin_name = hookimpl.plugin_name @@ -521,7 +521,7 @@ def register_dock_widget( def register_function_widget( self, - args: Union[Callable, List[Callable]], + args: Union[Callable, list[Callable]], hookimpl: HookImplementation, ): plugin_name = hookimpl.plugin_name @@ -538,7 +538,7 @@ def register_function_widget( if isinstance(func, tuple): warn_message += trans._( - " To provide multiple function widgets please use a LIST of callables", + ' To provide multiple function widgets please use a LIST of callables', deferred=True, ) warn(message=warn_message) @@ -587,7 +587,7 @@ def discover_widgets(self): def get_widget( self, plugin_name: str, widget_name: Optional[str] = None - ) -> Tuple[WidgetCallable, Dict[str, Any]]: + ) -> tuple[WidgetCallable, dict[str, Any]]: """Get widget `widget_name` provided by plugin `plugin_name`. Note: it's important that :func:`discover_dock_widgets` has been called @@ -704,14 +704,14 @@ def _get_plugin_for_extension( if ext_map is None: raise ValueError( trans._( - "invalid plugin type: {type_!r}", + 'invalid plugin type: {type_!r}', deferred=True, type_=type_, ) ) - if not extension.startswith("."): - extension = f".{extension}" + if not extension.startswith('.'): + extension = f'.{extension}' plugin = ext_map.get(extension) # make sure it's still an active plugin @@ -731,7 +731,7 @@ def _assign_plugin_to_extensions( if caller is None: raise ValueError( trans._( - "invalid plugin type: {type_!r}", + 'invalid plugin type: {type_!r}', deferred=True, type_=type_, ) @@ -740,7 +740,7 @@ def _assign_plugin_to_extensions( plugins = caller.get_hookimpls() if plugin not in {p.plugin_name for p in plugins}: msg = trans._( - "{plugin!r} is not a valid {type_} plugin name", + '{plugin!r} is not a valid {type_} plugin name', plugin=plugin, type_=type_, deferred=True, @@ -751,8 +751,8 @@ def _assign_plugin_to_extensions( if isinstance(extensions, str): extensions = [extensions] for ext in extensions: - if not ext.startswith("."): - ext = f".{ext}" + if not ext.startswith('.'): + ext = f'.{ext}' ext_map[ext] = plugin func = None diff --git a/napari/plugins/_tests/test_exceptions.py b/napari/plugins/_tests/test_exceptions.py index 0627fb38d39..70a53f954c4 100644 --- a/napari/plugins/_tests/test_exceptions.py +++ b/napari/plugins/_tests/test_exceptions.py @@ -27,15 +27,15 @@ def test_format_exceptions(cgitb, as_html, monkeypatch): raise PluginError( 'some error', plugin_name='test_plugin', - plugin="mock", + plugin='mock', cause=e, ) from e except PluginError: pass formatted = exceptions.format_exceptions('test_plugin', as_html=as_html) - assert "some error" in formatted - assert "version: 0.1.0" in formatted - assert "plugin package: test-package" in formatted + assert 'some error' in formatted + assert 'version: 0.1.0' in formatted + assert 'plugin package: test-package' in formatted assert exceptions.format_exceptions('nonexistent', as_html=as_html) == '' diff --git a/napari/plugins/_tests/test_hook_specifications.py b/napari/plugins/_tests/test_hook_specifications.py index b219d6c9d3d..a175cc71eb3 100644 --- a/napari/plugins/_tests/test_hook_specifications.py +++ b/napari/plugins/_tests/test_hook_specifications.py @@ -27,7 +27,7 @@ ] -@pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) +@pytest.mark.parametrize('name, func', HOOK_SPECIFICATIONS) def test_hook_specification_naming(name, func): """All hook specifications should begin with napari_.""" assert name.startswith('napari_'), ( @@ -35,13 +35,13 @@ def test_hook_specification_naming(name, func): ) -@pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) +@pytest.mark.parametrize('name, func', HOOK_SPECIFICATIONS) def test_docstring_on_hook_specification(name, func): """All hook specifications should have documentation.""" assert func.__doc__, "no docstring for '%s'" % name -@pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) +@pytest.mark.parametrize('name, func', HOOK_SPECIFICATIONS) def test_annotation_on_hook_specification(name, func): """All hook specifications should have type annotations for all parameters. @@ -63,12 +63,12 @@ def test_annotation_on_hook_specification(name, func): ) else: assert sig.return_annotation is not sig.empty, ( - f"hook specifications with no parameters ({name})," - " must declare a return type annotation" + f'hook specifications with no parameters ({name}),' + ' must declare a return type annotation' ) -@pytest.mark.parametrize("name, func", HOOK_SPECIFICATIONS) +@pytest.mark.parametrize('name, func', HOOK_SPECIFICATIONS) def test_docs_match_signature(name, func): sig = inspect.signature(func) docs = FunctionDoc(func) diff --git a/napari/plugins/_tests/test_npe2.py b/napari/plugins/_tests/test_npe2.py index 614ce05a0bf..a74e3e21449 100644 --- a/napari/plugins/_tests/test_npe2.py +++ b/napari/plugins/_tests/test_npe2.py @@ -31,28 +31,28 @@ def mock_pm(npe2pm: 'TestPluginManager'): def test_read(mock_pm: 'TestPluginManager'): - _, hookimpl = _npe2.read(["some.fzzy"], stack=False) + _, hookimpl = _npe2.read(['some.fzzy'], stack=False) mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.some_reader') assert hookimpl.plugin_name == PLUGIN_NAME mock_pm.commands.get.reset_mock() - _, hookimpl = _npe2.read(["some.fzzy"], stack=True) + _, hookimpl = _npe2.read(['some.fzzy'], stack=True) mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.some_reader') mock_pm.commands.get.reset_mock() with pytest.raises(ValueError): - _npe2.read(["some.randomext"], stack=True) + _npe2.read(['some.randomext'], stack=False) mock_pm.commands.get.assert_not_called() mock_pm.commands.get.reset_mock() assert ( - _npe2.read(["some.randomext"], stack=True, plugin='not-npe2-plugin') + _npe2.read(['some.randomext'], stack=True, plugin='not-npe2-plugin') is None ) mock_pm.commands.get.assert_not_called() mock_pm.commands.get.reset_mock() _, hookimpl = _npe2.read( - ["some.fzzy"], stack=False, plugin='my-plugin.some_reader' + ['some.fzzy'], stack=False, plugin='my-plugin.some_reader' ) mock_pm.commands.get.assert_called_once_with(f'{PLUGIN_NAME}.some_reader') assert hookimpl.plugin_name == PLUGIN_NAME @@ -64,7 +64,7 @@ def test_read(mock_pm: 'TestPluginManager'): ) def test_read_with_plugin_failure(mock_pm: 'TestPluginManager'): with pytest.raises(ValueError): - _npe2.read(["some.randomext"], stack=True, plugin=PLUGIN_NAME) + _npe2.read(['some.randomext'], stack=True, plugin=PLUGIN_NAME) def test_write(mock_pm: 'TestPluginManager'): @@ -117,9 +117,9 @@ def test_get_widget_contribution(mock_pm: 'TestPluginManager'): def test_populate_qmenu(mock_pm: 'TestPluginManager'): menu = MagicMock() - _npe2.populate_qmenu(menu, '/napari/layer_context') - assert menu.addMenu.called_once_with('My SubMenu') - assert menu.addAction.called_once_with('Hello World') + _npe2.populate_qmenu(menu, 'napari/layers/context') + menu.addMenu.assert_called_once_with('My SubMenu') + menu.addAction.assert_called_once_with('Hello World') def test_file_extensions_string_for_layers(mock_pm: 'TestPluginManager'): @@ -131,7 +131,7 @@ def test_file_extensions_string_for_layers(mock_pm: 'TestPluginManager'): def test_get_readers(mock_pm): - assert _npe2.get_readers("some.fzzy") == {PLUGIN_NAME: 'My Plugin'} + assert _npe2.get_readers('some.fzzy') == {PLUGIN_NAME: 'My Plugin'} def test_iter_manifest(mock_pm): diff --git a/napari/plugins/_tests/test_plugin_widgets.py b/napari/plugins/_tests/test_plugin_widgets.py index 63dd0c3be1b..8da70cdd696 100644 --- a/napari/plugins/_tests/test_plugin_widgets.py +++ b/napari/plugins/_tests/test_plugin_widgets.py @@ -21,8 +21,8 @@ def func2(x, y): 'bad_tuple2': (func, {}, 1), 'bad_tuple3': (func, 1, {}), 'bad_double_tuple': ((func, {}), (func2, {})), - 'bad_magic_kwargs': (func, {"non_magicgui_kwarg": True}), - 'bad_good_magic_kwargs': (func, {'call_button': True, "x": {'max': 200}}), + 'bad_magic_kwargs': (func, {'non_magicgui_kwarg': True}), + 'bad_good_magic_kwargs': (func, {'call_button': True, 'x': {'max': 200}}), } diff --git a/napari/plugins/_tests/test_provide_theme.py b/napari/plugins/_tests/test_provide_theme.py index 624b626e465..bc66e693bb9 100644 --- a/napari/plugins/_tests/test_provide_theme.py +++ b/napari/plugins/_tests/test_provide_theme.py @@ -13,41 +13,41 @@ from napari.plugins._plugin_manager import NapariPluginManager -def test_provide_theme_hook(napari_plugin_manager: "NapariPluginManager"): - dark = get_theme("dark").to_rgb_dict() - dark["name"] = "dark-test" +def test_provide_theme_hook(napari_plugin_manager: 'NapariPluginManager'): + dark = get_theme('dark').to_rgb_dict() + dark['name'] = 'dark-test' class TestPlugin: @napari_hook_implementation def napari_experimental_provide_theme(): - return {"dark-test": dark} + return {'dark-test': dark} viewer = ViewerModel() napari_plugin_manager.discover_themes() napari_plugin_manager.register(TestPlugin) # make sure theme data is present in the plugin - reg = napari_plugin_manager._theme_data["TestPlugin"] + reg = napari_plugin_manager._theme_data['TestPlugin'] assert isinstance(reg, dict) assert len(reg) == 1 - assert isinstance(reg["dark-test"], Theme) + assert isinstance(reg['dark-test'], Theme) # make sure theme was registered - assert "dark-test" in available_themes() - viewer.theme = "dark-test" + assert 'dark-test' in available_themes() + viewer.theme = 'dark-test' -def test_provide_theme_hook_bad(napari_plugin_manager: "NapariPluginManager"): +def test_provide_theme_hook_bad(napari_plugin_manager: 'NapariPluginManager'): napari_plugin_manager.discover_themes() - dark = get_theme("dark").to_rgb_dict() - dark.pop("foreground") - dark["name"] = "dark-bad" + dark = get_theme('dark').to_rgb_dict() + dark.pop('foreground') + dark['name'] = 'dark-bad' class TestPluginBad: @napari_hook_implementation def napari_experimental_provide_theme(): - return {"dark-bad": dark} + return {'dark-bad': dark} with pytest.warns( UserWarning, @@ -56,21 +56,21 @@ def napari_experimental_provide_theme(): napari_plugin_manager.register(TestPluginBad) # make sure theme data is present in the plugin but the theme is not there - reg = napari_plugin_manager._theme_data["TestPluginBad"] + reg = napari_plugin_manager._theme_data['TestPluginBad'] assert isinstance(reg, dict) assert len(reg) == 0 - assert "dark-bad" not in available_themes() + assert 'dark-bad' not in available_themes() def test_provide_theme_hook_not_dict( - napari_plugin_manager: "NapariPluginManager", + napari_plugin_manager: 'NapariPluginManager', ): napari_plugin_manager.discover_themes() class TestPluginBad: @napari_hook_implementation def napari_experimental_provide_theme(): - return ["bad-theme", []] + return ['bad-theme', []] with pytest.warns( UserWarning, @@ -79,37 +79,37 @@ def napari_experimental_provide_theme(): napari_plugin_manager.register(TestPluginBad) # make sure theme data is present in the plugin but the theme is not there - assert "TestPluginBad" not in napari_plugin_manager._theme_data + assert 'TestPluginBad' not in napari_plugin_manager._theme_data def test_provide_theme_hook_unregister( - napari_plugin_manager: "NapariPluginManager", + napari_plugin_manager: 'NapariPluginManager', ): - dark = get_theme("dark").to_rgb_dict() - dark["name"] = "dark-test" + dark = get_theme('dark').to_rgb_dict() + dark['name'] = 'dark-test' class TestPlugin: @napari_hook_implementation def napari_experimental_provide_theme(): - return {"dark-test": dark} + return {'dark-test': dark} napari_plugin_manager.discover_themes() napari_plugin_manager.register(TestPlugin) # make sure theme was registered - assert "TestPlugin" in napari_plugin_manager._theme_data - reg = napari_plugin_manager._theme_data["TestPlugin"] + assert 'TestPlugin' in napari_plugin_manager._theme_data + reg = napari_plugin_manager._theme_data['TestPlugin'] assert isinstance(reg, dict) assert len(reg) == 1 - assert "dark-test" in available_themes() - get_settings().appearance.theme = "dark-test" + assert 'dark-test' in available_themes() + get_settings().appearance.theme = 'dark-test' - with pytest.warns(UserWarning, match="The current theme "): - napari_plugin_manager.unregister("TestPlugin") + with pytest.warns(UserWarning, match='The current theme '): + napari_plugin_manager.unregister('TestPlugin') # make sure that plugin-specific data was removed - assert "TestPlugin" not in napari_plugin_manager._theme_data + assert 'TestPlugin' not in napari_plugin_manager._theme_data # since the plugin was unregistered, the current theme cannot # be the theme registered by the plugin - assert get_settings().appearance.theme != "dark-test" - assert "dark-test" not in available_themes() + assert get_settings().appearance.theme != 'dark-test' + assert 'dark-test' not in available_themes() diff --git a/napari/plugins/_tests/test_sample_data.py b/napari/plugins/_tests/test_sample_data.py index 45acc6b4d2a..1bd2ad2b127 100644 --- a/napari/plugins/_tests/test_sample_data.py +++ b/napari/plugins/_tests/test_sample_data.py @@ -16,7 +16,7 @@ def test_sample_hook(builtins, tmp_plugin: DynamicPlugin): viewer = ViewerModel() NAME = tmp_plugin.name KEY = 'random data' - with pytest.raises(KeyError, match=f"Plugin {NAME!r} does not provide"): + with pytest.raises(KeyError, match=f'Plugin {NAME!r} does not provide'): viewer.open_sample(NAME, KEY) @tmp_plugin.contribute.sample_data(key=KEY) @@ -79,6 +79,6 @@ def test_sample_uses_reader_plugin(builtins, tmp_plugin, tmp_path): with pytest.raises(ValueError) as e: viewer.open_sample(NAME, 'fake sample', reader_plugin='napari') assert ( - f"Chosen reader napari failed to open sample. Plugin {NAME} declares gibberish" + f'Chosen reader napari failed to open sample. Plugin {NAME} declares gibberish' in str(e) ) diff --git a/napari/plugins/exceptions.py b/napari/plugins/exceptions.py index bcf61496115..efd03549074 100644 --- a/napari/plugins/exceptions.py +++ b/napari/plugins/exceptions.py @@ -4,7 +4,7 @@ def format_exceptions( - plugin_name: str, as_html: bool = False, color="Neutral" + plugin_name: str, as_html: bool = False, color='Neutral' ): """Return formatted tracebacks for all exceptions raised by plugin. @@ -64,4 +64,4 @@ def format_exceptions( msg.append('=' * _linewidth) - return ("
" if as_html else "\n").join(msg) + return ('
' if as_html else '\n').join(msg) diff --git a/napari/plugins/hook_specifications.py b/napari/plugins/hook_specifications.py index acee9087b7f..2cc332f270a 100644 --- a/napari/plugins/hook_specifications.py +++ b/napari/plugins/hook_specifications.py @@ -37,7 +37,7 @@ from __future__ import annotations from types import FunctionType -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Optional, Union from napari_plugin_engine import napari_hook_specification @@ -55,7 +55,7 @@ @napari_hook_specification(historic=True) -def napari_provide_sample_data() -> Dict[str, Union[SampleData, SampleDict]]: +def napari_provide_sample_data() -> dict[str, Union[SampleData, SampleDict]]: """Provide sample data. Plugins may implement this hook to provide sample data for use in napari. @@ -110,7 +110,7 @@ def napari_provide_sample_data(): @napari_hook_specification(firstresult=True) -def napari_get_reader(path: Union[str, List[str]]) -> Optional[ReaderFunction]: +def napari_get_reader(path: Union[str, list[str]]) -> Optional[ReaderFunction]: """Return a function capable of loading ``path`` into napari, or ``None``. This is the primary "**reader plugin**" function. It accepts a path or @@ -167,7 +167,7 @@ def napari_get_reader(path: Union[str, List[str]]) -> Optional[ReaderFunction]: @napari_hook_specification(firstresult=True) def napari_get_writer( - path: str, layer_types: List[str] + path: str, layer_types: list[str] ) -> Optional[WriterFunction]: """Return function capable of writing napari layer data to ``path``. @@ -391,7 +391,7 @@ def napari_write_vectors(path: str, data: Any, meta: dict) -> Optional[str]: @napari_hook_specification(historic=True) def napari_experimental_provide_function() -> ( - Union[FunctionType, List[FunctionType]] + Union[FunctionType, list[FunctionType]] ): """Provide function(s) that can be passed to magicgui. @@ -427,7 +427,7 @@ def napari_experimental_provide_function() -> ( @napari_hook_specification(historic=True) def napari_experimental_provide_dock_widget() -> ( - Union[AugmentedWidget, List[AugmentedWidget]] + Union[AugmentedWidget, list[AugmentedWidget]] ): """Provide functions that return widgets to be docked in the viewer. @@ -501,7 +501,7 @@ def napari_experimental_provide_dock_widget() -> ( @napari_hook_specification(historic=True) def napari_experimental_provide_theme() -> ( - Dict[str, Dict[str, Union[str, Tuple, List]]] + dict[str, dict[str, Union[str, tuple, list]]] ): """Provide GUI with a set of colors used through napari. This hook allows you to provide additional color schemes so you can accomplish your desired styling. diff --git a/napari/plugins/io.py b/napari/plugins/io.py index 30fa61712a9..1ce53891acb 100644 --- a/napari/plugins/io.py +++ b/napari/plugins/io.py @@ -3,8 +3,9 @@ import os import pathlib import warnings +from collections.abc import Sequence from logging import getLogger -from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple +from typing import TYPE_CHECKING, Any, Optional from napari_plugin_engine import HookImplementation, PluginCallError @@ -23,7 +24,7 @@ def read_data_with_plugins( paths: Sequence[PathLike], plugin: Optional[str] = None, stack: bool = False, -) -> Tuple[Optional[List[LayerData]], Optional[HookImplementation]]: +) -> tuple[Optional[list[LayerData]], Optional[HookImplementation]]: """Iterate reader hooks and return first non-None LayerData or None. This function returns as soon as the path has been read successfully, @@ -105,13 +106,13 @@ def read_data_with_plugins( ) err_helper = ( trans._( - "No readers are available. " - "Do you have any plugins installed?", + 'No readers are available. ' + 'Do you have any plugins installed?', deferred=True, ) if len(names) <= 1 else trans._( - f"\nNames of plugins offering readers are: {names}.", + f'\nNames of plugins offering readers are: {names}.', deferred=True, ) ) @@ -180,11 +181,11 @@ def read_data_with_plugins( def save_layers( path: str, - layers: List[Layer], + layers: list[Layer], *, plugin: Optional[str] = None, _writer: Optional[WriterContribution] = None, -) -> List[str]: +) -> list[str]: """Write list of layers or individual layer to a path using writer plugins. If ``plugin`` is not provided and only one layer is passed, then we @@ -245,7 +246,7 @@ def save_layers( ) written = [_written] if _written else [] else: - warnings.warn(trans._("No layers to write.")) + warnings.warn(trans._('No layers to write.')) return [] # If written is empty, something went wrong. @@ -254,7 +255,7 @@ def save_layers( if writer_name: warnings.warn( trans._( - "Plugin \'{name}\' was selected but did not return any written paths.", + "Plugin '{name}' was selected but did not return any written paths.", deferred=True, name=writer_name, ) @@ -297,11 +298,11 @@ def _is_null_layer_sentinel(layer_data: Any) -> bool: def _write_multiple_layers_with_plugins( path: str, - layers: List[Layer], + layers: list[Layer], *, plugin_name: Optional[str] = None, _writer: Optional[WriterContribution] = None, -) -> Tuple[List[str], str]: +) -> tuple[list[str], str]: """Write data from multiple layers data with a plugin. If a ``plugin_name`` is not provided we loop through plugins to find the @@ -347,7 +348,7 @@ def _write_multiple_layers_with_plugins( ) if written_paths or writer_name: return (written_paths, writer_name) - logger.debug("Falling back to original plugin engine.") + logger.debug('Falling back to original plugin engine.') layer_data = [layer.as_layer_data_tuple() for layer in layers] layer_types = [ld[2] for ld in layer_data] @@ -358,7 +359,7 @@ def _write_multiple_layers_with_plugins( hook_caller = plugin_manager.hook.napari_get_writer path = abspath_or_url(path) - logger.debug("Writing to %s. Hook caller: %s", path, hook_caller) + logger.debug('Writing to %s. Hook caller: %s', path, hook_caller) if plugin_name: # if plugin has been specified we just directly call napari_get_writer # with that plugin_name. @@ -415,7 +416,7 @@ def _write_single_layer_with_plugins( *, plugin_name: Optional[str] = None, _writer: Optional[WriterContribution] = None, -) -> Tuple[Optional[str], str]: +) -> tuple[Optional[str], str]: """Write single layer data with a plugin. If ``plugin_name`` is not provided then we just directly call @@ -458,7 +459,7 @@ def _write_single_layer_with_plugins( ) if writer_name: return (written_paths[0], writer_name) - logger.debug("Falling back to original plugin engine.") + logger.debug('Falling back to original plugin engine.') hook_caller = getattr( plugin_manager.hook, f'napari_write_{layer._type_string}' @@ -468,7 +469,7 @@ def _write_single_layer_with_plugins( extension = os.path.splitext(path)[-1] plugin_name = plugin_manager.get_writer_for_extension(extension) - logger.debug("Writing to %s. Hook caller: %s", path, hook_caller) + logger.debug('Writing to %s. Hook caller: %s', path, hook_caller) if plugin_name and (plugin_name not in plugin_manager.plugins): names = {i.plugin_name for i in hook_caller.get_hookimpls()} raise ValueError( diff --git a/napari/plugins/npe2api.py b/napari/plugins/npe2api.py index caabee19e2b..8120bc6f95c 100644 --- a/napari/plugins/npe2api.py +++ b/napari/plugins/npe2api.py @@ -4,14 +4,11 @@ """ import json +from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor from functools import lru_cache from typing import ( - Dict, - Iterator, - List, Optional, - Tuple, TypedDict, cast, ) @@ -64,27 +61,27 @@ class _ShortSummaryDict(TypedDict): class SummaryDict(_ShortSummaryDict): display_name: NotRequired[str] - pypi_versions: NotRequired[List[str]] - conda_versions: NotRequired[List[str]] + pypi_versions: NotRequired[list[str]] + conda_versions: NotRequired[list[str]] @lru_cache -def plugin_summaries() -> List[SummaryDict]: +def plugin_summaries() -> list[SummaryDict]: """Return PackageMetadata object for all known napari plugins.""" - url = "https://npe2api.vercel.app/api/extended_summary" + url = 'https://npe2api.vercel.app/api/extended_summary' with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp: return json.load(resp) @lru_cache -def conda_map() -> Dict[PyPIname, Optional[str]]: +def conda_map() -> dict[PyPIname, Optional[str]]: """Return map of PyPI package name to conda_channel/package_name ().""" - url = "https://npe2api.vercel.app/api/conda" + url = 'https://npe2api.vercel.app/api/conda' with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp: return json.load(resp) -def iter_napari_plugin_info() -> Iterator[Tuple[PackageMetadata, bool, dict]]: +def iter_napari_plugin_info() -> Iterator[tuple[PackageMetadata, bool, dict]]: """Iterator of tuples of ProjectInfo, Conda availability for all napari plugins.""" with ThreadPoolExecutor() as executor: data = executor.submit(plugin_summaries) @@ -94,9 +91,9 @@ def iter_napari_plugin_info() -> Iterator[Tuple[PackageMetadata, bool, dict]]: conda_set = {normalized_name(x) for x in conda} for info in data.result(): info_copy = dict(info) - info_copy.pop("display_name", None) - pypi_versions = info_copy.pop("pypi_versions") - conda_versions = info_copy.pop("conda_versions") + info_copy.pop('display_name', None) + pypi_versions = info_copy.pop('pypi_versions') + conda_versions = info_copy.pop('conda_versions') info_ = cast(_ShortSummaryDict, info_copy) # TODO: use this better. @@ -106,11 +103,11 @@ def iter_napari_plugin_info() -> Iterator[Tuple[PackageMetadata, bool, dict]]: # TODO: once the new version of npe2 is out, this can be refactored # to all the metadata includes the conda and pypi versions. extra_info = { - 'home_page': info_.get("home_page", ""), + 'home_page': info_.get('home_page', ''), 'pypi_versions': pypi_versions, 'conda_versions': conda_versions, } - info_["name"] = normalized_name(info_["name"]) + info_['name'] = normalized_name(info_['name']) meta = PackageMetadata(**info_) - yield meta, (info_["name"] in conda_set), extra_info + yield meta, (info_['name'] in conda_set), extra_info diff --git a/napari/plugins/utils.py b/napari/plugins/utils.py index eb1f448a8c4..fc8600b85fc 100644 --- a/napari/plugins/utils.py +++ b/napari/plugins/utils.py @@ -5,7 +5,7 @@ from fnmatch import fnmatch from functools import lru_cache from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Optional, Union from npe2 import PluginManifest @@ -22,7 +22,7 @@ class MatchFlag(IntFlag): @lru_cache -def score_specificity(pattern: str) -> Tuple[bool, int, List[MatchFlag]]: +def score_specificity(pattern: str) -> tuple[bool, int, list[MatchFlag]]: """Score an fnmatch pattern, with higher specificities having lower scores. Absolute paths have highest specificity, @@ -46,7 +46,7 @@ def score_specificity(pattern: str) -> Tuple[bool, int, List[MatchFlag]]: pattern = osp.normpath(pattern) segments = pattern.split(osp.sep) - score: List[MatchFlag] = [] + score: list[MatchFlag] = [] ends_with_star = False def add(match_flag): @@ -71,7 +71,7 @@ def add(match_flag): return not osp.isabs(pattern), 1 - len(score), score -def _get_preferred_readers(path: PathLike) -> List[Tuple[str, str]]: +def _get_preferred_readers(path: PathLike) -> list[tuple[str, str]]: """Given filepath, find matching readers from preferences. Parameters @@ -91,7 +91,7 @@ def _get_preferred_readers(path: PathLike) -> List[Tuple[str, str]]: reader_settings = get_settings().plugins.extension2reader - def filter_fn(kv: Tuple[str, str]) -> bool: + def filter_fn(kv: tuple[str, str]) -> bool: return fnmatch(path, kv[0]) ret = list(filter(filter_fn, reader_settings.items())) @@ -122,7 +122,7 @@ def get_preferred_reader(path: PathLike) -> Optional[str]: return None -def get_potential_readers(filename: PathLike) -> Dict[str, str]: +def get_potential_readers(filename: PathLike) -> dict[str, str]: """Given filename, returns all readers that may read the file. Original plugin engine readers are checked based on returning @@ -147,7 +147,7 @@ def get_potential_readers(filename: PathLike) -> Dict[str, str]: return readers -def get_all_readers() -> Tuple[Dict[str, str], Dict[str, str]]: +def get_all_readers() -> tuple[dict[str, str], dict[str, str]]: """ Return a dict of all npe2 readers and one of all npe1 readers @@ -171,7 +171,7 @@ def normalized_name(name: str) -> str: Normalize a plugin name by replacing underscores and dots by dashes and lower casing it. """ - return re.sub(r"[-_.]+", "-", name).lower() + return re.sub(r'[-_.]+', '-', name).lower() def get_filename_patterns_for_reader(plugin_name: str): @@ -190,7 +190,7 @@ def get_filename_patterns_for_reader(plugin_name: str): set set of filename patterns accepted by all plugin's reader contributions """ - all_fn_patterns: Set[str] = set() + all_fn_patterns: set[str] = set() current_plugin: Union[PluginManifest, None] = None for manifest in _npe2.iter_manifests(): if manifest.name == plugin_name: diff --git a/napari/resources/_icons.py b/napari/resources/_icons.py index 9cb004c3ac9..ec8cc05b27c 100644 --- a/napari/resources/_icons.py +++ b/napari/resources/_icons.py @@ -1,8 +1,9 @@ import re +from collections.abc import Iterable, Iterator from functools import lru_cache from itertools import product from pathlib import Path -from typing import Dict, Iterable, Iterator, Optional, Tuple, Union +from typing import Optional, Union from napari.utils._appdirs import user_cache_dir from napari.utils.translations import trans @@ -10,7 +11,7 @@ LOADING_GIF_PATH = str((Path(__file__).parent / 'loading.gif').resolve()) ICON_PATH = (Path(__file__).parent / 'icons').resolve() ICONS = {x.stem: str(x) for x in ICON_PATH.iterdir() if x.suffix == '.svg'} -PLUGIN_FILE_NAME = "plugin.txt" +PLUGIN_FILE_NAME = 'plugin.txt' def get_icon_path(name: str) -> str: @@ -18,7 +19,7 @@ def get_icon_path(name: str) -> str: if name not in ICONS: raise ValueError( trans._( - "unrecognized icon name: {name!r}. Known names: {icons}", + 'unrecognized icon name: {name!r}. Known names: {icons}', deferred=True, name=name, icons=set(ICONS), @@ -61,7 +62,7 @@ def get_colorized_svg( if not svg_elem.search(xml): raise ValueError( trans._( - "Could not detect svg tag in {path_or_xml!r}", + 'Could not detect svg tag in {path_or_xml!r}', deferred=True, path_or_xml=path_or_xml, ) @@ -73,10 +74,10 @@ def get_colorized_svg( def generate_colorized_svgs( svg_paths: Iterable[Union[str, Path]], - colors: Iterable[Union[str, Tuple[str, str]]], + colors: Iterable[Union[str, tuple[str, str]]], opacities: Iterable[float] = (1.0,), - theme_override: Optional[Dict[str, str]] = None, -) -> Iterator[Tuple[str, str]]: + theme_override: Optional[dict[str, str]] = None, +) -> Iterator[tuple[str, str]]: """Helper function to generate colorized SVGs. This is a generator that yields tuples of ``(alias, icon_xml)`` for every @@ -130,7 +131,7 @@ def generate_colorized_svgs( color = getattr(get_theme(clrkey), theme_key).as_hex() # convert color to string to fit get_colorized_svg signature - op_key = "" if op == 1 else f"_{op * 100:.0f}" + op_key = '' if op == 1 else f'_{op * 100:.0f}' alias = ALIAS_T.format(color=clrkey, svg_stem=svg_stem, opacity=op_key) yield alias, get_colorized_svg(path, color, op) @@ -138,9 +139,9 @@ def generate_colorized_svgs( def write_colorized_svgs( dest: Union[str, Path], svg_paths: Iterable[Union[str, Path]], - colors: Iterable[Union[str, Tuple[str, str]]], + colors: Iterable[Union[str, tuple[str, str]]], opacities: Iterable[float] = (1.0,), - theme_override: Optional[Dict[str, str]] = None, + theme_override: Optional[dict[str, str]] = None, ): dest = Path(dest) dest.mkdir(parents=True, exist_ok=True) diff --git a/napari/settings/__init__.py b/napari/settings/__init__.py index 37846cffcc8..dffd8d63bcb 100644 --- a/napari/settings/__init__.py +++ b/napari/settings/__init__.py @@ -57,7 +57,7 @@ def get_settings(path=_NOT_SET) -> NapariSettings: calframe = inspect.getouterframes(curframe, 2) raise RuntimeError( trans._( - "The path can only be set once per session. Settings called from {calframe}", + 'The path can only be set once per session. Settings called from {calframe}', deferred=True, calframe=calframe[1][3], ) diff --git a/napari/settings/_appearance.py b/napari/settings/_appearance.py index fc0302de66a..81957f4782f 100644 --- a/napari/settings/_appearance.py +++ b/napari/settings/_appearance.py @@ -7,33 +7,50 @@ from napari.utils.translations import trans +class HighlightSettings(EventedModel): + highlight_thickness: int = Field( + 1, + title=trans._('Highlight thickness'), + description=trans._( + 'Select the highlight thickness when hovering over shapes/points.' + ), + ge=1, + le=10, + ) + highlight_color: list[float] = Field( + [0.0, 0.6, 1.0, 1.0], + title=trans._('Highlight color'), + description=trans._( + 'Select the highlight color when hovering over shapes/points.' + ), + ) + + class AppearanceSettings(EventedModel): theme: Theme = Field( - Theme("dark"), - title=trans._("Theme"), - description=trans._("Select the user interface theme."), - env="napari_theme", + Theme('dark'), + title=trans._('Theme'), + description=trans._('Select the user interface theme.'), + env='napari_theme', ) font_size: int = Field( - int(get_theme("dark").font_size[:-2]), - title=trans._("Font size"), - description=trans._("Select the user interface font size."), + int(get_theme('dark').font_size[:-2]), + title=trans._('Font size'), + description=trans._('Select the user interface font size.'), ge=5, le=20, ) - highlight_thickness: int = Field( - 1, - title=trans._("Highlight thickness"), + highlight: HighlightSettings = Field( + HighlightSettings(), + title=trans._('Highlight'), description=trans._( - "Select the highlight thickness when hovering over shapes/points." + 'Select the highlight color and thickness to use when hovering over shapes/points.' ), - ge=1, - le=10, ) layer_tooltip_visibility: bool = Field( False, - title=trans._("Show layer tooltips"), - description=trans._("Toggle to display a tooltip on mouse hover."), + title=trans._('Show layer tooltips'), + description=trans._('Toggle to display a tooltip on mouse hover.'), ) def update( @@ -47,19 +64,19 @@ def update( # If the font_size setting doesn't correspond to the default value # of the current theme no change is done, otherwise # the font_size value is set to the new selected theme font size value - if "theme" in values and values["theme"] != self.theme: + if 'theme' in values and values['theme'] != self.theme: current_theme = get_theme(self.theme) - new_theme = get_theme(values["theme"]) - if values["font_size"] == int(current_theme.font_size[:-2]): - values["font_size"] = int(new_theme.font_size[:-2]) + new_theme = get_theme(values['theme']) + if values['font_size'] == int(current_theme.font_size[:-2]): + values['font_size'] = int(new_theme.font_size[:-2]) super().update(values, recurse) - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: Theme) -> None: # Check if a font_size change is needed when changing theme: # If the font_size setting doesn't correspond to the default value # of the current theme no change is done, otherwise # the font_size value is set to the new selected theme font size value - if key == "theme" and value != self.theme: + if key == 'theme' and value != self.theme: with ComparisonDelayer(self): new_theme = None current_theme = None @@ -81,10 +98,10 @@ class NapariConfig: # Napari specific configuration preferences_exclude = ('schema_version',) - def refresh_themes(self): + def refresh_themes(self) -> None: """Updates theme data. This is not a fantastic solution but it works. Each time a new theme is added (either by a plugin or directly by the user) the enum is updated in place, ensuring that Preferences dialog can still be opened. """ - self.schema()["properties"]["theme"].update(enum=available_themes()) + self.schema()['properties']['theme'].update(enum=available_themes()) diff --git a/napari/settings/_application.py b/napari/settings/_application.py index 2d71e042215..8548bcb9689 100644 --- a/napari/settings/_application.py +++ b/napari/settings/_application.py @@ -1,11 +1,15 @@ from __future__ import annotations -from typing import List, Optional, Tuple +from typing import Any, Optional from psutil import virtual_memory from napari._pydantic_compat import Field, validator -from napari.settings._constants import BrushSizeOnMouseModifiers, LoopMode +from napari.settings._constants import ( + BrushSizeOnMouseModifiers, + LabelDTypes, + LoopMode, +) from napari.settings._fields import Language from napari.utils._base import _DEFAULT_LOCALE from napari.utils.events.custom_types import conint @@ -27,16 +31,16 @@ class DaskSettings(EventedModel): virtual_memory().total * _DEFAULT_MEM_FRACTION / 1e9, ge=0, le=MAX_CACHE, - title="Cache size (GB)", + title='Cache size (GB)', ) class ApplicationSettings(EventedModel): - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.dask.events.connect(self._dask_changed) - def _dask_changed(self): + def _dask_changed(self) -> None: self.events.dask(value=self.dask) first_time: bool = Field( @@ -55,161 +59,169 @@ def _dask_changed(self): ) language: Language = Field( Language(_DEFAULT_LOCALE), - title=trans._("Language"), + title=trans._('Language'), description=trans._( - "Select the display language for the user interface." + 'Select the display language for the user interface.' ), ) # Window state, geometry and position save_window_geometry: bool = Field( True, - title=trans._("Save window geometry"), + title=trans._('Save window geometry'), description=trans._( - "Toggle saving the main window size and position." + 'Toggle saving the main window size and position.' ), ) save_window_state: bool = Field( False, # changed from True to False in schema v0.2.1 - title=trans._("Save window state"), - description=trans._("Toggle saving the main window state of widgets."), + title=trans._('Save window state'), + description=trans._('Toggle saving the main window state of widgets.'), ) - window_position: Optional[Tuple[int, int]] = Field( + window_position: Optional[tuple[int, int]] = Field( None, - title=trans._("Window position"), + title=trans._('Window position'), description=trans._( - "Last saved x and y coordinates for the main window. This setting is managed by the application." + 'Last saved x and y coordinates for the main window. This setting is managed by the application.' ), ) - window_size: Optional[Tuple[int, int]] = Field( + window_size: Optional[tuple[int, int]] = Field( None, - title=trans._("Window size"), + title=trans._('Window size'), description=trans._( - "Last saved width and height for the main window. This setting is managed by the application." + 'Last saved width and height for the main window. This setting is managed by the application.' ), ) window_maximized: bool = Field( False, - title=trans._("Window maximized state"), + title=trans._('Window maximized state'), description=trans._( - "Last saved maximized state for the main window. This setting is managed by the application." + 'Last saved maximized state for the main window. This setting is managed by the application.' ), ) window_fullscreen: bool = Field( False, - title=trans._("Window fullscreen"), + title=trans._('Window fullscreen'), description=trans._( - "Last saved fullscreen state for the main window. This setting is managed by the application." + 'Last saved fullscreen state for the main window. This setting is managed by the application.' ), ) window_state: Optional[str] = Field( None, - title=trans._("Window state"), + title=trans._('Window state'), description=trans._( - "Last saved state of dockwidgets and toolbars for the main window. This setting is managed by the application." + 'Last saved state of dockwidgets and toolbars for the main window. This setting is managed by the application.' ), ) window_statusbar: bool = Field( True, - title=trans._("Show status bar"), + title=trans._('Show status bar'), description=trans._( - "Toggle diplaying the status bar for the main window." + 'Toggle diplaying the status bar for the main window.' ), ) - preferences_size: Optional[Tuple[int, int]] = Field( + preferences_size: Optional[tuple[int, int]] = Field( None, - title=trans._("Preferences size"), + title=trans._('Preferences size'), description=trans._( - "Last saved width and height for the preferences dialog. This setting is managed by the application." + 'Last saved width and height for the preferences dialog. This setting is managed by the application.' ), ) gui_notification_level: NotificationSeverity = Field( NotificationSeverity.INFO, - title=trans._("GUI notification level"), + title=trans._('GUI notification level'), description=trans._( - "Select the notification level for the user interface." + 'Select the notification level for the user interface.' ), ) console_notification_level: NotificationSeverity = Field( NotificationSeverity.NONE, - title=trans._("Console notification level"), - description=trans._("Select the notification level for the console."), + title=trans._('Console notification level'), + description=trans._('Select the notification level for the console.'), ) - open_history: List[str] = Field( + open_history: list[str] = Field( [], - title=trans._("Opened folders history"), + title=trans._('Opened folders history'), description=trans._( - "Last saved list of opened folders. This setting is managed by the application." + 'Last saved list of opened folders. This setting is managed by the application.' ), ) - save_history: List[str] = Field( + save_history: list[str] = Field( [], - title=trans._("Saved folders history"), + title=trans._('Saved folders history'), description=trans._( - "Last saved list of saved folders. This setting is managed by the application." + 'Last saved list of saved folders. This setting is managed by the application.' ), ) playback_fps: int = Field( 10, - title=trans._("Playback frames per second"), - description=trans._("Playback speed in frames per second."), + title=trans._('Playback frames per second'), + description=trans._('Playback speed in frames per second.'), ) playback_mode: LoopMode = Field( LoopMode.LOOP, - title=trans._("Playback loop mode"), - description=trans._("Loop mode for playback."), + title=trans._('Playback loop mode'), + description=trans._('Loop mode for playback.'), ) grid_stride: GridStride = Field( # type: ignore [valid-type] default=1, - title=trans._("Grid Stride"), - description=trans._("Number of layers to place in each grid square."), + title=trans._('Grid Stride'), + description=trans._('Number of layers to place in each grid square.'), ) grid_width: GridWidth = Field( # type: ignore [valid-type] default=-1, - title=trans._("Grid Width"), - description=trans._("Number of columns in the grid."), + title=trans._('Grid Width'), + description=trans._('Number of columns in the grid.'), ) grid_height: GridHeight = Field( # type: ignore [valid-type] default=-1, - title=trans._("Grid Height"), - description=trans._("Number of rows in the grid."), + title=trans._('Grid Height'), + description=trans._('Number of rows in the grid.'), ) confirm_close_window: bool = Field( default=True, - title=trans._("Confirm window or application closing"), + title=trans._('Confirm window or application closing'), description=trans._( - "Ask for confirmation before closing a napari window or application (all napari windows).", + 'Ask for confirmation before closing a napari window or application (all napari windows).', ), ) hold_button_delay: float = Field( default=0.5, - title=trans._("Delay to treat button as hold in seconds"), + title=trans._('Delay to treat button as hold in seconds'), description=trans._( - "This affects certain actions where a short press and a long press have different behaviors, such as changing the mode of a layer permanently or only during the long press." + 'This affects certain actions where a short press and a long press have different behaviors, such as changing the mode of a layer permanently or only during the long press.' ), ) brush_size_on_mouse_move_modifiers: BrushSizeOnMouseModifiers = Field( BrushSizeOnMouseModifiers.ALT, - title=trans._("Brush size on mouse move modifiers"), + title=trans._('Brush size on mouse move modifiers'), description=trans._( - "Modifiers to activate changing the brush size by moving the mouse." + 'Modifiers to activate changing the brush size by moving the mouse.' ), ) # convert cache (and max cache) from bytes to mb for widget dask: DaskSettings = Field( default=DaskSettings(), - title=trans._("Dask cache"), + title=trans._('Dask cache'), description=trans._( - "Settings for dask cache (does not work with distributed arrays)" + 'Settings for dask cache (does not work with distributed arrays)' + ), + ) + + new_labels_dtype: LabelDTypes = Field( + default=LabelDTypes.uint8, + title=trans._('New labels data type'), + description=trans._( + 'data type for labels layers created with the "new labels" button.' ), ) @validator('window_state', allow_reuse=True) - def _validate_qbtye(cls, v): + def _validate_qbtye(cls, v: str) -> str: if v and (not isinstance(v, str) or not v.startswith('!QBYTE_')): raise ValueError( trans._("QByte strings must start with '!QBYTE_'") @@ -222,16 +234,16 @@ class Config: class NapariConfig: # Napari specific configuration preferences_exclude = ( - "schema_version", - "preferences_size", - "first_time", - "window_position", - "window_size", - "window_maximized", - "window_fullscreen", - "window_state", - "window_statusbar", - "open_history", - "save_history", - "ipy_interactive", + 'schema_version', + 'preferences_size', + 'first_time', + 'window_position', + 'window_size', + 'window_maximized', + 'window_fullscreen', + 'window_state', + 'window_statusbar', + 'open_history', + 'save_history', + 'ipy_interactive', ) diff --git a/napari/settings/_base.py b/napari/settings/_base.py index 6ebf5a4d6a1..f7484738fe0 100644 --- a/napari/settings/_base.py +++ b/napari/settings/_base.py @@ -3,10 +3,10 @@ import contextlib import logging import os -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, cast +from typing import TYPE_CHECKING, Optional, cast from warnings import warn from napari._pydantic_compat import ( @@ -24,7 +24,8 @@ _logger = logging.getLogger(__name__) if TYPE_CHECKING: - from typing import AbstractSet, Any, Union + from collections.abc import Set as AbstractSet + from typing import Any, Union from napari._pydantic_compat import ( EnvSettingsSource, @@ -34,9 +35,11 @@ IntStr = Union[int, str] AbstractSetIntStr = AbstractSet[IntStr] - DictStrAny = Dict[str, Any] + DictStrAny = dict[str, Any] MappingIntStrAny = Mapping[IntStr, Any] +Dict = dict # rename, because EventedSettings has method dict + class EventedSettings(BaseSettings, EventedModel): """A variant of EventedModel designed for settings. @@ -63,7 +66,7 @@ def __init__(self, **values: Any) -> None: def _warn_restart(*_): warn( trans._( - "Restart required for this change to take effect.", + 'Restart required for this change to take effect.', deferred=True, ) ) @@ -71,7 +74,7 @@ def _warn_restart(*_): def _on_sub_event(self, event: Event, field=None): """emit the field.attr name and new value""" if field: - field += "." + field += '.' value = getattr(event, 'value', None) self.events.changed(key=f'{field}{event._type}', value=value) @@ -172,7 +175,7 @@ def save(self, path: Union[str, Path, None] = None, **dict_kwargs): if not path: raise ValueError( trans._( - "No path provided in config or save argument.", + 'No path provided in config or save argument.', deferred=True, ) ) @@ -185,13 +188,13 @@ def _dump(self, path: str, data: Dict) -> None: """Encode and dump `data` to `path` using a path-appropriate encoder.""" if str(path).endswith(('.yaml', '.yml')): _data = self._yaml_dump(data) - elif str(path).endswith(".json"): + elif str(path).endswith('.json'): json_dumps = self.__config__.json_dumps _data = json_dumps(data, default=self.__json_encoder__) else: raise NotImplementedError( trans._( - "Can only currently dump to `.json` or `.yaml`, not {path!r}", + 'Can only currently dump to `.json` or `.yaml`, not {path!r}', deferred=True, path=path, ) @@ -232,7 +235,7 @@ def customise_sources( init_settings: SettingsSourceCallable, env_settings: EnvSettingsSource, file_secret_settings: SettingsSourceCallable, - ) -> Tuple[SettingsSourceCallable, ...]: + ) -> tuple[SettingsSourceCallable, ...]: """customise the way data is loaded. This does 2 things: @@ -256,7 +259,7 @@ def customise_sources( @classmethod def _config_file_settings_source( cls, settings: EventedConfigFileSettings - ) -> Dict[str, Any]: + ) -> dict[str, Any]: return config_file_settings_source(settings) @@ -282,7 +285,7 @@ def nested_env_settings( nesting as well. """ - def _inner(settings: BaseSettings) -> Dict[str, Any]: + def _inner(settings: BaseSettings) -> dict[str, Any]: # first call the original implementation d = super_eset(settings) @@ -343,7 +346,7 @@ def _inner(settings: BaseSettings) -> Dict[str, Any]: def config_file_settings_source( settings: EventedConfigFileSettings, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Read config files during init of an EventedConfigFileSettings obj. The two important values are the `settings._config_path` @@ -369,7 +372,7 @@ def config_file_settings_source( default_cfg = getattr(default_cfg, 'default', None) # if the config has a `sources` list, read those too and merge. - sources: List[str] = list(getattr(settings.__config__, 'sources', [])) + sources: list[str] = list(getattr(settings.__config__, 'sources', [])) if config_path: sources.append(config_path) if not sources: @@ -388,7 +391,7 @@ def config_file_settings_source( if path_ != default_cfg: _logger.warning( trans._( - "Requested config path is not a file: {path}", + 'Requested config path is not a file: {path}', path=path_, ) ) @@ -397,12 +400,12 @@ def config_file_settings_source( # get loader for yaml/json if str(path).endswith(('.yaml', '.yml')): load = __import__('yaml').safe_load - elif str(path).endswith(".json"): + elif str(path).endswith('.json'): load = __import__('json').load else: warn( trans._( - "Unrecognized file extension for config_path: {path}", + 'Unrecognized file extension for config_path: {path}', path=path, ) ) @@ -414,7 +417,7 @@ def config_file_settings_source( except Exception as err: # noqa: BLE001 _logger.warning( trans._( - "The content of the napari settings file could not be read\n\nThe default settings will be used and the content of the file will be replaced the next time settings are changed.\n\nError:\n{err}", + 'The content of the napari settings file could not be read\n\nThe default settings will be used and the content of the file will be replaced the next time settings are changed.\n\nError:\n{err}', deferred=True, err=err, ) @@ -434,7 +437,7 @@ def config_file_settings_source( # if errors occur, we still want to boot, so we just remove bad keys errors = err.errors() msg = trans._( - "Validation errors in config file(s).\nThe following fields have been reset to the default value:\n\n{errors}\n", + 'Validation errors in config file(s).\nThe following fields have been reset to the default value:\n\n{errors}\n', deferred=True, errors=display_errors(errors), ) @@ -458,7 +461,7 @@ def config_file_settings_source( return data -def _remove_bad_keys(data: dict, keys: List[Tuple[Union[int, str], ...]]): +def _remove_bad_keys(data: dict, keys: list[tuple[Union[int, str], ...]]): """Remove list of keys (as string tuples) from dict (in place). Parameters diff --git a/napari/settings/_constants.py b/napari/settings/_constants.py index c4e6c802926..2405c1f815d 100644 --- a/napari/settings/_constants.py +++ b/napari/settings/_constants.py @@ -4,6 +4,19 @@ from napari.utils.misc import StringEnum +class LabelDTypes(StrEnum): + uint8 = 'uint8' + int8 = 'int8' + uint16 = 'uint16' + int16 = 'int16' + uint32 = 'uint32' + int32 = 'int32' + uint64 = 'uint64' + int64 = 'int64' + uint = 'uint' + int = 'int' + + class LoopMode(StringEnum): """Looping mode for animating an axis. diff --git a/napari/settings/_experimental.py b/napari/settings/_experimental.py index 111fd8925c4..92200918e56 100644 --- a/napari/settings/_experimental.py +++ b/napari/settings/_experimental.py @@ -8,28 +8,28 @@ class ExperimentalSettings(EventedSettings): async_: bool = Field( False, - title=trans._("Render Images Asynchronously"), + title=trans._('Render Images Asynchronously'), description=trans._( - "Asynchronous loading of image data. \nThis setting partially loads data while viewing." + 'Asynchronous loading of image data. \nThis setting partially loads data while viewing.' ), - env="napari_async", + env='napari_async', requires_restart=False, ) autoswap_buffers: bool = Field( False, - title=trans._("Enable autoswapping rendering buffers."), + title=trans._('Enable autoswapping rendering buffers.'), description=trans._( - "Autoswapping rendering buffers improves quality by reducing tearing artifacts, while sacrificing some performance." + 'Autoswapping rendering buffers improves quality by reducing tearing artifacts, while sacrificing some performance.' ), - env="napari_autoswap", + env='napari_autoswap', requires_restart=True, ) rdp_epsilon: float = Field( 0.5, - title=trans._("Shapes polygon lasso RDP epsilon"), + title=trans._('Shapes polygon lasso RDP epsilon'), description=trans._( - "Setting this higher removes more points from polygons. \nSetting this to 0 keeps all vertices of a given polygon" + 'Setting this higher removes more points from polygons. \nSetting this to 0 keeps all vertices of a given polygon' ), type=float, ge=0, @@ -37,9 +37,9 @@ class ExperimentalSettings(EventedSettings): lasso_vertex_distance: int = Field( 10, - title=trans._("Minimum distance threshold of shapes lasso tool"), + title=trans._('Minimum distance threshold of shapes lasso tool'), description=trans._( - "Value determines how many screen pixels one has to move before another vertex can be added to the polygon." + 'Value determines how many screen pixels one has to move before another vertex can be added to the polygon.' ), type=int, gt=0, diff --git a/napari/settings/_fields.py b/napari/settings/_fields.py index 37868f7baa5..51d3075acda 100644 --- a/napari/settings/_fields.py +++ b/napari/settings/_fields.py @@ -1,7 +1,7 @@ import re from dataclasses import dataclass from functools import total_ordering -from typing import Any, Dict, Optional, SupportsInt, Tuple, Union +from typing import Any, Optional, SupportsInt, Union from napari.utils.theme import available_themes, is_theme_available from napari.utils.translations import _load_language, get_language_packs, trans @@ -36,7 +36,7 @@ def validate(cls, v): '"{value}" is not valid. It must be one of {themes}', deferred=True, value=value, - themes=", ".join(available_themes()), + themes=', '.join(available_themes()), ) ) @@ -73,7 +73,7 @@ def validate(cls, v): '"{value}" is not valid. It must be one of {language_packs}.', deferred=True, value=v, - language_packs=", ".join(language_packs), + language_packs=', '.join(language_packs), ) ) @@ -120,7 +120,7 @@ class Version: def parse(cls, version: Union[bytes, str]) -> 'Version': """Convert string or bytes into Version object.""" if isinstance(version, bytes): - version = version.decode("UTF-8") + version = version.decode('UTF-8') match = cls._SEMVER_PATTERN.match(version) if match is None: raise ValueError( @@ -130,7 +130,7 @@ def parse(cls, version: Union[bytes, str]) -> 'Version': version=version, ) ) - matched_version_parts: Dict[str, Any] = match.groupdict() + matched_version_parts: dict[str, Any] = match.groupdict() return cls(**matched_version_parts) # NOTE: we're only comparing the numeric parts for now. @@ -158,7 +158,7 @@ def _from_obj(cls, other): elif not isinstance(other, Version): raise TypeError( trans._( - "Expected str, bytes, dict, tuple, list, or {cls} instance, but got {other_type}", + 'Expected str, bytes, dict, tuple, list, or {cls} instance, but got {other_type}', deferred=True, cls=cls, other_type=type(other), @@ -166,7 +166,7 @@ def _from_obj(cls, other): ) return other - def to_tuple(self) -> Tuple[int, int, int, Optional[str], Optional[str]]: + def to_tuple(self) -> tuple[int, int, int, Optional[str], Optional[str]]: """Return version as tuple (first three are int, last two Opt[str]).""" return ( int(self.major), @@ -180,7 +180,7 @@ def __iter__(self): yield from self.to_tuple() def __str__(self) -> str: - v = f"{self.major}.{self.minor}.{self.patch}" + v = f'{self.major}.{self.minor}.{self.patch}' if self.prerelease: # pragma: no cover v += str(self.prerelease) if self.build: # pragma: no cover diff --git a/napari/settings/_migrations.py b/napari/settings/_migrations.py index 297a38e4b72..cb0f7e5b1a6 100644 --- a/napari/settings/_migrations.py +++ b/napari/settings/_migrations.py @@ -3,14 +3,14 @@ import warnings from contextlib import contextmanager from importlib.metadata import distributions -from typing import TYPE_CHECKING, Callable, List, NamedTuple +from typing import TYPE_CHECKING, Callable, NamedTuple from napari.settings._fields import Version if TYPE_CHECKING: from napari.settings._napari_settings import NapariSettings -_MIGRATORS: List[Migrator] = [] +_MIGRATORS: list[Migrator] = [] MigratorF = Callable[['NapariSettings'], None] @@ -33,8 +33,8 @@ def do_migrations(model: NapariSettings): model.schema_version = migration.to_ except Exception as e: # noqa BLE001 msg = ( - f"Failed to migrate settings from v{migration.from_} " - f"to v{migration.to_}. Error: {e}. " + f'Failed to migrate settings from v{migration.from_} ' + f'to v{migration.to_}. Error: {e}. ' ) try: model.update(backup) @@ -97,7 +97,7 @@ def v030_v040(model: NapariSettings): """ for dist in distributions(): for ep in dist.entry_points: - if ep.group == "napari.manifest": + if ep.group == 'napari.manifest': model.plugins.disabled_plugins.discard(dist.metadata['Name']) diff --git a/napari/settings/_napari_settings.py b/napari/settings/_napari_settings.py index ebd6ac0eb0f..8e76033bb48 100644 --- a/napari/settings/_napari_settings.py +++ b/napari/settings/_napari_settings.py @@ -33,36 +33,36 @@ class NapariSettings(EventedConfigFileSettings): # 3. You don't need to touch this value if you're just adding a new option schema_version: Version = Field( CURRENT_SCHEMA_VERSION, - description=trans._("Napari settings schema version."), + description=trans._('Napari settings schema version.'), ) application: ApplicationSettings = Field( default_factory=ApplicationSettings, - title=trans._("Application"), - description=trans._("Main application settings."), + title=trans._('Application'), + description=trans._('Main application settings.'), ) appearance: AppearanceSettings = Field( default_factory=AppearanceSettings, - title=trans._("Appearance"), - description=trans._("User interface appearance settings."), + title=trans._('Appearance'), + description=trans._('User interface appearance settings.'), allow_mutation=False, ) plugins: PluginsSettings = Field( default_factory=PluginsSettings, - title=trans._("Plugins"), - description=trans._("Plugins settings."), + title=trans._('Plugins'), + description=trans._('Plugins settings.'), allow_mutation=False, ) shortcuts: ShortcutsSettings = Field( default_factory=ShortcutsSettings, - title=trans._("Shortcuts"), - description=trans._("Shortcut settings."), + title=trans._('Shortcuts'), + description=trans._('Shortcut settings.'), allow_mutation=False, ) experimental: ExperimentalSettings = Field( default_factory=ExperimentalSettings, - title=trans._("Experimental"), - description=trans._("Experimental settings."), + title=trans._('Experimental'), + description=trans._('Experimental settings.'), allow_mutation=False, ) diff --git a/napari/settings/_plugins.py b/napari/settings/_plugins.py index 1347a79373e..3928a1119b6 100644 --- a/napari/settings/_plugins.py +++ b/napari/settings/_plugins.py @@ -1,5 +1,3 @@ -from typing import Dict, List, Set - from typing_extensions import TypedDict from napari._pydantic_compat import Field @@ -14,13 +12,13 @@ class PluginHookOption(TypedDict): enabled: bool -CallOrderDict = Dict[str, List[PluginHookOption]] +CallOrderDict = dict[str, list[PluginHookOption]] class PluginsSettings(EventedSettings): use_npe2_adaptor: bool = Field( False, - title=trans._("Use npe2 adaptor"), + title=trans._('Use npe2 adaptor'), description=trans._( "Use npe2-adaptor for first generation plugins.\nWhen an npe1 plugin is found, this option will\nimport its contributions and create/cache\na 'shim' npe2 manifest that allows it to be treated\nlike an npe2 plugin (with delayed imports, etc...)", ), @@ -29,26 +27,26 @@ class PluginsSettings(EventedSettings): call_order: CallOrderDict = Field( default_factory=dict, - title=trans._("Plugin sort order"), + title=trans._('Plugin sort order'), description=trans._( - "Sort plugins for each action in the order to be called.", + 'Sort plugins for each action in the order to be called.', ), ) - disabled_plugins: Set[str] = Field( + disabled_plugins: set[str] = Field( set(), - title=trans._("Disabled plugins"), + title=trans._('Disabled plugins'), description=trans._( - "Plugins to disable on application start.", + 'Plugins to disable on application start.', ), ) - extension2reader: Dict[str, str] = Field( + extension2reader: dict[str, str] = Field( default_factory=dict, title=trans._('File extension readers'), description=trans._( 'Assign file extensions to specific reader plugins' ), ) - extension2writer: Dict[str, str] = Field( + extension2writer: dict[str, str] = Field( default_factory=dict, title=trans._('Writer plugin extension association.'), description=trans._( diff --git a/napari/settings/_shortcuts.py b/napari/settings/_shortcuts.py index f1af3eddd76..e1eb9887372 100644 --- a/napari/settings/_shortcuts.py +++ b/napari/settings/_shortcuts.py @@ -1,5 +1,3 @@ -from typing import Dict, List - from napari._pydantic_compat import Field, validator from napari.utils.events.evented_model import EventedModel from napari.utils.key_bindings import KeyBinding, coerce_keybinding @@ -8,11 +6,11 @@ class ShortcutsSettings(EventedModel): - shortcuts: Dict[str, List[KeyBinding]] = Field( + shortcuts: dict[str, list[KeyBinding]] = Field( default_shortcuts, - title=trans._("shortcuts"), + title=trans._('shortcuts'), description=trans._( - "Set keyboard shortcuts for actions.", + 'Set keyboard shortcuts for actions.', ), ) @@ -21,7 +19,9 @@ class NapariConfig: preferences_exclude = ('schema_version',) @validator('shortcuts', allow_reuse=True) - def shortcut_validate(cls, v): + def shortcut_validate( + cls, v: dict[str, list[KeyBinding]] + ) -> dict[str, list[KeyBinding]]: for name, value in default_shortcuts.items(): if name not in v: v[name] = value diff --git a/napari/settings/_tests/test_migrations.py b/napari/settings/_tests/test_migrations.py index c6d66c3dcce..d4c70fa9b5f 100644 --- a/napari/settings/_tests/test_migrations.py +++ b/napari/settings/_tests/test_migrations.py @@ -13,7 +13,7 @@ def _test_migrator(monkeypatch): # but rather only using migrators that get declared IN the test _TEST_MIGRATORS = [] with monkeypatch.context() as m: - m.setattr(_migrations, "_MIGRATORS", _TEST_MIGRATORS) + m.setattr(_migrations, '_MIGRATORS', _TEST_MIGRATORS) yield _migrations.migrator diff --git a/napari/settings/_tests/test_settings.py b/napari/settings/_tests/test_settings.py index 41dbb38f827..25033f5a861 100644 --- a/napari/settings/_tests/test_settings.py +++ b/napari/settings/_tests/test_settings.py @@ -46,23 +46,23 @@ def test_settings_file_not_created(test_settings): def test_settings_loads(tmp_path): - data = "appearance:\n theme: light" + data = 'appearance:\n theme: light' fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) - assert NapariSettings(fake_path).appearance.theme == "light" + assert NapariSettings(fake_path).appearance.theme == 'light' def test_settings_load_invalid_content(tmp_path): # This is invalid content fake_path = tmp_path / 'fake_path.yml' - fake_path.write_text(":") + fake_path.write_text(':') NapariSettings(fake_path) def test_settings_load_invalid_type(tmp_path, caplog): # The invalid data will be replaced by the default value - data = "appearance:\n theme: 1" + data = 'appearance:\n theme: 1' fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) assert NapariSettings(fake_path).application.save_window_geometry is True @@ -72,7 +72,7 @@ def test_settings_load_invalid_type(tmp_path, caplog): def test_settings_load_strict(tmp_path, monkeypatch): # use Config.strict_config_check to enforce good config files monkeypatch.setattr(NapariSettings.__config__, 'strict_config_check', True) - data = "appearance:\n theme: 1" + data = 'appearance:\n theme: 1' fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) with pytest.raises(ValidationError): @@ -92,7 +92,7 @@ def test_settings_load_invalid_key(tmp_path, monkeypatch): monkeypatch.setattr(os, 'environ', {}) s = NapariSettings(fake_path) - assert getattr(s, "non_existing_key", None) is None + assert getattr(s, 'non_existing_key', None) is None s.save() text = fake_path.read_text() # removed bad key @@ -104,21 +104,21 @@ def test_settings_load_invalid_key(tmp_path, monkeypatch): def test_settings_load_invalid_section(tmp_path): # The invalid section will be removed from the file - data = "non_existing_section:\n foo: bar" + data = 'non_existing_section:\n foo: bar' fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) settings_ = NapariSettings(fake_path) - assert getattr(settings_, "non_existing_section", None) is None + assert getattr(settings_, 'non_existing_section', None) is None def test_settings_to_dict(test_settings): data_dict = test_settings.dict() - assert isinstance(data_dict, dict) and data_dict.get("application") + assert isinstance(data_dict, dict) and data_dict.get('application') data_dict = test_settings.dict(exclude_defaults=True) - assert not data_dict.get("application") + assert not data_dict.get('application') def test_settings_to_dict_no_env(monkeypatch): @@ -127,7 +127,7 @@ def test_settings_to_dict_no_env(monkeypatch): assert s.dict()['appearance']['theme'] == 'light' assert s.dict(exclude_env=True)['appearance']['theme'] == 'light' - monkeypatch.setenv("NAPARI_APPEARANCE_THEME", 'light') + monkeypatch.setenv('NAPARI_APPEARANCE_THEME', 'light') s = NapariSettings(None) assert s.dict()['appearance']['theme'] == 'light' assert 'theme' not in s.dict(exclude_env=True).get('appearance', {}) @@ -137,11 +137,11 @@ def test_settings_reset(test_settings): appearance_id = id(test_settings.appearance) test_settings.reset() assert id(test_settings.appearance) == appearance_id - assert test_settings.appearance.theme == "dark" - test_settings.appearance.theme = "light" - assert test_settings.appearance.theme == "light" + assert test_settings.appearance.theme == 'dark' + test_settings.appearance.theme = 'light' + assert test_settings.appearance.theme == 'light' test_settings.reset() - assert test_settings.appearance.theme == "dark" + assert test_settings.appearance.theme == 'dark' assert id(test_settings.appearance) == appearance_id @@ -152,12 +152,12 @@ def test_settings_model(test_settings): with pytest.raises(ValidationError): # Should be a valid string - test_settings.appearance.theme = "vaporwave" + test_settings.appearance.theme = 'vaporwave' def test_custom_theme_settings(test_settings): # See: https://github.com/napari/napari/issues/2340 - custom_theme_name = "_test_blue_" + custom_theme_name = '_test_blue_' # No theme registered yet, this should fail with pytest.raises(ValidationError): @@ -170,7 +170,7 @@ def test_custom_theme_settings(test_settings): primary='rgb(80, 88, 108)', current='rgb(184, 112, 0)', ) - register_theme(custom_theme_name, blue_theme, "test") + register_theme(custom_theme_name, blue_theme, 'test') # Theme registered, should pass validation test_settings.appearance.theme = custom_theme_name @@ -197,7 +197,7 @@ def test_model_fields_are_annotated(test_settings): ) if errors: - raise ValueError("\n\n".join(errors)) + raise ValueError('\n\n'.join(errors)) def test_settings_env_variables(monkeypatch): @@ -215,7 +215,7 @@ def test_settings_env_variables(monkeypatch): # can also use json in nested vars assert NapariSettings(None).plugins.extension2reader == {} monkeypatch.setenv('NAPARI_PLUGINS_EXTENSION2READER', '{"*.zarr": "hi"}') - assert NapariSettings(None).plugins.extension2reader == {"*.zarr": "hi"} + assert NapariSettings(None).plugins.extension2reader == {'*.zarr': 'hi'} def test_settings_env_variables_fails(monkeypatch): @@ -234,14 +234,14 @@ class Sub(EventedSettings): class T(NapariSettings): sub: Sub - monkeypatch.setenv("VARNAME", '42') + monkeypatch.setenv('VARNAME', '42') assert T(sub={}).sub.x == 42 # Failing because dark is actually the default... def test_settings_env_variables_do_not_write_to_disk(tmp_path, monkeypatch): # create a settings file with light theme - data = "appearance:\n theme: light" + data = 'appearance:\n theme: light' fake_path = tmp_path / 'fake_path.yml' fake_path.write_text(data) @@ -249,7 +249,7 @@ def test_settings_env_variables_do_not_write_to_disk(tmp_path, monkeypatch): disk_settings = fake_path.read_text() assert 'theme: light' in disk_settings # make sure they load correctly - assert NapariSettings(fake_path).appearance.theme == "light" + assert NapariSettings(fake_path).appearance.theme == 'light' # now load settings again with an Env-var override monkeypatch.setenv('NAPARI_APPEARANCE_THEME', 'dark') @@ -269,7 +269,7 @@ def test_settings_env_variables_do_not_write_to_disk(tmp_path, monkeypatch): # and it's back if we reread without the env var override monkeypatch.delenv('NAPARI_APPEARANCE_THEME') - assert NapariSettings(fake_path).appearance.theme == "light" + assert NapariSettings(fake_path).appearance.theme == 'light' def test_settings_only_saves_non_default_values(monkeypatch, tmp_path): diff --git a/napari/settings/_utils.py b/napari/settings/_utils.py index db7c318db8f..8b1c043bb2d 100644 --- a/napari/settings/_utils.py +++ b/napari/settings/_utils.py @@ -1,8 +1,13 @@ -def _coerce_extensions_to_globs(reader_settings): +from typing import TypeVar + +T = TypeVar('T') + + +def _coerce_extensions_to_globs(reader_settings: dict[str, T]) -> dict[str, T]: """Coerce existing reader settings for file extensions to glob patterns""" new_settings = {} for pattern, reader in reader_settings.items(): if pattern.startswith('.') and '*' not in pattern: - pattern = f"*{pattern}" + pattern = f'*{pattern}' new_settings[pattern] = reader return new_settings diff --git a/napari/settings/_yaml.py b/napari/settings/_yaml.py index f069c8f2182..5f1aed02e27 100644 --- a/napari/settings/_yaml.py +++ b/napari/settings/_yaml.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Type +from typing import TYPE_CHECKING from app_model.types import KeyBinding from yaml import SafeDumper, dump_all @@ -10,12 +10,12 @@ from napari.settings._fields import Version if TYPE_CHECKING: - from collections.abc import Mapping - from typing import AbstractSet, Any, Dict, Optional, TypeVar, Union + from collections.abc import Mapping, Set as AbstractSet + from typing import Any, Optional, TypeVar, Union IntStr = Union[int, str] AbstractSetIntStr = AbstractSet[IntStr] - DictStrAny = Dict[str, Any] + DictStrAny = dict[str, Any] MappingIntStrAny = Mapping[IntStr, Any] Model = TypeVar('Model', bound=BaseModel) @@ -68,7 +68,7 @@ def yaml( exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, - dumper: Optional[Type[SafeDumper]] = None, + dumper: Optional[type[SafeDumper]] = None, **dumps_kwargs: Any, ) -> str: """Serialize model to yaml.""" @@ -87,7 +87,7 @@ def yaml( return self._yaml_dump(data, dumper, **dumps_kwargs) def _yaml_dump( - self, data, dumper: Optional[Type[SafeDumper]] = None, **kw + self, data, dumper: Optional[type[SafeDumper]] = None, **kw ) -> str: kw.setdefault('sort_keys', False) dumper = dumper or getattr(self.__config__, 'yaml_dumper', YamlDumper) diff --git a/napari/types.py b/napari/types.py index a34a143eaa7..35381a361b1 100644 --- a/napari/types.py +++ b/napari/types.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable, Sequence from functools import partial, wraps from pathlib import Path from types import TracebackType @@ -5,14 +6,8 @@ TYPE_CHECKING, Any, Callable, - Dict, - Iterable, - List, NewType, Optional, - Sequence, - Tuple, - Type, Union, ) @@ -69,22 +64,22 @@ # layer data may be: (data,) (data, meta), or (data, meta, layer_type) # using "Any" for the data type until ArrayLike is more mature. -FullLayerData = Tuple[Any, Dict, LayerTypeName] -LayerData = Union[Tuple[Any], Tuple[Any, Dict], FullLayerData] +FullLayerData = tuple[Any, dict, LayerTypeName] +LayerData = Union[tuple[Any], tuple[Any, dict], FullLayerData] PathLike = Union[str, Path] PathOrPaths = Union[PathLike, Sequence[PathLike]] -ReaderFunction = Callable[[PathOrPaths], List[LayerData]] -WriterFunction = Callable[[str, List[FullLayerData]], List[str]] +ReaderFunction = Callable[[PathOrPaths], list[LayerData]] +WriterFunction = Callable[[str, list[FullLayerData]], list[str]] ExcInfo = Union[ - Tuple[Type[BaseException], BaseException, TracebackType], - Tuple[None, None, None], + tuple[type[BaseException], BaseException, TracebackType], + tuple[None, None, None], ] # Types for GUI HookSpecs WidgetCallable = Callable[..., Union['FunctionGui', 'QWidget']] -AugmentedWidget = Union[WidgetCallable, Tuple[WidgetCallable, dict]] +AugmentedWidget = Union[WidgetCallable, tuple[WidgetCallable, dict]] # Sample Data for napari_provide_sample_data hookspec is either a string/path @@ -105,17 +100,17 @@ class SampleDict(TypedDict): # while their names should not change (without deprecation), their typing # implementations may... or may be rolled over to napari/image-types -ArrayBase: Type[np.ndarray] = np.ndarray +ArrayBase: type[np.ndarray] = np.ndarray -GraphData = NewType("GraphData", BaseGraph) # type: ignore [valid-newtype] -ImageData = NewType("ImageData", np.ndarray) -LabelsData = NewType("LabelsData", np.ndarray) -PointsData = NewType("PointsData", np.ndarray) -ShapesData = NewType("ShapesData", List[np.ndarray]) -SurfaceData = NewType("SurfaceData", Tuple[np.ndarray, np.ndarray, np.ndarray]) -TracksData = NewType("TracksData", np.ndarray) -VectorsData = NewType("VectorsData", np.ndarray) +GraphData = NewType('GraphData', BaseGraph) # type: ignore [valid-newtype] +ImageData = NewType('ImageData', np.ndarray) +LabelsData = NewType('LabelsData', np.ndarray) +PointsData = NewType('PointsData', np.ndarray) +ShapesData = NewType('ShapesData', list[np.ndarray]) +SurfaceData = NewType('SurfaceData', tuple[np.ndarray, np.ndarray, np.ndarray]) +TracksData = NewType('TracksData', np.ndarray) +VectorsData = NewType('VectorsData', np.ndarray) _LayerData = Union[ GraphData, ImageData, @@ -127,7 +122,7 @@ class SampleDict(TypedDict): VectorsData, ] -LayerDataTuple = NewType("LayerDataTuple", tuple) +LayerDataTuple = NewType('LayerDataTuple', tuple) def image_reader_to_layerdata_reader( @@ -150,7 +145,7 @@ def image_reader_to_layerdata_reader( """ @wraps(func) - def reader_function(*args, **kwargs) -> List[LayerData]: + def reader_function(*args, **kwargs) -> list[LayerData]: result = func(*args, **kwargs) return [(result,)] @@ -159,21 +154,19 @@ def reader_function(*args, **kwargs) -> List[LayerData]: def _register_types_with_magicgui(): """Register ``napari.types`` objects with magicgui.""" - import sys from concurrent.futures import Future from magicgui import register_type from napari.utils import _magicgui as _mgui - for type_ in (LayerDataTuple, List[LayerDataTuple]): + for type_ in (LayerDataTuple, list[LayerDataTuple]): register_type( type_, return_callback=_mgui.add_layer_data_tuples_to_viewer, ) - if sys.version_info >= (3, 9): - future_type = Future[type_] # type: ignore [valid-type] - register_type(future_type, return_callback=_mgui.add_future_data) + future_type = Future[type_] # type: ignore [valid-type] + register_type(future_type, return_callback=_mgui.add_future_data) for data_type in get_args(_LayerData): register_type( @@ -181,27 +174,21 @@ def _register_types_with_magicgui(): choices=_mgui.get_layers_data, return_callback=_mgui.add_layer_data_to_viewer, ) - if sys.version_info >= (3, 9): - register_type( - Future[data_type], # type: ignore [valid-type] - choices=_mgui.get_layers_data, - return_callback=partial( - _mgui.add_future_data, _from_tuple=False - ), - ) + register_type( + Future[data_type], # type: ignore [valid-type] + choices=_mgui.get_layers_data, + return_callback=partial(_mgui.add_future_data, _from_tuple=False), + ) register_type( Optional[data_type], # type: ignore [call-overload] choices=_mgui.get_layers_data, return_callback=_mgui.add_layer_data_to_viewer, ) - if sys.version_info >= (3, 9): - register_type( - Future[Optional[data_type]], # type: ignore [valid-type] - choices=_mgui.get_layers_data, - return_callback=partial( - _mgui.add_future_data, _from_tuple=False - ), - ) + register_type( + Future[Optional[data_type]], # type: ignore [valid-type] + choices=_mgui.get_layers_data, + return_callback=partial(_mgui.add_future_data, _from_tuple=False), + ) _register_types_with_magicgui() diff --git a/napari/utils/_appdirs.py b/napari/utils/_appdirs.py index cf3cc427b36..b2b6d789140 100644 --- a/napari/utils/_appdirs.py +++ b/napari/utils/_appdirs.py @@ -8,7 +8,7 @@ PREFIX_PATH = os.path.realpath(sys.prefix) -sha_short = f"{os.path.basename(PREFIX_PATH)}_{hashlib.sha1(PREFIX_PATH.encode()).hexdigest()}" +sha_short = f'{os.path.basename(PREFIX_PATH)}_{hashlib.sha1(PREFIX_PATH.encode()).hexdigest()}' _appname = 'napari' _appauthor = False diff --git a/napari/utils/_base.py b/napari/utils/_base.py index 47dc9e409af..002dfc25be0 100644 --- a/napari/utils/_base.py +++ b/napari/utils/_base.py @@ -9,6 +9,6 @@ from napari.utils._appdirs import user_config_dir -_FILENAME = "settings.yaml" -_DEFAULT_LOCALE = "en" +_FILENAME = 'settings.yaml' +_DEFAULT_LOCALE = 'en' _DEFAULT_CONFIG_PATH = os.path.join(user_config_dir(), _FILENAME) diff --git a/napari/utils/_dask_utils.py b/napari/utils/_dask_utils.py index 7a98bea6aa7..3183938f861 100644 --- a/napari/utils/_dask_utils.py +++ b/napari/utils/_dask_utils.py @@ -3,7 +3,8 @@ import collections.abc import contextlib -from typing import Any, Callable, ContextManager, Iterator, Optional, Tuple +from collections.abc import Iterator +from typing import Any, Callable, Optional import dask import dask.array as da @@ -16,7 +17,9 @@ _DASK_CACHE = Cache(1) _DEFAULT_MEM_FRACTION = 0.25 -DaskIndexer = Callable[[], ContextManager[Optional[Tuple[dict, Cache]]]] +DaskIndexer = Callable[ + [], contextlib.AbstractContextManager[Optional[tuple[dict, Cache]]] +] def resize_dask_cache( @@ -140,8 +143,8 @@ def configure_dask(data: Any, cache: bool = True) -> DaskIndexer: @contextlib.contextmanager def dask_optimized_slicing( memfrac: float = 0.5, - ) -> Iterator[Tuple[Any, Any]]: - opts = {"optimization.fuse.active": False} + ) -> Iterator[tuple[Any, Any]]: + opts = {'optimization.fuse.active': False} with dask.config.set(opts) as cfg, _cache as c: yield cfg, c diff --git a/napari/utils/_dtype.py b/napari/utils/_dtype.py index 4f9d3516228..4c758bbfeee 100644 --- a/napari/utils/_dtype.py +++ b/napari/utils/_dtype.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union +from typing import Union import numpy as np @@ -88,7 +88,7 @@ def normalize_dtype(dtype_spec): return dtype_spec -def get_dtype_limits(dtype_spec) -> Tuple[float, float]: +def get_dtype_limits(dtype_spec) -> tuple[float, float]: """Return machine limits for numeric types. Parameters diff --git a/napari/utils/_indexing.py b/napari/utils/_indexing.py index 03c1099e4d8..5d169e1274c 100644 --- a/napari/utils/_indexing.py +++ b/napari/utils/_indexing.py @@ -1,11 +1,9 @@ -from typing import Dict, Tuple - import numpy as np import numpy.typing as npt def elements_in_slice( - index: Tuple[npt.NDArray[np.int_], ...], position_in_axes: Dict[int, int] + index: tuple[npt.NDArray[np.int_], ...], position_in_axes: dict[int, int] ) -> npt.NDArray[np.bool_]: """Mask elements from a multi-dimensional index not in a given slice. @@ -35,10 +33,10 @@ def elements_in_slice( def index_in_slice( - index: Tuple[npt.NDArray[np.int_], ...], - position_in_axes: Dict[int, int], - indices_order: Tuple[int, ...], -) -> Tuple[npt.NDArray[np.int_], ...]: + index: tuple[npt.NDArray[np.int_], ...], + position_in_axes: dict[int, int], + indices_order: tuple[int, ...], +) -> tuple[npt.NDArray[np.int_], ...]: """Convert a NumPy fancy indexing expression from data to sliced space. Parameters diff --git a/napari/utils/_magicgui.py b/napari/utils/_magicgui.py index 7e587796cdd..f496309e692 100644 --- a/napari/utils/_magicgui.py +++ b/napari/utils/_magicgui.py @@ -15,8 +15,8 @@ from __future__ import annotations import weakref -from functools import lru_cache, partial -from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type +from functools import cache, partial +from typing import TYPE_CHECKING, Any, Optional from typing_extensions import get_args @@ -33,7 +33,7 @@ from napari.viewer import Viewer -def add_layer_data_to_viewer(gui: FunctionGui, result: Any, return_type: Type): +def add_layer_data_to_viewer(gui: FunctionGui, result: Any, return_type: type): """Show a magicgui result in the viewer. This function will be called when a magicgui-decorated function has a @@ -250,7 +250,7 @@ def proxy_viewer_ancestor(widget) -> Optional[PublicOnlyProxy[Viewer]]: return None -def get_layers(gui: CategoricalWidget) -> List[Layer]: +def get_layers(gui: CategoricalWidget) -> list[Layer]: """Retrieve layers matching gui.annotation, from the Viewer the gui is in. Parameters @@ -279,7 +279,7 @@ def get_layers(gui: CategoricalWidget) -> List[Layer]: return [] -def get_layers_data(gui: CategoricalWidget) -> List[Tuple[str, Any]]: +def get_layers_data(gui: CategoricalWidget) -> list[tuple[str, Any]]: """Retrieve layers matching gui.annotation, from the Viewer the gui is in. As opposed to `get_layers`, this function returns just `layer.data` rather @@ -312,7 +312,7 @@ def get_layers_data(gui: CategoricalWidget) -> List[Tuple[str, Any]]: if not (viewer := find_viewer_ancestor(gui.native)): return () - layer_type_name = gui.annotation.__name__.replace("Data", "").title() + layer_type_name = gui.annotation.__name__.replace('Data', '').title() layer_type = getattr(layers, layer_type_name) choices = [] for layer in [x for x in viewer.layers if isinstance(x, layer_type)]: @@ -323,7 +323,7 @@ def get_layers_data(gui: CategoricalWidget) -> List[Tuple[str, Any]]: return choices -@lru_cache(maxsize=None) +@cache def _make_choice_data_setter(gui: CategoricalWidget, choice_name: str): """Return a function that sets the ``data`` for ``choice_name`` in ``gui``. @@ -343,7 +343,7 @@ def setter(event): return setter -def add_layer_to_viewer(gui, result: Any, return_type: Type[Layer]) -> None: +def add_layer_to_viewer(gui, result: Any, return_type: type[Layer]) -> None: """Show a magicgui result in the viewer. Parameters @@ -365,11 +365,11 @@ def add_layer_to_viewer(gui, result: Any, return_type: Type[Layer]) -> None: ... return napari.layers.Image(np.random.rand(64, 64)) """ - add_layers_to_viewer(gui, [result], List[return_type]) + add_layers_to_viewer(gui, [result], list[return_type]) def add_layers_to_viewer( - gui: FunctionGui[Any], result: Any, return_type: Type[List[Layer]] + gui: FunctionGui[Any], result: Any, return_type: type[list[Layer]] ) -> None: """Show a magicgui result in the viewer. diff --git a/napari/utils/_proxies.py b/napari/utils/_proxies.py index d3ba99da3e5..6aa8f0e6d61 100644 --- a/napari/utils/_proxies.py +++ b/napari/utils/_proxies.py @@ -2,14 +2,14 @@ import re import sys import warnings -from typing import Any, Callable, Generic, List, Tuple, TypeVar, Union +from typing import Any, Callable, Generic, TypeVar, Union import wrapt from napari.utils import misc from napari.utils.translations import trans -_T = TypeVar("_T") +_T = TypeVar('_T') class ReadOnlyWrapper(wrapt.ObjectProxy): @@ -17,7 +17,7 @@ class ReadOnlyWrapper(wrapt.ObjectProxy): Disable item and attribute setting with the exception of ``__wrapped__``. """ - def __init__(self, wrapped: Any, exceptions: Tuple[str, ...] = ()): + def __init__(self, wrapped: Any, exceptions: tuple[str, ...] = ()): super().__init__(wrapped) self._self_exceptions = exceptions @@ -54,7 +54,7 @@ class PublicOnlyProxy(wrapt.ObjectProxy, Generic[_T]): @staticmethod def _is_private_attr(name: str) -> bool: - return name.startswith("_") and not ( + return name.startswith('_') and not ( name.startswith('__') and name.endswith('__') ) @@ -86,7 +86,7 @@ def _is_called_from_napari() -> bool: """ Check if the getter or setter is called from inner napari. """ - if hasattr(sys, "_getframe"): + if hasattr(sys, '_getframe'): frame = sys._getframe(2) return frame.f_code.co_filename.startswith(misc.ROOT_DIR) return False @@ -109,11 +109,11 @@ def __getattr__(self, name: str) -> Any: def __setattr__(self, name: str, value: Any) -> None: if ( - os.environ.get("NAPARI_ENSURE_PLUGIN_MAIN_THREAD", "0") - not in ("0", "False") + os.environ.get('NAPARI_ENSURE_PLUGIN_MAIN_THREAD', '0') + not in ('0', 'False') ) and not in_main_thread(): raise RuntimeError( - "Setting attributes on a napari object is only allowed from the main Qt thread." + 'Setting attributes on a napari object is only allowed from the main Qt thread.' ) if self._is_private_attr(name): @@ -150,7 +150,7 @@ def __getitem__(self, key: Any) -> Any: def __repr__(self) -> str: return repr(self.__wrapped__) - def __dir__(self) -> List[str]: + def __dir__(self) -> list[str]: return [x for x in dir(self.__wrapped__) if not _SUNDER.match(x)] @classmethod @@ -230,7 +230,7 @@ def _in_main_thread() -> bool: return in_main_thread_py() except AttributeError: warnings.warn( - "Qt libs are available but no QtApplication instance is created" + 'Qt libs are available but no QtApplication instance is created' ) return in_main_thread_py() return res diff --git a/napari/utils/_register.py b/napari/utils/_register.py index 6ceb966bbd1..5268fd4f2ee 100644 --- a/napari/utils/_register.py +++ b/napari/utils/_register.py @@ -62,7 +62,7 @@ def create_func(cls, name=None, doc=None, filename: str = ''): sig = signature(cls) additional_parameters = [] - if hasattr(cls.__init__, "_deprecated_constructor_args"): + if hasattr(cls.__init__, '_deprecated_constructor_args'): additional_parameters = [ Parameter( name=arg, @@ -73,7 +73,7 @@ def create_func(cls, name=None, doc=None, filename: str = ''): ] new_sig = sig.replace( parameters=[ - Parameter("self", Parameter.POSITIONAL_OR_KEYWORD), + Parameter('self', Parameter.POSITIONAL_OR_KEYWORD), *list(sig.parameters.values()), *additional_parameters, ], @@ -93,7 +93,7 @@ def create_func(cls, name=None, doc=None, filename: str = ''): func.__doc__ = doc func.__signature__ = sig.replace( parameters=[ - Parameter("self", Parameter.POSITIONAL_OR_KEYWORD), + Parameter('self', Parameter.POSITIONAL_OR_KEYWORD), *list(sig.parameters.values()), ], return_annotation=cls, diff --git a/napari/utils/_test_utils.py b/napari/utils/_test_utils.py index fdc43a89c5b..aa665b7d0b7 100644 --- a/napari/utils/_test_utils.py +++ b/napari/utils/_test_utils.py @@ -3,7 +3,7 @@ """ from dataclasses import dataclass, field -from typing import List, Optional, Tuple, Union +from typing import Optional, Union import numpy as np @@ -16,18 +16,18 @@ class MouseEvent: type: str is_dragging: bool = False - modifiers: List[str] = field(default_factory=list) - position: Union[Tuple[int, int], Tuple[int, int, int]] = ( + modifiers: list[str] = field(default_factory=list) + position: Union[tuple[int, int], tuple[int, int, int]] = ( 0, 0, ) # world coords pos: np.ndarray = field( default_factory=lambda: np.zeros(2) ) # canvas coords - view_direction: Optional[List[float]] = None - up_direction: Optional[List[float]] = None - dims_displayed: List[int] = field(default_factory=lambda: [0, 1]) - delta: Optional[Tuple[float, float]] = None + view_direction: Optional[list[float]] = None + up_direction: Optional[list[float]] = None + dims_displayed: list[int] = field(default_factory=lambda: [0, 1]) + delta: Optional[tuple[float, float]] = None native: Optional[bool] = None diff --git a/napari/utils/_tests/test_action_manager.py b/napari/utils/_tests/test_action_manager.py index c3b04548c2e..443e2ce524a 100644 --- a/napari/utils/_tests/test_action_manager.py +++ b/napari/utils/_tests/test_action_manager.py @@ -58,11 +58,11 @@ def _sample_generator(): yield 'X' action_manager.register_action( - "napari:test_action_1", + 'napari:test_action_1', _sample_generator, - "this is a test action", + 'this is a test action', None, ) - with pytest.raises(ValueError, match="generator functions"): + with pytest.raises(ValueError, match='generator functions'): action_manager.bind_button('napari:test_action_1', Mock()) diff --git a/napari/utils/_tests/test_compat.py b/napari/utils/_tests/test_compat.py index 829fbd41b72..8c5052f98d9 100644 --- a/napari/utils/_tests/test_compat.py +++ b/napari/utils/_tests/test_compat.py @@ -3,12 +3,12 @@ def test_str_enum(): class Cake(StrEnum): - CHOC = "chocolate" - VANILLA = "vanilla" - STRAWBERRY = "strawberry" + CHOC = 'chocolate' + VANILLA = 'vanilla' + STRAWBERRY = 'strawberry' - assert Cake.CHOC == "chocolate" + assert Cake.CHOC == 'chocolate' assert Cake.CHOC == Cake.CHOC - assert f'{Cake.CHOC}' == "chocolate" - assert Cake.CHOC != "vanilla" + assert f'{Cake.CHOC}' == 'chocolate' + assert Cake.CHOC != 'vanilla' assert Cake.CHOC != Cake.VANILLA diff --git a/napari/utils/_tests/test_dtype.py b/napari/utils/_tests/test_dtype.py index 45359694c0c..fa9d5f8600b 100644 --- a/napari/utils/_tests/test_dtype.py +++ b/napari/utils/_tests/test_dtype.py @@ -65,7 +65,7 @@ def test_pure_python_types(dtype_str): @pytest.mark.parametrize( - "dtype", + 'dtype', [ int, 'uint8', diff --git a/napari/utils/_tests/test_geometry.py b/napari/utils/_tests/test_geometry.py index 6bedba05d29..e28e2834ed1 100644 --- a/napari/utils/_tests/test_geometry.py +++ b/napari/utils/_tests/test_geometry.py @@ -34,7 +34,7 @@ @pytest.mark.parametrize( - "point,expected_projected_point,expected_distances", + 'point,expected_projected_point,expected_distances', [ (single_point, expected_point_single, expected_distance_single), (multiple_point, expected_multiple_point, expected_distance_multiple), @@ -54,7 +54,7 @@ def test_project_point_to_plane( @pytest.mark.parametrize( - "vec_1, vec_2", + 'vec_1, vec_2', [ (np.array([10, 0]), np.array([0, 5])), (np.array([0, 5]), np.array([0, 5])), @@ -73,7 +73,7 @@ def test_rotation_matrix_from_vectors_2d(vec_1, vec_2): @pytest.mark.parametrize( - "vec_1, vec_2", + 'vec_1, vec_2', [ (np.array([10, 0, 0]), np.array([0, 5, 0])), (np.array([0, 5, 0]), np.array([0, 5, 0])), @@ -93,7 +93,7 @@ def test_rotation_matrix_from_vectors_3d(vec_1, vec_2): @pytest.mark.parametrize( - "line_position, line_direction, plane_position, plane_normal, expected", + 'line_position, line_direction, plane_position, plane_normal, expected', [ ([0, 0, 1], [0, 0, -1], [0, 0, 0], [0, 0, 1], [0, 0, 0]), ([1, 1, 1], [-1, -1, -1], [0, 0, 0], [0, 0, 1], [0, 0, 0]), @@ -127,7 +127,7 @@ def test_intersect_line_with_multiple_planes_3d(): @pytest.mark.parametrize( - "point, bounding_box, expected", + 'point, bounding_box, expected', [ ([5, 5, 5], np.array([[0, 10], [0, 10], [0, 10]]), [5, 5, 5]), ([10, 10, 10], np.array([[0, 10], [0, 10], [0, 10]]), [9, 9, 9]), @@ -485,7 +485,7 @@ def test_line_in_triangles_3d(): @pytest.mark.parametrize( - "ray_start,ray_direction,expected_index,expected_position", + 'ray_start,ray_direction,expected_index,expected_position', [ ([0, 1, 1], [1, 0, 0], 0, [3, 1, 1]), ([6, 1, 1], [-1, 0, 0], 1, [5, 1, 1]), diff --git a/napari/utils/_tests/test_history.py b/napari/utils/_tests/test_history.py index 7ba42d146aa..7159725c87c 100644 --- a/napari/utils/_tests/test_history.py +++ b/napari/utils/_tests/test_history.py @@ -15,7 +15,7 @@ def test_open_history(): def test_update_open_history(tmpdir): - new_folder = Path(tmpdir) / "some-file.svg" + new_folder = Path(tmpdir) / 'some-file.svg' update_open_history(new_folder) assert str(new_folder.parent) in get_open_history() @@ -27,6 +27,6 @@ def test_save_history(): def test_update_save_history(tmpdir): - new_folder = Path(tmpdir) / "some-file.svg" + new_folder = Path(tmpdir) / 'some-file.svg' update_save_history(new_folder) assert str(new_folder.parent) in get_save_history() diff --git a/napari/utils/_tests/test_info.py b/napari/utils/_tests/test_info.py index 1cc7b5af05f..b0094d43534 100644 --- a/napari/utils/_tests/test_info.py +++ b/napari/utils/_tests/test_info.py @@ -10,34 +10,34 @@ def test_citation_text(): def test_linux_os_name_file(monkeypatch, tmp_path): - with open(tmp_path / "os-release", "w") as f_p: + with open(tmp_path / 'os-release', 'w') as f_p: f_p.write('PRETTY_NAME="Test text"\n') - monkeypatch.setattr(info, "OS_RELEASE_PATH", str(tmp_path / "os-release")) + monkeypatch.setattr(info, 'OS_RELEASE_PATH', str(tmp_path / 'os-release')) - assert info._linux_sys_name() == "Test text" + assert info._linux_sys_name() == 'Test text' - with open(tmp_path / "os-release", "w") as f_p: + with open(tmp_path / 'os-release', 'w') as f_p: f_p.write('NAME="Test2"\nVERSION="text"') - assert info._linux_sys_name() == "Test2 text" + assert info._linux_sys_name() == 'Test2 text' - with open(tmp_path / "os-release", "w") as f_p: + with open(tmp_path / 'os-release', 'w') as f_p: f_p.write('NAME="Test2"\nVERSION_ID="text2"') - assert info._linux_sys_name() == "Test2 text2" + assert info._linux_sys_name() == 'Test2 text2' - with open(tmp_path / "os-release", "w") as f_p: + with open(tmp_path / 'os-release', 'w') as f_p: f_p.write('NAME="Test2"\nVERSION="text"\nVERSION_ID="text2"') - assert info._linux_sys_name() == "Test2 text" + assert info._linux_sys_name() == 'Test2 text' - with open(tmp_path / "os-release", "w") as f_p: + with open(tmp_path / 'os-release', 'w') as f_p: f_p.write( 'PRETTY_NAME="Test text"\nNAME="Test2"\nVERSION="text"\nVERSION_ID="text2"' ) - assert info._linux_sys_name() == "Test text" + assert info._linux_sys_name() == 'Test text' class _CompletedProcessMock(NamedTuple): @@ -57,8 +57,8 @@ def _lsb_mock2(*_args, **_kwargs): def test_linux_os_name_lsb(monkeypatch, tmp_path): - monkeypatch.setattr(info, "OS_RELEASE_PATH", str(tmp_path / "os-release")) - monkeypatch.setattr(subprocess, "run", _lsb_mock) - assert info._linux_sys_name() == "Ubuntu Test 20.04" - monkeypatch.setattr(subprocess, "run", _lsb_mock2) - assert info._linux_sys_name() == "Ubuntu Test 20.05" + monkeypatch.setattr(info, 'OS_RELEASE_PATH', str(tmp_path / 'os-release')) + monkeypatch.setattr(subprocess, 'run', _lsb_mock) + assert info._linux_sys_name() == 'Ubuntu Test 20.04' + monkeypatch.setattr(subprocess, 'run', _lsb_mock2) + assert info._linux_sys_name() == 'Ubuntu Test 20.05' diff --git a/napari/utils/_tests/test_io.py b/napari/utils/_tests/test_io.py index e0e8294a653..39bd32ae86d 100644 --- a/napari/utils/_tests/test_io.py +++ b/napari/utils/_tests/test_io.py @@ -6,7 +6,7 @@ @pytest.mark.parametrize( - "image_file", ["image", "image.png", "image.tif", "image.bmp"] + 'image_file', ['image', 'image.png', 'image.tif', 'image.bmp'] ) def test_imsave(tmp_path, image_file): """Save data to image file.""" diff --git a/napari/utils/_tests/test_key_bindings.py b/napari/utils/_tests/test_key_bindings.py index 5b63577e22f..03742ee46fe 100644 --- a/napari/utils/_tests/test_key_bindings.py +++ b/napari/utils/_tests/test_key_bindings.py @@ -25,7 +25,7 @@ def forty_two(): return 42 bind_key(kb, 'A', forty_two) - assert kb == {KeyBinding.from_str("A"): forty_two} + assert kb == {KeyBinding.from_str('A'): forty_two} # overwrite def spam(): @@ -35,7 +35,7 @@ def spam(): bind_key(kb, 'A', spam) bind_key(kb, 'A', spam, overwrite=True) - assert kb == {KeyBinding.from_str("A"): spam} + assert kb == {KeyBinding.from_str('A'): spam} # unbind bind_key(kb, 'A', None) @@ -67,7 +67,7 @@ def test_bind_key_decorator(): @bind_key(kb, 'A') def foo(): ... - assert kb == {KeyBinding.from_str("A"): foo} + assert kb == {KeyBinding.from_str('A'): foo} def test_keymap_provider(): @@ -375,7 +375,7 @@ def test_bind_key_doc(): def test_key_release_callback(monkeypatch): called = False called2 = False - monkeypatch.setattr(time, "time", lambda: 1) + monkeypatch.setattr(time, 'time', lambda: 1) class Foo(KeymapProvider): ... @@ -388,21 +388,21 @@ def _call(): nonlocal called2 called2 = True - @Foo.bind_key("K") + @Foo.bind_key('K') def callback(x): nonlocal called called = True return _call - handler.press_key("K") + handler.press_key('K') assert called assert not called2 - handler.release_key("K") + handler.release_key('K') assert not called2 - handler.press_key("K") + handler.press_key('K') assert called assert not called2 - monkeypatch.setattr(time, "time", lambda: 2) - handler.release_key("K") + monkeypatch.setattr(time, 'time', lambda: 2) + handler.release_key('K') assert called2 diff --git a/napari/utils/_tests/test_migrations.py b/napari/utils/_tests/test_migrations.py index 458e7b216da..ee3323ec684 100644 --- a/napari/utils/_tests/test_migrations.py +++ b/napari/utils/_tests/test_migrations.py @@ -8,7 +8,7 @@ def test_simple(): - @rename_argument("a", "b", "1", "0.5") + @rename_argument('a', 'b', '1', '0.5') def sample_fun(b): return b @@ -22,7 +22,7 @@ def sample_fun(b): def test_constructor(): class Sample: - @rename_argument("a", "b", "1", "0.5") + @rename_argument('a', 'b', '1', '0.5') def __init__(self, b) -> None: self.b = b @@ -48,14 +48,14 @@ def new_property(self, value: int) -> int: instance = Dummy() add_deprecated_property( - Dummy, "old_property", "new_property", "0.1.0", "0.0.0" + Dummy, 'old_property', 'new_property', '0.1.0', '0.0.0' ) assert instance.new_property == 0 instance.new_property = 1 - msg = "Dummy.old_property is deprecated since 0.0.0 and will be removed in 0.1.0. Please use new_property" + msg = 'Dummy.old_property is deprecated since 0.0.0 and will be removed in 0.1.0. Please use new_property' with pytest.warns(FutureWarning, match=msg): assert instance.old_property == 1 diff --git a/napari/utils/_tests/test_misc.py b/napari/utils/_tests/test_misc.py index cfebd76008d..af32fd74e0c 100644 --- a/napari/utils/_tests/test_misc.py +++ b/napari/utils/_tests/test_misc.py @@ -163,7 +163,7 @@ class AnotherTestEnum(StringEnum): def test_abspath_or_url(): - relpath = "~" + sep + "something" + relpath = '~' + sep + 'something' assert abspath_or_url(relpath) == expanduser(relpath) assert abspath_or_url('something') == abspath('something') assert abspath_or_url(sep + 'something') == abspath(sep + 'something') @@ -208,8 +208,8 @@ class MyNPArray(np.ndarray): @pytest.mark.skipif( - parse_version(package_version("numpy")) >= parse_version("1.25.0"), - reason="Numpy 1.25.0 return true for below comparison", + parse_version(package_version('numpy')) >= parse_version('1.25.0'), + reason='Numpy 1.25.0 return true for below comparison', ) def test_equality_operator_silence(): import numpy as np @@ -238,8 +238,8 @@ def test_is_array_type_with_xarray(): ([([1, 10],)], [([1, 10],)]), ([([1, 10], {'name': 'hi'})], [([1, 10], {'name': 'hi'})]), ( - [([1, 10], {'name': 'hi'}, "image")], - [([1, 10], {'name': 'hi'}, "image")], + [([1, 10], {'name': 'hi'}, 'image')], + [([1, 10], {'name': 'hi'}, 'image')], ), ([], []), ], diff --git a/napari/utils/_tests/test_naming.py b/napari/utils/_tests/test_naming.py index afa2e33f3b5..6b53e600fb4 100644 --- a/napari/utils/_tests/test_naming.py +++ b/napari/utils/_tests/test_naming.py @@ -116,7 +116,7 @@ def bar(y): def test_empty_path_prefix(): """Test an empty path prefix that matches the entire stack""" # Repeat tests with an empty path_prefix - mname = functools.partial(magic_name, path_prefix="") + mname = functools.partial(magic_name, path_prefix='') def foo(x): def bar(y): diff --git a/napari/utils/_tests/test_notification_manager.py b/napari/utils/_tests/test_notification_manager.py index fa8272845ad..4f2c9fd0e56 100644 --- a/napari/utils/_tests/test_notification_manager.py +++ b/napari/utils/_tests/test_notification_manager.py @@ -1,7 +1,6 @@ import sys import threading import warnings -from typing import List import pytest @@ -31,7 +30,7 @@ class PurposefulException(Exception): def test_notification_repr_has_message(): assert "='this is the message'" in repr( - Notification("this is the message") + Notification('this is the message') ) @@ -46,7 +45,7 @@ def test_notification_manager_no_gui(monkeypatch): with notification_manager: notification_manager.records.clear() # save all of the events that get emitted - store: List[Notification] = [] + store: list[Notification] = [] notification_manager.notification_ready.connect(store.append) show_info('this is one way of showing an information message') @@ -65,12 +64,12 @@ def test_notification_manager_no_gui(monkeypatch): # test that exceptions that go through sys.excepthook are catalogued with pytest.raises(PurposefulException): - raise PurposefulException("this is an exception") + raise PurposefulException('this is an exception') # pytest intercepts the error, so we can manually call sys.excepthook assert sys.excepthook == notification_manager.receive_error try: - raise ValueError("a") + raise ValueError('a') except ValueError: sys.excepthook(*sys.exc_info()) assert len(notification_manager.records) == 3 @@ -119,14 +118,14 @@ def _warn(): def _raise(): with pytest.raises(PurposefulException): - raise PurposefulException("this is an exception") + raise PurposefulException('this is an exception') previous_threading_exhook = threading.excepthook with notification_manager: notification_manager.records.clear() # save all of the events that get emitted - store: List[Notification] = [] + store: list[Notification] = [] notification_manager.notification_ready.connect(store.append) # Test exception inside threads @@ -139,7 +138,7 @@ def _raise(): exception_thread.join(timeout=DEFAULT_TIMEOUT_SECS) try: - raise ValueError("a") + raise ValueError('a') except ValueError: threading.excepthook(sys.exc_info()) @@ -173,7 +172,7 @@ def fun(): with notification_manager: notification_manager.records.clear() # save all of the events that get emitted - store: List[Notification] = [] + store: list[Notification] = [] notification_manager.notification_ready.connect(store.append) fun() diff --git a/napari/utils/_tests/test_progress.py b/napari/utils/_tests/test_progress.py index 7e1077b67cf..9ba7da1cea0 100644 --- a/napari/utils/_tests/test_progress.py +++ b/napari/utils/_tests/test_progress.py @@ -80,9 +80,9 @@ def test_progress_update(): def test_progress_set_description(): """Test setting description works as expected""" pbr = progress(total=5) - pbr.set_description("Test") + pbr.set_description('Test') - assert pbr.desc == "Test: " + assert pbr.desc == 'Test: ' pbr.close() assert pbr not in progress._all_instances @@ -93,10 +93,10 @@ def test_progress_set_disable(): # before the changes in #5964 this failed with an AttributeError, because self.desc was not # set in the super constructor of tqdm pbr = progress( - total=5, disable=True, desc="This description will not be set by tqdm." + total=5, disable=True, desc='This description will not be set by tqdm.' ) # make sure the dummy desscription (empty string) was set - assert pbr.desc == "progress: " + assert pbr.desc == 'progress: ' pbr.close() diff --git a/napari/utils/_tests/test_proxies.py b/napari/utils/_tests/test_proxies.py index 671e01aedbf..539f2c7e7af 100644 --- a/napari/utils/_tests/test_proxies.py +++ b/napari/utils/_tests/test_proxies.py @@ -79,7 +79,7 @@ def __getitem__(self, key): assert '_private' in dir(t) -@pytest.mark.filterwarnings("ignore:Qt libs are available but") +@pytest.mark.filterwarnings('ignore:Qt libs are available but') def test_thread_proxy_guard(monkeypatch, single_threaded_executor): class X: a = 1 @@ -145,7 +145,7 @@ def test_unwrap_on_call(): """ evset = EventedSet() public_only_evset = PublicOnlyProxy(evset) - text = "aaa" + text = 'aaa' wrapped_text = PublicOnlyProxy(text) public_only_evset.add(wrapped_text) retrieved_text = next(iter(evset)) @@ -162,12 +162,12 @@ def test_unwrap_setattr(): @dataclass class Sample: - attribute = "aaa" + attribute = 'aaa' sample = Sample() public_only_sample = PublicOnlyProxy(sample) - text = "bbb" + text = 'bbb' wrapped_text = PublicOnlyProxy(text) public_only_sample.attribute = wrapped_text diff --git a/napari/utils/_tests/test_register.py b/napari/utils/_tests/test_register.py index 07faa129e4c..58c403080c1 100644 --- a/napari/utils/_tests/test_register.py +++ b/napari/utils/_tests/test_register.py @@ -21,7 +21,7 @@ def __init__(self, a): class SimpleClassDeprecated: """Simple class to test create_func""" - @deprecated_constructor_arg_by_attr("b") + @deprecated_constructor_arg_by_attr('b') def __init__(self, a=1): self.a = a diff --git a/napari/utils/_tests/test_status.py b/napari/utils/_tests/test_status.py index 7e97cae4eaa..752d729cd74 100644 --- a/napari/utils/_tests/test_status.py +++ b/napari/utils/_tests/test_status.py @@ -3,10 +3,10 @@ from napari.utils.status_messages import status_format -STRING = "hello world" +STRING = 'hello world' STRING_FORMATTED = STRING MISSING = None -MISSING_FORMATTED = "" +MISSING_FORMATTED = '' NUMERIC = [ 1, 10, @@ -20,10 +20,10 @@ np.exp(1), ] NUMERIC_FORMATTED = ( - "[1, 10, 100, 1000, 1e+06, -6.28, 124, 1.12e+03, 6.28, 2.72]" + '[1, 10, 100, 1000, 1e+06, -6.28, 124, 1.12e+03, 6.28, 2.72]' ) COMBINED = [1e6, MISSING, STRING] -COMBINED_FORMATTED = f"[1e+06, {MISSING_FORMATTED}, {STRING_FORMATTED}]" +COMBINED_FORMATTED = f'[1e+06, {MISSING_FORMATTED}, {STRING_FORMATTED}]' @pytest.mark.parametrize( diff --git a/napari/utils/_tests/test_theme.py b/napari/utils/_tests/test_theme.py index 414c151e32c..b2003698518 100644 --- a/napari/utils/_tests/test_theme.py +++ b/napari/utils/_tests/test_theme.py @@ -29,11 +29,11 @@ def test_default_themes(): def test_get_theme(): # get theme in the old-style dict format - theme = get_theme("dark").to_rgb_dict() + theme = get_theme('dark').to_rgb_dict() assert isinstance(theme, dict) # get theme in the new model-based format - theme = get_theme("dark") + theme = get_theme('dark') assert isinstance(theme, Theme) @@ -59,7 +59,7 @@ def test_register_theme(): ) # Register blue theme - register_theme('test_blue', blue_theme, "test") + register_theme('test_blue', blue_theme, 'test') # Check that blue theme is listed in available themes themes = available_themes() @@ -73,8 +73,8 @@ def test_register_theme(): theme = get_theme('test_blue').to_rgb_dict() assert theme['background'] == blue_theme['background'] - theme = get_theme("test_blue") - assert theme.background.as_rgb() == blue_theme["background"] + theme = get_theme('test_blue') + assert theme.background.as_rgb() == blue_theme['background'] def test_unregister_theme(): @@ -88,39 +88,39 @@ def test_unregister_theme(): ) # Register blue theme - register_theme('test_blue', blue_theme, "test") + register_theme('test_blue', blue_theme, 'test') # Check that blue theme is listed in available themes themes = available_themes() assert 'test_blue' in themes # Remove theme from available themes - unregister_theme("test_blue") + unregister_theme('test_blue') themes = available_themes() assert 'test_blue' not in themes def test_rebuild_theme_settings(): settings = get_settings() - assert "another-theme" not in available_themes() + assert 'another-theme' not in available_themes() # theme is not updated with pytest.raises(ValidationError): - settings.appearance.theme = "another-theme" - blue_theme = get_theme("dark").to_rgb_dict() - register_theme("another-theme", blue_theme, "test") - settings.appearance.theme = "another-theme" + settings.appearance.theme = 'another-theme' + blue_theme = get_theme('dark').to_rgb_dict() + register_theme('another-theme', blue_theme, 'test') + settings.appearance.theme = 'another-theme' @pytest.mark.skipif( os.getenv('CI') and sys.version_info < (3, 9), - reason="Testing theme on CI is extremely slow ~ 15s per test." - "Skip for now until we find the reason", + reason='Testing theme on CI is extremely slow ~ 15s per test.' + 'Skip for now until we find the reason', ) @pytest.mark.parametrize( - "color", + 'color', [ - "#FF0000", - "white", + '#FF0000', + 'white', (0, 127, 127), (0, 255, 255, 0.5), [50, 200, 200], @@ -128,85 +128,85 @@ def test_rebuild_theme_settings(): ], ) def test_theme(color): - theme = get_theme("dark") + theme = get_theme('dark') theme.background = color @pytest.mark.skipif( os.getenv('CI') and sys.version_info < (3, 9), - reason="Testing theme on CI is extremely slow ~ 15s per test." - "Skip for now until we find the reason", + reason='Testing theme on CI is extremely slow ~ 15s per test.' + 'Skip for now until we find the reason', ) def test_theme_font_size(): - theme = get_theme("dark") - theme.font_size = "15pt" - assert theme.font_size == "15pt" + theme = get_theme('dark') + theme.font_size = '15pt' + assert theme.font_size == '15pt' with pytest.raises(ValidationError): - theme.font_size = "0pt" + theme.font_size = '0pt' with pytest.raises(ValidationError): - theme.font_size = "12px" + theme.font_size = '12px' def test_theme_syntax_highlight(): - theme = get_theme("dark") + theme = get_theme('dark') with pytest.raises(ValidationError): - theme.syntax_style = "invalid" + theme.syntax_style = 'invalid' def test_is_theme_available(tmp_path, monkeypatch): - (tmp_path / "test_blue").mkdir() - (tmp_path / "yellow").mkdir() - (tmp_path / "test_blue" / PLUGIN_FILE_NAME).write_text("test-blue") + (tmp_path / 'test_blue').mkdir() + (tmp_path / 'yellow').mkdir() + (tmp_path / 'test_blue' / PLUGIN_FILE_NAME).write_text('test-blue') monkeypatch.setattr( - "napari.utils.theme._theme_path", lambda x: tmp_path / x + 'napari.utils.theme._theme_path', lambda x: tmp_path / x ) n_themes = len(available_themes()) def mock_install_theme(_themes): - theme_dict = _themes["dark"].to_rgb_dict() - theme_dict["id"] = "test_blue" - register_theme("test_blue", theme_dict, "test") + theme_dict = _themes['dark'].to_rgb_dict() + theme_dict['id'] = 'test_blue' + register_theme('test_blue', theme_dict, 'test') monkeypatch.setattr( - "napari.utils.theme._install_npe2_themes", mock_install_theme + 'napari.utils.theme._install_npe2_themes', mock_install_theme ) assert len(available_themes()) == n_themes - assert is_theme_available("dark") - assert not is_theme_available("green") - assert not is_theme_available("yellow") - assert is_theme_available("test_blue") + assert is_theme_available('dark') + assert not is_theme_available('green') + assert not is_theme_available('yellow') + assert is_theme_available('test_blue') assert len(available_themes()) == n_themes + 1 - assert "test_blue" in available_themes() + assert 'test_blue' in available_themes() @pytest.mark.skipif( - parse_version(npe2_version) < parse_version("0.6.2"), - reason="requires npe2 0.6.2 for syntax style contributions", + parse_version(npe2_version) < parse_version('0.6.2'), + reason='requires npe2 0.6.2 for syntax style contributions', ) def test_theme_registration(monkeypatch, caplog): - data = {"dark": get_theme("dark")} + data = {'dark': get_theme('dark')} manifest = PluginManifest( - name="theme_test", - display_name="Theme Test", + name='theme_test', + display_name='Theme Test', contributions=ContributionPoints( themes=[ { - "id": "theme1", - "label": "Theme 1", - "type": "dark", - "syntax_style": "native", - "colors": {}, + 'id': 'theme1', + 'label': 'Theme 1', + 'type': 'dark', + 'syntax_style': 'native', + 'colors': {}, }, { - "id": "theme2", - "label": "Theme 2", - "type": "dark", - "syntax_style": "does_not_exist", - "colors": {}, + 'id': 'theme2', + 'label': 'Theme 2', + 'type': 'dark', + 'syntax_style': 'does_not_exist', + 'colors': {}, }, ] ), @@ -216,11 +216,11 @@ def mock_iter_manifests(disabled): return [manifest] monkeypatch.setattr( - PluginManager.instance(), "iter_manifests", mock_iter_manifests + PluginManager.instance(), 'iter_manifests', mock_iter_manifests ) - monkeypatch.setattr("napari.utils.theme._themes", data) + monkeypatch.setattr('napari.utils.theme._themes', data) _install_npe2_themes(data) - assert "theme1" in data - assert "theme2" not in data - assert "Registration theme failed" in caplog.text + assert 'theme1' in data + assert 'theme2' not in data + assert 'Registration theme failed' in caplog.text diff --git a/napari/utils/_tests/test_translations.py b/napari/utils/_tests/test_translations.py index 7a9a390d04b..96356efd5f2 100644 --- a/napari/utils/_tests/test_translations.py +++ b/napari/utils/_tests/test_translations.py @@ -14,9 +14,9 @@ translator, ) -TEST_LOCALE = "es_CO" +TEST_LOCALE = 'es_CO' HERE = Path(__file__).parent -TEST_LANGUAGE_PACK_PATH = HERE / "napari-language-pack-es_CO" +TEST_LANGUAGE_PACK_PATH = HERE / 'napari-language-pack-es_CO' es_CO_po = r"""msgid "" @@ -84,23 +84,23 @@ @pytest.fixture def trans(tmp_path): """A good plugin that uses entry points.""" - distinfo = tmp_path / "napari_language_pack_es_CO-0.1.0.dist-info" + distinfo = tmp_path / 'napari_language_pack_es_CO-0.1.0.dist-info' distinfo.mkdir() - (distinfo / "top_level.txt").write_text('napari_language_pack_es_CO') - (distinfo / "entry_points.txt").write_text( - "[napari.languagepack]\nes_CO = napari_language_pack_es_CO\n" + (distinfo / 'top_level.txt').write_text('napari_language_pack_es_CO') + (distinfo / 'entry_points.txt').write_text( + '[napari.languagepack]\nes_CO = napari_language_pack_es_CO\n' ) - (distinfo / "METADATA").write_text( - "Metadata-Version: 2.1\n" - "Name: napari-language-pack-es-CO\n" - "Version: 0.1.0\n" + (distinfo / 'METADATA').write_text( + 'Metadata-Version: 2.1\n' + 'Name: napari-language-pack-es-CO\n' + 'Version: 0.1.0\n' ) pkgdir = tmp_path / 'napari_language_pack_es_CO' msgs = pkgdir / 'locale' / 'es_CO' / 'LC_MESSAGES' msgs.mkdir(parents=True) (pkgdir / '__init__.py').touch() - (msgs / "napari.po").write_text(es_CO_po) - (msgs / "napari.mo").write_bytes(es_CO_mo) + (msgs / 'napari.po').write_text(es_CO_po) + (msgs / 'napari.mo').write_bytes(es_CO_mo) from napari_plugin_engine.manager import temp_path_additions @@ -118,29 +118,29 @@ def test_get_language_packs(trans): def test_get_display_name_valid(): - assert _get_display_name("en", "en") == "English" - assert _get_display_name("en", "es") == "Inglés" - assert _get_display_name("en", "es_CO") == "Inglés" - assert _get_display_name("en", "fr") == "Anglais" - assert _get_display_name("es", "en") == "Spanish" - assert _get_display_name("fr", "en") == "French" + assert _get_display_name('en', 'en') == 'English' + assert _get_display_name('en', 'es') == 'Inglés' + assert _get_display_name('en', 'es_CO') == 'Inglés' + assert _get_display_name('en', 'fr') == 'Anglais' + assert _get_display_name('es', 'en') == 'Spanish' + assert _get_display_name('fr', 'en') == 'French' def test_get_display_name_invalid(): - assert _get_display_name("en", "foo") == "English" - assert _get_display_name("foo", "en") == "English" - assert _get_display_name("foo", "bar") == "English" + assert _get_display_name('en', 'foo') == 'English' + assert _get_display_name('foo', 'en') == 'English' + assert _get_display_name('foo', 'bar') == 'English' def test_is_valid_locale_valid(): - assert _is_valid_locale("en") - assert _is_valid_locale("es") - assert _is_valid_locale("es_CO") + assert _is_valid_locale('en') + assert _is_valid_locale('es') + assert _is_valid_locale('es_CO') def test_is_valid_locale_invalid(): - assert not _is_valid_locale("foo_SPAM") - assert not _is_valid_locale("bar") + assert not _is_valid_locale('foo_SPAM') + assert not _is_valid_locale('bar') def test_load_language_valid(tmp_path): @@ -149,19 +149,19 @@ def test_load_language_valid(tmp_path): application: language: es_ES """ - temp_config_path = tmp_path / "tempconfig.yml" - with open(temp_config_path, "w") as fh: + temp_config_path = tmp_path / 'tempconfig.yml' + with open(temp_config_path, 'w') as fh: fh.write(data) result = _load_language(temp_config_path) - assert result == "es_ES" + assert result == 'es_ES' def test_load_language_invalid(tmp_path): # This is invalid content - data = ":" - temp_config_path = tmp_path / "tempconfig.yml" - with open(temp_config_path, "w") as fh: + data = ':' + temp_config_path = tmp_path / 'tempconfig.yml' + with open(temp_config_path, 'w') as fh: fh.write(data) with pytest.warns(UserWarning): @@ -172,31 +172,31 @@ def test_locale_invalid(): with pytest.warns(UserWarning): translator._set_locale(TEST_LOCALE) trans = translator.load() - result = trans._("BOO") - assert result == "BOO" + result = trans._('BOO') + assert result == 'BOO' # Test trans methods # ------------------ def test_locale_singular(trans): - expected_result = "Más sobre napari" - result = trans._("More about napari") + expected_result = 'Más sobre napari' + result = trans._('More about napari') assert result == expected_result def test_locale_singular_with_format(trans): variable = 1 - singular = "More about napari with {variable}" - expected_result = f"Más sobre napari con {variable}" + singular = 'More about napari with {variable}' + expected_result = f'Más sobre napari con {variable}' result = trans._(singular, variable=variable) assert result == expected_result def test_locale_singular_deferred_with_format(trans): variable = 1 - singular = "More about napari with {variable}" - original_result = f"More about napari with {variable}" - translated_result = f"Más sobre napari con {variable}" + singular = 'More about napari with {variable}' + original_result = f'More about napari with {variable}' + translated_result = f'Más sobre napari con {variable}' result = trans._(singular, deferred=True, variable=variable) assert isinstance(result, TranslationString) assert result.translation() == translated_result @@ -205,29 +205,29 @@ def test_locale_singular_deferred_with_format(trans): def test_locale_singular_context(trans): - context = "singular-context" - singular = "More about napari with context" + context = 'singular-context' + singular = 'More about napari with context' result = trans._p(context, singular) - assert result == "Más sobre napari con contexto" + assert result == 'Más sobre napari con contexto' def test_locale_singular_context_with_format(trans): - context = "singular-context-variables" + context = 'singular-context-variables' variable = 1 - singular = "More about napari with context and {variable}" + singular = 'More about napari with context and {variable}' result = trans._p(context, singular, variable=variable) - assert result == f"Más sobre napari con contexto y {variable}" + assert result == f'Más sobre napari con contexto y {variable}' def test_locale_singular_context_deferred_with_format(trans): - context = "singular-context-variables" + context = 'singular-context-variables' variable = 1 - singular = "More about napari with context and {variable}" - original_result = f"More about napari with context and {variable}" + singular = 'More about napari with context and {variable}' + original_result = f'More about napari with context and {variable}' - translated_result = f"Más sobre napari con contexto y {variable}" + translated_result = f'Más sobre napari con contexto y {variable}' result = trans._p(context, singular, deferred=True, variable=variable) assert isinstance(result, TranslationString) @@ -237,43 +237,43 @@ def test_locale_singular_context_deferred_with_format(trans): def test_locale_plural(trans): - singular = "I have napari" - plural = "I have naparis" + singular = 'I have napari' + plural = 'I have naparis' n = 1 result = trans._n(singular, plural, n=n) - assert result == "Tengo napari" + assert result == 'Tengo napari' n = 2 result_plural = trans._n(singular, plural, n=n) - assert result_plural == "Tengo naparis" + assert result_plural == 'Tengo naparis' def test_locale_plural_with_format(trans): - singular = "I have {n} napari with {variable}" - plural = "I have {n} naparis with {variable}" + singular = 'I have {n} napari with {variable}' + plural = 'I have {n} naparis with {variable}' variable = 1 n = 1 result = trans._n(singular, plural, n=n, variable=variable) - expected_result = f"Tengo {n} napari con {variable}" + expected_result = f'Tengo {n} napari con {variable}' assert result == expected_result n = 2 result_plural = trans._n(singular, plural, n=n, variable=variable) - expected_result_plural = f"Tengo {n} naparis con {variable}" + expected_result_plural = f'Tengo {n} naparis con {variable}' assert result_plural == expected_result_plural def test_locale_plural_deferred_with_format(trans): variable = 1 - singular = "I have {n} napari with {variable}" - plural = "I have {n} naparis with {variable}" + singular = 'I have {n} napari with {variable}' + plural = 'I have {n} naparis with {variable}' n = 1 original_result = singular.format(n=n, variable=variable) result = trans._n(singular, plural, n=n, deferred=True, variable=variable) - expected_result = f"Tengo {n} napari con {variable}" + expected_result = f'Tengo {n} napari con {variable}' assert isinstance(result, TranslationString) assert result.translation() == expected_result assert result.value() == original_result @@ -284,7 +284,7 @@ def test_locale_plural_deferred_with_format(trans): result_plural = trans._n( singular, plural, n=n, deferred=True, variable=variable ) - expected_result_plural = f"Tengo {n} naparis con {variable}" + expected_result_plural = f'Tengo {n} naparis con {variable}' assert isinstance(result, TranslationString) assert result_plural.translation() == expected_result_plural assert result_plural.value() == original_result_plural @@ -292,48 +292,48 @@ def test_locale_plural_deferred_with_format(trans): def test_locale_plural_context(trans): - context = "plural-context" - singular = "I have napari with context" - plural = "I have naparis with context" + context = 'plural-context' + singular = 'I have napari with context' + plural = 'I have naparis with context' n = 1 result = trans._np(context, singular, plural, n=n) - assert result == "Tengo napari con contexto" + assert result == 'Tengo napari con contexto' n = 2 result_plural = trans._np(context, singular, plural, n=n) - assert result_plural == "Tengo naparis con contexto" + assert result_plural == 'Tengo naparis con contexto' def test_locale_plural_context_with_format(trans): - context = "plural-context-variables" - singular = "I have {n} napari with {variable} and context" - plural = "I have {n} naparis with {variable} and context" + context = 'plural-context-variables' + singular = 'I have {n} napari with {variable} and context' + plural = 'I have {n} naparis with {variable} and context' variable = 1 n = 1 result = trans._np(context, singular, plural, n=n, variable=variable) - assert result == f"Tengo {n} napari con {variable} y contexto" + assert result == f'Tengo {n} napari con {variable} y contexto' n = 2 result_plural = trans._np( context, singular, plural, n=n, variable=variable ) - assert result_plural == f"Tengo {n} naparis con {variable} y contexto" + assert result_plural == f'Tengo {n} naparis con {variable} y contexto' def test_locale_plural_context_deferred_with_format(trans): - context = "plural-context-variables" + context = 'plural-context-variables' variable = 1 - singular = "I have {n} napari with {variable} and context" - plural = "I have {n} naparis with {variable} and context" + singular = 'I have {n} napari with {variable} and context' + plural = 'I have {n} naparis with {variable} and context' n = 1 original_result = singular.format(n=n, variable=variable) result = trans._np( context, singular, plural, n=n, deferred=True, variable=variable ) - expected_result = f"Tengo {n} napari con {variable} y contexto" + expected_result = f'Tengo {n} napari con {variable} y contexto' assert isinstance(result, TranslationString) assert result.translation() == expected_result @@ -345,7 +345,7 @@ def test_locale_plural_context_deferred_with_format(trans): result_plural = trans._np( context, singular, plural, n=n, deferred=True, variable=variable ) - expected_result_plural = f"Tengo {n} naparis con {variable} y contexto" + expected_result_plural = f'Tengo {n} naparis con {variable} y contexto' assert isinstance(result, TranslationString) assert result_plural.translation() == expected_result_plural @@ -356,10 +356,10 @@ def test_locale_plural_context_deferred_with_format(trans): # Deferred strings in exceptions # ------------------------------ def test_exception_string(trans): - expected_result = "Más sobre napari" - result = trans._("MORE ABOUT NAPARI", deferred=True) + expected_result = 'Más sobre napari' + result = trans._('MORE ABOUT NAPARI', deferred=True) assert str(result) != expected_result - assert str(result) == "MORE ABOUT NAPARI" + assert str(result) == 'MORE ABOUT NAPARI' with pytest.raises(ValueError) as err: raise ValueError(result) @@ -382,7 +382,7 @@ def test_bundle_exceptions(trans): @pytest.mark.parametrize( - "kwargs", + 'kwargs', [ { 'msgid': 'huhu', diff --git a/napari/utils/_testsupport.py b/napari/utils/_testsupport.py index f20b5500005..c945c83ad75 100644 --- a/napari/utils/_testsupport.py +++ b/napari/utils/_testsupport.py @@ -4,7 +4,7 @@ import warnings from contextlib import suppress from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Tuple +from typing import TYPE_CHECKING from unittest.mock import patch from weakref import WeakSet @@ -13,7 +13,7 @@ if TYPE_CHECKING: from pytest import FixtureRequest -_SAVE_GRAPH_OPNAME = "--save-leaked-object-graph" +_SAVE_GRAPH_OPNAME = '--save-leaked-object-graph' def _empty(*_, **__): @@ -22,15 +22,15 @@ def _empty(*_, **__): def pytest_addoption(parser): parser.addoption( - "--show-napari-viewer", - action="store_true", + '--show-napari-viewer', + action='store_true', default=False, help="don't show viewer during tests", ) parser.addoption( _SAVE_GRAPH_OPNAME, - action="store_true", + action='store_true', default=False, help="Try to save a graph of leaked object's reference (need objgraph" "and graphviz installed", @@ -123,7 +123,7 @@ def pytest_runtest_makereport(item, call): # set a report attribute for each phase of a call, which can # be "setup", "call", "teardown" - setattr(item, f"rep_{rep.when}", rep) + setattr(item, f'rep_{rep.when}', rep) @pytest.fixture @@ -140,8 +140,10 @@ def make_napari_viewer( viewer = make_napari_viewer() - It accepts all the same arguments as napari.Viewer, plus the following - test-related paramaters: + It accepts all the same arguments as `napari.Viewer`, notably `show` + which should be set to `True` for tests that require the `Viewer` to be visible + (e.g., tests that check aspects of the Qt window or layer rendering). + It also accepts the following test-related paramaters: ViewerClass : Type[napari.Viewer], optional Override the viewer class being used. By default, will @@ -196,10 +198,10 @@ def make_napari_viewer( fail_obj_graph(QtViewer) QtViewer._instances.clear() assert _do_not_inline_below == 0, ( - "Some instance of QtViewer is not properly cleaned in one of previous test. For easier debug one may " - f"use {_SAVE_GRAPH_OPNAME} flag for pytest to get graph of leaked objects. If you use qtbot (from pytest-qt)" - " to clean Qt objects after test you may need to switch to manual clean using " - "`deleteLater()` and `qtbot.wait(50)` later." + 'Some instance of QtViewer is not properly cleaned in one of previous test. For easier debug one may ' + f'use {_SAVE_GRAPH_OPNAME} flag for pytest to get graph of leaked objects. If you use qtbot (from pytest-qt)' + ' to clean Qt objects after test you may need to switch to manual clean using ' + '`deleteLater()` and `qtbot.wait(50)` later.' ) settings = get_settings() @@ -215,15 +217,15 @@ def make_napari_viewer( initial = QApplication.topLevelWidgets() prior_exception = getattr(sys, 'last_value', None) - is_internal_test = request.module.__name__.startswith("napari.") + is_internal_test = request.module.__name__.startswith('napari.') # disable throttling cursor event in tests monkeypatch.setattr( - "napari._qt.qt_main_window._QtMainWindow._throttle_cursor_to_status_connection", + 'napari._qt.qt_main_window._QtMainWindow._throttle_cursor_to_status_connection', _empty, ) - if "enable_console" not in request.keywords: + if 'enable_console' not in request.keywords: def _dummy_widget(*_): w = QWidget() @@ -231,7 +233,7 @@ def _dummy_widget(*_): return w monkeypatch.setattr( - "napari._qt.qt_viewer.QtViewer._get_console", _dummy_widget + 'napari._qt.qt_viewer.QtViewer._get_console', _dummy_widget ) def actual_factory( @@ -242,14 +244,14 @@ def actual_factory( **model_kwargs, ): if strict_qt is None: - strict_qt = is_internal_test or os.getenv("NAPARI_STRICT_QT") + strict_qt = is_internal_test or os.getenv('NAPARI_STRICT_QT') nonlocal _strict _strict = strict_qt if not block_plugin_discovery: napari_plugin_manager.discovery_blocker.stop() - should_show = request.config.getoption("--show-napari-viewer") + should_show = request.config.getoption('--show-napari-viewer') model_kwargs['show'] = model_kwargs.pop('show', should_show) viewer = ViewerClass(*model_args, **model_kwargs) viewers.add(viewer) @@ -342,7 +344,7 @@ def make_napari_viewer_proxy(make_napari_viewer, monkeypatch): def actual_factory(*model_args, ensure_main_thread=True, **model_kwargs): monkeypatch.setenv( - "NAPARI_ENSURE_PLUGIN_MAIN_THREAD", str(ensure_main_thread) + 'NAPARI_ENSURE_PLUGIN_MAIN_THREAD', str(ensure_main_thread) ) viewer = make_napari_viewer(*model_args, **model_kwargs) proxies.append(PublicOnlyProxy(viewer)) @@ -367,12 +369,12 @@ def MouseEvent(): @dataclass class Event: type: str - position: Tuple[float] + position: tuple[float] is_dragging: bool = False - dims_displayed: Tuple[int] = (0, 1) - dims_point: List[float] = None - view_direction: List[int] = None - pos: List[int] = (0, 0) + dims_displayed: tuple[int] = (0, 1) + dims_point: list[float] = None + view_direction: list[int] = None + pos: list[int] = (0, 0) button: int = None handled: bool = False diff --git a/napari/utils/_tracebacks.py b/napari/utils/_tracebacks.py index 1bf23cfa886..09c62aec84e 100644 --- a/napari/utils/_tracebacks.py +++ b/napari/utils/_tracebacks.py @@ -1,6 +1,7 @@ import re import sys -from typing import Callable, Dict, Generator +from collections.abc import Generator +from typing import Callable import numpy as np @@ -38,13 +39,13 @@ def format_exc_info( ) vbtb = IPython.core.ultratb.VerboseTB(color_scheme=color) if as_html: - ansi_string = vbtb.text(*info).replace(" ", " ") - html = "".join(ansi2html(ansi_string)) - html = html.replace("\n", "
") + ansi_string = vbtb.text(*info).replace(' ', ' ') + html = ''.join(ansi2html(ansi_string)) + html = html.replace('\n', '
') html = ( "" + html - + "" + + '' ) tb_text = html else: @@ -91,7 +92,7 @@ def format_exc_info( lambda arr: f'{type(arr)} {arr.shape} {arr.dtype}' ) if as_html: - html = "\n".join(cgitb_chain(info[1])) + html = '\n'.join(cgitb_chain(info[1])) # cgitb has a lot of hardcoded colors that don't work for us # remove bgcolor, and let theme handle it html = re.sub('bgcolor="#.*"', '', html) @@ -103,21 +104,21 @@ def format_exc_info( ) # weird 2-part syntax is a workaround for hard-to-grep text. html = html.replace( - "

A problem occurred in a Python script. " - "Here is the sequence of", - "", + '

A problem occurred in a Python script. ' + 'Here is the sequence of', + '', ) html = html.replace( - "function calls leading up to the error, " - "in the order they occurred.

", - "
", + 'function calls leading up to the error, ' + 'in the order they occurred.

', + '
', ) # remove hardcoded fonts - html = html.replace('face="helvetica, arial"', "") + html = html.replace('face="helvetica, arial"', '') html = ( "" + html - + "" + + '' ) tb_text = html else: @@ -146,27 +147,27 @@ def format_exc_info( ANSI_STYLES = { - 1: {"font_weight": "bold"}, - 2: {"font_weight": "lighter"}, - 3: {"font_weight": "italic"}, - 4: {"text_decoration": "underline"}, - 5: {"text_decoration": "blink"}, - 6: {"text_decoration": "blink"}, - 8: {"visibility": "hidden"}, - 9: {"text_decoration": "line-through"}, - 30: {"color": "black"}, - 31: {"color": "red"}, - 32: {"color": "green"}, - 33: {"color": "yellow"}, - 34: {"color": "blue"}, - 35: {"color": "magenta"}, - 36: {"color": "cyan"}, - 37: {"color": "white"}, + 1: {'font_weight': 'bold'}, + 2: {'font_weight': 'lighter'}, + 3: {'font_weight': 'italic'}, + 4: {'text_decoration': 'underline'}, + 5: {'text_decoration': 'blink'}, + 6: {'text_decoration': 'blink'}, + 8: {'visibility': 'hidden'}, + 9: {'text_decoration': 'line-through'}, + 30: {'color': 'black'}, + 31: {'color': 'red'}, + 32: {'color': 'green'}, + 33: {'color': 'yellow'}, + 34: {'color': 'blue'}, + 35: {'color': 'magenta'}, + 36: {'color': 'cyan'}, + 37: {'color': 'white'}, } def ansi2html( - ansi_string: str, styles: Dict[int, Dict[str, str]] = ANSI_STYLES + ansi_string: str, styles: dict[int, dict[str, str]] = ANSI_STYLES ) -> Generator[str, None, None]: """Convert ansi string to colored HTML @@ -186,17 +187,17 @@ def ansi2html( previous_end = 0 in_span = False ansi_codes = [] - ansi_finder = re.compile("\033\\[([\\d;]*)([a-zA-Z])") + ansi_finder = re.compile('\033\\[([\\d;]*)([a-zA-Z])') for match in ansi_finder.finditer(ansi_string): yield ansi_string[previous_end : match.start()] previous_end = match.end() params, command = match.groups() - if command not in "mM": + if command not in 'mM': continue try: - params = [int(p) for p in params.split(";")] + params = [int(p) for p in params.split(';')] except ValueError: params = [0] @@ -205,29 +206,29 @@ def ansi2html( params = params[i + 1 :] if in_span: in_span = False - yield "" + yield '' ansi_codes = [] if not params: continue ansi_codes.extend(params) if in_span: - yield "" + yield '' in_span = False if not ansi_codes: continue style = [ - "; ".join([f"{k}: {v}" for k, v in styles[k].items()]).strip() + '; '.join([f'{k}: {v}' for k, v in styles[k].items()]).strip() for k in ansi_codes if k in styles ] - yield '' % "; ".join(style) + yield '' % '; '.join(style) in_span = True yield ansi_string[previous_end:] if in_span: - yield "" + yield '' in_span = False diff --git a/napari/utils/action_manager.py b/napari/utils/action_manager.py index ee79218e125..af1011aed3e 100644 --- a/napari/utils/action_manager.py +++ b/napari/utils/action_manager.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from functools import cached_property from inspect import isgeneratorfunction -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Callable, Optional from napari.utils.events import EmitterGroup from napari.utils.interactions import Shortcut @@ -76,13 +76,13 @@ class ActionManager: in. """ - _actions: Dict[str, Action] + _actions: dict[str, Action] def __init__(self) -> None: # map associating a name/id with a Comm - self._actions: Dict[str, Action] = {} - self._shortcuts: Dict[str, List[str]] = defaultdict(list) - self._stack: List[str] = [] + self._actions: dict[str, Action] = {} + self._shortcuts: dict[str, list[str]] = defaultdict(list) + self._stack: list[str] = [] self._tooltip_include_action_name = False self.events = EmitterGroup(source=self, shorcut_changed=None) @@ -215,7 +215,7 @@ def bind_button( self._validate_action_name(name) if (action := self._actions.get(name)) and isgeneratorfunction( - getattr(action, "command", None) + getattr(action, 'command', None) ): raise ValueError( trans._( @@ -266,7 +266,7 @@ def bind_shortcut(self, name: str, shortcut: str) -> None: self._update_shortcut_bindings(name) self._emit_shortcut_change(name, shortcut) - def unbind_shortcut(self, name: str) -> Optional[List[str]]: + def unbind_shortcut(self, name: str) -> Optional[list[str]]: """ Unbind all shortcuts for a given action name. @@ -292,7 +292,7 @@ def unbind_shortcut(self, name: str) -> Optional[List[str]]: if action is None: warnings.warn( trans._( - "Attempting to unbind an action which does not exists ({name}), this may have no effects. This can happen if your settings are out of date, if you upgraded napari, upgraded or deactivated a plugin, or made a typo in in your custom keybinding.", + 'Attempting to unbind an action which does not exists ({name}), this may have no effects. This can happen if your settings are out of date, if you upgraded napari, upgraded or deactivated a plugin, or made a typo in in your custom keybinding.', name=name, ), UserWarning, @@ -319,7 +319,7 @@ def _build_tooltip(self, name: str) -> str: if name in self._shortcuts: jstr = ' ' + trans._p(' or ', 'or') + ' ' - shorts = jstr.join(f"{Shortcut(s)}" for s in self._shortcuts[name]) + shorts = jstr.join(f'{Shortcut(s)}' for s in self._shortcuts[name]) ttip += f' ({shorts})' ttip += f'[{name}]' if self._tooltip_include_action_name else '' diff --git a/napari/utils/color.py b/napari/utils/color.py index 5fcc76044ff..1b1444d05fa 100644 --- a/napari/utils/color.py +++ b/napari/utils/color.py @@ -1,6 +1,7 @@ """Contains napari color constants and utilities.""" -from typing import Callable, Iterator, Union +from collections.abc import Iterator +from typing import Callable, Union import numpy as np diff --git a/napari/utils/colormaps/_tests/test_color_to_array.py b/napari/utils/colormaps/_tests/test_color_to_array.py index 580a7457ee1..0587aa65af9 100644 --- a/napari/utils/colormaps/_tests/test_color_to_array.py +++ b/napari/utils/colormaps/_tests/test_color_to_array.py @@ -13,7 +13,7 @@ @pytest.mark.parametrize( - "colors, true_colors", zip(single_color_options, single_colors_as_array) + 'colors, true_colors', zip(single_color_options, single_colors_as_array) ) def test_oned_points(colors, true_colors): np.testing.assert_array_equal(true_colors, transform_color(colors)) @@ -38,19 +38,19 @@ def test_warns_but_parses(): @pytest.mark.parametrize( - "colors, true_colors", zip(two_color_options, two_colors_as_array) + 'colors, true_colors', zip(two_color_options, two_colors_as_array) ) def test_twod_points(colors, true_colors): np.testing.assert_array_equal(true_colors, transform_color(colors)) -@pytest.mark.parametrize("color", invalid_colors) +@pytest.mark.parametrize('color', invalid_colors) def test_invalid_colors(color): with pytest.raises((ValueError, AttributeError, KeyError)): transform_color(color) -@pytest.mark.parametrize("colors", warning_colors) +@pytest.mark.parametrize('colors', warning_colors) def test_warning_colors(colors): with pytest.warns(UserWarning): np.testing.assert_array_equal( diff --git a/napari/utils/colormaps/_tests/test_colormap.py b/napari/utils/colormaps/_tests/test_colormap.py index 4c978cf6a82..7a467cfdaf0 100644 --- a/napari/utils/colormaps/_tests/test_colormap.py +++ b/napari/utils/colormaps/_tests/test_colormap.py @@ -111,7 +111,7 @@ def test_colormap_equality(): def test_colormap_recreate(): - c_map = Colormap("black") + c_map = Colormap('black') Colormap(**c_map.dict()) @@ -125,7 +125,7 @@ def test_mapped_shape(ndim): @pytest.mark.parametrize( - "num,dtype", [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] + 'num,dtype', [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] ) def test_minimum_dtype_for_labels(num, dtype): assert colormap.minimum_dtype_for_labels(num) == dtype @@ -133,15 +133,15 @@ def test_minimum_dtype_for_labels(num, dtype): @pytest.fixture() def disable_jit(monkeypatch): - pytest.importorskip("numba") - with patch("numba.core.config.DISABLE_JIT", True): + pytest.importorskip('numba') + with patch('numba.core.config.DISABLE_JIT', True): importlib.reload(colormap) yield importlib.reload(colormap) # revert to original state -@pytest.mark.parametrize("num,dtype", [(40, np.uint8), (1000, np.uint16)]) -@pytest.mark.usefixtures("disable_jit") +@pytest.mark.parametrize('num,dtype', [(40, np.uint8), (1000, np.uint16)]) +@pytest.mark.usefixtures('disable_jit') def test_cast_labels_to_minimum_type_auto(num: int, dtype, monkeypatch): cmap = label_colormap(num) data = np.zeros(3, dtype=np.uint32) @@ -210,7 +210,7 @@ def test_direct_label_colormap_selection(direct_label_colormap): assert len(color_dict) == 2 -@pytest.mark.usefixtures("disable_jit") +@pytest.mark.usefixtures('disable_jit') def test_cast_direct_labels_to_minimum_type(direct_label_colormap): data = np.arange(15, dtype=np.uint32) cast = colormap._labels_raw_to_texture_direct(data, direct_label_colormap) @@ -243,9 +243,9 @@ def test_cast_direct_labels_to_minimum_type(direct_label_colormap): @pytest.mark.parametrize( - "num,dtype", [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] + 'num,dtype', [(40, np.uint8), (1000, np.uint16), (80000, np.float32)] ) -@pytest.mark.usefixtures("disable_jit") +@pytest.mark.usefixtures('disable_jit') def test_test_cast_direct_labels_to_minimum_type_no_jit(num, dtype): cmap = DirectLabelColormap( color_dict={ @@ -266,7 +266,7 @@ def test_test_cast_direct_labels_to_minimum_type_no_jit(num, dtype): def test_zero_preserving_modulo_naive(): - pytest.importorskip("numba") + pytest.importorskip('numba') data = np.arange(1000, dtype=np.uint32) res1 = colormap._zero_preserving_modulo_numpy(data, 49, np.uint8) res2 = colormap._zero_preserving_modulo(data, 49, np.uint8) @@ -285,8 +285,8 @@ def test_label_colormap_map_with_uint8_values(dtype): npt.assert_array_equal(cmap.map(values), expected) -@pytest.mark.parametrize("selection", [1, -1]) -@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.int64]) +@pytest.mark.parametrize('selection', [1, -1]) +@pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32, np.int64]) def test_label_colormap_map_with_selection(selection, dtype): cmap = colormap.CyclicLabelColormap( colors=ColorArray( @@ -300,8 +300,8 @@ def test_label_colormap_map_with_selection(selection, dtype): npt.assert_array_equal(cmap.map(values), expected) -@pytest.mark.parametrize("background", [1, -1]) -@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.int64]) +@pytest.mark.parametrize('background', [1, -1]) +@pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32, np.int64]) def test_label_colormap_map_with_background(background, dtype): cmap = colormap.CyclicLabelColormap( colors=ColorArray( @@ -314,7 +314,7 @@ def test_label_colormap_map_with_background(background, dtype): npt.assert_array_equal(cmap.map(values), expected) -@pytest.mark.parametrize("dtype", [np.uint8, np.uint16]) +@pytest.mark.parametrize('dtype', [np.uint8, np.uint16]) def test_label_colormap_using_cache(dtype, monkeypatch): cmap = colormap.CyclicLabelColormap( colors=ColorArray(np.array([[0, 0, 0, 0], [1, 0, 0, 1], [0, 1, 0, 1]])) @@ -328,9 +328,9 @@ def test_label_colormap_using_cache(dtype, monkeypatch): npt.assert_array_equal(map1, map2) -@pytest.mark.parametrize("size", [100, 1000]) +@pytest.mark.parametrize('size', [100, 1000]) def test_cast_direct_labels_to_minimum_type_naive(size): - pytest.importorskip("numba") + pytest.importorskip('numba') data = np.arange(size, dtype=np.uint32) dtype = colormap.minimum_dtype_for_labels(size) cmap = DirectLabelColormap( diff --git a/napari/utils/colormaps/_tests/test_colormap_utils.py b/napari/utils/colormaps/_tests/test_colormap_utils.py index d0697bdc76b..587e824a79e 100644 --- a/napari/utils/colormaps/_tests/test_colormap_utils.py +++ b/napari/utils/colormaps/_tests/test_colormap_utils.py @@ -22,7 +22,7 @@ ] -@pytest.mark.parametrize("index, expected", enumerate(FIRST_COLORS, start=1)) +@pytest.mark.parametrize('index, expected', enumerate(FIRST_COLORS, start=1)) def test_label_colormap(index, expected): """Test the label colormap. @@ -33,14 +33,14 @@ def test_label_colormap(index, expected): def test_label_colormap_exception(): - with pytest.raises(ValueError, match="num_colors must be >= 1"): + with pytest.raises(ValueError, match='num_colors must be >= 1'): label_colormap(0) - with pytest.raises(ValueError, match="num_colors must be >= 1"): + with pytest.raises(ValueError, match='num_colors must be >= 1'): label_colormap(-1) with pytest.raises( - ValueError, match=r".*Only up to 2\*\*16=65535 colors are supported" + ValueError, match=r'.*Only up to 2\*\*16=65535 colors are supported' ): label_colormap(2**16 + 1) diff --git a/napari/utils/colormaps/_tests/test_colormaps.py b/napari/utils/colormaps/_tests/test_colormaps.py index 5cbd035c195..007db4611d0 100644 --- a/napari/utils/colormaps/_tests/test_colormaps.py +++ b/napari/utils/colormaps/_tests/test_colormaps.py @@ -18,7 +18,7 @@ from napari.utils.colormaps.vendored import cm -@pytest.mark.parametrize("name", list(AVAILABLE_COLORMAPS.keys())) +@pytest.mark.parametrize('name', list(AVAILABLE_COLORMAPS.keys())) def test_colormap(name): if name in {'label_colormap', 'custom'}: pytest.skip( @@ -110,7 +110,7 @@ def test_can_accept_named_mpl_colormap(): assert cmap.name == cmap_name -@pytest.mark.filterwarnings("ignore::UserWarning") +@pytest.mark.filterwarnings('ignore::UserWarning') def test_can_accept_vispy_colormaps_in_dict(): """Test that we can accept vispy colormaps in a dictionary.""" colors_a = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) @@ -123,7 +123,7 @@ def test_can_accept_vispy_colormaps_in_dict(): assert cmap.name == 'a' -@pytest.mark.filterwarnings("ignore::UserWarning") +@pytest.mark.filterwarnings('ignore::UserWarning') def test_can_accept_napari_colormaps_in_dict(): """Test that we can accept vispy colormaps in a dictionary""" colors_a = np.array([[0, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1]]) @@ -168,7 +168,7 @@ def test_mpl_colormap_exists(): @pytest.mark.parametrize( - "name,display_name", + 'name,display_name', [ ('twilight_shifted', 'twilight shifted'), # MPL ('light_blues', 'light blues'), # Vispy @@ -179,7 +179,7 @@ def test_colormap_error_suggestion(name, display_name): Test that vispy/mpl errors, when using `display_name`, suggest `name`. """ with pytest.raises( - KeyError, match=rf"{display_name}.*you might want to use.*{name}" + KeyError, match=rf'{display_name}.*you might want to use.*{name}' ): vispy_or_mpl_colormap(display_name) @@ -189,7 +189,7 @@ def test_colormap_error_from_inexistent_name(): Test that vispy/mpl errors when using a wrong name. """ name = 'foobar' - with pytest.raises(KeyError, match=rf"{name}.*Recognized colormaps are"): + with pytest.raises(KeyError, match=rf'{name}.*Recognized colormaps are'): vispy_or_mpl_colormap(name) @@ -262,7 +262,7 @@ def test_ensure_colormap_error_with_invalid_hex_color_string(): Test that ensure_colormap errors when using an invalid hex color string """ color = '#ff' - with pytest.raises(KeyError, match=rf"{color}.*Recognized colormaps are"): + with pytest.raises(KeyError, match=rf'{color}.*Recognized colormaps are'): ensure_colormap(color) diff --git a/napari/utils/colormaps/bop_colors.py b/napari/utils/colormaps/bop_colors.py index 101e477e100..f05b1e3baba 100644 --- a/napari/utils/colormaps/bop_colors.py +++ b/napari/utils/colormaps/bop_colors.py @@ -784,7 +784,7 @@ ] bopd = { - "bop blue": (trans._("bop blue"), bop_blue), - "bop orange": (trans._("bop orange"), bop_orange), - "bop purple": (trans._("bop purple"), bop_purple), + 'bop blue': (trans._('bop blue'), bop_blue), + 'bop orange': (trans._('bop orange'), bop_orange), + 'bop purple': (trans._('bop purple'), bop_purple), } diff --git a/napari/utils/colormaps/categorical_colormap.py b/napari/utils/colormaps/categorical_colormap.py index 29ab96fa4f6..31009c9358c 100644 --- a/napari/utils/colormaps/categorical_colormap.py +++ b/napari/utils/colormaps/categorical_colormap.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Union +from typing import Any, Union import numpy as np @@ -28,7 +28,7 @@ class CategoricalColormap(EventedModel): The default value is a cycle of all white. """ - colormap: Dict[Any, ColorValue] = Field(default_factory=dict) + colormap: dict[Any, ColorValue] = Field(default_factory=dict) fallback_color: ColorCycle = Field( default_factory=lambda: ColorCycle.validate_type('white') ) @@ -79,7 +79,7 @@ def from_dict(cls, params: dict): } else: colormap = {} - fallback_color = params.get("fallback_color", "white") + fallback_color = params.get('fallback_color', 'white') else: colormap = {k: transform_color(v)[0] for k, v in params.items()} fallback_color = 'white' diff --git a/napari/utils/colormaps/categorical_colormap_utils.py b/napari/utils/colormaps/categorical_colormap_utils.py index c85422bb361..486525b2ea0 100644 --- a/napari/utils/colormaps/categorical_colormap_utils.py +++ b/napari/utils/colormaps/categorical_colormap_utils.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from itertools import cycle -from typing import Dict, Union +from typing import Union import numpy as np @@ -53,7 +53,7 @@ def __eq__(self, other): def _coerce_colorcycle_from_dict( - val: Dict[str, Union[str, list, np.ndarray, cycle]] + val: dict[str, Union[str, list, np.ndarray, cycle]] ) -> ColorCycle: # validate values color_values = val.get('values') @@ -70,12 +70,12 @@ def _coerce_colorcycle_from_dict( transformed_color_cycle = transform_color_cycle( color_cycle=color_values, elem_name='color_cycle', - default="white", + default='white', )[0] elif isinstance(color_cycle, cycle): transformed_color_cycle = color_cycle else: - raise TypeError(f"cycle entry must be type(cycle), got {type(cycle)}") + raise TypeError(f'cycle entry must be type(cycle), got {type(cycle)}') return ColorCycle( values=transformed_color_values, cycle=transformed_color_cycle @@ -93,7 +93,7 @@ def _coerce_colorcycle_from_colors( ) = transform_color_cycle( color_cycle=val, elem_name='color_cycle', - default="white", + default='white', ) return ColorCycle( values=transformed_color_values, cycle=transformed_color_cycle diff --git a/napari/utils/colormaps/colorbars.py b/napari/utils/colormaps/colorbars.py index cb1c414db94..2f3e6d538cd 100644 --- a/napari/utils/colormaps/colorbars.py +++ b/napari/utils/colormaps/colorbars.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING import numpy as np import numpy.typing as npt @@ -8,7 +8,7 @@ def make_colorbar( - cmap: 'Colormap', size: Tuple[int, int] = (18, 28), horizontal: bool = True + cmap: 'Colormap', size: tuple[int, int] = (18, 28), horizontal: bool = True ) -> npt.NDArray[np.uint8]: """Make a colorbar from a colormap. diff --git a/napari/utils/colormaps/colormap.py b/napari/utils/colormaps/colormap.py index cdd58b8a709..a3b4b2bbc19 100644 --- a/napari/utils/colormaps/colormap.py +++ b/napari/utils/colormaps/colormap.py @@ -3,12 +3,8 @@ from typing import ( TYPE_CHECKING, Any, - DefaultDict, - Dict, - List, Literal, Optional, - Tuple, Union, cast, overload, @@ -151,7 +147,7 @@ def map(self, values): # One color per bin # Colors beyond max clipped to final bin indices = np.clip( - np.searchsorted(self.controls, values, side="right") - 1, + np.searchsorted(self.controls, values, side='right') - 1, 0, len(self.colors) - 1, ) @@ -178,10 +174,10 @@ class LabelColormapBase(Colormap): interpolation: Literal[ColormapInterpolationMode.ZERO] = Field( ColormapInterpolationMode.ZERO, frozen=True ) - _cache_mapping: Dict[Tuple[np.dtype, np.dtype], np.ndarray] = PrivateAttr( + _cache_mapping: dict[tuple[np.dtype, np.dtype], np.ndarray] = PrivateAttr( default={} ) - _cache_other: Dict[str, Any] = PrivateAttr(default={}) + _cache_other: dict[str, Any] = PrivateAttr(default={}) class Config(Colormap.Config): # this config is to avoid deepcopy of cached_property @@ -202,7 +198,7 @@ def _data_to_texture( """Map input values to values for send to GPU.""" raise NotImplementedError - def _cmap_without_selection(self) -> "LabelColormapBase": + def _cmap_without_selection(self) -> 'LabelColormapBase': if self.use_selection: cmap = self.__class__(**self.dict()) cmap.use_selection = False @@ -285,7 +281,7 @@ class CyclicLabelColormap(LabelColormapBase): def _validate_color(cls, v): if len(v) > 2**16: raise ValueError( - "Only up to 2**16=65535 colors are supported for LabelColormap" + 'Only up to 2**16=65535 colors are supported for LabelColormap' ) return v @@ -393,15 +389,15 @@ class DirectLabelColormap(LabelColormapBase): Exist because of implementation details. Please do not use it. """ - color_dict: DefaultDict[Optional[int], np.ndarray] = Field( + color_dict: defaultdict[Optional[int], np.ndarray] = Field( default_factory=lambda: defaultdict(lambda: np.zeros(4)) ) use_selection: bool = False selection: int = 0 def __init__(self, *args, **kwargs) -> None: - if "colors" not in kwargs and not args: - kwargs["colors"] = np.zeros(3) + if 'colors' not in kwargs and not args: + kwargs['colors'] = np.zeros(3) super().__init__(*args, **kwargs) def __len__(self): @@ -412,7 +408,7 @@ def __len__(self): """ return self._num_unique_colors + 2 - @validator("color_dict", pre=True, always=True, allow_reuse=True) + @validator('color_dict', pre=True, always=True, allow_reuse=True) def _validate_color_dict(cls, v, values): """Ensure colors are RGBA arrays, not strings. @@ -436,7 +432,7 @@ def _validate_color_dict(cls, v, values): """ if not isinstance(v, defaultdict) and None not in v: raise ValueError( - "color_dict must contain None or be defaultdict instance" + 'color_dict must contain None or be defaultdict instance' ) res = { label: transform_color(color_str)[0] @@ -492,7 +488,7 @@ def map(self, values: Union[np.ndarray, np.integer, int]) -> np.ndarray: if isinstance(values, (list, tuple)): values = np.array(values) if not isinstance(values, np.ndarray) or values.dtype.kind in 'fU': - raise TypeError("DirectLabelColormap can only be used with int") + raise TypeError('DirectLabelColormap can only be used with int') mapper = self._get_mapping_from_cache(values.dtype) if mapper is not None: mapped = mapper[values] @@ -546,16 +542,16 @@ def _num_unique_colors(self) -> int: def _clear_cache(self): super()._clear_cache() - if "_num_unique_colors" in self.__dict__: - del self.__dict__["_num_unique_colors"] - if "_label_mapping_and_color_dict" in self.__dict__: - del self.__dict__["_label_mapping_and_color_dict"] - if "_array_map" in self.__dict__: - del self.__dict__["_array_map"] + if '_num_unique_colors' in self.__dict__: + del self.__dict__['_num_unique_colors'] + if '_label_mapping_and_color_dict' in self.__dict__: + del self.__dict__['_label_mapping_and_color_dict'] + if '_array_map' in self.__dict__: + del self.__dict__['_array_map'] def _values_mapping_to_minimum_values_set( self, apply_selection=True - ) -> Tuple[Dict[Optional[int], int], Dict[int, np.ndarray]]: + ) -> tuple[dict[Optional[int], int], dict[int, np.ndarray]]: """Create mapping from original values to minimum values set. To use minimum possible dtype for labels. @@ -581,12 +577,12 @@ def _values_mapping_to_minimum_values_set( @cached_property def _label_mapping_and_color_dict( self, - ) -> Tuple[Dict[Optional[int], int], Dict[int, np.ndarray]]: - color_to_labels: Dict[Tuple[int, ...], List[Optional[int]]] = {} - labels_to_new_labels: Dict[Optional[int], int] = { + ) -> tuple[dict[Optional[int], int], dict[int, np.ndarray]]: + color_to_labels: dict[tuple[int, ...], list[Optional[int]]] = {} + labels_to_new_labels: dict[Optional[int], int] = { None: MAPPING_OF_UNKNOWN_VALUE } - new_color_dict: Dict[int, np.ndarray] = { + new_color_dict: dict[int, np.ndarray] = { MAPPING_OF_UNKNOWN_VALUE: self.default_color, } @@ -628,7 +624,7 @@ def _get_typed_dict_mapping(self, data_dtype: np.dtype) -> 'typed.Dict': # we cache the result to avoid recomputing it on each slice; # check first if it's already in the cache. - key = f"_{data_dtype}_typed_dict" + key = f'_{data_dtype}_typed_dict' if key in self._cache_other: return self._cache_other[key] @@ -661,8 +657,8 @@ def _array_map(self): max_value *= 2 if max_value > 2**16: raise RuntimeError( # pragma: no cover - "Cannot use numpy implementation for large values of labels " - "direct colormap. Please install numba." + 'Cannot use numpy implementation for large values of labels ' + 'direct colormap. Please install numba.' ) dtype = minimum_dtype_for_labels(self._num_unique_colors + 2) label_mapping = self._values_mapping_to_minimum_values_set()[0] diff --git a/napari/utils/colormaps/colormap_utils.py b/napari/utils/colormaps/colormap_utils.py index 312562e62d8..8c53b16b3c4 100644 --- a/napari/utils/colormaps/colormap_utils.py +++ b/napari/utils/colormaps/colormap_utils.py @@ -1,8 +1,9 @@ import warnings from collections import OrderedDict, defaultdict +from collections.abc import Iterable from functools import lru_cache from threading import Lock -from typing import Dict, Iterable, List, NamedTuple, Optional, Tuple, Union +from typing import NamedTuple, Optional, Union import numpy as np import skimage.color as colorconv @@ -29,7 +30,7 @@ from napari.utils.translations import trans # All parsable input color types that a user can provide -ColorType = Union[List, Tuple, np.ndarray, str, Color, ColorArray] +ColorType = Union[list, tuple, np.ndarray, str, Color, ColorArray] ValidColormapArg = Union[ @@ -37,11 +38,11 @@ ColorType, VispyColormap, Colormap, - Tuple[str, VispyColormap], - Tuple[str, Colormap], - Dict[str, VispyColormap], - Dict[str, Colormap], - Dict, + tuple[str, VispyColormap], + tuple[str, Colormap], + dict[str, VispyColormap], + dict[str, Colormap], + dict, ] @@ -256,6 +257,7 @@ def low_discrepancy_image(image, seed=0.5, margin=1 / 256) -> np.ndarray: A set of labels or label image. seed : float The seed from which to start the quasirandom sequence. + Effective range is [0,1.0), as only the decimals are used. margin : float Values too close to 0 or 1 will get mapped to the edge of the colormap, so we need to offset to a margin slightly inside those values. Since @@ -341,6 +343,7 @@ def _low_discrepancy(dim, n, seed=0.5): How many points to generate. seed : float or array of float, shape (dim,) The seed from which to start the quasirandom sequence. + Effective range is [0,1.0), as only the decimals are used. Returns ------- @@ -376,6 +379,7 @@ def _color_random(n, *, colorspace='lab', tolerance=0.0, seed=0.5): clipped to be in-range). seed : float or array of float, shape (3,) Value from which to start the quasirandom sequence. + Effective range is [0,1.0), as only the decimals are used. Returns ------- @@ -425,8 +429,9 @@ def label_colormap( num_colors : int, optional Number of unique colors to use. Default used if not given. Colors are in addition to a transparent color 0. - seed : float or array of float, length 3 + seed : float, optional The seed for the random color generator. + Effective range is [0,1.0), as only the decimals are used. Returns ------- @@ -438,7 +443,7 @@ def label_colormap( 0 always maps to fully transparent. """ if num_colors < 1: - raise ValueError("num_colors must be >= 1") + raise ValueError('num_colors must be >= 1') # Starting the control points slightly above 0 and below 1 is necessary # to ensure that the background pixel 0 is transparent @@ -461,7 +466,7 @@ def label_colormap( randomized_values = low_discrepancy_image(values_, seed=seed) indices = np.clip( - np.searchsorted(control_points, randomized_values, side="right") - 1, + np.searchsorted(control_points, randomized_values, side='right') - 1, 0, len(control_points) - 1, ) @@ -640,7 +645,7 @@ def vispy_or_mpl_colormap(name) -> Colormap: 'Colormap "{name}" not found in either vispy or matplotlib. Recognized colormaps are: {colormaps}', deferred=True, name=name, - colormaps=", ".join(sorted(f'"{cm}"' for cm in colormaps)), + colormaps=', '.join(sorted(f'"{cm}"' for cm in colormaps)), ) ) from e mpl_colors = mpl_cmap(np.linspace(0, 1, 256)) @@ -681,7 +686,7 @@ def vispy_or_mpl_colormap(name) -> Colormap: def _increment_unnamed_colormap( existing: Iterable[str], name: str = '[unnamed colormap]' -) -> Tuple[str, str]: +) -> tuple[str, str]: """Increment name for unnamed colormap. NOTE: this assumes colormaps are *never* deleted, and does not check @@ -706,7 +711,7 @@ def _increment_unnamed_colormap( past_names = [n for n in existing if n.startswith('[unnamed colormap')] name = f'[unnamed colormap {len(past_names)}]' display_name = trans._( - "[unnamed colormap {number}]", + '[unnamed colormap {number}]', number=len(past_names), ) @@ -747,7 +752,7 @@ def ensure_colormap(colormap: ValidColormapArg) -> Colormap: with AVAILABLE_COLORMAPS_LOCK: if isinstance(colormap, str): # Is a colormap with this name already available? - custom_cmap = AVAILABLE_COLORMAPS.get(colormap, None) + custom_cmap = AVAILABLE_COLORMAPS.get(colormap) if custom_cmap is None: name = ( colormap.lower() if colormap.startswith('#') else colormap @@ -810,7 +815,7 @@ def ensure_colormap(colormap: ValidColormapArg) -> Colormap: if colormap is None: raise TypeError( trans._( - "When providing a tuple as a colormap argument, either 1) the first element must be a string and the second a Colormap instance 2) or the tuple should be convertible to one or more colors", + 'When providing a tuple as a colormap argument, either 1) the first element must be a string and the second a Colormap instance 2) or the tuple should be convertible to one or more colors', deferred=True, ) ) @@ -834,7 +839,7 @@ def ensure_colormap(colormap: ValidColormapArg) -> Colormap: ): raise TypeError( trans._( - "When providing a dict as a colormap, all values must be Colormap instances", + 'When providing a dict as a colormap, all values must be Colormap instances', deferred=True, ) ) @@ -856,14 +861,14 @@ def ensure_colormap(colormap: ValidColormapArg) -> Colormap: warnings.warn( trans._( - "only the first item in a colormap dict is used as an argument", + 'only the first item in a colormap dict is used as an argument', deferred=True, ) ) else: raise ValueError( trans._( - "Received an empty dict as a colormap argument.", + 'Received an empty dict as a colormap argument.', deferred=True, ) ) @@ -917,7 +922,7 @@ def display_name_to_name(display_name): class CoercedContrastLimits(NamedTuple): - contrast_limits: Tuple[float, float] + contrast_limits: tuple[float, float] offset: float scale: float @@ -928,7 +933,7 @@ def coerce_data(self, data: np.ndarray) -> np.ndarray: return (data + self.offset / self.scale) * self.scale -def _coerce_contrast_limits(contrast_limits: Tuple[float, float]): +def _coerce_contrast_limits(contrast_limits: tuple[float, float]): """Coerce contrast limits to be in the float32 range.""" if np.abs(contrast_limits).max() > _MAX_VISPY_SUPPORTED_VALUE: return scale_down(contrast_limits) @@ -945,7 +950,7 @@ def _coerce_contrast_limits(contrast_limits: Tuple[float, float]): return CoercedContrastLimits(contrast_limits, 0, 1) -def scale_down(contrast_limits: Tuple[float, float]): +def scale_down(contrast_limits: tuple[float, float]): """Scale down contrast limits to be in the float32 range.""" scale: float = min( 1.0, @@ -960,7 +965,7 @@ def scale_down(contrast_limits: Tuple[float, float]): return CoercedContrastLimits(ctrl_lim, offset, scale) -def scale_up(contrast_limits: Tuple[float, float]): +def scale_up(contrast_limits: tuple[float, float]): """Scale up contrast limits to be in the float32 precision.""" scale = 1000 / (contrast_limits[1] - contrast_limits[0]) shift = -contrast_limits[0] * scale diff --git a/napari/utils/colormaps/inverse_colormaps.py b/napari/utils/colormaps/inverse_colormaps.py index bd7a9faae88..52541f5d904 100644 --- a/napari/utils/colormaps/inverse_colormaps.py +++ b/napari/utils/colormaps/inverse_colormaps.py @@ -12,9 +12,9 @@ I_Purple = [[1, 1, 1], [117 / 255, 0, 1]] # inverted ChrisLUT OPF Purple inverse_cmaps = { - "I Bordeaux": (trans._("I Bordeaux"), I_Bordeaux), - "I Blue": (trans._("I Blue"), I_Blue), - "I Forest": (trans._("I Forest"), I_Forest), - "I Orange": (trans._("I Orange"), I_Orange), - "I Purple": (trans._("I Purple"), I_Purple), + 'I Bordeaux': (trans._('I Bordeaux'), I_Bordeaux), + 'I Blue': (trans._('I Blue'), I_Blue), + 'I Forest': (trans._('I Forest'), I_Forest), + 'I Orange': (trans._('I Orange'), I_Orange), + 'I Purple': (trans._('I Purple'), I_Purple), } diff --git a/napari/utils/colormaps/standardize_color.py b/napari/utils/colormaps/standardize_color.py index a4635a456c5..910b6ffc121 100644 --- a/napari/utils/colormaps/standardize_color.py +++ b/napari/utils/colormaps/standardize_color.py @@ -21,7 +21,8 @@ import functools import types import warnings -from typing import Any, Callable, Dict, Optional, Sequence, Union +from collections.abc import Sequence +from typing import Any, Callable, Optional, Union import numpy as np from vispy.color import ColorArray, get_color_dict, get_color_names @@ -89,7 +90,7 @@ def _handle_str(color: str) -> np.ndarray: if len(color) == 0: warnings.warn( trans._( - "Empty string detected. Returning black instead.", + 'Empty string detected. Returning black instead.', deferred=True, ) ) @@ -175,7 +176,7 @@ def _handle_array(colors: np.ndarray) -> np.ndarray: if kind == 'O': warnings.warn( trans._( - "An object array was passed as the color input. Please convert its datatype before sending it to napari. Converting input to a white color array.", + 'An object array was passed as the color input. Please convert its datatype before sending it to napari. Converting input to a white color array.', deferred=True, ) ) @@ -188,7 +189,7 @@ def _handle_array(colors: np.ndarray) -> np.ndarray: warnings.warn( trans._( - "String color arrays should be one-dimensional. Converting input to a white color array.", + 'String color arrays should be one-dimensional. Converting input to a white color array.', deferred=True, ) ) @@ -202,7 +203,7 @@ def _handle_array(colors: np.ndarray) -> np.ndarray: if colors.shape[-1] == 0: warnings.warn( trans._( - "Given color input is empty. Converting input to a white color array.", + 'Given color input is empty. Converting input to a white color array.', deferred=True, ) ) @@ -215,7 +216,7 @@ def _handle_array(colors: np.ndarray) -> np.ndarray: if colors.ndim > 2: raise ValueError( trans._( - "Given colors input should contain one or two dimensions. Received array with {ndim} dimensions.", + 'Given colors input should contain one or two dimensions. Received array with {ndim} dimensions.', deferred=True, ndim=colors.ndim, ) @@ -227,7 +228,7 @@ def _handle_array(colors: np.ndarray) -> np.ndarray: if colors.shape[0] == 1 and colors.shape[1] not in {3, 4}: raise ValueError( trans._( - "Given color array has an unsupported format. Received the following array:\n{colors}\nA proper color array should have 3-4 columns with a row per data entry.", + 'Given color array has an unsupported format. Received the following array:\n{colors}\nA proper color array should have 3-4 columns with a row per data entry.', deferred=True, colors=colors, ) @@ -243,7 +244,7 @@ def _handle_array(colors: np.ndarray) -> np.ndarray: if not 3 <= colors.shape[1] <= 4: warnings.warn( trans._( - "Given colors input should contain three or four columns. Received array with {shape} columns. Converting input to a white color array.", + 'Given colors input should contain three or four columns. Received array with {shape} columns. Converting input to a white color array.', deferred=True, shape=colors.shape[1], ) @@ -256,7 +257,7 @@ def _handle_array(colors: np.ndarray) -> np.ndarray: raise ValueError( trans._( - "Data type of array ({color_dtype}) not supported.", + 'Data type of array ({color_dtype}) not supported.', deferred=True, color_dtype=colors.dtype, ) @@ -290,7 +291,7 @@ def _convert_array_to_correct_format(colors: np.ndarray) -> np.ndarray: if colors.min() < 0: raise ValueError( trans._( - "Colors input had negative values.", + 'Colors input had negative values.', deferred=True, ) ) @@ -327,7 +328,7 @@ def _handle_str_list_like(colors: Union[Sequence, np.ndarray]) -> np.ndarray: except (ValueError, TypeError, KeyError) as e: raise ValueError( trans._( - "Invalid color found: {color} at index {idx}.", + 'Invalid color found: {color} at index {idx}.', deferred=True, color=c, idx=idx, @@ -379,7 +380,7 @@ def _normalize_color_array(colors: np.ndarray) -> np.ndarray: return colors.astype(np.float32) -_color_switch: Dict[Any, Callable] = { +_color_switch: dict[Any, Callable] = { str: _handle_str, np.str_: _handle_str, list: _handle_list_like, @@ -400,7 +401,7 @@ def _create_hex_to_name_dict(): Mapping from hexadecimal RGB ('#ff0000') to name ('red'). """ colordict = get_color_dict() - hex_to_name = {f"{v.lower()}ff": k for k, v in colordict.items()} + hex_to_name = {f'{v.lower()}ff': k for k, v in colordict.items()} return hex_to_name diff --git a/napari/utils/config.py b/napari/utils/config.py index c80a038bed7..c17f15908b6 100644 --- a/napari/utils/config.py +++ b/napari/utils/config.py @@ -16,7 +16,7 @@ def _set(env_var: str) -> bool: bool True if the env var was set to a non-zero value. """ - return os.getenv(env_var) not in [None, "0"] + return os.getenv(env_var) not in [None, '0'] """ @@ -80,4 +80,4 @@ def __getattr__(name: str) -> Optional[bool]: # Shared Memory Server -monitor = _set("NAPARI_MON") +monitor = _set('NAPARI_MON') diff --git a/napari/utils/events/_tests/test_event_emitter.py b/napari/utils/events/_tests/test_event_emitter.py index c32bfa83a89..aebf64dc94b 100644 --- a/napari/utils/events/_tests/test_event_emitter.py +++ b/napari/utils/events/_tests/test_event_emitter.py @@ -8,7 +8,7 @@ def test_event_blocker_count_none(): """Test event emitter block counter with no emission.""" - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') with e.blocker() as block: pass assert block.count == 0 @@ -16,7 +16,7 @@ def test_event_blocker_count_none(): def test_event_blocker_count(): """Test event emitter block counter with emission.""" - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') with e.blocker() as block: e() e() @@ -106,11 +106,11 @@ class Test: def __init__(self) -> None: self.m1, self.m2, self.m4 = 0, 0, 0 - @rename("nonexist") + @rename('nonexist') def meth1(self, _event): self.m1 += 1 - @rename("meth1") + @rename('meth1') def meth2(self, _event): self.m2 += 1 @@ -122,7 +122,7 @@ def meth4(self, _event): t = Test() - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') e.connect(t.meth1) e() @@ -134,7 +134,7 @@ def meth4(self, _event): meth = t.meth3 - t.meth3 = "aaaa" + t.meth3 = 'aaaa' with pytest.raises(RuntimeError): e.connect(meth) @@ -144,7 +144,7 @@ def meth4(self, _event): e() assert t.m4 == 1 t.meth4 = None - with pytest.warns(RuntimeWarning, match="Problem with function"): + with pytest.warns(RuntimeWarning, match='Problem with function'): e() assert t.m4 == 1 @@ -170,11 +170,11 @@ def fun5(val): def fun6(val): res_li.append(val) - fun1.__module__ = "napari.test.sample" - fun3.__module__ = "napari.test.sample" - fun5.__module__ = "napari.test.sample" + fun1.__module__ = 'napari.test.sample' + fun3.__module__ = 'napari.test.sample' + fun5.__module__ = 'napari.test.sample' - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') e.connect(fun1) e.connect(partial(fun2, val=2)) e() @@ -188,11 +188,11 @@ def fun6(val): e() assert res_li == [3, 1, 4, 2] res_li = [] - e.connect(partial(fun5, val=5), position="last") + e.connect(partial(fun5, val=5), position='last') e() assert res_li == [3, 1, 5, 4, 2] res_li = [] - e.connect(partial(fun6, val=6), position="last") + e.connect(partial(fun6, val=6), position='last') e() assert res_li == [3, 1, 5, 4, 2, 6] @@ -214,12 +214,12 @@ def fun3(self): def fun4(self): res_li.append(4) - Test.__module__ = "napari.test.sample" + Test.__module__ = 'napari.test.sample' t1 = Test() t2 = Test2() - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') e.connect(t1.fun1) e.connect(t2.fun3) e() @@ -246,7 +246,7 @@ def simple_fun(): t = TestOb() - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') e.connect(t.fun) e.connect(simple_fun) e() @@ -264,7 +264,7 @@ def simple_fun(a, b): t = TestOb() - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') with pytest.raises(RuntimeError): e.connect(t.fun) with pytest.raises(RuntimeError): @@ -289,7 +289,7 @@ def fun2(self): t = TestOb() - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') e.connect(t.fun1) e.connect(t.fun2) e.connect(fun1) @@ -319,12 +319,12 @@ def fun2(self, event): t = TestOb() - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') e.connect(t.fun1) e() assert t.call_list_1 == [1] - e.disconnect((weakref.ref(t), "fun1")) + e.disconnect((weakref.ref(t), 'fun1')) e() assert t.call_list_1 == [1] e.connect(t.fun2) @@ -341,7 +341,7 @@ def fun1(): def fun2(event): count_list.append(2) - e = EventEmitter(type_name="test") + e = EventEmitter(type_name='test') e.connect(fun1) e() assert count_list == [1] diff --git a/napari/utils/events/_tests/test_event_migrations.py b/napari/utils/events/_tests/test_event_migrations.py index 83857837480..7006af61fd3 100644 --- a/napari/utils/events/_tests/test_event_migrations.py +++ b/napari/utils/events/_tests/test_event_migrations.py @@ -5,7 +5,7 @@ def test_deprecation_warning_event() -> None: event = deprecation_warning_event( - "obj.events", "old", "new", "0.1.0", "0.0.0" + 'obj.events', 'old', 'new', '0.1.0', '0.0.0' ) class Counter: @@ -16,7 +16,7 @@ def add(self, event) -> None: self.count += event.value counter = Counter() - msg = "obj.events.old is deprecated since 0.0.0 and will be removed in 0.1.0. Please use obj.events.new" + msg = 'obj.events.old is deprecated since 0.0.0 and will be removed in 0.1.0. Please use obj.events.new' with pytest.warns(FutureWarning, match=msg): event.connect(counter.add) diff --git a/napari/utils/events/_tests/test_event_utils.py b/napari/utils/events/_tests/test_event_utils.py index a1eab26363c..395cfd7628d 100644 --- a/napari/utils/events/_tests/test_event_utils.py +++ b/napari/utils/events/_tests/test_event_utils.py @@ -10,43 +10,43 @@ def test_connect_no_arg(): - mock = Mock(["meth"]) + mock = Mock(['meth']) emiter = EventEmitter() - connect_no_arg(emiter, mock, "meth") - emiter(type_name="a", value=1) + connect_no_arg(emiter, mock, 'meth') + emiter(type_name='a', value=1) mock.meth.assert_called_once_with() assert len(emiter.callbacks) == 1 del mock gc.collect() assert len(emiter.callbacks) == 1 - emiter(type_name="a", value=1) + emiter(type_name='a', value=1) assert len(emiter.callbacks) == 0 def test_connect_setattr_value(): mock = Mock() emiter = EventEmitter() - connect_setattr_value(emiter, mock, "meth") - emiter(type_name="a", value=1) + connect_setattr_value(emiter, mock, 'meth') + emiter(type_name='a', value=1) assert mock.meth == 1 assert len(emiter.callbacks) == 1 del mock gc.collect() assert len(emiter.callbacks) == 1 - emiter(type_name="a", value=1) + emiter(type_name='a', value=1) assert len(emiter.callbacks) == 0 def test_connect_setattr(): mock = Mock() emiter = EventEmitter() - connect_setattr(emiter, mock, "meth") - emiter(type_name="a", value=1) + connect_setattr(emiter, mock, 'meth') + emiter(type_name='a', value=1) assert isinstance(mock.meth, Event) assert mock.meth.value == 1 assert len(emiter.callbacks) == 1 del mock gc.collect() assert len(emiter.callbacks) == 1 - emiter(type_name="a", value=1) + emiter(type_name='a', value=1) assert len(emiter.callbacks) == 0 diff --git a/napari/utils/events/_tests/test_evented_dict.py b/napari/utils/events/_tests/test_evented_dict.py index c5e639ed35f..c5abe06b558 100644 --- a/napari/utils/events/_tests/test_evented_dict.py +++ b/napari/utils/events/_tests/test_evented_dict.py @@ -8,7 +8,7 @@ @pytest.fixture def regular_dict(): - return {"A": 0, "B": 1, "C": 2, False: "3", 4: 5} + return {'A': 0, 'B': 1, 'C': 2, False: '3', 4: 5} @pytest.fixture(params=[EventedDict]) @@ -23,14 +23,14 @@ def test_dict(request, regular_dict): [ # METHOD, ARGS, EXPECTED EVENTS # primary interface - ('__getitem__', ("A",), ()), # read - ('__setitem__', ("A", 3), ('changed',)), # update - ('__setitem__', ("D", 3), ('adding', 'added')), # add new entry - ('__delitem__', ("A",), ('removing', 'removed')), # delete + ('__getitem__', ('A',), ()), # read + ('__setitem__', ('A', 3), ('changed',)), # update + ('__setitem__', ('D', 3), ('adding', 'added')), # add new entry + ('__delitem__', ('A',), ('removing', 'removed')), # delete # inherited interface ('key', (3,), ()), ('clear', (), ('removing', 'removed') * 3), - ('pop', ("B",), ('removing', 'removed')), + ('pop', ('B',), ('removing', 'removed')), ], ids=lambda x: x[0], ) @@ -55,7 +55,7 @@ def test_copy(test_dict, regular_dict): new_test = test_dict.copy() assert ( len({type(k) for k in new_test}) >= 2 - ), "We want at least non-string keys to test edge cases" + ), 'We want at least non-string keys to test edge cases' new_reg = regular_dict.copy() assert id(new_test) != id(test_dict) @@ -76,13 +76,13 @@ def test_child_events(): root = EventedDict() observed = [] root.events.connect(lambda e: observed.append(e)) - root["A"] = e_obj - e_obj.events.test(value="hi") + root['A'] = e_obj + e_obj.events.test(value='hi') obs = [(e.type, e.key, getattr(e, 'value', None)) for e in observed] expected = [ - ('adding', "A", None), # before we adding b into root - ('added', "A", e_obj), # after b was added into root - ('test', "A", 'hi'), # when e_obj emitted an event called "test" + ('adding', 'A', None), # before we adding b into root + ('added', 'A', e_obj), # after b was added into root + ('test', 'A', 'hi'), # when e_obj emitted an event called "test" ] for o, e in zip(obs, expected): assert o == e @@ -97,7 +97,7 @@ class A: class B(A, EventedDict): pass - dct = B({"A": 1, "B": 2}) + dct = B({'A': 1, 'B': 2}) assert hasattr(dct, 'events') assert 'boom' in dct.events.emitters - assert dct == {"A": 1, "B": 2} + assert dct == {'A': 1, 'B': 2} diff --git a/napari/utils/events/_tests/test_evented_list.py b/napari/utils/events/_tests/test_evented_list.py index 3e834c1e055..74468b1bfd4 100644 --- a/napari/utils/events/_tests/test_evented_list.py +++ b/napari/utils/events/_tests/test_evented_list.py @@ -112,7 +112,7 @@ def test_move(test_list): test_list.events = Mock(wraps=test_list.events) def _fail(): - raise AssertionError("unexpected event called") + raise AssertionError('unexpected event called') test_list.events.removing.connect(_fail) test_list.events.removed.connect(_fail) @@ -170,7 +170,7 @@ def test_move_multiple(sources, dest, expectation): assert el == [0, 1, 2, 3, 4, 5, 6, 7] def _fail(): - raise AssertionError("unexpected event called") + raise AssertionError('unexpected event called') el.events.removing.connect(_fail) el.events.removed.connect(_fail) @@ -256,7 +256,7 @@ def test_nested_indexing(): # 110 -> '110' -> (1, 1, 0) indices = [tuple(int(x) for x in str(n)) for n in flatten(NEST)] for index in indices: - assert ne_list[index] == int("".join(map(str, index))) + assert ne_list[index] == int(''.join(map(str, index))) assert ne_list.has_index(1) assert ne_list.has_index((1,)) @@ -390,7 +390,7 @@ def test_child_events(): observed = [] root.events.connect(lambda e: observed.append(e)) root.append(e_obj) - e_obj.events.test(value="hi") + e_obj.events.test(value='hi') obs = [(e.type, e.index, getattr(e, 'value', None)) for e in observed] expected = [ ('inserting', 0, None), # before we inserted b into root @@ -428,7 +428,7 @@ def test_nested_child_events(): # and append the event-emitter object to the nested list b.append(e_obj) # then have the deeply nested event-emitter actually emit an event - e_obj.events.test(value="hi") + e_obj.events.test(value='hi') # look at the (type, index, and value) of all of the events emitted by root # and make sure they match expectations diff --git a/napari/utils/events/_tests/test_evented_model.py b/napari/utils/events/_tests/test_evented_model.py index 5ff35949ab3..d894b9f7729 100644 --- a/napari/utils/events/_tests/test_evented_model.py +++ b/napari/utils/events/_tests/test_evented_model.py @@ -1,7 +1,8 @@ import inspect import operator +from collections.abc import Sequence from enum import auto -from typing import ClassVar, List, Protocol, Sequence, Union, runtime_checkable +from typing import ClassVar, Protocol, Union, runtime_checkable from unittest.mock import Mock import dask.array as da @@ -357,7 +358,7 @@ class NestedModel(EventedModel): class Model(EventedModel): nest: NestedModel - m = Model(nest={'obj': {"a": 1, "b": "hi"}}) + m = Model(nest={'obj': {'a': 1, 'b': 'hi'}}) raw = m.json() assert raw == r'{"nest": {"obj": {"a": 1, "b": "hi"}}}' deserialized = Model.parse_raw(raw) @@ -438,7 +439,7 @@ class T(EventedModel): b: int = 1 @property - def c(self) -> List[int]: + def c(self) -> list[int]: return [self.a, self.b] @c.setter @@ -562,7 +563,7 @@ class Config: dependencies = {'x': ['a']} # should warn if field does not exist - with pytest.warns(match="Unrecognized field dependency"): + with pytest.warns(match='Unrecognized field dependency'): class T(EventedModel): a: int = 1 @@ -599,7 +600,7 @@ class Tt(EventedModel): a: int = 1 @property - def b(self) -> "np.ndarray": # pragma: no cover + def b(self) -> 'np.ndarray': # pragma: no cover return np.ndarray([self.a, self.a]) @property @@ -623,7 +624,7 @@ def c(self): eq_op_get = Mock(return_value=operator.eq) monkeypatch.setattr( - "napari.utils.events.evented_model.pick_equality_operator", eq_op_get + 'napari.utils.events.evented_model.pick_equality_operator', eq_op_get ) t = Tt() @@ -631,8 +632,8 @@ def c(self): a_eq = Mock(return_value=False) b_eq = Mock(return_value=False) - t.__eq_operators__["a"] = a_eq - t.__eq_operators__["b"] = b_eq + t.__eq_operators__['a'] = a_eq + t.__eq_operators__['b'] = b_eq t.a = 2 a_eq.assert_not_called() diff --git a/napari/utils/events/_tests/test_evented_set.py b/napari/utils/events/_tests/test_evented_set.py index a0972419962..c4bcce7f28a 100644 --- a/napari/utils/events/_tests/test_evented_set.py +++ b/napari/utils/events/_tests/test_evented_set.py @@ -23,21 +23,21 @@ def test_set(request, regular_set): # METHOD, ARGS, EXPECTED EVENTS # primary interface ('add', 2, []), - ('add', 10, [call.changed(added={10}, removed={})]), - ('discard', 2, [call.changed(added={}, removed={2})]), - ('remove', 2, [call.changed(added={}, removed={2})]), + ('add', 10, [call.changed(added={10}, removed=set())]), + ('discard', 2, [call.changed(added=set(), removed={2})]), + ('remove', 2, [call.changed(added=set(), removed={2})]), ('discard', 10, []), # parity with set - ('update', {3, 4, 5, 6}, [call.changed(added={5, 6}, removed={})]), + ('update', {3, 4, 5, 6}, [call.changed(added={5, 6}, removed=set())]), ( 'difference_update', {3, 4, 5, 6}, - [call.changed(added={}, removed={3, 4})], + [call.changed(added=set(), removed={3, 4})], ), ( 'intersection_update', {3, 4, 5, 6}, - [call.changed(added={}, removed={0, 1, 2})], + [call.changed(added=set(), removed={0, 1, 2})], ), ( 'symmetric_difference_update', @@ -78,7 +78,7 @@ def test_set_clear(test_set): assert test_set.events.mock_calls == [] test_set.clear() assert test_set.events.mock_calls == [ - call.changed(added={}, removed={0, 1, 2, 3, 4}) + call.changed(added=set(), removed={0, 1, 2, 3, 4}) ] diff --git a/napari/utils/events/_tests/test_selectable_list.py b/napari/utils/events/_tests/test_selectable_list.py index d606af2adad..505b7db921b 100644 --- a/napari/utils/events/_tests/test_selectable_list.py +++ b/napari/utils/events/_tests/test_selectable_list.py @@ -1,4 +1,5 @@ -from typing import Iterable, TypeVar +from collections.abc import Iterable +from typing import TypeVar from napari.utils.events.containers import SelectableEventedList diff --git a/napari/utils/events/_tests/test_selection.py b/napari/utils/events/_tests/test_selection.py index 11d6e23b3a7..92a94b3b209 100644 --- a/napari/utils/events/_tests/test_selection.py +++ b/napari/utils/events/_tests/test_selection.py @@ -22,7 +22,7 @@ class T(EventedModel): assert t.sel._current == 1 assert t.json() == r'{"sel": {"selection": [1], "_current": 1}}' - assert T(sel={"selection": [1], "_current": 1}) == t + assert T(sel={'selection': [1], '_current': 1}) == t t.sel.remove(1) assert not t.sel @@ -31,4 +31,4 @@ class T(EventedModel): T(sel=['asdf']) with pytest.raises(ValidationError): - T(sel={"selection": [1], "_current": 'asdf'}) + T(sel={'selection': [1], '_current': 'asdf'}) diff --git a/napari/utils/events/_tests/test_typed_dict.py b/napari/utils/events/_tests/test_typed_dict.py index b084c1f4e65..47e41c8bd6d 100644 --- a/napari/utils/events/_tests/test_typed_dict.py +++ b/napari/utils/events/_tests/test_typed_dict.py @@ -13,23 +13,23 @@ def dict_type(request): def test_type_enforcement(dict_type): """Test that TypedDicts enforce type during mutation events.""" - a = dict_type({"A": 1, "B": 3, "C": 5}, basetype=int) + a = dict_type({'A': 1, 'B': 3, 'C': 5}, basetype=int) assert tuple(a.values()) == (1, 3, 5) with pytest.raises(TypeError): - a["D"] = "string" + a['D'] = 'string' with pytest.raises(TypeError): - a.update({"E": 3.5}) + a.update({'E': 3.5}) # also on instantiation with pytest.raises(TypeError): - dict_type({"A": 1, "B": 3.3, "C": "5"}, basetype=int) + dict_type({'A': 1, 'B': 3.3, 'C': '5'}, basetype=int) def test_multitype_enforcement(dict_type): """Test that basetype also accepts/enforces a sequence of types.""" - a = dict_type({"A": 1, "B": 3, "C": 5.5}, basetype=(int, float)) + a = dict_type({'A': 1, 'B': 3, 'C': 5.5}, basetype=(int, float)) assert tuple(a.values()) == (1, 3, 5.5) with pytest.raises(TypeError): - a["D"] = "string" - a["D"] = 2.4 - a.update({"E": 3.5}) + a['D'] = 'string' + a['D'] = 2.4 + a.update({'E': 3.5}) diff --git a/napari/utils/events/_tests/test_typed_list.py b/napari/utils/events/_tests/test_typed_list.py index da7f138ab01..10b022c0c69 100644 --- a/napari/utils/events/_tests/test_typed_list.py +++ b/napari/utils/events/_tests/test_typed_list.py @@ -22,11 +22,11 @@ def test_type_enforcement(list_type): a = list_type([1, 2, 3, 4], basetype=int) assert tuple(a) == (1, 2, 3, 4) with pytest.raises(TypeError): - a.append("string") + a.append('string') with pytest.raises(TypeError): - a.insert(0, "string") + a.insert(0, 'string') with pytest.raises(TypeError): - a[0] = "string" + a[0] = 'string' with pytest.raises(TypeError): a[0] = 1.23 @@ -53,7 +53,7 @@ def test_multitype_enforcement(list_type): a = list_type([1, 2, 3, 4, 5.5], basetype=(int, float)) assert tuple(a) == (1, 2, 3, 4, 5.5) with pytest.raises(TypeError): - a.append("string") + a.append('string') a.append(2) a.append(2.4) @@ -76,7 +76,7 @@ def __init__(self, name='', data=()) -> None: ) # index with integer as usual assert a[1].name == 'hi' - assert a.index("hi") == 1 + assert a.index('hi') == 1 # index with string also works assert a['hi'] == hi @@ -113,19 +113,19 @@ def test_nested_type_enforcement(): # first level with pytest.raises(TypeError): - a.append("string") + a.append('string') with pytest.raises(TypeError): - a.insert(0, "string") + a.insert(0, 'string') with pytest.raises(TypeError): - a[0] = "string" + a[0] = 'string' # deeply nested with pytest.raises(TypeError): - a[2, 2].append("string") + a[2, 2].append('string') with pytest.raises(TypeError): - a[2, 2].insert(0, "string") + a[2, 2].insert(0, 'string') with pytest.raises(TypeError): - a[2, 2, 0] = "string" + a[2, 2, 0] = 'string' # also works during instantiation with pytest.raises(TypeError): @@ -152,10 +152,10 @@ def __init__(self, name='') -> None: ) # first level assert a[1].name == 'c1' # index with integer as usual - assert a.index("c1") == 1 + assert a.index('c1') == 1 assert a['c1'] == c1 # index with string also works # second level assert a[2, 0].name == 'c2' - assert a.index("c2") == (2, 0) + assert a.index('c2') == (2, 0) assert a['c2'] == c2 diff --git a/napari/utils/events/containers/_dict.py b/napari/utils/events/containers/_dict.py index 1adcc067d0c..85cc6e5e82e 100644 --- a/napari/utils/events/containers/_dict.py +++ b/napari/utils/events/containers/_dict.py @@ -1,20 +1,15 @@ """Evented dictionary""" +from collections.abc import Iterator, Mapping, MutableMapping, Sequence from typing import ( Any, - Dict, - Iterator, - Mapping, - MutableMapping, Optional, - Sequence, - Type, TypeVar, Union, ) -_K = TypeVar("_K") -_T = TypeVar("_T") +_K = TypeVar('_K') +_T = TypeVar('_T') class TypedMutableMapping(MutableMapping[_K, _T]): @@ -23,11 +18,11 @@ class TypedMutableMapping(MutableMapping[_K, _T]): def __init__( self, data: Optional[Mapping[_K, _T]] = None, - basetype: Union[Type[_T], Sequence[Type[_T]]] = (), + basetype: Union[type[_T], Sequence[type[_T]]] = (), ) -> None: if data is None: data = {} - self._dict: Dict[_K, _T] = {} + self._dict: dict[_K, _T] = {} self._basetypes = ( basetype if isinstance(basetype, Sequence) else (basetype,) ) @@ -35,7 +30,7 @@ def __init__( # #### START Required Abstract Methods - def __setitem__(self, key: _K, value: _T): + def __setitem__(self, key: _K, value: _T) -> None: self._dict[key] = self._type_check(value) def __delitem__(self, key: _K) -> None: @@ -50,7 +45,7 @@ def __len__(self) -> int: def __iter__(self) -> Iterator[_K]: return iter(self._dict) - def __repr__(self): + def __repr__(self) -> str: return str(self._dict) def _type_check(self, e: Any) -> _T: @@ -58,17 +53,19 @@ def _type_check(self, e: Any) -> _T: isinstance(e, t) for t in self._basetypes ): raise TypeError( - f"Cannot add object with type {type(e)} to TypedDict expecting type {self._basetypes}", + f'Cannot add object with type {type(e)} to TypedDict expecting type {self._basetypes}', ) return e - def __newlike__(self, iterable: MutableMapping[_K, _T]): + def __newlike__( + self, iterable: MutableMapping[_K, _T] + ) -> 'TypedMutableMapping[_K, _T]': new = self.__class__() # separating this allows subclasses to omit these from their `__init__` new._basetypes = self._basetypes new.update(iterable) return new - def copy(self) -> "TypedMutableMapping[_K, _T]": + def copy(self) -> 'TypedMutableMapping[_K, _T]': """Return a shallow copy of the dictionary.""" return self.__newlike__(self) diff --git a/napari/utils/events/containers/_evented_dict.py b/napari/utils/events/containers/_evented_dict.py index 8c4b48e4b18..60c1c8ba44f 100644 --- a/napari/utils/events/containers/_evented_dict.py +++ b/napari/utils/events/containers/_evented_dict.py @@ -1,6 +1,7 @@ """MutableMapping that emits events when altered.""" -from typing import Mapping, Optional, Sequence, Type, Union +from collections.abc import Mapping, Sequence +from typing import Optional, Union from napari.utils.events.containers._dict import _K, _T, TypedMutableMapping from napari.utils.events.event import EmitterGroup, Event @@ -47,19 +48,19 @@ class EventedDict(TypedMutableMapping[_K, _T]): def __init__( self, data: Optional[Mapping[_K, _T]] = None, - basetype: Union[Type[_T], Sequence[Type[_T]]] = (), + basetype: Union[type[_T], Sequence[type[_T]]] = (), ) -> None: _events = { - "changing": None, - "changed": None, - "adding": None, - "added": None, - "removing": None, - "removed": None, - "updated": None, + 'changing': None, + 'changed': None, + 'adding': None, + 'added': None, + 'removing': None, + 'removed': None, + 'updated': None, } # For inheritance: If the mro already provides an EmitterGroup, add... - if hasattr(self, "events") and isinstance(self.events, EmitterGroup): + if hasattr(self, 'events') and isinstance(self.events, EmitterGroup): self.events.add(**_events) else: # otherwise create a new one @@ -68,7 +69,7 @@ def __init__( ) super().__init__(data, basetype) - def __setitem__(self, key: _K, value: _T): + def __setitem__(self, key: _K, value: _T) -> None: old = self._dict.get(key) if value is old or value == old: return @@ -82,26 +83,26 @@ def __setitem__(self, key: _K, value: _T): super().__setitem__(key, value) self.events.changed(key=key, old_value=old, value=value) - def __delitem__(self, key: _K): + def __delitem__(self, key: _K) -> None: self.events.removing(key=key) self._disconnect_child_emitters(self[key]) item = self._dict.pop(key) self.events.removed(key=key, value=item) - def _reemit_child_event(self, event: Event): + def _reemit_child_event(self, event: Event) -> None: """An item in the dict emitted an event. Re-emit with key""" - if not hasattr(event, "key"): + if not hasattr(event, 'key'): event.key = self.key(event.source) # re-emit with this object's EventEmitter self.events(event) - def _disconnect_child_emitters(self, child: _T): + def _disconnect_child_emitters(self, child: _T) -> None: """Disconnect all events from the child from the re-emitter.""" if isinstance(child, SupportsEvents): child.events.disconnect(self._reemit_child_event) - def _connect_child_emitters(self, child: _T): + def _connect_child_emitters(self, child: _T) -> None: """Connect all events from the child to be re-emitted.""" if isinstance(child, SupportsEvents): # make sure the event source has been set on the child @@ -109,7 +110,7 @@ def _connect_child_emitters(self, child: _T): child.events.source = child child.events.connect(self._reemit_child_event) - def key(self, value: _T): + def key(self, value: _T) -> Optional[_K]: """Return first instance of value.""" for k, v in self._dict.items(): if v is value or v == value: diff --git a/napari/utils/events/containers/_evented_list.py b/napari/utils/events/containers/_evented_list.py index ce4f069f3d8..48e8c8298e9 100644 --- a/napari/utils/events/containers/_evented_list.py +++ b/napari/utils/events/containers/_evented_list.py @@ -24,15 +24,10 @@ import contextlib import logging +from collections.abc import Generator, Iterable, Sequence from typing import ( Callable, - Dict, - Iterable, - List, Optional, - Sequence, - Tuple, - Type, Union, ) @@ -94,8 +89,8 @@ def __init__( self, data: Iterable[_T] = (), *, - basetype: Union[Type[_T], Sequence[Type[_T]]] = (), - lookup: Optional[Dict[Type[_L], Callable[[_T], Union[_T, _L]]]] = None, + basetype: Union[type[_T], Sequence[type[_T]]] = (), + lookup: Optional[dict[type[_L], Callable[[_T], Union[_T, _L]]]] = None, ) -> None: if lookup is None: lookup = {} @@ -127,7 +122,7 @@ def __init__( # def extend(self, value: Iterable[_T]): ... # def remove(self, value: T): ... - def __setitem__(self, key, value): + def __setitem__(self, key, value: _T) -> None: old = self._list[key] # https://github.com/napari/napari/pull/2120 if isinstance(key, slice): if not isinstance(value, Iterable): @@ -148,7 +143,7 @@ def __setitem__(self, key, value): if not len(value) == len(indices): raise ValueError( trans._( - "attempt to assign sequence of size {size} to extended slice of size {slice_size}", + 'attempt to assign sequence of size {size} to extended slice of size {slice_size}', deferred=True, size=len(value), slice_size=len(indices), @@ -169,7 +164,7 @@ def __setitem__(self, key, value): def _delitem_indices( self, key: Index - ) -> Iterable[Tuple['EventedList[_T]', int]]: + ) -> Iterable[tuple['EventedList[_T]', int]]: # returning List[(self, int)] allows subclasses to pass nested members if isinstance(key, int): return [(self, key if key >= 0 else key + len(self))] @@ -181,14 +176,14 @@ def _delitem_indices( valid = {int, slice}.union(set(self._lookup)) raise TypeError( trans._( - "Deletion index must be {valid!r}, got {dtype}", + 'Deletion index must be {valid!r}, got {dtype}', deferred=True, valid=valid, dtype=type(key), ) ) - def __delitem__(self, key: Index): + def __delitem__(self, key: Index) -> None: # delete from the end for parent, index in sorted(self._delitem_indices(key), reverse=True): parent.events.removing(index=index) @@ -197,17 +192,17 @@ def __delitem__(self, key: Index): self._process_delete_item(item) parent.events.removed(index=index, value=item) - def _process_delete_item(self, item: _T): + def _process_delete_item(self, item: _T) -> None: """Allow process item in inherited class before event was emitted""" - def insert(self, index: int, value: _T): + def insert(self, index: int, value: _T) -> None: """Insert ``value`` before index.""" self.events.inserting(index=index) super().insert(index, value) self.events.inserted(index=index, value=value) self._connect_child_emitters(value) - def _reemit_child_event(self, event: Event): + def _reemit_child_event(self, event: Event) -> None: """An item in the list emitted an event. Re-emit with index""" if not hasattr(event, 'index'): with contextlib.suppress(ValueError): @@ -216,12 +211,12 @@ def _reemit_child_event(self, event: Event): # reemit with this object's EventEmitter self.events(event) - def _disconnect_child_emitters(self, child: _T): + def _disconnect_child_emitters(self, child: _T) -> None: """Disconnect all events from the child from the reemitter.""" if isinstance(child, SupportsEvents): child.events.disconnect(self._reemit_child_event) - def _connect_child_emitters(self, child: _T): + def _connect_child_emitters(self, child: _T) -> None: """Connect all events from the child to be reemitted.""" if isinstance(child, SupportsEvents): # make sure the event source has been set on the child @@ -281,8 +276,8 @@ def move_multiple( are not ``int`` or ``slice``. """ logger.debug( - "move_multiple(sources={sources}, dest_index={dest_index})", - extra={"sources": sources, "dest_index": dest_index}, + 'move_multiple(sources={sources}, dest_index={dest_index})', + extra={'sources': sources, 'dest_index': dest_index}, ) # calling list here makes sure that there are no index errors up front @@ -304,7 +299,9 @@ def move_multiple( self.events.reordered(value=self) return len(move_plan) - def _move_plan(self, sources: Iterable[Index], dest_index: int): + def _move_plan( + self, sources: Iterable[Index], dest_index: int + ) -> Generator[tuple[int, int], None, None]: """Prepared indices for a multi-move. Given a set of ``sources`` from anywhere in the list, @@ -326,12 +323,12 @@ def _move_plan(self, sources: Iterable[Index], dest_index: int): if isinstance(dest_index, slice): raise TypeError( trans._( - "Destination index may not be a slice", + 'Destination index may not be a slice', deferred=True, ) ) - to_move: List[int] = [] + to_move: list[int] = [] for idx in sources: if isinstance(idx, slice): to_move.extend(list(range(*idx.indices(len(self))))) @@ -340,7 +337,7 @@ def _move_plan(self, sources: Iterable[Index], dest_index: int): else: raise TypeError( trans._( - "Can only move integer or slice indices, not {t}", + 'Can only move integer or slice indices, not {t}', deferred=True, t=type(idx), ) @@ -352,7 +349,7 @@ def _move_plan(self, sources: Iterable[Index], dest_index: int): dest_index += len(self) + 1 d_inc = 0 - popped: List[int] = [] + popped: list[int] = [] for i, src in enumerate(to_move): if src != dest_index: # we need to decrement the src_i by 1 for each time we have diff --git a/napari/utils/events/containers/_nested_list.py b/napari/utils/events/containers/_nested_list.py index f65000a9a84..6804ea57eb0 100644 --- a/napari/utils/events/containers/_nested_list.py +++ b/napari/utils/events/containers/_nested_list.py @@ -8,13 +8,10 @@ import contextlib import logging from collections import defaultdict +from collections.abc import Generator, Iterable, MutableSequence from typing import ( - DefaultDict, - Generator, - Iterable, - MutableSequence, NewType, - Tuple, + Optional, TypeVar, Union, cast, @@ -27,10 +24,10 @@ logger = logging.getLogger(__name__) -NestedIndex = Tuple[Index, ...] +NestedIndex = tuple[Index, ...] MaybeNestedIndex = Union[Index, NestedIndex] -ParentIndex = NewType('ParentIndex', Tuple[int, ...]) -_T = TypeVar("_T") +ParentIndex = NewType('ParentIndex', tuple[int, ...]) +_T = TypeVar('_T') def ensure_tuple_index(index: MaybeNestedIndex) -> NestedIndex: @@ -58,7 +55,7 @@ def ensure_tuple_index(index: MaybeNestedIndex) -> NestedIndex: raise TypeError( trans._( - "Invalid nested index: {index}. Must be an int or tuple", + 'Invalid nested index: {index}. Must be an int or tuple', deferred=True, index=index, ) @@ -198,14 +195,14 @@ def __getitem__(self, key: MaybeNestedIndex): @overload def __setitem__( self, key: Union[int, NestedIndex], value: _T - ): ... # pragma: no cover + ) -> None: ... # pragma: no cover @overload def __setitem__( self, key: slice, value: Iterable[_T] - ): ... # pragma: no cover + ) -> None: ... # pragma: no cover - def __setitem__(self, key: MaybeNestedIndex, value): + def __setitem__(self, key, value): # NOTE: if we check isinstance(..., MutableList), then we'll actually # clobber object of specialized classes being inserted into the list # (for instance, subclasses of NestableEventedList) @@ -233,7 +230,7 @@ def _delitem_indices( return [(self[parent_i], i) for i in indices] return super()._delitem_indices(key) - def insert(self, index: int, value: _T): + def insert(self, index: int, value: _T) -> None: """Insert object before index.""" # this is delicate, we want to preserve the evented list when nesting # but there is a high risk here of clobbering attributes of a special @@ -242,7 +239,7 @@ def insert(self, index: int, value: _T): value = self.__newlike__(value) super().insert(index, value) - def _reemit_child_event(self, event: Event): + def _reemit_child_event(self, event: Event) -> None: """An item in the list emitted an event. Re-emit with index""" if hasattr(event, 'index'): # This event is coming from a nested List... @@ -318,7 +315,7 @@ def _move_plan( if isinstance(dest_i, slice): raise TypeError( trans._( - "Destination index may not be a slice", + 'Destination index may not be a slice', deferred=True, ) ) @@ -326,7 +323,7 @@ def _move_plan( # need to update indices as we pop, so we keep track of the indices # we have previously popped - popped: DefaultDict[NestedIndex, list[int]] = defaultdict(list) + popped: defaultdict[NestedIndex, list[int]] = defaultdict(list) dumped: list[int] = [] # we iterate indices from the end first, so pop() always works @@ -336,7 +333,7 @@ def _move_plan( if idx == (): raise IndexError( trans._( - "Group cannot move itself", + 'Group cannot move itself', deferred=True, ) ) @@ -360,7 +357,7 @@ def _move_plan( if isinstance(src_i, slice): raise TypeError( trans._( - "Terminal source index may not be a slice", + 'Terminal source index may not be a slice', deferred=True, ) ) @@ -411,7 +408,7 @@ def move( object """ logger.debug( - "move(src_index=%s, dest_index=%s)", + 'move(src_index=%s, dest_index=%s)', src_index, dest_index, ) @@ -423,7 +420,7 @@ def move( if isinstance(src_i, slice): raise TypeError( trans._( - "Terminal source index may not be a slice", + 'Terminal source index may not be a slice', deferred=True, ) ) @@ -431,7 +428,7 @@ def move( if isinstance(dest_i, slice): raise TypeError( trans._( - "Destination index may not be a slice", + 'Destination index may not be a slice', deferred=True, ) ) @@ -439,7 +436,7 @@ def move( if src_i == (): raise ValueError( trans._( - "Group cannot move itself", + 'Group cannot move itself', deferred=True, ) ) @@ -478,7 +475,12 @@ def _type_check(self, e) -> _T: ) return e - def _iter_indices(self, start=0, stop=None, root=()): + def _iter_indices( + self, + start: int = 0, + stop: Optional[int] = None, + root: tuple[int, ...] = (), + ) -> Generator[Union[int, tuple[int]]]: """Iter indices from start to stop. Depth first traversal of the tree @@ -488,7 +490,7 @@ def _iter_indices(self, start=0, stop=None, root=()): if isinstance(item, NestableEventedList): yield from item._iter_indices(root=(*root, i)) - def has_index(self, index: Union[int, Tuple[int, ...]]) -> bool: + def has_index(self, index: Union[int, tuple[int, ...]]) -> bool: """Return true if `index` is valid for this nestable list.""" if isinstance(index, int): return -len(self) <= index < len(self) @@ -499,4 +501,4 @@ def has_index(self, index: Union[int, Tuple[int, ...]]) -> bool: return False else: return True - raise TypeError(f"Not supported index type {type(index)}") + raise TypeError(f'Not supported index type {type(index)}') diff --git a/napari/utils/events/containers/_selectable_list.py b/napari/utils/events/containers/_selectable_list.py index 671cea28f68..23d6559d909 100644 --- a/napari/utils/events/containers/_selectable_list.py +++ b/napari/utils/events/containers/_selectable_list.py @@ -1,12 +1,12 @@ import warnings -from typing import TypeVar +from typing import Any, TypeVar from napari.utils.events.containers._evented_list import EventedList from napari.utils.events.containers._nested_list import NestableEventedList from napari.utils.events.containers._selection import Selectable from napari.utils.translations import trans -_T = TypeVar("_T") +_T = TypeVar('_T') class SelectableEventedList(Selectable[_T], EventedList[_T]): @@ -42,39 +42,39 @@ class SelectableEventedList(Selectable[_T], EventedList[_T]): emitted when the current item has changed. (Private event) """ - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: self._activate_on_insert = True super().__init__(*args, **kwargs) # bound/unbound methods are ambiguous for mypy so we need to ignore # https://mypy.readthedocs.io/en/stable/error_code_list.html?highlight=method-assign#check-that-assignment-target-is-not-a-method-method-assign self.selection._pre_add_hook = self._preselect_hook # type: ignore[method-assign] - def _preselect_hook(self, value): + def _preselect_hook(self, value: _T) -> _T: """Called before adding an item to the selection.""" if value not in self: raise ValueError( trans._( - "Cannot select item that is not in list: {value!r}", + 'Cannot select item that is not in list: {value!r}', deferred=True, value=value, ) ) return value - def _process_delete_item(self, item: _T): + def _process_delete_item(self, item: _T) -> None: self.selection.discard(item) - def insert(self, index: int, value: _T): + def insert(self, index: int, value: _T) -> None: super().insert(index, value) if self._activate_on_insert: # Make layer selected and unselect all others self.selection.active = value - def select_all(self): + def select_all(self) -> None: """Select all items in the list.""" self.selection.update(self) - def remove_selected(self): + def remove_selected(self) -> None: """Remove selected items from list.""" idx = 0 for i in list(self.selection): @@ -90,7 +90,7 @@ def remove_selected(self): if do_add: self.selection.add(self[new]) - def move_selected(self, index: int, insert: int): + def move_selected(self, index: int, insert: int) -> None: """Reorder list by moving the item at index and inserting it at the insert index. If additional items are selected these will get inserted at the insert index too. This allows for rearranging @@ -127,7 +127,7 @@ def move_selected(self, index: int, insert: int): offset = insert >= index self.move_multiple(moving, insert + offset) - def select_next(self, step=1, shift=False): + def select_next(self, step: int = 1, shift: bool = False) -> None: """Selects next item from list.""" if self.selection: idx = self.index(self.selection._current) + step @@ -141,7 +141,7 @@ def select_next(self, step=1, shift=False): elif len(self) > 0: self.selection.active = self[-1 if step > 0 else 0] - def select_previous(self, shift=False): + def select_previous(self, shift: bool = False) -> None: """Selects previous item from list.""" self.select_next(-1, shift=shift) diff --git a/napari/utils/events/containers/_selection.py b/napari/utils/events/containers/_selection.py index 23364c73dc7..8304aeaea19 100644 --- a/napari/utils/events/containers/_selection.py +++ b/napari/utils/events/containers/_selection.py @@ -1,4 +1,12 @@ -from typing import TYPE_CHECKING, Generic, Iterable, Optional, TypeVar +from collections.abc import Generator, Iterable +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Optional, + TypeVar, + Union, +) from napari.utils.events.containers._set import EventedSet from napari.utils.events.event import EmitterGroup @@ -7,8 +15,8 @@ if TYPE_CHECKING: from napari._pydantic_compat import ModelField -_T = TypeVar("_T") -_S = TypeVar("_S") +_T = TypeVar('_T') +_S = TypeVar('_S') class Selection(EventedSet[_T]): @@ -61,7 +69,11 @@ def __init__(self, data: Iterable[_T] = ()) -> None: super().__init__(data=data) self._update_active() - def _emit_change(self, added=None, removed=None): + def _emit_change( + self, + added: Optional[set[_T]] = None, + removed: Optional[set[_T]] = None, + ) -> None: if added is None: added = set() if removed is None: @@ -70,7 +82,7 @@ def _emit_change(self, added=None, removed=None): return super()._emit_change(added=added, removed=removed) def __repr__(self) -> str: - return f"{type(self).__name__}({self._set!r})" + return f'{type(self).__name__}({self._set!r})' def __hash__(self) -> int: """Make selection hashable.""" @@ -82,7 +94,7 @@ def _current(self) -> Optional[_T]: return self._current_ @_current.setter - def _current(self, index: Optional[_T]): + def _current(self, index: Optional[_T]) -> None: """Set current item.""" if index == self._current_: return @@ -95,7 +107,7 @@ def active(self) -> Optional[_T]: return self._active @active.setter - def active(self, value: Optional[_T]): + def active(self, value: Optional[_T]) -> None: """Set the active item. This make `value` the only selected item, and make it current. @@ -107,7 +119,7 @@ def active(self, value: Optional[_T]): self._current = value self.events.active(value=value) - def _update_active(self): + def _update_active(self) -> None: """On a selection event, update the active item based on selection. (An active item is a single selected item). @@ -124,27 +136,27 @@ def clear(self, keep_current: bool = False) -> None: self._current = None super().clear() - def toggle(self, obj: _T): + def toggle(self, obj: _T) -> None: """Toggle selection state of obj.""" self.symmetric_difference_update({obj}) - def select_only(self, obj: _T): + def select_only(self, obj: _T) -> None: """Unselect everything but `obj`. Add to selection if not present.""" self.intersection_update({obj}) self.add(obj) @classmethod - def __get_validators__(cls): + def __get_validators__(cls) -> Generator: yield cls.validate @classmethod - def validate(cls, v, field: 'ModelField'): + def validate(cls, v: Union['Selection', dict], field: 'ModelField') -> 'Selection': # type: ignore[override] """Pydantic validator.""" from napari._pydantic_compat import sequence_like if isinstance(v, dict): - data = v.get("selection", []) - current = v.get("_current", None) + data = v.get('selection', []) + current = v.get('_current', None) elif isinstance(v, Selection): data = v._set current = v._current @@ -182,12 +194,12 @@ def validate(cls, v, field: 'ModelField'): if errors: from napari._pydantic_compat import ValidationError - raise ValidationError(errors, cls) # type: ignore + raise ValidationError(errors, cls) obj = cls(data=data) obj._current_ = current return obj - def _json_encode(self): + def _json_encode(self) -> dict: # type: ignore[override] """Return an object that can be used by json.dumps.""" # we don't serialize active, as it's gleaned from the selection. return {'selection': super()._json_encode(), '_current': self._current} @@ -196,9 +208,9 @@ def _json_encode(self): class Selectable(Generic[_S]): """Mixin that adds a selection model to an object.""" - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: self._selection: Selection[_S] = Selection() - super().__init__(*args, **kwargs) # type: ignore + super().__init__(*args, **kwargs) @property def selection(self) -> Selection[_S]: diff --git a/napari/utils/events/containers/_set.py b/napari/utils/events/containers/_set.py index 4b671d71992..112dd3837fd 100644 --- a/napari/utils/events/containers/_set.py +++ b/napari/utils/events/containers/_set.py @@ -1,11 +1,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable, Iterator, MutableSet, TypeVar +from collections.abc import Generator, Iterable, Iterator, MutableSet, Sequence +from typing import ( + TYPE_CHECKING, + Any, + Optional, + TypeVar, +) from napari.utils.events import EmitterGroup from napari.utils.translations import trans -_T = TypeVar("_T") +_T = TypeVar('_T') if TYPE_CHECKING: from napari._pydantic_compat import ModelField @@ -51,11 +57,15 @@ def __iter__(self) -> Iterator[_T]: def __len__(self) -> int: return len(self._set) - def _pre_add_hook(self, value): + def _pre_add_hook(self, value: _T) -> _T: # for subclasses to potentially check value before adding return value - def _emit_change(self, added=None, removed=None): + def _emit_change( + self, + added: Optional[set[_T]] = None, + removed: Optional[set[_T]] = None, + ) -> None: # provides a hook for subclasses to update internal state before emit if added is None: added = set() @@ -68,7 +78,7 @@ def add(self, value: _T) -> None: if value not in self: value = self._pre_add_hook(value) self._set.add(value) - self._emit_change(added={value}, removed={}) + self._emit_change(added={value}, removed=set()) def discard(self, value: _T) -> None: """Remove an element from a set if it is a member. @@ -77,7 +87,7 @@ def discard(self, value: _T) -> None: """ if value in self: self._set.discard(value) - self._emit_change(added={}, removed={value}) + self._emit_change(added=set(), removed={value}) # #### END Required Abstract Methods @@ -94,10 +104,10 @@ def clear(self) -> None: if self._set: values = set(self) self._set.clear() - self._emit_change(added={}, removed=values) + self._emit_change(added=set(), removed=values) def __repr__(self) -> str: - return f"{type(self).__name__}({self._set!r})" + return f'{type(self).__name__}({self._set!r})' def update(self, others: Iterable[_T] = ()) -> None: """Update this set with the union of this set and others""" @@ -105,7 +115,7 @@ def update(self, others: Iterable[_T] = ()) -> None: if to_add: to_add = {self._pre_add_hook(i) for i in to_add} self._set.update(to_add) - self._emit_change(added=set(to_add), removed={}) + self._emit_change(added=set(to_add), removed=set()) def copy(self) -> EventedSet[_T]: """Return a shallow copy of this set.""" @@ -120,7 +130,7 @@ def difference_update(self, others: Iterable[_T] = ()) -> None: to_remove = self._set.intersection(others) if to_remove: self._set.difference_update(to_remove) - self._emit_change(added={}, removed=set(to_remove)) + self._emit_change(added=set(), removed=set(to_remove)) def intersection(self, others: Iterable[_T] = ()) -> EventedSet[_T]: """Return all elements that are in both sets as a new set.""" @@ -159,11 +169,11 @@ def union(self, others: Iterable[_T] = ()) -> EventedSet[_T]: return type(self)(self._set.union(others)) @classmethod - def __get_validators__(cls): + def __get_validators__(cls) -> Generator: yield cls.validate @classmethod - def validate(cls, v, field: ModelField): + def validate(cls, v: Sequence, field: ModelField) -> EventedSet: """Pydantic validator.""" from napari._pydantic_compat import sequence_like @@ -190,6 +200,6 @@ def validate(cls, v, field: ModelField): raise ValidationError(errors, cls) return cls(v) - def _json_encode(self): + def _json_encode(self) -> list: """Return an object that can be used by json.dumps.""" return list(self) diff --git a/napari/utils/events/containers/_typed.py b/napari/utils/events/containers/_typed.py index 719ea0abd88..4e3e47f188b 100644 --- a/napari/utils/events/containers/_typed.py +++ b/napari/utils/events/containers/_typed.py @@ -1,15 +1,9 @@ import logging +from collections.abc import Iterable, MutableSequence, Sequence from typing import ( Any, Callable, - Dict, - Iterable, - List, - MutableSequence, Optional, - Sequence, - Tuple, - Type, TypeVar, Union, overload, @@ -25,8 +19,8 @@ Index = Union[int, slice] -_T = TypeVar("_T") -_L = TypeVar("_L") +_T = TypeVar('_T') +_L = TypeVar('_L') class TypedMutableSequence(MutableSequence[_T]): @@ -53,13 +47,13 @@ def __init__( self, data: Iterable[_T] = (), *, - basetype: Union[Type[_T], Sequence[Type[_T]]] = (), - lookup: Optional[Dict[Type[_L], Callable[[_T], Union[_T, _L]]]] = None, + basetype: Union[type[_T], Sequence[type[_T]]] = (), + lookup: Optional[dict[type[_L], Callable[[_T], Union[_T, _L]]]] = None, ) -> None: if lookup is None: lookup = {} - self._list: List[_T] = [] - self._basetypes: Tuple[Type[_T], ...] = ( + self._list: list[_T] = [] + self._basetypes: tuple[type[_T], ...] = ( tuple(basetype) if isinstance(basetype, Sequence) else (basetype,) ) self._lookup = lookup.copy() @@ -71,7 +65,7 @@ def __len__(self) -> int: def __repr__(self) -> str: return repr(self._list) - def __eq__(self, other: object): + def __eq__(self, other: object) -> bool: return self._list == other def __hash__(self) -> int: @@ -100,10 +94,10 @@ def __setitem__(self, key, value): else: self._list[key] = self._type_check(value) - def insert(self, index: int, value: _T): + def insert(self, index: int, value: _T) -> None: self._list.insert(index, self._type_check(value)) - def __contains__(self, key): + def __contains__(self, key: Any) -> bool: if type(key) in self._lookup: try: self[self.index(key)] @@ -154,7 +148,7 @@ def __getitem__(self, key): result = self._list[key] return self.__newlike__(result) if isinstance(result, list) else result - def __delitem__(self, key): + def __delitem__(self, key) -> None: _key = self.index(key) if type(key) in self._lookup else key del self._list[_key] @@ -172,7 +166,9 @@ def _type_check(self, e: Any) -> _T: ) return e - def __newlike__(self, iterable: Iterable[_T]): + def __newlike__( + self, iterable: Iterable[_T] + ) -> 'TypedMutableSequence[_T]': new = self.__class__() # seperating this allows subclasses to omit these from their `__init__` new._basetypes = self._basetypes @@ -180,11 +176,11 @@ def __newlike__(self, iterable: Iterable[_T]): new.extend(iterable) return new - def copy(self) -> Self: + def copy(self) -> 'TypedMutableSequence[_T]': """Return a shallow copy of the list.""" return self.__newlike__(self) - def __add__(self, other: Iterable[_T]) -> Self: + def __add__(self, other: Iterable[_T]) -> 'TypedMutableSequence[_T]': """Add other to self, return new object.""" copy = self.copy() copy.extend(other) @@ -195,7 +191,7 @@ def __iadd__(self, other: Iterable[_T]) -> Self: self.extend(other) return self - def __radd__(self, other: List) -> List: + def __radd__(self, other: list) -> list: """Add other to self in place (self += other).""" return other + list(self) @@ -239,13 +235,15 @@ def index( raise ValueError( trans._( - "{value!r} is not in list", + '{value!r} is not in list', deferred=True, value=value, ) ) - def _iter_indices(self, start=0, stop=None) -> Iterable[int]: + def _iter_indices( + self, start: int = 0, stop: Optional[int] = None + ) -> Iterable[int]: """Iter indices from start to stop. While this is trivial for this basic sequence type, this method lets @@ -259,5 +257,5 @@ def _ipython_key_completions_(self): return None # type: ignore -def _noop(x): +def _noop(x: _T) -> _T: return x diff --git a/napari/utils/events/custom_types.py b/napari/utils/events/custom_types.py index b7c69a4e156..a001a8a3177 100644 --- a/napari/utils/events/custom_types.py +++ b/napari/utils/events/custom_types.py @@ -1,12 +1,9 @@ +from collections.abc import Generator from typing import ( TYPE_CHECKING, Any, Callable, - Dict, - Generator, - List, Optional, - Type, Union, ) @@ -59,10 +56,10 @@ def __init__(self, *, prohibited: 'Number') -> None: class ConstrainedInt(types.ConstrainedInt): """ConstrainedInt extension that adds not-equal""" - ne: Optional[Union[int, List[int]]] = None + ne: Optional[Union[int, list[int]]] = None @classmethod - def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + def __modify_schema__(cls, field_schema: dict[str, Any]) -> None: super().__modify_schema__(field_schema) if cls.ne is not None: f = 'const' if isinstance(cls.ne, int) else 'enum' @@ -91,16 +88,16 @@ def conint( le: Optional[int] = None, multiple_of: Optional[int] = None, ne: Optional[int] = None, -) -> Type[int]: +) -> type[int]: """Extended version of `pydantic.types.conint` that includes not-equal.""" # use kwargs then define conf in a dict to aid with IDE type hinting namespace = { - "strict": strict, - "gt": gt, - "ge": ge, - "lt": lt, - "le": le, - "multiple_of": multiple_of, - "ne": ne, + 'strict': strict, + 'gt': gt, + 'ge': ge, + 'lt': lt, + 'le': le, + 'multiple_of': multiple_of, + 'ne': ne, } return type('ConstrainedIntValue', (ConstrainedInt,), namespace) diff --git a/napari/utils/events/debugging.py b/napari/utils/events/debugging.py index 639bd0a14b6..12fcb0bbc4b 100644 --- a/napari/utils/events/debugging.py +++ b/napari/utils/events/debugging.py @@ -2,7 +2,7 @@ import os import site from textwrap import indent -from typing import TYPE_CHECKING, ClassVar, Set +from typing import TYPE_CHECKING, ClassVar from napari._pydantic_compat import BaseSettings, Field, PrivateAttr from napari.utils.misc import ROOT_DIR @@ -13,7 +13,7 @@ except ModuleNotFoundError: print( trans._( - "TIP: run `pip install rich` for much nicer event debug printout." + 'TIP: run `pip install rich` for much nicer event debug printout.' ) ) try: @@ -36,12 +36,12 @@ class EventDebugSettings(BaseSettings): # event emitters (e.g. 'Shapes') and event names (e.g. 'set_data') # to include/exclude when printing events. - include_emitters: Set[str] = Field(default_factory=set) - include_events: Set[str] = Field(default_factory=set) - exclude_emitters: Set[str] = Field( + include_emitters: set[str] = Field(default_factory=set) + include_events: set[str] = Field(default_factory=set) + exclude_emitters: set[str] = Field( default_factory=lambda: {'TransformChain', 'Context'} ) - exclude_events: Set[str] = Field( + exclude_events: set[str] = Field( default_factory=lambda: {'status', 'position'} ) # stack depth to show @@ -66,7 +66,7 @@ def _shorten_fname(fname: str) -> str: """Reduce extraneous stuff from filenames""" fname = fname.replace(_SP, '.../site-packages') fname = fname.replace(_STD_LIB, '.../python') - return fname.replace(ROOT_DIR, "napari") + return fname.replace(ROOT_DIR, 'napari') def log_event_stack(event: 'Event', cfg: EventDebugSettings = _SETTINGS): @@ -86,7 +86,7 @@ def log_event_stack(event: 'Event', cfg: EventDebugSettings = _SETTINGS): return # get values being emitted - vals = ",".join(f"{k}={v}" for k, v in event._kwargs.items()) + vals = ','.join(f'{k}={v}' for k, v in event._kwargs.items()) # show event type and source lines = [f'{source}.events.{event.type}({vals})'] # climb stack and show what caused it. @@ -101,26 +101,26 @@ def log_event_stack(event: 'Event', cfg: EventDebugSettings = _SETTINGS): obj = type(frame.frame.f_locals['self']).__name__ + '.' ln = f' "{fname}", line {frame.lineno}, in {obj}{frame.function}' lines.append(ln) - lines.append("") + lines.append('') # find the first caller in the call stack for f in reversed(call_stack): if 'self' in f.frame.f_locals: obj_type = type(f.frame.f_locals['self']) module = obj_type.__module__ or '' - if module.startswith("napari"): + if module.startswith('napari'): trigger = f'{obj_type.__name__}.{f.function}()' lines.insert(1, f' was triggered by {trigger}, via:') break # seperate groups of events if not cfg._cur_depth: - lines = ["─" * 79, "", *lines] + lines = ['─' * 79, '', *lines] elif not cfg.nesting_allowance: return # log it - print(indent("\n".join(lines), ' ' * cfg._cur_depth)) + print(indent('\n'.join(lines), ' ' * cfg._cur_depth)) # spy on nested events... # (i.e. events that were emitted while another was being emitted) diff --git a/napari/utils/events/event.py b/napari/utils/events/event.py index 807bafccd8c..17c4e310195 100644 --- a/napari/utils/events/event.py +++ b/napari/utils/events/event.py @@ -53,20 +53,14 @@ import os import warnings import weakref -from collections.abc import Sequence +from collections.abc import Iterable, Iterator, Sequence from functools import partial from typing import ( Any, Callable, - Dict, - Generator, Generic, - Iterable, - List, Literal, Optional, - Tuple, - Type, TypeVar, Union, cast, @@ -103,16 +97,16 @@ class Event: """ @rename_argument( - from_name="type", - to_name="type_name", - version="0.6.0", - since_version="0.4.18", + from_name='type', + to_name='type_name', + version='0.6.0', + since_version='0.4.18', ) def __init__( self, type_name: str, native: Any = None, **kwargs: Any ) -> None: # stack of all sources this event has been emitted through - self._sources: List[Any] = [] + self._sources: list[Any] = [] self._handled: bool = False self._blocked: bool = False # Store args @@ -128,7 +122,7 @@ def source(self) -> Any: return self._sources[-1] if self._sources else None @property - def sources(self) -> List[Any]: + def sources(self) -> list[Any]: """List of objects that the event applies to (i.e. are or have been a source of the event). Can contain multiple objects in case the event traverses a hierarchy of objects. @@ -187,7 +181,7 @@ def __repr__(self) -> str: _event_repr_depth += 1 try: if _event_repr_depth > 2: - return "<...>" + return '<...>' attrs = [] for name in dir(self): if name.startswith('_'): @@ -199,7 +193,7 @@ def __repr__(self) -> str: continue attr = getattr(self, name) - attrs.append(f"{name}={attr!r}") + attrs.append(f'{name}={attr!r}') finally: _event_repr_depth -= 1 return f'<{self.__class__.__name__} {" ".join(attrs)}>' @@ -217,13 +211,13 @@ def __getattr__(self, name: str) -> Any: Callback = Union[Callable[[Event], None], Callable[[], None]] -CallbackRef = Tuple['weakref.ReferenceType[Any]', str] # dereferenced method -CallbackStr = Tuple[ +CallbackRef = tuple['weakref.ReferenceType[Any]', str] # dereferenced method +CallbackStr = tuple[ Union['weakref.ReferenceType[Any]', object], str ] # dereferenced method -_T = TypeVar("_T") +_T = TypeVar('_T') class _WeakCounter(Generic[_T]): @@ -289,21 +283,21 @@ class EventEmitter: The class of events that this emitter will generate. """ - @rename_argument("type", "type_name", "0.6.0", "0.4.18") + @rename_argument('type', 'type_name', '0.6.0', '0.4.18') def __init__( self, source: Any = None, type_name: Optional[str] = None, - event_class: Type[Event] = Event, + event_class: type[Event] = Event, ) -> None: # connected callbacks - self._callbacks: List[Union[Callback, CallbackRef]] = [] + self._callbacks: list[Union[Callback, CallbackRef]] = [] # used when connecting new callbacks at specific positions - self._callback_refs: List[Optional[str]] = [] - self._callback_pass_event: List[bool] = [] + self._callback_refs: list[Optional[str]] = [] + self._callback_pass_event: list[bool] = [] # count number of times this emitter is blocked for each callback. - self._blocked: Dict[Optional[Callback], int] = {None: 0} + self._blocked: dict[Optional[Callback], int] = {None: 0} self._block_counter: _WeakCounter[Optional[Callback]] = _WeakCounter() # used to detect emitter loops @@ -361,12 +355,12 @@ def print_callback_errors( self._print_callback_errors = val @property - def callback_refs(self) -> Tuple[Optional[str], ...]: + def callback_refs(self) -> tuple[Optional[str], ...]: """The set of callback references""" return tuple(self._callback_refs) @property - def callbacks(self) -> Tuple[Union[Callback, CallbackRef], ...]: + def callbacks(self) -> tuple[Union[Callback, CallbackRef], ...]: """The set of callbacks""" return tuple(self._callbacks) @@ -416,8 +410,8 @@ def connect( callback: Union[Callback, CallbackRef, CallbackStr, 'EventEmitter'], ref: Union[bool, str] = False, position: Literal['first', 'last'] = 'first', - before: Union[str, Callback, List[Union[str, Callback]], None] = None, - after: Union[str, Callback, List[Union[str, Callback]], None] = None, + before: Union[str, Callback, list[Union[str, Callback]], None] = None, + after: Union[str, Callback, list[Union[str, Callback]], None] = None, until: Optional['EventEmitter'] = None, ): """Connect this emitter to a new callback. @@ -529,7 +523,7 @@ def connect( callback_bounds = (core_callbacks_count, len(callback_refs)) # bounds: upper & lower bnds (inclusive) of possible cb locs - bounds: List[int] = [] + bounds: list[int] = [] for ri, criteria in enumerate((before, after)): if criteria is None or criteria == []: bounds.append( @@ -638,7 +632,7 @@ def _get_proper_name(callback): return obj, name raise RuntimeError( trans._( - "During bind method {callback} of object {obj} an error happen", + 'During bind method {callback} of object {obj} an error happen', deferred=True, callback=callback, obj=obj, @@ -657,7 +651,7 @@ def _check_signature(fun: Callable) -> bool: if sum(map(_is_pos_arg, parameters_list)) > 1: raise RuntimeError( trans._( - "Binning function cannot have more than one positional argument", + 'Binning function cannot have more than one positional argument', deferred=True, ) ) @@ -674,7 +668,7 @@ def _check_signature(fun: Callable) -> bool: def _normalize_cb( self, callback - ) -> Tuple[Union[CallbackRef, Callback], bool]: + ) -> tuple[Union[CallbackRef, Callback], bool]: # dereference methods into a (self, method_name) pair so that we can # make the connection without making a strong reference to the # instance. @@ -736,7 +730,7 @@ def __call__(self, *args, **kwargs) -> Event: _log_event_stack(event) - rem: List[CallbackRef] = [] + rem: list[CallbackRef] = [] for cb, pass_event in zip( self._callbacks[:], self._callback_pass_event[:] ): @@ -750,7 +744,7 @@ def __call__(self, *args, **kwargs) -> Event: if cb is None: warnings.warn( trans._( - "Problem with function {old_cb} of {obj} connected to event {self_}", + 'Problem with function {old_cb} of {obj} connected to event {self_}', deferred=True, old_cb=old_cb[1], obj=obj, @@ -779,7 +773,7 @@ def __call__(self, *args, **kwargs) -> Event: if ps is not self.source: raise RuntimeError( trans._( - "Event source-stack mismatch.", + 'Event source-stack mismatch.', deferred=True, ) ) @@ -826,7 +820,7 @@ def _prepare_event(self, *args, **kwargs) -> Event: else: raise ValueError( trans._( - "Event emitters can be called with an Event instance or with keyword arguments only.", + 'Event emitters can be called with an Event instance or with keyword arguments only.', deferred=True, ) ) @@ -858,7 +852,7 @@ def unblock(self, callback: Optional[Callback] = None): if callback not in self._blocked or self._blocked[callback] == 0: raise RuntimeError( trans._( - "Cannot unblock {self_} for callback {callback}; emitter was not previously blocked.", + 'Cannot unblock {self_} for callback {callback}; emitter was not previously blocked.', deferred=True, self_=self, callback=callback, @@ -892,7 +886,7 @@ class WarningEmitter(EventEmitter): def __init__( self, message: str, - category: Type[Warning] = FutureWarning, + category: type[Warning] = FutureWarning, stacklevel: int = 3, *args, **kwargs, @@ -968,13 +962,13 @@ def __init__( self, source: Any = None, auto_connect: bool = False, - **emitters: Union[Type[Event], EventEmitter, None], + **emitters: Union[type[Event], EventEmitter, None], ) -> None: EventEmitter.__init__(self, source) self.auto_connect = auto_connect - self.auto_connect_format = "on_%s" - self._emitters: Dict[str, EventEmitter] = {} + self.auto_connect_format = 'on_%s' + self._emitters: dict[str, EventEmitter] = {} # whether the sub-emitters have been connected to the group: self._emitters_connected: bool = False self.add(**emitters) # type: ignore @@ -991,7 +985,7 @@ def __getitem__(self, name: str) -> EventEmitter: return self._emitters[name] def __setitem__( - self, name: str, emitter: Union[Type[Event], EventEmitter, None] + self, name: str, emitter: Union[type[Event], EventEmitter, None] ): """ Alias for EmitterGroup.add(name=emitter) @@ -1001,7 +995,7 @@ def __setitem__( def add( self, auto_connect: Optional[bool] = None, - **kwargs: Union[Type[Event], EventEmitter, None], + **kwargs: Union[type[Event], EventEmitter, None], ): """Add one or more EventEmitter instances to this emitter group. Each keyword argument may be specified as either an EventEmitter @@ -1078,11 +1072,11 @@ def add( emitter.connect(self) @property - def emitters(self) -> Dict[str, EventEmitter]: + def emitters(self) -> dict[str, EventEmitter]: """List of current emitters in this group.""" return self._emitters - def __iter__(self) -> Generator[str, None, None]: + def __iter__(self) -> Iterator[str]: """ Iterates over the names of emitters in this group. """ @@ -1111,8 +1105,8 @@ def connect( callback: Union[Callback, CallbackRef, EventEmitter, 'EmitterGroup'], ref: Union[bool, str] = False, position: Literal['first', 'last'] = 'first', - before: Union[str, Callback, List[Union[str, Callback]], None] = None, - after: Union[str, Callback, List[Union[str, Callback]], None] = None, + before: Union[str, Callback, list[Union[str, Callback]], None] = None, + after: Union[str, Callback, list[Union[str, Callback]], None] = None, ): """Connect the callback to the event group. The callback will receive events from *all* of the emitters in the group. @@ -1253,5 +1247,5 @@ def set_event_tracing_enabled(enabled=True, cfg=None): _log_event_stack = _noop -if os.getenv("NAPARI_DEBUG_EVENTS", '').lower() in ('1', 'true'): +if os.getenv('NAPARI_DEBUG_EVENTS', '').lower() in ('1', 'true'): set_event_tracing_enabled(True) diff --git a/napari/utils/events/evented_model.py b/napari/utils/events/evented_model.py index ab81e86e988..e7741197e45 100644 --- a/napari/utils/events/evented_model.py +++ b/napari/utils/events/evented_model.py @@ -1,7 +1,7 @@ import sys import warnings from contextlib import contextmanager -from typing import Any, Callable, ClassVar, Dict, Set, Tuple, Union +from typing import Any, Callable, ClassVar, Union import numpy as np from app_model.types import KeyBinding @@ -55,7 +55,7 @@ def no_class_attributes(): - https://codereview.qt-project.org/c/pyside/pyside-setup/+/261411 """ - if "PySide2" not in sys.modules: + if 'PySide2' not in sys.modules: yield return @@ -111,14 +111,14 @@ def __new__(mcs, name, bases, namespace, **kwargs): cls.__properties__[name] = attr # determine compare operator if ( - hasattr(attr.fget, "__annotations__") - and "return" in attr.fget.__annotations__ + hasattr(attr.fget, '__annotations__') + and 'return' in attr.fget.__annotations__ and not isinstance( - attr.fget.__annotations__["return"], str + attr.fget.__annotations__['return'], str ) ): cls.__eq_operators__[name] = pick_equality_operator( - attr.fget.__annotations__["return"] + attr.fget.__annotations__['return'] ) cls.__field_dependents__ = _get_field_dependents(cls) @@ -145,7 +145,7 @@ def _update_dependents_from_property_code( ) -def _get_field_dependents(cls: 'EventedModel') -> Dict[str, Set[str]]: +def _get_field_dependents(cls: 'EventedModel') -> dict[str, set[str]]: """Return mapping of field name -> dependent set of property names. Dependencies will be guessed by inspecting the code of each property @@ -188,7 +188,7 @@ class Config: if not cls.__properties__: return {} - deps: Dict[str, Set[str]] = {} + deps: dict[str, set[str]] = {} _deps = getattr(cls.__config__, 'dependencies', None) if _deps: @@ -200,7 +200,7 @@ class Config: ) for field in fields: if field not in cls.__fields__: - warnings.warn(f"Unrecognized field dependency: {field}") + warnings.warn(f'Unrecognized field dependency: {field}') deps.setdefault(field, set()).add(prop_name) else: # if dependencies haven't been explicitly defined, we can glean @@ -221,15 +221,15 @@ class EventedModel(BaseModel, metaclass=EventedMetaclass): _events: EmitterGroup = PrivateAttr(default_factory=EmitterGroup) # mapping of name -> property obj for methods that are properties - __properties__: ClassVar[Dict[str, property]] + __properties__: ClassVar[dict[str, property]] # mapping of field name -> dependent set of property names # when field is changed, an event for dependent properties will be emitted. - __field_dependents__: ClassVar[Dict[str, Set[str]]] - __eq_operators__: ClassVar[Dict[str, Callable[[Any, Any], bool]]] - _changes_queue: Dict[str, Any] = PrivateAttr(default_factory=dict) - _primary_changes: Set[str] = PrivateAttr(default_factory=set) + __field_dependents__: ClassVar[dict[str, set[str]]] + __eq_operators__: ClassVar[dict[str, Callable[[Any, Any], bool]]] + _changes_queue: dict[str, Any] = PrivateAttr(default_factory=dict) + _primary_changes: set[str] = PrivateAttr(default_factory=set) _delay_check_semaphore: int = PrivateAttr(0) - __slots__: ClassVar[Set[str]] = {"__weakref__"} # type: ignore + __slots__: ClassVar[set[str]] = {'__weakref__'} # type: ignore # pydantic BaseModel configuration. see: # https://pydantic-docs.helpmanual.io/usage/model_config/ @@ -285,7 +285,7 @@ def _super_setattr_(self, name: str, value: Any) -> None: else: super().__setattr__(name, value) - def _check_if_differ(self, name: str, old_value: Any) -> Tuple[bool, Any]: + def _check_if_differ(self, name: str, old_value: Any) -> tuple[bool, Any]: """ Check new value of a field and emit event if it is different from the old one. @@ -438,7 +438,7 @@ def update( if not isinstance(values, dict): raise TypeError( trans._( - "Unsupported update from {values}", + 'Unsupported update from {values}', deferred=True, values=type(values), ) diff --git a/napari/utils/events/migrations.py b/napari/utils/events/migrations.py index cf970a33cc4..4aea86730ba 100644 --- a/napari/utils/events/migrations.py +++ b/napari/utils/events/migrations.py @@ -32,11 +32,11 @@ def deprecation_warning_event( WarningEmitter Event emitter that prints a deprecation warning. """ - previous_path = f"{prefix}.{previous_name}" - new_path = f"{prefix}.{new_name}" + previous_path = f'{prefix}.{previous_name}' + new_path = f'{prefix}.{new_name}' return WarningEmitter( trans._( - "{previous_path} is deprecated since {since_version} and will be removed in {version}. Please use {new_path}", + '{previous_path} is deprecated since {since_version} and will be removed in {version}. Please use {new_path}', deferred=True, previous_path=previous_path, since_version=since_version, diff --git a/napari/utils/geometry.py b/napari/utils/geometry.py index 99687d2b589..67645d67db4 100644 --- a/napari/utils/geometry.py +++ b/napari/utils/geometry.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, Tuple +from typing import Optional import numpy as np import numpy.typing as npt @@ -6,18 +6,18 @@ # normal vectors for a 3D axis-aligned box # coordinates are ordered [z, y, x] FACE_NORMALS = { - "x_pos": np.array([0, 0, 1]), - "x_neg": np.array([0, 0, -1]), - "y_pos": np.array([0, 1, 0]), - "y_neg": np.array([0, -1, 0]), - "z_pos": np.array([1, 0, 0]), - "z_neg": np.array([-1, 0, 0]), + 'x_pos': np.array([0, 0, 1]), + 'x_neg': np.array([0, 0, -1]), + 'y_pos': np.array([0, 1, 0]), + 'y_neg': np.array([0, -1, 0]), + 'z_pos': np.array([1, 0, 0]), + 'z_neg': np.array([-1, 0, 0]), } def project_points_onto_plane( points: np.ndarray, plane_point: np.ndarray, plane_normal: np.ndarray -) -> Tuple[np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray]: """Project points on to a plane. Plane is defined by a point and a normal vector. This function @@ -147,7 +147,7 @@ def rotate_points( points: np.ndarray, current_plane_normal: np.ndarray, new_plane_normal: np.ndarray, -) -> Tuple[np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray]: """Rotate points using a rotation matrix defined by the rotation from current_plane to new_plane. @@ -285,7 +285,7 @@ def intersect_line_with_axis_aligned_plane( def bounding_box_to_face_vertices( bounding_box: np.ndarray, -) -> Dict[str, np.ndarray]: +) -> dict[str, np.ndarray]: """From a layer bounding box (N, 2), N=ndim, return a dictionary containing the vertices of each face of the bounding_box. @@ -310,7 +310,7 @@ def bounding_box_to_face_vertices( z_min, z_max = bounding_box[-3, :] face_coords = { - "x_pos": np.array( + 'x_pos': np.array( [ [z_min, y_min, x_max], [z_min, y_max, x_max], @@ -318,7 +318,7 @@ def bounding_box_to_face_vertices( [z_max, y_min, x_max], ] ), - "x_neg": np.array( + 'x_neg': np.array( [ [z_min, y_min, x_min], [z_min, y_max, x_min], @@ -326,7 +326,7 @@ def bounding_box_to_face_vertices( [z_max, y_min, x_min], ] ), - "y_pos": np.array( + 'y_pos': np.array( [ [z_min, y_max, x_min], [z_min, y_max, x_max], @@ -334,7 +334,7 @@ def bounding_box_to_face_vertices( [z_max, y_max, x_min], ] ), - "y_neg": np.array( + 'y_neg': np.array( [ [z_min, y_min, x_min], [z_min, y_min, x_max], @@ -342,7 +342,7 @@ def bounding_box_to_face_vertices( [z_max, y_min, x_min], ] ), - "z_pos": np.array( + 'z_pos': np.array( [ [z_max, y_min, x_min], [z_max, y_min, x_max], @@ -350,7 +350,7 @@ def bounding_box_to_face_vertices( [z_max, y_max, x_min], ] ), - "z_neg": np.array( + 'z_neg': np.array( [ [z_min, y_min, x_min], [z_min, y_min, x_max], @@ -786,7 +786,7 @@ def distance_between_point_and_line_3d( def find_nearest_triangle_intersection( ray_position: np.ndarray, ray_direction: np.ndarray, triangles: np.ndarray -) -> Tuple[Optional[int], Optional[np.ndarray]]: +) -> tuple[Optional[int], Optional[np.ndarray]]: """Given an array of triangles, find the index and intersection location of a ray and the nearest triangle. diff --git a/napari/utils/history.py b/napari/utils/history.py index 55cd0afc284..775476b562e 100644 --- a/napari/utils/history.py +++ b/napari/utils/history.py @@ -1,6 +1,5 @@ import os from pathlib import Path -from typing import List from napari.settings import get_settings @@ -45,7 +44,7 @@ def update_save_history(filename: str) -> None: settings.application.save_history = folders -def get_open_history() -> List[str]: +def get_open_history() -> list[str]: """A helper for history handling.""" settings = get_settings() folders = settings.application.open_history @@ -53,7 +52,7 @@ def get_open_history() -> List[str]: return folders or [str(Path.home())] -def get_save_history() -> List[str]: +def get_save_history() -> list[str]: """A helper for history handling.""" settings = get_settings() folders = settings.application.save_history diff --git a/napari/utils/indexing.py b/napari/utils/indexing.py index 110ff7aae87..16c213661e0 100644 --- a/napari/utils/indexing.py +++ b/napari/utils/indexing.py @@ -5,7 +5,7 @@ __all__ = ['index_in_slice'] warnings.warn( - "napari.utils.indexing is deprecated since 0.4.19 and will be removed in 0.5.0.", + 'napari.utils.indexing is deprecated since 0.4.19 and will be removed in 0.5.0.', FutureWarning, stacklevel=2, ) diff --git a/napari/utils/info.py b/napari/utils/info.py index 55165d81206..028538d10e4 100644 --- a/napari/utils/info.py +++ b/napari/utils/info.py @@ -3,10 +3,11 @@ import platform import subprocess import sys +from importlib.metadata import PackageNotFoundError, version import napari -OS_RELEASE_PATH = "/etc/os-release" +OS_RELEASE_PATH = '/etc/os-release' def _linux_sys_name() -> str: @@ -18,14 +19,14 @@ def _linux_sys_name() -> str: with open(OS_RELEASE_PATH) as f_p: data = {} for line in f_p: - field, value = line.split("=") + field, value = line.split('=') data[field.strip()] = value.strip().strip('"') - if "PRETTY_NAME" in data: - return data["PRETTY_NAME"] - if "NAME" in data: - if "VERSION" in data: + if 'PRETTY_NAME' in data: + return data['PRETTY_NAME'] + if 'NAME' in data: + if 'VERSION' in data: return f'{data["NAME"]} {data["VERSION"]}' - if "VERSION_ID" in data: + if 'VERSION_ID' in data: return f'{data["NAME"]} {data["VERSION_ID"]}' return f'{data["NAME"]} (no version)' @@ -38,18 +39,18 @@ def _linux_sys_name_lsb_release() -> str: """ with contextlib.suppress(subprocess.CalledProcessError): res = subprocess.run( - ["lsb_release", "-d", "-r"], check=True, capture_output=True + ['lsb_release', '-d', '-r'], check=True, capture_output=True ) text = res.stdout.decode() data = {} - for line in text.split("\n"): - key, val = line.split(":") + for line in text.split('\n'): + key, val = line.split(':') data[key.strip()] = val.strip() - version_str = data["Description"] - if not version_str.endswith(data["Release"]): - version_str += " " + data["Release"] + version_str = data['Description'] + if not version_str.endswith(data['Release']): + version_str += ' ' + data['Release'] return version_str - return "" + return '' def _sys_name() -> str: @@ -57,17 +58,17 @@ def _sys_name() -> str: Discover MacOS or Linux Human readable information. For Linux provide information about distribution. """ with contextlib.suppress(Exception): - if sys.platform == "linux": + if sys.platform == 'linux': return _linux_sys_name() - if sys.platform == "darwin": + if sys.platform == 'darwin': with contextlib.suppress(subprocess.CalledProcessError): res = subprocess.run( - ["sw_vers", "-productVersion"], + ['sw_vers', '-productVersion'], check=True, capture_output=True, ) - return f"MacOS {res.stdout.decode().strip()}" - return "" + return f'MacOS {res.stdout.decode().strip()}' + return '' def sys_info(as_html: bool = False) -> str: @@ -80,15 +81,15 @@ def sys_info(as_html: bool = False) -> str: """ sys_version = sys.version.replace('\n', ' ') text = ( - f"napari: {napari.__version__}
" - f"Platform: {platform.platform()}
" + f'napari: {napari.__version__}
' + f'Platform: {platform.platform()}
' ) __sys_name = _sys_name() if __sys_name: - text += f"System: {__sys_name}
" + text += f'System: {__sys_name}
' - text += f"Python: {sys_version}
" + text += f'Python: {sys_version}
' try: from qtpy import API_NAME, PYQT_VERSION, PYSIDE_VERSION, QtCore @@ -101,12 +102,12 @@ def sys_info(as_html: bool = False) -> str: API_VERSION = '' text += ( - f"Qt: {QtCore.__version__}
" - f"{API_NAME}: {API_VERSION}
" + f'Qt: {QtCore.__version__}
' + f'{API_NAME}: {API_VERSION}
' ) except Exception as e: # noqa BLE001 - text += f"Qt: Import failed ({e})
" + text += f'Qt: Import failed ({e})
' modules = ( ('numpy', 'NumPy'), @@ -124,43 +125,60 @@ def sys_info(as_html: bool = False) -> str: for module, name in modules: try: loaded[module] = __import__(module) - text += f"{name}: {loaded[module].__version__}
" - except Exception as e: # noqa BLE001 - text += f"{name}: Import failed ({e})
" + text += f'{name}: {version(module)}
' + except PackageNotFoundError: + text += f'{name}: Import failed
' - text += "
OpenGL:
" + text += '
OpenGL:
' if loaded.get('vispy', False): + from napari._vispy.utils.gl import get_max_texture_sizes + sys_info_text = ( - "
".join( + '
'.join( [ - loaded['vispy'].sys_info().split("\n")[index] + loaded['vispy'].sys_info().split('\n')[index] for index in [-4, -3] ] ) - .replace("'", "") - .replace("
", "
- ") + .replace("'", '') + .replace('
', '
- ') ) text += f' - {sys_info_text}
' + _, max_3d_texture_size = get_max_texture_sizes() + text += f' - GL_MAX_3D_TEXTURE_SIZE: {max_3d_texture_size}
' else: - text += " - failed to load vispy" + text += ' - failed to load vispy' - text += "
Screens:
" + text += '
Screens:
' try: from qtpy.QtGui import QGuiApplication screen_list = QGuiApplication.screens() for i, screen in enumerate(screen_list, start=1): - text += f" - screen {i}: resolution {screen.geometry().width()}x{screen.geometry().height()}, scale {screen.devicePixelRatio()}
" + text += f' - screen {i}: resolution {screen.geometry().width()}x{screen.geometry().height()}, scale {screen.devicePixelRatio()}
' except Exception as e: # noqa BLE001 - text += f" - failed to load screen information {e}" + text += f' - failed to load screen information {e}' + + text += '
Optional:
' + + optional_modules = ( + ('numba', 'numba'), + ('triangle', 'triangle'), + ) + + for module, name in optional_modules: + try: + text += f' - {name}: {version(module)}
' + except PackageNotFoundError: + text += f' - {name} not installed
' - text += "
Settings path:
" + text += '
Settings path:
' try: from napari.settings import get_settings - text += f" - {get_settings().config_path}" + text += f' - {get_settings().config_path}' except ValueError: from napari.utils._appdirs import user_config_dir @@ -168,7 +186,7 @@ def sys_info(as_html: bool = False) -> str: if not as_html: text = ( - text.replace("
", "\n").replace("", "").replace("", "") + text.replace('
', '\n').replace('', '').replace('', '') ) return text diff --git a/napari/utils/interactions.py b/napari/utils/interactions.py index 88366f38e46..2493085faac 100644 --- a/napari/utils/interactions.py +++ b/napari/utils/interactions.py @@ -2,7 +2,6 @@ import inspect import sys import warnings -from typing import List from numpydoc.docscrape import FunctionDoc @@ -247,7 +246,7 @@ def hello_world(layer, event): KEY_SYMBOLS.update({'Meta': 'Super'}) -def _kb2mods(key_bind: KeyBinding) -> List[str]: +def _kb2mods(key_bind: KeyBinding) -> list[str]: """Extract list of modifiers from a key binding. Parameters @@ -291,7 +290,7 @@ def __init__(self, shortcut: KeyBindingLike) -> None: shortcut to format """ error_msg = trans._( - "`{shortcut}` does not seem to be a valid shortcut Key.", + '`{shortcut}` does not seem to be a valid shortcut Key.', shortcut=shortcut, ) error = False @@ -323,7 +322,7 @@ def parse_platform(text: str) -> str: # as you can't get two non-modifier keys, or alone. if text == '+': return text - if JOINCHAR == "+": + if JOINCHAR == '+': text = text.replace('++', '+Plus') text = text.replace('+', '') text = text.replace('Plus', '+') @@ -389,7 +388,7 @@ def get_key_bindings_summary(keymap, col='rgb(134, 142, 147)'): key_bindings_strs = [''] for key in keymap: keycodes = [KEY_SYMBOLS.get(k, k) for k in key.split('-')] - keycodes = "+".join( + keycodes = '+'.join( [f"{k}" for k in keycodes] ) key_bindings_strs.append( diff --git a/napari/utils/io.py b/napari/utils/io.py index 4a8da4e427a..af44504879f 100644 --- a/napari/utils/io.py +++ b/napari/utils/io.py @@ -9,7 +9,7 @@ import numpy as np -def imsave(filename: str, data: "np.ndarray"): +def imsave(filename: str, data: 'np.ndarray'): """Custom implementation of imsave to avoid skimage dependency. Parameters @@ -21,12 +21,12 @@ def imsave(filename: str, data: "np.ndarray"): """ ext = os.path.splitext(filename)[1].lower() # If no file extension was specified, choose .png by default - if ext == "": - ext = ".png" + if ext == '': + ext = '.png' # Save screenshot image data to output file - if ext in [".png"]: + if ext in ['.png']: imsave_png(filename, data) - elif ext in [".tif", ".tiff"]: + elif ext in ['.tif', '.tiff']: imsave_tiff(filename, data) else: import imageio.v3 as iio @@ -53,7 +53,7 @@ def imsave_png(filename, data): # Digital watermark, adds info about the napari version to the bytes of the PNG file pnginfo = PIL.PngImagePlugin.PngInfo() pnginfo.add_text( - "Software", f"napari version {__version__} https://napari.org/" + 'Software', f'napari version {__version__} https://napari.org/' ) iio.imwrite( filename, @@ -76,30 +76,10 @@ def imsave_tiff(filename, data): """ import tifffile - compression_instead_of_compress = False - try: - current_version = tuple( - int(x) for x in tifffile.__version__.split('.')[:3] - ) - compression_instead_of_compress = current_version >= (2021, 6, 6) - except Exception: # noqa: BLE001 - # Just in case anything goes wrong in parsing version number - # like repackaging on linux or anything else we fallback to - # using compress - warnings.warn( - trans._( - 'Error parsing tiffile version number {version_number}', - deferred=True, - version_number=f"{tifffile.__version__:!r}", - ) - ) - - if compression_instead_of_compress: - # 'compression' scheme is more complex. See: - # https://forum.image.sc/t/problem-saving-generated-labels-in-cellpose-napari/54892/8 - tifffile.imwrite(filename, data, compression=('zlib', 1)) - else: # older version of tifffile since 2021.6.6 this is deprecated - tifffile.imwrite(filename, data, compress=1) + # 'compression' kwarg since 2021.6.6; we depend on more recent versions + # now. See: + # https://forum.image.sc/t/problem-saving-generated-labels-in-cellpose-napari/54892/8 + tifffile.imwrite(filename, data, compression=('zlib', 1)) def __getattr__(name: str): @@ -123,4 +103,4 @@ def __getattr__(name: str): return getattr(napari_builtins.io, name) - raise AttributeError(f"module {__name__} has no attribute {name}") + raise AttributeError(f'module {__name__} has no attribute {name}') diff --git a/napari/utils/key_bindings.py b/napari/utils/key_bindings.py index bc55edd9267..5fd6eaff08b 100644 --- a/napari/utils/key_bindings.py +++ b/napari/utils/key_bindings.py @@ -37,8 +37,9 @@ def hello_world(viewer): import sys import time from collections import ChainMap +from collections.abc import Mapping from types import MethodType -from typing import Callable, Mapping, Union +from typing import Callable, Union from app_model.types import KeyBinding, KeyCode, KeyMod from vispy.util import keys @@ -210,7 +211,7 @@ def inner(func): if func is not None and key_bind in keymap and not overwrite: raise ValueError( trans._( - 'keybinding {key} already used! specify \'overwrite=True\' to bypass this check', + "keybinding {key} already used! specify 'overwrite=True' to bypass this check", deferred=True, key=str(key_bind), ) @@ -424,7 +425,7 @@ def press_key(self, key_bind): if not callable(func): raise TypeError( trans._( - "expected {func} to be callable", + 'expected {func} to be callable', deferred=True, func=func, ) @@ -492,10 +493,10 @@ def on_key_press(self, event): repeatables = { *action_manager._get_repeatable_shortcuts(self.keymap_chain), - "Up", - "Down", - "Left", - "Right", + 'Up', + 'Down', + 'Left', + 'Right', } if ( diff --git a/napari/utils/migrations.py b/napari/utils/migrations.py index 11ace080b4b..ae3c3cdcacd 100644 --- a/napari/utils/migrations.py +++ b/napari/utils/migrations.py @@ -9,7 +9,7 @@ def rename_argument( - from_name: str, to_name: str, version: str, since_version: str = "" + from_name: str, to_name: str, version: str, since_version: str = '' ) -> Callable: """ This is decorator for simple rename function argument @@ -28,10 +28,10 @@ def rename_argument( """ if not since_version: - since_version = "unknown" + since_version = 'unknown' warnings.warn( trans._( - "The since_version argument was added in napari 0.4.18 and will be mandatory since 0.6.0 release.", + 'The since_version argument was added in napari 0.4.18 and will be mandatory since 0.6.0 release.', deferred=True, ), stacklevel=2, @@ -45,14 +45,14 @@ def _update_from_dict(*args, **kwargs): if to_name in kwargs: raise ValueError( trans._( - "Argument {to_name} already defined, please do not mix {from_name} and {to_name} in one call.", + 'Argument {to_name} already defined, please do not mix {from_name} and {to_name} in one call.', from_name=from_name, to_name=to_name, ) ) warnings.warn( trans._( - "Argument {from_name!r} is deprecated, please use {to_name!r} instead. The argument {from_name!r} was deprecated in {since_version} and it will be removed in {version}.", + 'Argument {from_name!r} is deprecated, please use {to_name!r} instead. The argument {from_name!r} was deprecated in {since_version} and it will be removed in {version}.', from_name=from_name, to_name=to_name, version=version, @@ -97,7 +97,7 @@ def add_deprecated_property( if hasattr(obj, previous_name): raise RuntimeError( trans._( - "{previous_name} property already exists.", + '{previous_name} property already exists.', deferred=True, previous_name=previous_name, ) @@ -106,15 +106,15 @@ def add_deprecated_property( if not hasattr(obj, new_name): raise RuntimeError( trans._( - "{new_name} property must exist.", + '{new_name} property must exist.', deferred=True, new_name=new_name, ) ) - name = f"{obj.__name__}.{previous_name}" + name = f'{obj.__name__}.{previous_name}' msg = trans._( - "{name} is deprecated since {since_version} and will be removed in {version}. Please use {new_name}", + '{name} is deprecated since {since_version} and will be removed in {version}. Please use {new_name}', deferred=True, name=name, since_version=since_version, @@ -190,8 +190,8 @@ class NewName: ) """ msg = ( - f"{previous_name} is deprecated since {since_version} and will be " - f"removed in {version}. Please use {new_class.__name__}." + f'{previous_name} is deprecated since {since_version} and will be ' + f'removed in {version}. Please use {new_class.__name__}.' ) prealloc_signature = inspect.signature(new_class.__new__) diff --git a/napari/utils/misc.py b/napari/utils/misc.py index 2d66d6dc350..c074133c8b5 100644 --- a/napari/utils/misc.py +++ b/napari/utils/misc.py @@ -11,6 +11,7 @@ import re import sys import warnings +from collections.abc import Iterable, Iterator, Sequence from enum import Enum, EnumMeta from os import fspath, path as os_path from pathlib import Path @@ -18,14 +19,7 @@ TYPE_CHECKING, Any, Callable, - Dict, - Iterable, - Iterator, - List, Optional, - Sequence, - Tuple, - Type, TypeVar, Union, ) @@ -60,15 +54,15 @@ def running_as_bundled_app(*, check_conda: bool = True) -> bool: # From 0.4.12 we add a sentinel file next to the bundled sys.executable warnings.warn( trans._( - "Briefcase installations are no longer supported as of v0.4.18. " - "running_as_bundled_app() will be removed in a 0.6.0 release.", + 'Briefcase installations are no longer supported as of v0.4.18. ' + 'running_as_bundled_app() will be removed in a 0.6.0 release.', ), DeprecationWarning, stacklevel=2, ) if ( check_conda - and (Path(sys.executable).parent / ".napari_is_bundled").exists() + and (Path(sys.executable).parent / '.napari_is_bundled').exists() ): return True @@ -92,7 +86,7 @@ def running_as_bundled_app(*, check_conda: bool = True) -> bool: def running_as_constructor_app() -> bool: """Infer whether we are running as a constructor bundle.""" return ( - Path(sys.prefix).parent.parent / ".napari_is_bundled_constructor" + Path(sys.prefix).parent.parent / '.napari_is_bundled_constructor' ).exists() @@ -125,7 +119,7 @@ def in_python_repl() -> bool: return False -def str_to_rgb(arg: str) -> List[int]: +def str_to_rgb(arg: str) -> list[int]: """Convert an rgb string 'rgb(x,y,z)' to a list of ints [x,y,z].""" match = re.match(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)', arg) if match is None: @@ -134,7 +128,7 @@ def str_to_rgb(arg: str) -> List[int]: def ensure_iterable( - arg: Union[None, str, Enum, float, List, npt.NDArray], color: bool = False + arg: Union[None, str, Enum, float, list, npt.NDArray], color: bool = False ): """Ensure an argument is an iterable. Useful when an input argument can either be a single value or a list. If a color is passed then it @@ -147,7 +141,7 @@ def ensure_iterable( def is_iterable( - arg: Union[None, str, Enum, float, List, npt.NDArray], + arg: Union[None, str, Enum, float, list, npt.NDArray], color: bool = False, allow_none: bool = False, ) -> bool: @@ -245,7 +239,7 @@ def ensure_sequence_of_iterables( # sequence of iterables of wrong length raise ValueError( trans._( - "length of {obj} must equal {length}", + 'length of {obj} must equal {length}', deferred=True, obj=obj, length=length, @@ -315,7 +309,7 @@ def __call__( start=start, ) - def keys(self) -> List[str]: + def keys(self) -> list[str]: return list(map(str, self)) @@ -344,7 +338,7 @@ def __hash__(self) -> int: camel_to_snake_pattern = re.compile(r'(.)([A-Z][a-z]+)') camel_to_spaces_pattern = re.compile( - r"((?<=[a-z])[A-Z]|(? str: def camel_to_spaces(val: str) -> str: - return camel_to_spaces_pattern.sub(r" \1", val) + return camel_to_spaces_pattern.sub(r' \1', val) T = TypeVar('T', str, Path) @@ -383,7 +377,7 @@ def abspath_or_url(relpath: T, *, must_exist: bool = False) -> T: if not isinstance(relpath, (str, Path)): raise TypeError( - trans._("Argument must be a string or Path", deferred=True) + trans._('Argument must be a string or Path', deferred=True) ) OriginType = type(relpath) @@ -396,7 +390,7 @@ def abspath_or_url(relpath: T, *, must_exist: bool = False) -> T: if must_exist and not (urlp.scheme or urlp.netloc or os.path.exists(path)): raise ValueError( trans._( - "Requested path {path!r} does not exist.", + 'Requested path {path!r} does not exist.', deferred=True, path=path, ) @@ -425,7 +419,7 @@ def __str__(self) -> str: return formatted -def all_subclasses(cls: Type) -> set: +def all_subclasses(cls: type) -> set: """Recursively find all subclasses of class ``cls``. Parameters @@ -443,7 +437,7 @@ def all_subclasses(cls: Type) -> set: ) -def ensure_n_tuple(val: Iterable, n: int, fill: int = 0) -> Tuple: +def ensure_n_tuple(val: Iterable, n: int, fill: int = 0) -> tuple: """Ensure input is a length n tuple. Parameters @@ -463,7 +457,7 @@ def ensure_n_tuple(val: Iterable, n: int, fill: int = 0) -> Tuple: return (fill,) * (n - len(tuple_value)) + tuple_value[-n:] -def ensure_layer_data_tuple(val: Tuple) -> Tuple: +def ensure_layer_data_tuple(val: tuple) -> tuple: msg = trans._( 'Not a valid layer data tuple: {value!r}', deferred=True, @@ -479,7 +473,7 @@ def ensure_layer_data_tuple(val: Tuple) -> Tuple: return val -def ensure_list_of_layer_data_tuple(val: List[Tuple]) -> List[tuple]: +def ensure_list_of_layer_data_tuple(val: list[tuple]) -> list[tuple]: # allow empty list to be returned but do nothing in that case if isinstance(val, list): with contextlib.suppress(TypeError): @@ -491,7 +485,7 @@ def ensure_list_of_layer_data_tuple(val: List[Tuple]) -> List[tuple]: def _quiet_array_equal(*a, **k) -> bool: with warnings.catch_warnings(): - warnings.filterwarnings("ignore", "elementwise comparison") + warnings.filterwarnings('ignore', 'elementwise comparison') return np.array_equal(*a, **k) @@ -531,7 +525,7 @@ def pick_equality_operator(obj: Any) -> Callable[[Any, Any], bool]: # yes, it's a little riskier, but we are checking namespaces instead of # actual `issubclass` here to avoid slow import times - _known_arrays: Dict[str, Callable[[Any, Any], bool]] = { + _known_arrays: dict[str, Callable[[Any, Any], bool]] = { 'numpy.ndarray': _quiet_array_equal, # numpy.ndarray 'dask.Array': operator.is_, # dask.array.core.Array 'dask.Delayed': operator.is_, # dask.delayed.Delayed @@ -596,7 +590,7 @@ def dir_hash( if not Path(path).is_dir(): raise TypeError( trans._( - "{path} is not a directory.", + '{path} is not a directory.', deferred=True, path=path, ) @@ -606,7 +600,7 @@ def dir_hash( _hash = hash_func() for root, _, files in os.walk(path): for fname in sorted(files): - if fname.startswith(".") and ignore_hidden: + if fname.startswith('.') and ignore_hidden: continue _file_hash(_hash, Path(root) / fname, Path(path), include_paths) return _hash.hexdigest() @@ -640,7 +634,7 @@ def paths_hash( _hash = hash_func() for file_path in sorted(paths): file_path = Path(file_path) - if ignore_hidden and str(file_path.stem).startswith("."): + if ignore_hidden and str(file_path.stem).startswith('.'): continue _file_hash(_hash, file_path, file_path.parent, include_paths) return _hash.hexdigest() @@ -731,7 +725,7 @@ def install_certifi_opener() -> None: request.install_opener(opener) -def reorder_after_dim_reduction(order: Sequence[int]) -> Tuple[int, ...]: +def reorder_after_dim_reduction(order: Sequence[int]) -> tuple[int, ...]: """Ensure current dimension order is preserved after dims are dropped. This is similar to :func:`scipy.stats.rankdata`, but only deals with @@ -763,7 +757,7 @@ def reorder_after_dim_reduction(order: Sequence[int]) -> Tuple[int, ...]: return tuple(argsort(argsort(order))) -def argsort(values: Sequence[int]) -> List[int]: +def argsort(values: Sequence[int]) -> list[int]: """Equivalent to :func:`numpy.argsort` but faster in some cases. Parameters diff --git a/napari/utils/mouse_bindings.py b/napari/utils/mouse_bindings.py index 56a794ec19b..52d40903fb8 100644 --- a/napari/utils/mouse_bindings.py +++ b/napari/utils/mouse_bindings.py @@ -1,6 +1,3 @@ -from typing import List - - class MousemapProvider: """Mix-in to add mouse binding functionality. @@ -16,10 +13,10 @@ class MousemapProvider: Callbacks from when mouse wheel is scrolled. """ - mouse_move_callbacks: List[callable] - mouse_wheel_callbacks: List[callable] - mouse_drag_callbacks: List[callable] - mouse_double_click_callbacks: List[callable] + mouse_move_callbacks: list[callable] + mouse_wheel_callbacks: list[callable] + mouse_drag_callbacks: list[callable] + mouse_double_click_callbacks: list[callable] def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/napari/utils/naming.py b/napari/utils/naming.py index d53a35283e8..77281198760 100644 --- a/napari/utils/naming.py +++ b/napari/utils/naming.py @@ -3,15 +3,12 @@ import inspect import re -from collections import ChainMap +from collections import ChainMap, ChainMap as ChainMapType from types import FrameType, TracebackType from typing import ( Any, Callable, - ChainMap as ChainMapType, Optional, - Tuple, - Type, ) from napari.utils.misc import ROOT_DIR, formatdoc @@ -92,7 +89,7 @@ def skip_napari_frames(index, frame): """ - names: Tuple[str, ...] + names: tuple[str, ...] namespace: ChainMapType[str, Any] predicate: Callable[[int, FrameType], bool] @@ -145,7 +142,7 @@ def __enter__(self) -> 'CallerFrame': def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: diff --git a/napari/utils/notebook_display.py b/napari/utils/notebook_display.py index fb7f85e2634..b94dc290722 100644 --- a/napari/utils/notebook_display.py +++ b/napari/utils/notebook_display.py @@ -97,8 +97,8 @@ def _clean_alt_text(self, alt_text): 'The provided alt text does not constitute valid html, so it was discarded.', stacklevel=3, ) - alt_text = "" - if alt_text == "": + alt_text = '' + if alt_text == '': alt_text = None return alt_text diff --git a/napari/utils/notifications.py b/napari/utils/notifications.py index 5a5c689f361..923d18a8d0c 100644 --- a/napari/utils/notifications.py +++ b/napari/utils/notifications.py @@ -4,10 +4,11 @@ import sys import threading import warnings +from collections.abc import Sequence from datetime import datetime from enum import auto from types import TracebackType -from typing import Callable, List, Optional, Sequence, Set, Tuple, Type, Union +from typing import Callable, Optional, Union from napari.utils.events import Event, EventEmitter from napari.utils.misc import StringEnum @@ -56,11 +57,11 @@ class NotificationSeverity(StringEnum): def as_icon(self): return { - self.ERROR: "ⓧ", - self.WARNING: "⚠️", - self.INFO: "ⓘ", - self.DEBUG: "🐛", - self.NONE: "", + self.ERROR: 'ⓧ', + self.WARNING: '⚠️', + self.INFO: 'ⓘ', + self.DEBUG: '🐛', + self.NONE: '', }[self] def __lt__(self, other): @@ -82,7 +83,7 @@ def __hash__(self): return hash(self.value) -ActionSequence = Sequence[Tuple[str, Callable[[], None]]] +ActionSequence = Sequence[tuple[str, Callable[[], None]]] class Notification(Event): @@ -172,7 +173,7 @@ def as_text(self): self.exception, self.exception.__traceback__, ) - return fmt(exc_info, as_html=False, color="NoColor") + return fmt(exc_info, as_html=False, color='NoColor') def __str__(self): from napari.utils._tracebacks import get_tb_formatter @@ -229,23 +230,23 @@ class NotificationManager: re-entrency of the hooks themselves. """ - records: List[Notification] + records: list[Notification] _instance: Optional[NotificationManager] = None def __init__(self) -> None: - self.records: List[Notification] = [] + self.records: list[Notification] = [] self.exit_on_error = os.getenv('NAPARI_EXIT_ON_ERROR') in ('1', 'True') - self.catch_error = os.getenv("NAPARI_CATCH_ERRORS") not in ( + self.catch_error = os.getenv('NAPARI_CATCH_ERRORS') not in ( '0', 'False', ) self.notification_ready = self.changed = EventEmitter( source=self, event_class=Notification ) - self._originals_except_hooks: List[Callable] = [] - self._original_showwarnings_hooks: List[Callable] = [] - self._originals_thread_except_hooks: List[Callable] = [] - self._seen_warnings: Set[Tuple[str, Type, str, int]] = set() + self._originals_except_hooks: list[Callable] = [] + self._original_showwarnings_hooks: list[Callable] = [] + self._originals_thread_except_hooks: list[Callable] = [] + self._seen_warnings: set[tuple[str, type, str, int]] = set() def __enter__(self): self.install_hooks() @@ -292,8 +293,8 @@ def dispatch(self, notification: Notification): def receive_thread_error( self, - args: Tuple[ - Type[BaseException], + args: tuple[ + type[BaseException], BaseException, Optional[TracebackType], Optional[threading.Thread], @@ -303,19 +304,19 @@ def receive_thread_error( def receive_error( self, - exctype: Type[BaseException], + exctype: type[BaseException], value: BaseException, traceback: Optional[TracebackType] = None, thread: Optional[threading.Thread] = None, ): if isinstance(value, KeyboardInterrupt): - sys.exit("Closed by KeyboardInterrupt") + sys.exit('Closed by KeyboardInterrupt') capture_exception(value) if self.exit_on_error: sys.__excepthook__(exctype, value, traceback) - sys.exit("Exit on error") + sys.exit('Exit on error') if not self.catch_error: sys.__excepthook__(exctype, value, traceback) return @@ -324,7 +325,7 @@ def receive_error( def receive_warning( self, message: Warning, - category: Type[Warning], + category: type[Warning], filename: str, lineno: int, file=None, @@ -399,9 +400,9 @@ def show_console_notification(notification: Notification): print(notification) except Exception: print( - "An error occurred while trying to format an error and show it in console.\n" - "You can try to uninstall IPython to disable rich traceback formatting\n" - "And/or report a bug to napari" + 'An error occurred while trying to format an error and show it in console.\n' + 'You can try to uninstall IPython to disable rich traceback formatting\n' + 'And/or report a bug to napari' ) # this will likely get silenced by QT. raise diff --git a/napari/utils/perf/__init__.py b/napari/utils/perf/__init__.py index baf4e3e14ba..a4d3119be0c 100644 --- a/napari/utils/perf/__init__.py +++ b/napari/utils/perf/__init__.py @@ -61,16 +61,16 @@ timers, ) -USE_PERFMON = os.getenv("NAPARI_PERFMON", "0") != "0" +USE_PERFMON = os.getenv('NAPARI_PERFMON', '0') != '0' __all__ = [ - "perf_config", - "USE_PERFMON", - "add_counter_event", - "add_instant_event", - "block_timer", - "perf_timer", - "timers", - "PerfEvent", + 'perf_config', + 'USE_PERFMON', + 'add_counter_event', + 'add_instant_event', + 'block_timer', + 'perf_timer', + 'timers', + 'PerfEvent', ] diff --git a/napari/utils/perf/_config.py b/napari/utils/perf/_config.py index 2eb821ad1b1..97e1e352917 100644 --- a/napari/utils/perf/_config.py +++ b/napari/utils/perf/_config.py @@ -4,7 +4,8 @@ import json import os from pathlib import Path -from typing import List, Optional +from types import ModuleType +from typing import Any, Callable, Optional, Union import wrapt @@ -12,17 +13,19 @@ from napari.utils.perf._timers import perf_timer from napari.utils.translations import trans -PERFMON_ENV_VAR = "NAPARI_PERFMON" +PERFMON_ENV_VAR = 'NAPARI_PERFMON' class PerfmonConfigError(Exception): """Error parsing or interpreting config file.""" - def __init__(self, message) -> None: + def __init__(self, message: str) -> None: self.message = message -def _patch_perf_timer(parent, callable_name: str, label: str) -> None: +def _patch_perf_timer( + parent: Union[ModuleType, type], callable_name: str, label: str +) -> None: """Patches the callable to run it inside a perf_timer. Parameters @@ -36,8 +39,13 @@ def _patch_perf_timer(parent, callable_name: str, label: str) -> None: """ @wrapt.patch_function_wrapper(parent, callable_name) - def perf_time_callable(wrapped, instance, args, kwargs): - with perf_timer(f"{label}"): + def perf_time_callable( + wrapped: Callable, + instance: Any, + args: tuple[Any], + kwargs: dict[str, Any], + ) -> Callable: + with perf_timer(f'{label}'): return wrapped(*args, **kwargs) @@ -84,7 +92,7 @@ def __init__(self, config_path: Optional[str]) -> None: with path.open() as infile: self.data = json.load(infile) - def patch_callables(self): + def patch_callables(self) -> None: """Patch callables according to the config file. Call once at startup but after main() has started running. Do not @@ -98,14 +106,14 @@ def patch_callables(self): self._patch_callables() self.patched = True - def _get_callables(self, list_name: str) -> List[str]: + def _get_callables(self, list_name: str) -> list[str]: """Get the list of callables from the config file. list_name : str The name of the list to return. """ try: - return self.data["callable_lists"][list_name] + return self.data['callable_lists'][list_name] except KeyError as e: raise PerfmonConfigError( trans._( @@ -116,7 +124,7 @@ def _get_callables(self, list_name: str) -> List[str]: ) ) from e - def _patch_callables(self): + def _patch_callables(self) -> None: """Add a perf_timer to every callable. Notes @@ -124,7 +132,7 @@ def _patch_callables(self): data["trace_callables"] should contain the names of one or more lists of callables which are defined in data["callable_lists"]. """ - for list_name in self.data["trace_callables"]: + for list_name in self.data['trace_callables']: callable_list = self._get_callables(list_name) patch_callables(callable_list, _patch_perf_timer) @@ -134,7 +142,7 @@ def trace_qt_events(self) -> bool: if self.config_path is None: return True # always trace qt events in legacy mode try: - return self.data["trace_qt_events"] + return self.data['trace_qt_events'] except KeyError: return False @@ -144,7 +152,7 @@ def trace_file_on_start(self) -> Optional[str]: if self.config_path is None: return None # don't trace on start in legacy mode try: - path = self.data["trace_file_on_start"] + path = self.data['trace_file_on_start'] # Return None if it was empty string or false. except KeyError: @@ -153,12 +161,12 @@ def trace_file_on_start(self) -> Optional[str]: return path or None -def _create_perf_config(): - value = os.getenv("NAPARI_PERFMON") +def _create_perf_config() -> Optional[PerfmonConfig]: + value = os.getenv('NAPARI_PERFMON') - if value is None or value == "0": + if value is None or value == '0': return None # Totally disabled - if value == "1": + if value == '1': return PerfmonConfig(None) # Legacy no config, Qt events only. return PerfmonConfig(value) # Normal parse the config file. diff --git a/napari/utils/perf/_event.py b/napari/utils/perf/_event.py index 58eddf71379..c323fde14d8 100644 --- a/napari/utils/perf/_event.py +++ b/napari/utils/perf/_event.py @@ -94,7 +94,7 @@ def __init__( category: Optional[str] = None, process_id: Optional[int] = None, thread_id: Optional[int] = None, - phase: str = "X", # "X" is a "complete event" in their spec. + phase: str = 'X', # "X" is a "complete event" in their spec. **kwargs: float, ) -> None: if process_id is None: @@ -120,26 +120,26 @@ def update_end_ns(self, end_ns: int) -> None: self.span = Span(self.span.start_ns, end_ns) @property - def start_us(self): + def start_us(self) -> float: """Start time in microseconds.""" return self.span.start_ns / 1e3 @property - def start_ms(self): + def start_ms(self) -> float: """Start time in milliseconds.""" return self.span.start_ns / 1e6 @property - def duration_ns(self): + def duration_ns(self) -> int: """Duration in nanoseconds.""" return self.span.end_ns - self.span.start_ns @property - def duration_us(self): + def duration_us(self) -> float: """Duration in microseconds.""" return self.duration_ns / 1e3 @property - def duration_ms(self): + def duration_ms(self) -> float: """Duration in milliseconds.""" return self.duration_ns / 1e6 diff --git a/napari/utils/perf/_patcher.py b/napari/utils/perf/_patcher.py index cb329b75aa4..bd5f58dd59e 100644 --- a/napari/utils/perf/_patcher.py +++ b/napari/utils/perf/_patcher.py @@ -6,7 +6,7 @@ import types from importlib import import_module -from typing import Callable, List, Set, Tuple, Union +from typing import Callable, Union from napari.utils.translations import trans @@ -21,13 +21,13 @@ class PatchError(Exception): """Failed to patch target, config file error?""" - def __init__(self, message) -> None: + def __init__(self, message: str) -> None: self.message = message def _patch_attribute( module: types.ModuleType, attribute_str: str, patch_func: PatchFunction -): +) -> None: """Patch the module's callable pointed to by the attribute string. Parameters @@ -45,7 +45,7 @@ def _patch_attribute( if attribute_str.count('.') > 1: raise PatchError( trans._( - "Nested attribute not found: {attribute_str}", + 'Nested attribute not found: {attribute_str}', deferred=True, attribute_str=attribute_str, ) @@ -59,7 +59,7 @@ def _patch_attribute( except AttributeError as e: raise PatchError( trans._( - "Module {module_name} has no attribute {attribute_str}", + 'Module {module_name} has no attribute {attribute_str}', deferred=True, module_name=module.__name__, attribute_str=attribute_str, @@ -78,7 +78,7 @@ def _patch_attribute( except AttributeError as e: raise PatchError( trans._( - "Parent {parent_str} has no attribute {callable_str}", + 'Parent {parent_str} has no attribute {callable_str}', deferred=True, parent_str=parent_str, callable_str=callable_str, @@ -86,17 +86,17 @@ def _patch_attribute( ) from e label = ( - callable_str if class_str is None else f"{class_str}.{callable_str}" + callable_str if class_str is None else f'{class_str}.{callable_str}' ) # Patch it with the user-provided patch_func. - print(f"Patcher: patching {module.__name__}.{label}") + print(f'Patcher: patching {module.__name__}.{label}') patch_func(parent, callable_str, label) def _import_module( target_str: str, -) -> Union[Tuple[types.ModuleType, str], Tuple[None, None]]: +) -> Union[tuple[types.ModuleType, str], tuple[None, None]]: """Import the module portion of this target string. Try importing successively longer segments of the target_str. For example: @@ -135,7 +135,7 @@ def _import_module( # The very first top-level module import failed! raise PatchError( trans._( - "Module not found: {module_path}", + 'Module not found: {module_path}', deferred=True, module_path=module_path, ) @@ -151,7 +151,7 @@ def _import_module( return None, None -def patch_callables(callables: List[str], patch_func: PatchFunction) -> None: +def patch_callables(callables: list[str], patch_func: PatchFunction) -> None: """Patch the given list of callables. Parameters @@ -181,12 +181,12 @@ def my_announcer(wrapped, instance, args, kwargs): print(f"Announce {label}") return wrapped(*args, **kwargs) """ - patched: Set[str] = set() + patched: set[str] = set() for target_str in callables: if target_str in patched: # Ignore duplicated targets in the config file. - print(f"Patcher: [WARN] skipping duplicate {target_str}") + print(f'Patcher: [WARN] skipping duplicate {target_str}') continue # Patch the target and note that we did. @@ -198,6 +198,6 @@ def my_announcer(wrapped, instance, args, kwargs): # We don't stop on error because if you switch around branches # but keep the same config file, it's easy for your config # file to contain targets that aren't in the code. - print(f"Patcher: [ERROR] {exc}") + print(f'Patcher: [ERROR] {exc}') patched.add(target_str) diff --git a/napari/utils/perf/_stat.py b/napari/utils/perf/_stat.py index f337119f643..9bd1e5e64a7 100644 --- a/napari/utils/perf/_stat.py +++ b/napari/utils/perf/_stat.py @@ -58,4 +58,4 @@ def average(self) -> int: """ if self.count > 0: return self.sum // self.count - raise ValueError("no values") # impossible for us + raise ValueError('no values') # impossible for us diff --git a/napari/utils/perf/_timers.py b/napari/utils/perf/_timers.py index 2a366176c1f..e5e85cc4236 100644 --- a/napari/utils/perf/_timers.py +++ b/napari/utils/perf/_timers.py @@ -3,14 +3,15 @@ import contextlib import os +from collections.abc import Generator from time import perf_counter_ns -from typing import Dict, Optional +from typing import Callable, Optional from napari.utils.perf._event import PerfEvent from napari.utils.perf._stat import Stat from napari.utils.perf._trace_file import PerfTraceFile -USE_PERFMON = os.getenv("NAPARI_PERFMON", "0") != "0" +USE_PERFMON = os.getenv('NAPARI_PERFMON', '0') != '0' class PerfTimers: @@ -48,7 +49,7 @@ class PerfTimers: def __init__(self) -> None: """Create PerfTimers.""" # Maps a timer name to one Stat object. - self.timers: Dict[str, Stat] = {} + self.timers: dict[str, Stat] = {} # Menu item "Debug -> Record Trace File..." starts a trace. self.trace_file: Optional[PerfTraceFile] = None @@ -65,16 +66,24 @@ def add_event(self, event: PerfEvent) -> None: if self.trace_file is not None: self.trace_file.add_event(event) - if event.phase == "X": # Complete Event + if event.phase == 'X': # Complete Event # Update our self.timers (in milliseconds). name = event.name - duration_ms = event.duration_ms + duration_ms = int(event.duration_ms) if name in self.timers: self.timers[name].add(duration_ms) else: self.timers[name] = Stat(duration_ms) - def add_instant_event(self, name: str, **kwargs) -> None: + def add_instant_event( + self, + name: str, + *, + category: Optional[str] = None, + process_id: Optional[int] = None, + thread_id: Optional[int] = None, + **kwargs: float, + ) -> None: """Add one instant event. Parameters @@ -85,7 +94,18 @@ def add_instant_event(self, name: str, **kwargs) -> None: Arguments to display in the Args section of the Tracing GUI. """ now = perf_counter_ns() - self.add_event(PerfEvent(name, now, now, phase="I", **kwargs)) + self.add_event( + PerfEvent( + name, + now, + now, + phase='I', + category=category, + process_id=process_id, + thread_id=thread_id, + **kwargs, + ) + ) def add_counter_event(self, name: str, **kwargs: float) -> None: """Add one counter event. @@ -107,7 +127,7 @@ def add_counter_event(self, name: str, **kwargs: float) -> None: name, now, now, - phase="C", + phase='C', category=None, process_id=None, thread_id=None, @@ -115,7 +135,7 @@ def add_counter_event(self, name: str, **kwargs: float) -> None: ) ) - def clear(self): + def clear(self) -> None: """Clear all timers.""" # After the GUI displays timing information it clears the timers # so that we start accumulating fresh information. @@ -143,8 +163,12 @@ def block_timer( name: str, category: Optional[str] = None, print_time: bool = False, - **kwargs, -): + *, + process_id: Optional[int] = None, + thread_id: Optional[int] = None, + phase: str = 'X', + **kwargs: float, +) -> Generator[PerfEvent, None, None]: """Time a block of code. block_timer can be used when perfmon is disabled. Use perf_timer instead @@ -181,7 +205,16 @@ def block_timer( # Pass in start_ns for start and end, we call update_end_ns # once the block as finished. - event = PerfEvent(name, start_ns, start_ns, category, **kwargs) + event = PerfEvent( + name, + start_ns, + start_ns, + category, + process_id=process_id, + thread_id=thread_id, + phase=phase, + **kwargs, + ) yield event # Update with the real end time. @@ -190,17 +223,24 @@ def block_timer( if timers: timers.add_event(event) if print_time: - print(f"{name} {event.duration_ms:.3f}ms") + print(f'{name} {event.duration_ms:.3f}ms') -def _create_timer(): +def _create_timer() -> tuple[PerfTimers, Callable, Callable, Callable]: # The one global instance timers = PerfTimers() # perf_timer is enabled perf_timer = block_timer - def add_instant_event(name: str, **kwargs): + def add_instant_event( + name: str, + *, + category: Optional[str] = None, + process_id: Optional[int] = None, + thread_id: Optional[int] = None, + **kwargs: float, + ) -> None: """Add one instant event. Parameters @@ -210,16 +250,22 @@ def add_instant_event(name: str, **kwargs): **kwargs Arguments to display in the Args section of the Chrome Tracing GUI. """ - timers.add_instant_event(name, **kwargs) + timers.add_instant_event( + name, + category=category, + process_id=process_id, + thread_id=thread_id, + **kwargs, + ) - def add_counter_event(name: str, **kwargs: Dict[str, float]): + def add_counter_event(name: str, **kwargs: float) -> None: """Add one counter event. Parameters ---------- name : str The name of this event like "draw". - **kwargs : Dict[str, float] + **kwargs : float The individual counters for this event. Notes @@ -231,6 +277,8 @@ def add_counter_event(name: str, **kwargs: Dict[str, float]): return timers, perf_timer, add_instant_event, add_counter_event +timers: Optional[PerfTimers] + if USE_PERFMON: timers, perf_timer, add_instant_event, add_counter_event = _create_timer() @@ -238,13 +286,29 @@ def add_counter_event(name: str, **kwargs: Dict[str, float]): # Make sure no one accesses the timers when they are disabled. timers = None - def add_instant_event(name: str, **kwargs) -> None: + def add_instant_event( + name: str, + *, + category: Optional[str] = None, + process_id: Optional[int] = None, + thread_id: Optional[int] = None, + **kwargs: float, + ) -> None: pass - def add_counter_event(name: str, **kwargs: Dict[str, float]) -> None: + def add_counter_event(name: str, **kwargs: float) -> None: pass # perf_timer is disabled. Using contextlib.nullcontext did not work. @contextlib.contextmanager - def perf_timer(name: str, category: Optional[str] = None, **kwargs): + def perf_timer( + name: str, + category: Optional[str] = None, + print_time: bool = False, + *, + process_id: Optional[int] = None, + thread_id: Optional[int] = None, + phase: str = 'X', + **kwargs: float, + ) -> Generator[None, None, None]: yield diff --git a/napari/utils/perf/_trace_file.py b/napari/utils/perf/_trace_file.py index 1f131eb2292..8f9c1514c6b 100644 --- a/napari/utils/perf/_trace_file.py +++ b/napari/utils/perf/_trace_file.py @@ -3,7 +3,7 @@ import json from time import perf_counter_ns -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING if TYPE_CHECKING: from napari.utils.perf._event import PerfEvent @@ -45,9 +45,9 @@ def __init__(self, output_path: str) -> None: # Accumulate events in a list and only write at the end so the cost # of writing to a file does not bloat our timings. - self.events: List[PerfEvent] = [] + self.events: list[PerfEvent] = [] - def add_event(self, event: "PerfEvent") -> None: + def add_event(self, event: 'PerfEvent') -> None: """Add one perf event to our in-memory list. Parameters @@ -57,13 +57,13 @@ def add_event(self, event: "PerfEvent") -> None: """ self.events.append(event) - def close(self): + def close(self) -> None: """Close the trace file, write all events to disk.""" event_data = [self._get_event_data(x) for x in self.events] - with open(self.output_path, "w") as outf: + with open(self.output_path, 'w') as outf: json.dump(event_data, outf) - def _get_event_data(self, event: "PerfEvent") -> dict: + def _get_event_data(self, event: 'PerfEvent') -> dict: """Return the data for one perf event. Parameters @@ -76,30 +76,30 @@ def _get_event_data(self, event: "PerfEvent") -> dict: dict The data to be written to JSON. """ - category = "none" if event.category is None else event.category + category = 'none' if event.category is None else event.category data = { - "pid": event.origin.process_id, - "tid": event.origin.thread_id, - "name": event.name, - "cat": category, - "ph": event.phase, - "ts": event.start_us, - "args": event.args, + 'pid': event.origin.process_id, + 'tid': event.origin.thread_id, + 'name': event.name, + 'cat': category, + 'ph': event.phase, + 'ts': event.start_us, + 'args': event.args, } # The three phase types we support. - assert event.phase in ["X", "I", "C"] + assert event.phase in ['X', 'I', 'C'] - if event.phase == "X": + if event.phase == 'X': # "X" is a Complete Event, it has a duration. - data["dur"] = event.duration_us - elif event.phase == "I": + data['dur'] = event.duration_us + elif event.phase == 'I': # "I is an Instant Event, it has a "scope" one of: # "g" - global # "p" - process # "t" - thread # We hard code "process" right now because that's all we've needed. - data["s"] = "p" + data['s'] = 'p' return data diff --git a/napari/utils/progress.py b/napari/utils/progress.py index 61d09c201d1..6eff172daad 100644 --- a/napari/utils/progress.py +++ b/napari/utils/progress.py @@ -1,5 +1,6 @@ +from collections.abc import Generator, Iterable, Iterator from itertools import takewhile -from typing import Callable, Generator, Iterable, Iterator, Optional +from typing import Callable, Optional from tqdm import tqdm @@ -7,7 +8,7 @@ from napari.utils.events.event import EmitterGroup, Event from napari.utils.translations import trans -__all__ = ["progress", "progrange", "cancelable_progress"] +__all__ = ['progress', 'progrange', 'cancelable_progress'] class progress(tqdm): @@ -90,9 +91,9 @@ def __init__( # if the progress bar is set to disable the 'desc' member is not set by the # tqdm super constructor, so we set it to a dummy value to avoid errors thrown below if self.disable: - self.desc = "" + self.desc = '' if not self.desc: - self.set_description(trans._("progress")) + self.set_description(trans._('progress')) progress._all_instances.add(self) self.is_init = False @@ -117,7 +118,7 @@ def display( super().display(msg, pos) return # TODO: This could break if user is formatting their own terminal tqdm - etas = str(self).split('|')[-1] if self.total != 0 else "" + etas = str(self).split('|')[-1] if self.total != 0 else '' self.events.eta(value=etas) def update(self, n=1): diff --git a/napari/utils/shortcuts.py b/napari/utils/shortcuts.py index fc1b8dd5090..7b5c137594d 100644 --- a/napari/utils/shortcuts.py +++ b/napari/utils/shortcuts.py @@ -1,5 +1,3 @@ -from typing import Dict, List - from app_model.types import KeyBinding, KeyCode, KeyMod _default_shortcuts = { @@ -21,6 +19,13 @@ 'napari:transpose_axes': [KeyMod.CtrlCmd | KeyCode.KeyT], 'napari:toggle_grid': [KeyMod.CtrlCmd | KeyCode.KeyG], 'napari:toggle_selected_visibility': [KeyCode.KeyV], + 'napari:toggle_unselected_visibility': [KeyMod.Shift | KeyCode.KeyV], + 'napari:show_only_layer_above': [ + KeyMod.Shift | KeyMod.Alt | KeyCode.UpArrow + ], + 'napari:show_only_layer_below': [ + KeyMod.Shift | KeyMod.Alt | KeyCode.DownArrow + ], 'napari:hold_for_pan_zoom': [KeyCode.Space], # labels 'napari:activate_labels_erase_mode': [KeyCode.Digit1, KeyCode.KeyE], @@ -92,7 +97,7 @@ 'napari:activate_surface_transform_mode': [KeyCode.Digit2], } -default_shortcuts: Dict[str, List[KeyBinding]] = { +default_shortcuts: dict[str, list[KeyBinding]] = { name: [KeyBinding.from_int(kb) for kb in value] for name, value in _default_shortcuts.items() } diff --git a/napari/utils/status_messages.py b/napari/utils/status_messages.py index 2794236ae40..ed85ad2f9f2 100644 --- a/napari/utils/status_messages.py +++ b/napari/utils/status_messages.py @@ -50,7 +50,7 @@ def generate_layer_coords_status( full_coord = map(str, np.round(np.array(position)).astype(int)) msg = f" [{' '.join(full_coord)}]" else: - msg = "" + msg = '' if value is not None: if isinstance(value, tuple) and value != (None, None): @@ -85,7 +85,7 @@ def generate_layer_status(name, position, value): full_coord = map(str, np.round(position).astype(int)) msg = f"{name} [{' '.join(full_coord)}]" else: - msg = f"{name}" + msg = f'{name}' if value is not None: if isinstance(value, tuple) and value != (None, None): diff --git a/napari/utils/stubgen.py b/napari/utils/stubgen.py index a85f9d0cf20..44dd58e0321 100644 --- a/napari/utils/stubgen.py +++ b/napari/utils/stubgen.py @@ -29,8 +29,9 @@ import textwrap import typing import warnings +from collections.abc import Iterator from types import ModuleType -from typing import Any, Iterator, List, Set, Tuple, Type, Union, get_type_hints +from typing import Any, Union, get_type_hints from typing_extensions import get_args, get_origin @@ -50,17 +51,17 @@ def _format_module_str(text: str, is_pyi=False) -> str: from black import FileMode, format_str from isort.api import sort_code_string - text = sort_code_string(text, profile="black", float_to_top=True) + text = sort_code_string(text, profile='black', float_to_top=True) text = format_str(text, mode=FileMode(line_length=79, is_pyi=is_pyi)) - return text.replace("NoneType", "None") + return text.replace('NoneType', 'None') -def _guess_exports(module, exclude=()) -> List[str]: +def _guess_exports(module, exclude=()) -> list[str]: """If __all__ wasn't provided, this function guesses what to stub.""" return [ k for k, v in vars(module).items() - if callable(v) and not k.startswith("_") and k not in exclude + if callable(v) and not k.startswith('_') and k not in exclude ] @@ -68,7 +69,7 @@ def _iter_imports(hint) -> Iterator[str]: """Get all imports necessary for `hint`""" # inspect.formatannotation strips "typing." from type annotations # so our signatures won't have it in there - if not repr(hint).startswith("typing.") and (orig := get_origin(hint)): + if not repr(hint).startswith('typing.') and (orig := get_origin(hint)): yield orig.__module__ for arg in get_args(hint): @@ -81,11 +82,11 @@ def _iter_imports(hint) -> Iterator[str]: yield hint.__module__ -def generate_function_stub(func) -> Tuple[Set[str], str]: +def generate_function_stub(func) -> tuple[set[str], str]: """Generate a stub and imports for a function.""" sig = inspect.signature(func) - if hasattr(func, "__wrapped__"): + if hasattr(func, '__wrapped__'): # unwrap @wraps decorated functions func = func.__wrapped__ globalns = {**getattr(func, '__globals__', {})} @@ -109,16 +110,16 @@ def generate_function_stub(func) -> Tuple[Set[str], str]: return imports, f'def {func.__name__}{sig}:\n {doc}\n' -def _get_subclass_methods(cls: Type[Any]) -> Set[str]: +def _get_subclass_methods(cls: type[Any]) -> set[str]: """Return the set of method names defined (only) on a subclass.""" all_methods = set(dir(cls)) base_methods = (dir(base()) for base in cls.__bases__) return all_methods.difference(*base_methods) -def generate_class_stubs(cls: Type) -> Tuple[Set[str], str]: +def generate_class_stubs(cls: type) -> tuple[set[str], str]: """Generate a stub and imports for a class.""" - bases = ", ".join(f'{b.__module__}.{b.__name__}' for b in cls.__bases__) + bases = ', '.join(f'{b.__module__}.{b.__name__}' for b in cls.__bases__) methods = [] attrs = [] @@ -148,8 +149,8 @@ def generate_class_stubs(cls: Type) -> Tuple[Set[str], str]: doc = f'"""{cls.__doc__.lstrip()}"""' if cls.__doc__ else '...' stub = f'class {cls.__name__}({bases}):\n {doc}\n' - stub += textwrap.indent("\n".join(attrs), ' ') - stub += "\n" + textwrap.indent("\n".join(methods), ' ') + stub += textwrap.indent('\n'.join(attrs), ' ') + stub += '\n' + textwrap.indent('\n'.join(methods), ' ') return imports, stub @@ -184,20 +185,20 @@ def generate_module_stub(module: Union[str, ModuleType], save=True) -> str: stubs.append(stub) # build the final file string - importstr = "\n".join(f'import {n}' for n in imports) + importstr = '\n'.join(f'import {n}' for n in imports) body = '\n'.join(stubs) pyi = PYI_TEMPLATE.format(imports=importstr, body=body) # format with black and isort # pyi = _format_module_str(pyi) - pyi = pyi.replace("NoneType", "None") + pyi = pyi.replace('NoneType', 'None') if save: - print("Writing stub:", module.__file__.replace(".py", ".pyi")) - file_path = module.__file__.replace(".py", ".pyi") + print('Writing stub:', module.__file__.replace('.py', '.pyi')) + file_path = module.__file__.replace('.py', '.pyi') with open(file_path, 'w') as f: f.write(pyi) - subprocess.run(["ruff", file_path]) - subprocess.run(["black", file_path]) + subprocess.run(['ruff', file_path]) + subprocess.run(['black', file_path]) return pyi diff --git a/napari/utils/theme.py b/napari/utils/theme.py index 81eecc0b82a..2fd0736522c 100644 --- a/napari/utils/theme.py +++ b/napari/utils/theme.py @@ -6,7 +6,7 @@ import warnings from ast import literal_eval from contextlib import suppress -from typing import Any, Dict, List, Literal, Optional, Tuple, Union, overload +from typing import Any, Literal, Optional, Union, overload import npe2 @@ -85,29 +85,29 @@ class Theme(EventedModel): current: Color font_size: str = '12pt' if sys.platform == 'darwin' else '9pt' - @validator("syntax_style", pre=True, allow_reuse=True) + @validator('syntax_style', pre=True, allow_reuse=True) def _ensure_syntax_style(cls, value: str) -> str: from pygments.styles import STYLE_MAP assert value in STYLE_MAP, trans._( - "Incorrect `syntax_style` value: {value} provided. Please use one of the following: {syntax_style}", + 'Incorrect `syntax_style` value: {value} provided. Please use one of the following: {syntax_style}', deferred=True, syntax_style=f" {', '.join(STYLE_MAP)}", value=value, ) return value - @validator("font_size", pre=True) + @validator('font_size', pre=True) def _ensure_font_size(cls, value: str) -> str: assert value.endswith('pt'), trans._( - "Font size must be in points (pt).", deferred=True + 'Font size must be in points (pt).', deferred=True ) assert int(value[:-2]) > 0, trans._( - "Font size must be greater than 0.", deferred=True + 'Font size must be greater than 0.', deferred=True ) return value - def to_rgb_dict(self) -> Dict[str, Any]: + def to_rgb_dict(self) -> dict[str, Any]: """ This differs from baseclass `dict()` by converting colors to rgb. """ @@ -118,8 +118,8 @@ def to_rgb_dict(self) -> Dict[str, Any]: } -increase_pattern = re.compile(r"{{\s?increase\((\w+),?\s?([-\d]+)?\)\s?}}") -decrease_pattern = re.compile(r"{{\s?decrease\((\w+),?\s?([-\d]+)?\)\s?}}") +increase_pattern = re.compile(r'{{\s?increase\((\w+),?\s?([-\d]+)?\)\s?}}') +decrease_pattern = re.compile(r'{{\s?decrease\((\w+),?\s?([-\d]+)?\)\s?}}') gradient_pattern = re.compile(r'([vh])gradient\((.+)\)') darken_pattern = re.compile(r'{{\s?darken\((\w+),?\s?([-\d]+)?\)\s?}}') lighten_pattern = re.compile(r'{{\s?lighten\((\w+),?\s?([-\d]+)?\)\s?}}') @@ -128,15 +128,15 @@ def to_rgb_dict(self) -> Dict[str, Any]: def decrease(font_size: str, pt: int) -> str: """Decrease fontsize.""" - return f"{int(font_size[:-2]) - int(pt)}pt" + return f'{int(font_size[:-2]) - int(pt)}pt' def increase(font_size: str, pt: int) -> str: """Increase fontsize.""" - return f"{int(font_size[:-2]) + int(pt)}pt" + return f'{int(font_size[:-2]) + int(pt)}pt' -def _parse_color_as_rgb(color: Union[str, Color]) -> Tuple[int, int, int]: +def _parse_color_as_rgb(color: Union[str, Color]) -> tuple[int, int, int]: if isinstance(color, str): if color.startswith('rgb('): return literal_eval(color.lstrip('rgb(').rstrip(')')) @@ -177,7 +177,7 @@ def gradient(stops, horizontal: bool = True) -> str: grad = 'qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, ' _stops = [f'stop: {n} {stop}' for n, stop in enumerate(stops)] - grad += ", ".join(_stops) + ")" + grad += ', '.join(_stops) + ')' return grad @@ -226,7 +226,7 @@ def get_system_theme() -> str: try: id_ = darkdetect.theme().lower() except AttributeError: - id_ = "dark" + id_ = 'dark' return id_ @@ -240,7 +240,7 @@ def get_theme(theme_id: str, as_dict: Literal[False]) -> Theme: ... @overload -def get_theme(theme_id: str, as_dict: Literal[True]) -> Dict[str, Any]: ... +def get_theme(theme_id: str, as_dict: Literal[True]) -> dict[str, Any]: ... def get_theme(theme_id: str, as_dict: Optional[bool] = None): @@ -269,13 +269,13 @@ def get_theme(theme_id: str, as_dict: Optional[bool] = None): so that manipulating this theme can be done without side effects. """ - if theme_id == "system": + if theme_id == 'system': theme_id = get_system_theme() if theme_id not in _themes: raise ValueError( trans._( - "Unrecognized theme {id}. Available themes are {themes}", + 'Unrecognized theme {id}. Available themes are {themes}', deferred=True, id=theme_id, themes=available_themes(), @@ -285,8 +285,8 @@ def get_theme(theme_id: str, as_dict: Optional[bool] = None): if as_dict is not None: warnings.warn( trans._( - "The `as_dict` kwarg has been deprecated since Napari 0.5.0 and " - "will be removed in future version. You can use `get_theme(...).to_rgb_dict()`", + 'The `as_dict` kwarg has been deprecated since Napari 0.5.0 and ' + 'will be removed in future version. You can use `get_theme(...).to_rgb_dict()`', deferred=True, ), category=FutureWarning, @@ -331,7 +331,7 @@ def unregister_theme(theme_id): _themes.pop(theme_id, None) -def available_themes() -> List[str]: +def available_themes() -> list[str]: """List available themes. Returns @@ -355,7 +355,7 @@ def is_theme_available(theme_id): bool True if the theme is available, False otherwise. """ - if theme_id == "system": + if theme_id == 'system': return True if theme_id not in _themes and _theme_path(theme_id).exists(): plugin_name_file = _theme_path(theme_id) / PLUGIN_FILE_NAME @@ -418,8 +418,8 @@ def rebuild_theme_settings(): font_size='12pt' if sys.platform == 'darwin' else '9pt', ) -register_theme('dark', DARK, "builtin") -register_theme('light', LIGHT, "builtin") +register_theme('dark', DARK, 'builtin') +register_theme('light', LIGHT, 'builtin') # this function here instead of plugins._npe2 to avoid circular import @@ -442,7 +442,7 @@ def _install_npe2_themes(themes=None): try: register_theme(theme.id, theme_dict, manifest.name) except ValueError: - logging.exception("Registration theme failed.") + logging.exception('Registration theme failed.') _install_npe2_themes(_themes) diff --git a/napari/utils/transforms/__init__.py b/napari/utils/transforms/__init__.py index e77bd23ccf7..deea0c5bb27 100644 --- a/napari/utils/transforms/__init__.py +++ b/napari/utils/transforms/__init__.py @@ -8,10 +8,10 @@ ) __all__ = [ - "shear_matrix_from_angle", - "Affine", - "CompositeAffine", - "ScaleTranslate", - "Transform", - "TransformChain", + 'shear_matrix_from_angle', + 'Affine', + 'CompositeAffine', + 'ScaleTranslate', + 'Transform', + 'TransformChain', ] diff --git a/napari/utils/transforms/transform_utils.py b/napari/utils/transforms/transform_utils.py index 7399f1be162..fbe6ac06b68 100644 --- a/napari/utils/transforms/transform_utils.py +++ b/napari/utils/transforms/transform_utils.py @@ -1,5 +1,3 @@ -from typing import Tuple - import numpy as np import numpy.typing as npt import scipy.linalg @@ -339,7 +337,7 @@ def embed_in_identity_matrix(matrix, ndim): def decompose_linear_matrix( matrix, upper_triangular=True -) -> Tuple[npt.NDArray, npt.NDArray, npt.NDArray]: +) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]: """Decompose linear transform matrix into rotate, scale, shear. Decomposition is based on code from https://github.com/matthew-brett/transforms3d. @@ -489,7 +487,7 @@ def is_diagonal(matrix, tol=1e-8): if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: raise ValueError( trans._( - "matrix must be square, but shape={shape}", + 'matrix must be square, but shape={shape}', deferred=True, shape=matrix.shape, ) diff --git a/napari/utils/transforms/transforms.py b/napari/utils/transforms/transforms.py index 84a09ba8b21..f3953aae636 100644 --- a/napari/utils/transforms/transforms.py +++ b/napari/utils/transforms/transforms.py @@ -1,4 +1,5 @@ -from typing import Generic, Iterable, Optional, Sequence, TypeVar, overload +from collections.abc import Iterable, Sequence +from typing import Generic, Optional, TypeVar, overload import numpy as np import numpy.typing as npt @@ -56,11 +57,11 @@ def inverse(self) -> 'Transform': raise ValueError( trans._('Inverse function was not provided.', deferred=True) ) - if "inverse" not in self._cache_dict: - self._cache_dict["inverse"] = Transform( + if 'inverse' not in self._cache_dict: + self._cache_dict['inverse'] = Transform( self._inverse_func, self.func ) - return self._cache_dict["inverse"] + return self._cache_dict['inverse'] def compose(self, transform: 'Transform') -> 'Transform': """Return the composite of this transform and the provided one.""" @@ -135,7 +136,7 @@ def __init__( # in turn call super().__init__(). So we call it explicitly here. Transform.__init__(self) for tr in self: - if hasattr(tr, "changed"): + if hasattr(tr, 'changed'): tr.changed.connect(self._clean_cache) def __call__(self, coords): @@ -154,21 +155,21 @@ def __getitem__(self, key: str) -> _T: ... def __getitem__(self, key: slice) -> 'TransformChain[_T]': ... def __getitem__(self, key): - if f"getitem_{key}" not in self._cache_dict: - self._cache_dict[f"getitem_{key}"] = super().__getitem__(key) - return self._cache_dict[f"getitem_{key}"] + if f'getitem_{key}' not in self._cache_dict: + self._cache_dict[f'getitem_{key}'] = super().__getitem__(key) + return self._cache_dict[f'getitem_{key}'] def __setitem__(self, key, value): - if key in self: + if key in self and hasattr(self[key], 'changed'): self[key].changed.disconnect(self._clean_cache) super().__setitem__(key, value) - if hasattr(value, "changed"): + if hasattr(value, 'changed'): value.changed.connect(self._clean_cache) self._clean_cache() def __delitem__(self, key): val = self[key] - if hasattr(val, "changed"): + if hasattr(val, 'changed'): val.changed.disconnect(self._clean_cache) super().__delitem__(key) self._clean_cache() @@ -176,11 +177,11 @@ def __delitem__(self, key): @property def inverse(self) -> 'TransformChain': """Return the inverse transform chain.""" - if "inverse" not in self._cache_dict: - self._cache_dict["inverse"] = TransformChain( + if 'inverse' not in self._cache_dict: + self._cache_dict['inverse'] = TransformChain( [tf.inverse for tf in self[::-1]] ) - return self._cache_dict["inverse"] + return self._cache_dict['inverse'] @property def _is_diagonal(self): @@ -189,18 +190,28 @@ def _is_diagonal(self): return getattr(self.simplified, '_is_diagonal', False) @property - def simplified(self) -> Optional[_T]: - """Return the composite of the transforms inside the transform chain.""" + def simplified(self) -> _T: + """ + Return the composite of the transforms inside the transform chain. + + Raises + ------ + ValueError + If the transform chain is empty. + """ if len(self) == 0: - return None + raise ValueError( + trans._('Cannot simplify an empty transform chain.') + ) + if len(self) == 1: return self[0] - if "simplified" not in self._cache_dict: - self._cache_dict["simplified"] = tz.pipe( + if 'simplified' not in self._cache_dict: + self._cache_dict['simplified'] = tz.pipe( self[0], *[tf.compose for tf in self[1:]] ) - return self._cache_dict["simplified"] + return self._cache_dict['simplified'] def set_slice(self, axes: Sequence[int]) -> 'TransformChain': """Return a transform chain subset to the visible dimensions. @@ -217,7 +228,7 @@ def set_slice(self, axes: Sequence[int]) -> 'TransformChain': """ return TransformChain([tf.set_slice(axes) for tf in self]) - def expand_dims(self, axes: Sequence[int]) -> 'Transform': + def expand_dims(self, axes: Sequence[int]) -> 'TransformChain': """Return a transform chain with added axes for non-visible dimensions. Parameters @@ -486,7 +497,7 @@ def scale(self) -> npt.NDArray: if self._is_diagonal: return np.diag(self._linear_matrix) self._setup_decompose_linear_matrix_cache() - return self._cache_dict["decompose_linear_matrix"][1] + return self._cache_dict['decompose_linear_matrix'][1] @scale.setter def scale(self, scale): @@ -513,9 +524,9 @@ def translate(self, translate): self._clean_cache() def _setup_decompose_linear_matrix_cache(self): - if "decompose_linear_matrix" in self._cache_dict: + if 'decompose_linear_matrix' in self._cache_dict: return - self._cache_dict["decompose_linear_matrix"] = decompose_linear_matrix( + self._cache_dict['decompose_linear_matrix'] = decompose_linear_matrix( self.linear_matrix, upper_triangular=self._upper_triangular ) @@ -523,7 +534,7 @@ def _setup_decompose_linear_matrix_cache(self): def rotate(self) -> npt.NDArray: """Return the rotation of the transform.""" self._setup_decompose_linear_matrix_cache() - return self._cache_dict["decompose_linear_matrix"][0] + return self._cache_dict['decompose_linear_matrix'][0] @rotate.setter def rotate(self, rotate): @@ -539,12 +550,7 @@ def shear(self) -> npt.NDArray: if self._is_diagonal: return np.zeros((self.ndim,)) self._setup_decompose_linear_matrix_cache() - return self._cache_dict["decompose_linear_matrix"][2] - - @property - def _shear_cache(self): - self._setup_decompose_linear_matrix_cache() - return self._cache_dict["decompose_linear_matrix"][2] + return self._cache_dict['decompose_linear_matrix'][2] @shear.setter def shear(self, shear): @@ -568,6 +574,11 @@ def shear(self, shear): ) self._clean_cache() + @property + def _shear_cache(self): + self._setup_decompose_linear_matrix_cache() + return self._cache_dict['decompose_linear_matrix'][2] + @property def linear_matrix(self) -> npt.NDArray: """Return the linear matrix of the transform.""" @@ -603,13 +614,19 @@ def __array__(self, *args, **kwargs): @property def inverse(self) -> 'Affine': """Return the inverse transform.""" - if "inverse" not in self._cache_dict: - self._cache_dict["inverse"] = Affine( + if 'inverse' not in self._cache_dict: + self._cache_dict['inverse'] = Affine( affine_matrix=np.linalg.inv(self.affine_matrix) ) - return self._cache_dict["inverse"] + return self._cache_dict['inverse'] - def compose(self, transform: 'Transform') -> 'Transform': + @overload + def compose(self, transform: 'Affine') -> 'Affine': ... + + @overload + def compose(self, transform: 'Transform') -> 'Transform': ... + + def compose(self, transform): """Return the composite of this transform and the provided one.""" if not isinstance(transform, Affine): return super().compose(transform) @@ -713,11 +730,11 @@ def _is_diagonal(self): Since only `self.linear_matrix` is checked, affines with a translation component can still be considered diagonal. """ - if "_is_diagonal" not in self._cache_dict: - self._cache_dict["_is_diagonal"] = is_diagonal( + if '_is_diagonal' not in self._cache_dict: + self._cache_dict['_is_diagonal'] = is_diagonal( self.linear_matrix, tol=1e-8 ) - return self._cache_dict["_is_diagonal"] + return self._cache_dict['_is_diagonal'] class CompositeAffine(Affine): diff --git a/napari/utils/translations.py b/napari/utils/translations.py index fcf41041efe..61d15d59fef 100644 --- a/napari/utils/translations.py +++ b/napari/utils/translations.py @@ -6,17 +6,17 @@ import gettext import os from pathlib import Path -from typing import ClassVar, Dict, Optional, Union +from typing import ClassVar, Optional, Union from yaml import safe_load from napari.utils._base import _DEFAULT_CONFIG_PATH, _DEFAULT_LOCALE # Entry points -NAPARI_LANGUAGEPACK_ENTRY = "napari.languagepack" +NAPARI_LANGUAGEPACK_ENTRY = 'napari.languagepack' # Constants -LOCALE_DIR = "locale" +LOCALE_DIR = 'locale' def _get_display_name( @@ -54,7 +54,7 @@ def _get_display_name( loc = babel.Locale.parse(locale) display_name_ = loc.get_display_name(display_locale) if display_name_ is None: - raise RuntimeError(f"Could not find {display_locale}") + raise RuntimeError(f'Could not find {display_locale}') display_name = display_name_.capitalize() return display_name @@ -147,14 +147,14 @@ def get_language_packs(display_locale: str = _DEFAULT_LOCALE) -> dict: ) locales = { _DEFAULT_LOCALE: { - "displayName": _get_display_name(_DEFAULT_LOCALE, display_locale), - "nativeName": _get_display_name(_DEFAULT_LOCALE, _DEFAULT_LOCALE), + 'displayName': _get_display_name(_DEFAULT_LOCALE, display_locale), + 'nativeName': _get_display_name(_DEFAULT_LOCALE, _DEFAULT_LOCALE), } } for locale in valid_locales: locales[locale] = { - "displayName": _get_display_name(locale, display_locale), - "nativeName": _get_display_name(locale, locale), + 'displayName': _get_display_name(locale, display_locale), + 'nativeName': _get_display_name(locale, locale), } return locales @@ -176,7 +176,7 @@ def __deepcopy__(self, memo): kwargs = deepcopy(self._kwargs) # Remove `n` from `kwargs` added in the initializer # See https://github.com/napari/napari/issues/4736 - kwargs.pop("n") + kwargs.pop('n') return TranslationString( domain=self._domain, msgctxt=self._msgctxt, @@ -199,10 +199,10 @@ def __new__( ): if msgid is None: raise ValueError( - trans._("Must provide at least a `msgid` parameter!") + trans._('Must provide at least a `msgid` parameter!') ) - kwargs["n"] = n + kwargs['n'] = n return str.__new__( cls, @@ -341,7 +341,7 @@ def _update_locale(self, locale: str): """ self._locale = locale localedir = None - if locale.split("_")[0] != _DEFAULT_LOCALE: + if locale.split('_')[0] != _DEFAULT_LOCALE: from napari_plugin_engine.manager import iter_available_plugins lang_packs = iter_available_plugins(NAPARI_LANGUAGEPACK_ENTRY) @@ -352,7 +352,7 @@ def _update_locale(self, locale: str): trans = self warnings.warn( trans._( - "Requested locale not available: {locale}", + 'Requested locale not available: {locale}', deferred=True, locale=locale, ) @@ -364,7 +364,7 @@ def _update_locale(self, locale: str): if mod.__file__ is not None: localedir = Path(mod.__file__).parent / LOCALE_DIR else: - raise RuntimeError(f"Could not find __file__ for {mod}") + raise RuntimeError(f'Could not find __file__ for {mod}') gettext.bindtextdomain(self._domain, localedir=localedir) @@ -599,7 +599,7 @@ class _Translator: Translations manager. """ - _TRANSLATORS: ClassVar[Dict[str, TranslationBundle]] = {} + _TRANSLATORS: ClassVar[dict[str, TranslationBundle]] = {} _LOCALE = _DEFAULT_LOCALE @staticmethod @@ -612,8 +612,8 @@ def _update_env(locale: str): locale : str The language name to use. """ - for key in ["LANGUAGE", "LANG"]: - os.environ[key] = f"{locale}.UTF-8" + for key in ['LANGUAGE', 'LANG']: + os.environ[key] = f'{locale}.UTF-8' @classmethod def _set_locale(cls, locale: str): @@ -628,14 +628,14 @@ def _set_locale(cls, locale: str): if _is_valid_locale(locale): cls._LOCALE = locale - if locale.split("_")[0] != _DEFAULT_LOCALE: + if locale.split('_')[0] != _DEFAULT_LOCALE: _Translator._update_env(locale) for bundle in cls._TRANSLATORS.values(): bundle._update_locale(locale) @classmethod - def load(cls, domain: str = "napari") -> TranslationBundle: + def load(cls, domain: str = 'napari') -> TranslationBundle: """ Load translation domain. @@ -687,20 +687,20 @@ def _load_language( import warnings warnings.warn( - "The `language` setting defined in the napari " - "configuration file could not be read.\n\n" - "The default language will be used.\n\n" - f"Error:\n{err}" + 'The `language` setting defined in the napari ' + 'configuration file could not be read.\n\n' + 'The default language will be used.\n\n' + f'Error:\n{err}' ) data = {} - locale = data.get("application", {}).get("language", locale) + locale = data.get('application', {}).get('language', locale) - return os.environ.get("NAPARI_LANGUAGE", locale) + return os.environ.get('NAPARI_LANGUAGE', locale) # Default translator -trans = _Translator.load("napari") +trans = _Translator.load('napari') # Update Translator locale before any other import uses it _Translator._set_locale(_load_language()) diff --git a/napari/utils/tree/__init__.py b/napari/utils/tree/__init__.py index aedb3457b98..ca4f6bbfb65 100644 --- a/napari/utils/tree/__init__.py +++ b/napari/utils/tree/__init__.py @@ -1,4 +1,4 @@ from napari.utils.tree.group import Group from napari.utils.tree.node import Node -__all__ = ["Node", "Group"] +__all__ = ['Node', 'Group'] diff --git a/napari/utils/tree/_tests/test_tree_model.py b/napari/utils/tree/_tests/test_tree_model.py index ceb281eeade..62d34bf56f3 100644 --- a/napari/utils/tree/_tests/test_tree_model.py +++ b/napari/utils/tree/_tests/test_tree_model.py @@ -9,21 +9,21 @@ def tree(): return Group( [ - Node(name="1"), + Node(name='1'), Group( [ - Node(name="2"), - Group([Node(name="3"), Node(name="4")], name="g2"), - Node(name="5"), - Node(name="6"), - Node(name="7"), + Node(name='2'), + Group([Node(name='3'), Node(name='4')], name='g2'), + Node(name='5'), + Node(name='6'), + Node(name='7'), ], - name="g1", + name='g1', ), - Node(name="8"), - Node(name="9"), + Node(name='8'), + Node(name='9'), ], - name="root", + name='root', ) @@ -102,7 +102,7 @@ def test_relative_node_indexing(tree): with pytest.raises(IndexError) as e: g1_1_0.unparent() - assert "Cannot unparent orphaned Node" in str(e) + assert 'Cannot unparent orphaned Node' in str(e) def test_traverse(tree): @@ -226,7 +226,7 @@ def test_nested_custom_lookup(tree: Group): # first level g1 = tree[1] assert g1.name == 'g1' # index with integer as usual - assert tree.index("g1") == 1 + assert tree.index('g1') == 1 assert tree['g1'] == g1 # index with string also works # second level diff --git a/napari/utils/tree/group.py b/napari/utils/tree/group.py index c2279024f82..3aa234204aa 100644 --- a/napari/utils/tree/group.py +++ b/napari/utils/tree/group.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Generator, Iterable, List, TypeVar, Union +from collections.abc import Generator, Iterable +from typing import TYPE_CHECKING, TypeVar, Union from napari.utils.events.containers._selectable_list import ( SelectableNestableEventedList, @@ -10,7 +11,7 @@ if TYPE_CHECKING: from napari.utils.events.containers._nested_list import MaybeNestedIndex -NodeType = TypeVar("NodeType", bound=Node) +NodeType = TypeVar('NodeType', bound=Node) class Group(Node, SelectableNestableEventedList[NodeType]): @@ -39,7 +40,7 @@ class Group(Node, SelectableNestableEventedList[NodeType]): def __init__( self, children: Iterable[NodeType] = (), - name: str = "Group", + name: str = 'Group', basetype=Node, ) -> None: Node.__init__(self, name=name) @@ -105,16 +106,16 @@ def traverse( for child in obj: yield from child.traverse(leaves_only) - def _render(self) -> List[str]: + def _render(self) -> list[str]: """Recursively return list of strings that can render ascii tree.""" lines = [self._node_name()] for n, child in enumerate(self): spacer, bul = ( - (" ", "└──") if n == len(self) - 1 else (" │", "├──") + (' ', '└──') if n == len(self) - 1 else (' │', '├──') ) child_tree = child._render() - lines.append(f" {bul}" + child_tree.pop(0)) + lines.append(f' {bul}' + child_tree.pop(0)) lines.extend([spacer + lay for lay in child_tree]) return lines diff --git a/napari/utils/tree/node.py b/napari/utils/tree/node.py index 7f85f38bb84..75adfb069d7 100644 --- a/napari/utils/tree/node.py +++ b/napari/utils/tree/node.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Generator, List, Optional, Tuple +from collections.abc import Generator +from typing import TYPE_CHECKING, Optional from napari.utils.translations import trans @@ -28,7 +29,7 @@ class Node: The parent of this Node. """ - def __init__(self, name: str = "Node") -> None: + def __init__(self, name: str = 'Node') -> None: self.parent: Optional[Group] = None self._name = name @@ -51,13 +52,13 @@ def index_in_parent(self) -> Optional[int]: """Return index of this Node in its parent, or None if no parent.""" return self.parent.index(self) if self.parent is not None else None - def index_from_root(self) -> Tuple[int, ...]: + def index_from_root(self) -> tuple[int, ...]: """Return index of this Node relative to root. Will return ``()`` if this object *is* the root. """ item = self - indices: List[int] = [] + indices: list[int] = [] while item.parent is not None: indices.insert(0, item.index_in_parent()) # type: ignore item = item.parent @@ -87,9 +88,9 @@ def traverse( def __str__(self): """Render ascii tree string representation of this node""" - return "\n".join(self._render()) + return '\n'.join(self._render()) - def _render(self) -> List[str]: + def _render(self) -> list[str]: """Return list of strings that can render ascii tree. For ``Node``, we just return the name of this specific node. @@ -111,7 +112,7 @@ def unparent(self): return self raise IndexError( trans._( - "Cannot unparent orphaned Node: {node!r}", + 'Cannot unparent orphaned Node: {node!r}', deferred=True, node=self, ), diff --git a/napari/utils/validators.py b/napari/utils/validators.py index 97201dd73d8..29e20525891 100644 --- a/napari/utils/validators.py +++ b/napari/utils/validators.py @@ -1,6 +1,5 @@ -from collections.abc import Collection, Generator +from collections.abc import Collection, Generator, Iterable from itertools import tee -from typing import Iterable from napari.utils.translations import trans @@ -64,7 +63,7 @@ def func(obj): if len(obj) != n: raise ValueError( trans._( - "object must have length {number}, got {obj_len}", + 'object must have length {number}, got {obj_len}', deferred=True, number=n, obj_len=len(obj), @@ -75,7 +74,7 @@ def func(obj): if not isinstance(item, dtype): raise TypeError( trans._( - "Every item in the sequence must be of type {dtype}, but {item} is of type {item_type}", + 'Every item in the sequence must be of type {dtype}, but {item} is of type {item_type}', deferred=True, dtype=dtype, item=item, @@ -126,7 +125,7 @@ def _validate_increasing(values: Iterable) -> None: if any(a >= b for a, b in _pairwise(values)): raise ValueError( trans._( - "Sequence {sequence} must be monotonically increasing.", + 'Sequence {sequence} must be monotonically increasing.', deferred=True, sequence=values, ) diff --git a/napari/view_layers.py b/napari/view_layers.py index 853535c7d8e..ce04c9e5147 100644 --- a/napari/view_layers.py +++ b/napari/view_layers.py @@ -14,7 +14,7 @@ def view_(*args, **kwargs): """ import inspect -from typing import Any, List, Optional, Tuple +from typing import Any, Optional from numpydoc.docscrape import NumpyDocString as _NumpyDocString @@ -46,7 +46,7 @@ def view_(*args, **kwargs): """ _VIEW_DOC = _NumpyDocString(Viewer.__doc__) -_VIEW_PARAMS = " " + "\n".join(_VIEW_DOC._str_param_list('Parameters')[2:]) +_VIEW_PARAMS = ' ' + '\n'.join(_VIEW_DOC._str_param_list('Parameters')[2:]) def _merge_docstrings(add_method, layer_string): @@ -58,8 +58,8 @@ def _merge_docstrings(add_method, layer_string): # this ugliness is because the indentation of the parsed numpydocstring # is different for the first parameter :( lines = add_method_doc._str_param_list('Parameters') - lines = lines[:3] + textwrap.dedent("\n".join(lines[3:])).splitlines() - params = "\n".join(lines) + "\n" + textwrap.dedent(_VIEW_PARAMS) + lines = lines[:3] + textwrap.dedent('\n'.join(lines[3:])).splitlines() + params = '\n'.join(lines) + '\n' + textwrap.dedent(_VIEW_PARAMS) n = 'n' if layer_string.startswith(tuple('aeiou')) else '' return _doc_template.format(n=n, layer_string=layer_string, params=params) @@ -85,7 +85,7 @@ def _merge_layer_viewer_sigs_docs(func): from napari.utils.misc import _combine_signatures # get the `Viewer.add_*` method - layer_string = func.__name__.replace("view_", "") + layer_string = func.__name__.replace('view_', '') if layer_string == 'path': add_method = Viewer.open else: @@ -122,7 +122,7 @@ def _make_viewer_then( *args, viewer: Optional[Viewer] = None, **kwargs, -) -> Tuple[Viewer, Any]: +) -> tuple[Viewer, Any]: """Create a viewer, call given add_* method, then return viewer and layer. This function will be deprecated soon (See #4693) @@ -157,7 +157,7 @@ def _make_viewer_then( } if viewer is None: viewer = Viewer(**vkwargs) - kwargs.update(kwargs.pop("kwargs", {})) + kwargs.update(kwargs.pop('kwargs', {})) method = getattr(viewer, add_method) added = method(*args, **kwargs) if isinstance(added, list): @@ -225,39 +225,39 @@ def imshow( data, *, channel_axis=None, - rgb=None, + affine=None, + attenuation=0.05, + blending=None, + cache=True, colormap=None, contrast_limits=None, + custom_interpolation_kernel_2d=None, + depiction='volume', + experimental_clipping_planes=None, gamma=1.0, interpolation2d='nearest', interpolation3d='linear', - rendering='mip', - depiction='volume', iso_threshold=None, - attenuation=0.05, - name=None, metadata=None, - scale=None, - translate=None, - rotate=None, - shear=None, - affine=None, - opacity=1.0, - blending=None, - visible=True, multiscale=None, - cache=True, + name=None, + opacity=1.0, plane=None, - experimental_clipping_planes=None, - custom_interpolation_kernel_2d=None, projection_mode='none', + rendering='mip', + rgb=None, + rotate=None, + scale=None, + shear=None, + translate=None, + visible=True, viewer=None, title='napari', ndisplay=2, order=(), axis_labels=(), show=True, -) -> Tuple[Viewer, List["Image"]]: +) -> tuple[Viewer, list['Image']]: """Load data into an Image layer and return the Viewer and Layer. Parameters @@ -270,126 +270,108 @@ def imshow( supported in 2D. In 3D, only the lowest resolution scale is displayed. channel_axis : int, optional - Axis to expand image along. If provided, each channel in the data - will be added as an individual image layer. In channel_axis mode, - all other parameters MAY be provided as lists, and the Nth value - will be applied to the Nth channel in the data. If a single value + Axis to expand image along. If provided, each channel in the data + will be added as an individual image layer. In channel_axis mode, + other parameters MAY be provided as lists. The Nth value of the list + will be applied to the Nth channel in the data. If a single value is provided, it will be broadcast to all Layers. - rgb : bool or list - Whether the image is rgb RGB or RGBA. If not specified by user and - the last dimension of the data has length 3 or 4 it will be set as - `True`. If `False` the image is interpreted as a luminance image. - If a list then must be same length as the axis that is being - expanded as channels. - colormap : str, napari.utils.Colormap, tuple, dict, list - Colormaps to use for luminance images. If a string must be the name - of a supported colormap from vispy or matplotlib. If a tuple the - first value must be a string to assign as a name to a colormap and - the second item must be a Colormap. If a dict the key must be a - string to assign as a name to a colormap and the value must be a - Colormap. If a list then must be same length as the axis that is - being expanded as channels, and each colormap is applied to each - new image layer. - contrast_limits : list (2,) - Color limits to be used for determining the colormap bounds for - luminance images. If not passed is calculated as the min and max of - the image. If list of lists then must be same length as the axis - that is being expanded and then each colormap is applied to each - image. - gamma : list, float - Gamma correction for determining colormap linearity. Defaults to 1. - If a list then must be same length as the axis that is being - expanded as channels. - interpolation : str or list - Deprecated, to be removed in 0.6.0 - interpolation2d : str or list - Interpolation mode used by vispy in 2D. Must be one of our supported - modes. If a list then must be same length as the axis that is being - expanded as channels. - interpolation3d : str or list - Interpolation mode used by vispy in 3D. Must be one of our supported - modes. If a list then must be same length as the axis that is being - expanded as channels. - rendering : str or list - Rendering mode used by vispy. Must be one of our supported - modes. If a list then must be same length as the axis that is being - expanded as channels. - depiction : str - Selects a preset volume depiction mode in vispy - - * volume: images are rendered as 3D volumes. - * plane: images are rendered as 2D planes embedded in 3D. - iso_threshold : float or list - Threshold for isosurface. If a list then must be same length as the - axis that is being expanded as channels. - attenuation : float or list - Attenuation rate for attenuated maximum intensity projection. If a - list then must be same length as the axis that is being expanded as - channels. - name : str or list of str - Name of the layer. If a list then must be same length as the axis - that is being expanded as channels. - metadata : dict or list of dict - Layer metadata. If a list then must be a list of dicts with the - same length as the axis that is being expanded as channels. - scale : tuple of float or list - Scale factors for the layer. If a list then must be a list of - tuples of float with the same length as the axis that is being - expanded as channels. - translate : tuple of float or list - Translation values for the layer. If a list then must be a list of - tuples of float with the same length as the axis that is being - expanded as channels. - rotate : float, 3-tuple of float, n-D array or list. - If a float convert into a 2D rotation matrix using that value as an - angle. If 3-tuple convert into a 3D rotation matrix, using a yaw, - pitch, roll convention. Otherwise assume an nD rotation. Angles are - assumed to be in degrees. They can be converted from radians with - np.degrees if needed. If a list then must have same length as + All parameters except data, rgb, and multiscale can be provided as + list of values. If a list is provided, it must be the same length as the axis that is being expanded as channels. - shear : 1-D array or list. - A vector of shear values for an upper triangular n-D shear matrix. - If a list then must have same length as the axis that is being - expanded as channels. affine : n-D array or napari.utils.transforms.Affine (N+1, N+1) affine transformation matrix in homogeneous coordinates. The first (N, N) entries correspond to a linear transform and the final column is a length N translation vector and a 1 or a napari `Affine` transform object. Applied as an extra transform on top of the provided scale, rotate, and shear values. - opacity : float or list - Opacity of the layer visual, between 0.0 and 1.0. If a list then - must be same length as the axis that is being expanded as channels. - blending : str or list + attenuation : float or list of float + Attenuation rate for attenuated maximum intensity projection. + blending : str or list of str One of a list of preset blending modes that determines how RGB and alpha values of the layer visual get mixed. Allowed values are - {'opaque', 'translucent', and 'additive'}. If a list then - must be same length as the axis that is being expanded as channels. - visible : bool or list of bool - Whether the layer visual is currently being displayed. - If a list then must be same length as the axis that is - being expanded as channels. + {'translucent', 'translucent_no_depth', 'additive', 'minimum', 'opaque'}. + cache : bool or list of bool + Whether slices of out-of-core datasets should be cached upon + retrieval. Currently, this only applies to dask arrays. + colormap : str, napari.utils.Colormap, tuple, dict, list or list of these types + Colormaps to use for luminance images. If a string, it can be the name + of a supported colormap from vispy or matplotlib or the name of + a vispy color or a hexadecimal RGB color representation. + If a tuple, the first value must be a string to assign as a name to a + colormap and the second item must be a Colormap. If a dict, the key must + be a string to assign as a name to a colormap and the value must be a + Colormap. + contrast_limits : list (2,) + Intensity value limits to be used for determining the minimum and maximum colormap bounds for + luminance images. If not passed, they will be calculated as the min and max intensity value of + the image. + custom_interpolation_kernel_2d : np.ndarray + Convolution kernel used with the 'custom' interpolation mode in 2D rendering. + depiction : str or list of str + 3D Depiction mode. Must be one of {'volume', 'plane'}. + The default value is 'volume'. + experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList + Each dict defines a clipping plane in 3D in data coordinates. + Valid dictionary keys are {'position', 'normal', and 'enabled'}. + Values on the negative side of the normal are discarded if the plane is enabled. + gamma : float or list of float + Gamma correction for determining colormap linearity; defaults to 1. + interpolation2d : str or list of str + Interpolation mode used by vispy for rendering 2d data. + Must be one of our supported modes. + (for list of supported modes see Interpolation enum) + 'custom' is a special mode for 2D interpolation in which a regular grid + of samples is taken from the texture around a position using 'linear' + interpolation before being multiplied with a custom interpolation kernel + (provided with 'custom_interpolation_kernel_2d'). + interpolation3d : str or list of str + Same as 'interpolation2d' but for 3D rendering. + iso_threshold : float or list of float + Threshold for isosurface. + metadata : dict or list of dict + Layer metadata. multiscale : bool Whether the data is a multiscale image or not. Multiscale data is - represented by a list of array like image data. If not specified by - the user and if the data is a list of arrays that decrease in shape + represented by a list of array-like image data. If not specified by + the user and if the data is a list of arrays that decrease in shape, then it will be taken to be multiscale. The first image in the list should be the largest. Please note multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed. - cache : bool - Whether slices of out-of-core datasets should be cached upon - retrieval. Currently, this only applies to dask arrays. + name : str or list of str + Name of the layer. + opacity : float or list + Opacity of the layer visual, between 0.0 and 1.0. plane : dict or SlicingPlane Properties defining plane rendering in 3D. Properties are defined in data coordinates. Valid dictionary keys are {'position', 'normal', 'thickness', and 'enabled'}. - experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList - Each dict defines a clipping plane in 3D in data coordinates. - Valid dictionary keys are {'position', 'normal', and 'enabled'}. - Values on the negative side of the normal are discarded if the plane is enabled. - custom_interpolation_kernel_2d : np.ndarray - Convolution kernel used with the 'custom' interpolation mode in 2D rendering. + projection_mode : str + How data outside the viewed dimensions, but inside the thick Dims slice will + be projected onto the viewed dimensions. Must fit to cls._projectionclass + rendering : str or list of str + Rendering mode used by vispy. Must be one of our supported + modes. If a list then must be same length as the axis that is being + expanded as channels. + rgb : bool, optional + Whether the image is RGB or RGBA if rgb. If not + specified by user, but the last dimension of the data has length 3 or 4, + it will be set as `True`. If `False`, the image is interpreted as a + luminance image. + rotate : float, 3-tuple of float, n-D array or list. + If a float, convert into a 2D rotation matrix using that value as an + angle. If 3-tuple, convert into a 3D rotation matrix, using a yaw, + pitch, roll convention. Otherwise, assume an nD rotation. Angles are + assumed to be in degrees. They can be converted from radians with + 'np.degrees' if needed. + scale : tuple of float or list of tuple of float + Scale factors for the layer. + shear : 1-D array or list. + A vector of shear values for an upper triangular n-D shear matrix. + translate : tuple of float or list of tuple of float + Translation values for the layer. + visible : bool or list of bool + Whether the layer visual is currently being displayed. viewer : Viewer object, optional, by default None. title : string, optional The title of the viewer window. By default 'napari'. diff --git a/napari/viewer.py b/napari/viewer.py index fd85c9d915b..af3a9dc49fd 100644 --- a/napari/viewer.py +++ b/napari/viewer.py @@ -1,4 +1,3 @@ -import sys import typing from typing import TYPE_CHECKING, Optional from weakref import WeakSet @@ -34,10 +33,7 @@ class Viewer(ViewerModel): """ _window: 'Window' = None # type: ignore - if sys.version_info < (3, 9): - _instances: typing.ClassVar[WeakSet] = WeakSet() - else: - _instances: typing.ClassVar[WeakSet['Viewer']] = WeakSet() + _instances: typing.ClassVar[WeakSet['Viewer']] = WeakSet() def __init__( self, diff --git a/napari/window.py b/napari/window.py index df2cce9c41d..60a734407a3 100644 --- a/napari/window.py +++ b/napari/window.py @@ -26,6 +26,6 @@ def close(self): def __getattr__(self, name): raise type(err)( trans._( - "An error occured when importing Qt dependencies. Cannot show napari window. See cause above", + 'An error occured when importing Qt dependencies. Cannot show napari window. See cause above', ) ) from err diff --git a/napari_builtins/__init__.py b/napari_builtins/__init__.py index 77b04ac7e35..51e8c94eeee 100644 --- a/napari_builtins/__init__.py +++ b/napari_builtins/__init__.py @@ -1,6 +1,6 @@ from importlib.metadata import PackageNotFoundError, version try: - __version__ = version("napari") + __version__ = version('napari') except PackageNotFoundError: __version__ = 'unknown' diff --git a/napari_builtins/_ndims_balls.py b/napari_builtins/_ndims_balls.py index c7d729313c2..373eb6e9696 100644 --- a/napari_builtins/_ndims_balls.py +++ b/napari_builtins/_ndims_balls.py @@ -10,9 +10,9 @@ def labeled_particles2d(): ) return [ - (density, {"name": "density", "metadata": {"seed": seed}}, "image"), - (labels, {"name": "labels", "metadata": {"seed": seed}}, "labels"), - (points, {"name": "points", "metadata": {"seed": seed}}, "points"), + (density, {'name': 'density', 'metadata': {'seed': seed}}, 'image'), + (labels, {'name': 'labels', 'metadata': {'seed': seed}}, 'labels'), + (points, {'name': 'points', 'metadata': {'seed': seed}}, 'points'), ] @@ -23,7 +23,7 @@ def labeled_particles3d(): ) return [ - (density, {"name": "density", "metadata": {"seed": seed}}, "image"), - (labels, {"name": "labels", "metadata": {"seed": seed}}, "labels"), - (points, {"name": "points", "metadata": {"seed": seed}}, "points"), + (density, {'name': 'density', 'metadata': {'seed': seed}}, 'image'), + (labels, {'name': 'labels', 'metadata': {'seed': seed}}, 'labels'), + (points, {'name': 'points', 'metadata': {'seed': seed}}, 'points'), ] diff --git a/napari_builtins/_tests/conftest.py b/napari_builtins/_tests/conftest.py index ed98adce39b..6d4b12bff39 100644 --- a/napari_builtins/_tests/conftest.py +++ b/napari_builtins/_tests/conftest.py @@ -1,5 +1,4 @@ from pathlib import Path -from typing import List from unittest.mock import patch import numpy as np @@ -30,7 +29,7 @@ def _use_builtins(_mock_npe2_pm: PluginManager): yield plugin -LAYERS: List[layers.Layer] = [ +LAYERS: list[layers.Layer] = [ layers.Image(np.random.rand(10, 10)), layers.Labels(np.random.randint(0, 16000, (32, 32), 'uint64')), layers.Points(np.random.rand(20, 2)), diff --git a/napari_builtins/_tests/test_io.py b/napari_builtins/_tests/test_io.py index 082d882fc44..b63f7b2c640 100644 --- a/napari_builtins/_tests/test_io.py +++ b/napari_builtins/_tests/test_io.py @@ -2,7 +2,7 @@ import os from pathlib import Path from tempfile import TemporaryDirectory -from typing import NamedTuple, Tuple +from typing import NamedTuple from uuid import uuid4 import dask.array as da @@ -24,7 +24,7 @@ class ImageSpec(NamedTuple): - shape: Tuple[int, ...] + shape: tuple[int, ...] dtype: str ext: str levels: int = 1 @@ -59,7 +59,7 @@ def writer(spec: ImageSpec): def test_no_files_raises(tmp_path): with pytest.raises(ValueError) as e: magic_imread(tmp_path) - assert "No files found in" in str(e.value) + assert 'No files found in' in str(e.value) def test_guess_zarr_path(): @@ -125,7 +125,7 @@ def test_write_csv(tmpdir): csv.reader(output_csv, delimiter=',') for row_index, row in enumerate(output_csv): if row_index == 0: - assert row == "column_1,column_2,column_3\n" + assert row == 'column_1,column_2,column_3\n' else: output_row_data = [float(i) for i in row.split(',')] np.testing.assert_allclose( diff --git a/napari_builtins/_tests/test_ndims_balls.py b/napari_builtins/_tests/test_ndims_balls.py index e2de9bf7216..5460404d627 100644 --- a/napari_builtins/_tests/test_ndims_balls.py +++ b/napari_builtins/_tests/test_ndims_balls.py @@ -10,12 +10,12 @@ def test_labeled_particles2d(): img, labels, points = labeled_particles2d() assert img[0].ndim == 2 assert labels[0].ndim == 2 - assert "seed" in img[1]["metadata"] - assert "seed" in labels[1]["metadata"] - assert "seed" in points[1]["metadata"] - assert img[2] == "image" - assert labels[2] == "labels" - assert points[2] == "points" + assert 'seed' in img[1]['metadata'] + assert 'seed' in labels[1]['metadata'] + assert 'seed' in points[1]['metadata'] + assert img[2] == 'image' + assert labels[2] == 'labels' + assert points[2] == 'points' assert np.all(img[0][labels[0] > 0] > 0) @@ -24,11 +24,11 @@ def test_labeled_particles3d(): img, labels, points = labeled_particles3d() assert img[0].ndim == 3 assert labels[0].ndim == 3 - assert "seed" in img[1]["metadata"] - assert "seed" in labels[1]["metadata"] - assert "seed" in points[1]["metadata"] - assert img[2] == "image" - assert labels[2] == "labels" - assert points[2] == "points" + assert 'seed' in img[1]['metadata'] + assert 'seed' in labels[1]['metadata'] + assert 'seed' in points[1]['metadata'] + assert img[2] == 'image' + assert labels[2] == 'labels' + assert points[2] == 'points' assert np.all(img[0][labels[0] > 0] > 0) diff --git a/napari_builtins/_tests/test_reader.py b/napari_builtins/_tests/test_reader.py index b7d28501204..bcf0c8df837 100644 --- a/napari_builtins/_tests/test_reader.py +++ b/napari_builtins/_tests/test_reader.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Callable, Optional -import imageio +import imageio.v3 as iio import npe2 import numpy as np import pytest @@ -17,14 +17,14 @@ def save_image(tmp_path: Path): def _save(filename: str, data: Optional[np.ndarray] = None): dest = tmp_path / filename data_: np.ndarray = np.random.rand(20, 20) if data is None else data - if filename.endswith(("png", "jpg")): + if filename.endswith(('png', 'jpg')): data_ = (data_ * 255).astype(np.uint8) - if dest.suffix in {".tif", ".tiff"}: + if dest.suffix in {'.tif', '.tiff'}: tifffile.imwrite(str(dest), data_) elif dest.suffix in {'.npy'}: np.save(str(dest), data_) else: - imageio.imsave(str(dest), data_) + iio.imwrite(str(dest), data_) return dest return _save @@ -43,6 +43,14 @@ def test_reader_plugin_tif(save_image: Callable[..., Path], ext, stack): assert isinstance(layer_data[0], tuple) +def test_animated_gif_reader(save_image): + threeD_data = (np.random.rand(5, 20, 20, 3) * 255).astype(np.uint8) + dest = save_image('animated.gif', threeD_data) + layer_data = npe2.read([str(dest)], stack=False) + assert len(layer_data) == 1 + assert layer_data[0][0].shape == (5, 20, 20, 3) + + def test_reader_plugin_url(): layer_data = npe2.read( ['https://samples.fiji.sc/FakeTracks.tif'], stack=False diff --git a/napari_builtins/_tests/test_writer.py b/napari_builtins/_tests/test_writer.py index 28d60134125..1a579314a0f 100644 --- a/napari_builtins/_tests/test_writer.py +++ b/napari_builtins/_tests/test_writer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING import npe2 import numpy as np @@ -55,7 +55,7 @@ def test_no_write_layer_bad_extension(some_layer: 'layers.Layer'): # test_plugin_manager fixture is provided by napari_plugin_engine._testsupport def test_get_writer_succeeds( - tmp_path: Path, layers_list: 'List[layers.Layer]' + tmp_path: Path, layers_list: 'list[layers.Layer]' ): """Test writing layers data.""" diff --git a/napari_builtins/io/_read.py b/napari_builtins/io/_read.py index 2860e06f165..fe79bd90406 100644 --- a/napari_builtins/io/_read.py +++ b/napari_builtins/io/_read.py @@ -3,15 +3,18 @@ import re import tempfile import urllib.parse +from collections.abc import Sequence from contextlib import contextmanager, suppress from glob import glob from pathlib import Path -from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Optional, Union from urllib.error import HTTPError, URLError import dask.array as da +import imageio.v3 as iio import numpy as np from dask import delayed +from imageio import formats from napari.utils.misc import abspath_or_url from napari.utils.translations import trans @@ -19,16 +22,12 @@ if TYPE_CHECKING: from napari.types import FullLayerData, LayerData, ReaderFunction -try: - import imageio.v2 as imageio -except ModuleNotFoundError: - import imageio # type: ignore -IMAGEIO_EXTENSIONS = {x for f in imageio.formats for x in f.extensions} +IMAGEIO_EXTENSIONS = {x for f in formats for x in f.extensions} READER_EXTENSIONS = IMAGEIO_EXTENSIONS.union({'.zarr', '.lsm', '.npy'}) -def _alphanumeric_key(s: str) -> List[Union[str, int]]: +def _alphanumeric_key(s: str) -> list[Union[str, int]]: """Convert string to list of strings and ints that gives intuitive sorting.""" return [int(c) if c.isdigit() else c for c in re.split('([0-9]+)', s)] @@ -90,8 +89,8 @@ def imread(filename: str) -> np.ndarray: if ext.lower() in ('.npy',): return np.load(filename) - if ext.lower() not in [".tif", ".tiff", ".lsm"]: - return imageio.imread(filename) + if ext.lower() not in ['.tif', '.tiff', '.lsm']: + return iio.imread(filename) import tifffile # Pre-download urls before loading them with tifffile @@ -101,7 +100,7 @@ def imread(filename: str) -> np.ndarray: def _guess_zarr_path(path: str) -> bool: """Guess whether string path is part of a zarr hierarchy.""" - return any(part.endswith(".zarr") for part in Path(path).parts) + return any(part.endswith('.zarr') for part in Path(path).parts) def read_zarr_dataset(path): @@ -136,7 +135,7 @@ def read_zarr_dataset(path): else: # pragma: no cover raise ValueError( trans._( - "Not a zarr dataset or group: {path}", deferred=True, path=path + 'Not a zarr dataset or group: {path}', deferred=True, path=path ) ) return image, shape @@ -146,7 +145,7 @@ def read_zarr_dataset(path): def magic_imread( - filenames: Union[PathOrStr, List[PathOrStr]], *, use_dask=None, stack=True + filenames: Union[PathOrStr, list[PathOrStr]], *, use_dask=None, stack=True ): """Dispatch the appropriate reader given some files. @@ -171,16 +170,16 @@ def magic_imread( image : array-like Array or list of images """ - _filenames: List[str] = ( + _filenames: list[str] = ( [str(x) for x in filenames] if isinstance(filenames, (list, tuple)) else [str(filenames)] ) if not _filenames: # pragma: no cover - raise ValueError("No files found") + raise ValueError('No files found') # replace folders with their contents - filenames_expanded: List[str] = [] + filenames_expanded: list[str] = [] for filename in _filenames: # zarr files are folders, but should be read as 1 file if ( @@ -205,7 +204,7 @@ def magic_imread( if not filenames_expanded: raise ValueError( trans._( - "No files found in {filenames} after removing subdirectories", + 'No files found in {filenames} after removing subdirectories', deferred=True, filenames=filenames, ) @@ -260,8 +259,8 @@ def magic_imread( def _points_csv_to_layerdata( - table: np.ndarray, column_names: List[str] -) -> "FullLayerData": + table: np.ndarray, column_names: list[str] +) -> 'FullLayerData': """Convert table data and column names from a csv file to Points LayerData. Parameters @@ -300,8 +299,8 @@ def _points_csv_to_layerdata( def _shapes_csv_to_layerdata( - table: np.ndarray, column_names: List[str] -) -> "FullLayerData": + table: np.ndarray, column_names: list[str] +) -> 'FullLayerData': """Convert table data and column names from a csv file to Shapes LayerData. Parameters @@ -340,7 +339,7 @@ def _shapes_csv_to_layerdata( def _guess_layer_type_from_column_names( - column_names: List[str], + column_names: list[str], ) -> Optional[str]: """Guess layer type based on column names from a csv file. @@ -366,7 +365,7 @@ def _guess_layer_type_from_column_names( def read_csv( filename: str, require_type: Optional[str] = None -) -> Tuple[np.ndarray, List[str], Optional[str]]: +) -> tuple[np.ndarray, list[str], Optional[str]]: """Return CSV data only if column names match format for ``require_type``. Reads only the first line of the CSV at first, then optionally raises an @@ -413,7 +412,7 @@ def read_csv( filename=filename, ) ) - if layer_type != require_type and require_type.lower() != "any": + if layer_type != require_type and require_type.lower() != 'any': raise ValueError( trans._( 'File "{filename}" not recognized as {require_type} data', @@ -435,7 +434,7 @@ def read_csv( def csv_to_layer_data( path: str, require_type: Optional[str] = None -) -> Optional["FullLayerData"]: +) -> Optional['FullLayerData']: """Return layer data from a CSV file if detected as a valid type. Parameters @@ -476,7 +475,7 @@ def csv_to_layer_data( return None # only reachable if it is a valid layer type without a reader -def _csv_reader(path: Union[str, Sequence[str]]) -> List["LayerData"]: +def _csv_reader(path: Union[str, Sequence[str]]) -> list['LayerData']: if isinstance(path, str): layer_data = csv_to_layer_data(path, require_type=None) return [layer_data] if layer_data else [] @@ -487,13 +486,13 @@ def _csv_reader(path: Union[str, Sequence[str]]) -> List["LayerData"]: ] -def _magic_imreader(path: str) -> List["LayerData"]: +def _magic_imreader(path: str) -> list['LayerData']: return [(magic_imread(path),)] def napari_get_reader( - path: Union[str, List[str]] -) -> Optional["ReaderFunction"]: + path: Union[str, list[str]] +) -> Optional['ReaderFunction']: """Our internal fallback file reader at the end of the reader plugin chain. This will assume that the filepath is an image, and will pass all of the diff --git a/napari_builtins/io/_write.py b/napari_builtins/io/_write.py index b001f9c4228..9e98710b14a 100644 --- a/napari_builtins/io/_write.py +++ b/napari_builtins/io/_write.py @@ -2,7 +2,7 @@ import os import shutil from tempfile import TemporaryDirectory -from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Optional, Union import numpy as np @@ -15,8 +15,8 @@ def write_csv( filename: str, - data: Union[List, np.ndarray], - column_names: Optional[List[str]] = None, + data: Union[list, np.ndarray], + column_names: Optional[list[str]] = None, ): """Write a csv file. @@ -42,7 +42,7 @@ def write_csv( writer.writerow(row) -def imsave_extensions() -> Tuple[str, ...]: +def imsave_extensions() -> tuple[str, ...]: """Valid extensions of files that imsave can write to. Returns @@ -191,7 +191,7 @@ def napari_write_points(path: str, data: Any, meta: dict) -> Optional[str]: prop_table = [] # add index of each point - column_names = ["index", *column_names] + column_names = ['index', *column_names] indices = np.expand_dims(list(range(data.shape[0])), axis=1) table = np.concatenate([indices, data, *prop_table], axis=1) @@ -239,7 +239,7 @@ def napari_write_shapes(path: str, data: Any, meta: dict) -> Optional[str]: column_names = [f'axis-{n!s}' for n in range(n_dimensions)] # add shape id and vertex id of each vertex - column_names = ["index", "shape-type", "vertex-index", *column_names] + column_names = ['index', 'shape-type', 'vertex-index', *column_names] # concatenate shape data into 2D array len_shapes = [s.shape[0] for s in data] @@ -268,8 +268,8 @@ def napari_write_shapes(path: str, data: Any, meta: dict) -> Optional[str]: def write_layer_data_with_plugins( - path: str, layer_data: List["FullLayerData"] -) -> List[str]: + path: str, layer_data: list['FullLayerData'] +) -> list[str]: """Write layer data out into a folder one layer at a time. Call ``napari_write_`` for each layer using the ``layer.name`` @@ -297,7 +297,7 @@ def write_layer_data_with_plugins( if not already_existed: os.makedirs(path) - written: List[str] = [] # the files that were actually written + written: list[str] = [] # the files that were actually written try: # build in a temporary directory and then move afterwards, # it makes cleanup easier if an exception is raised inside. diff --git a/pyproject.toml b/pyproject.toml index a1795c09d61..b559249acb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,204 @@ [build-system] requires = [ - "setuptools >= 56", - "wheel", - "setuptools_scm[toml]>=3.4" + "setuptools >= 69", + "setuptools_scm[toml]>=8" ] build-backend = "setuptools.build_meta" +[project] +name = "napari" +description = "n-dimensional array viewer in Python" +authors = [ + { name = "napari team", email = "napari-steering-council@googlegroups.com" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: X11 Applications :: Qt", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Programming Language :: C", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Visualization", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Topic :: Utilities", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS", +] +requires-python = ">=3.9" +dependencies = [ + "appdirs>=1.4.4", + "app-model>=0.2.2,<0.3.0", + "cachey>=0.2.1", + "certifi>=2018.1.18", + "dask[array]>=2021.10.0", + "imageio>=2.20,!=2.22.1", + "jsonschema>=3.2.0", + "lazy_loader>=0.2", + "magicgui>=0.7.0", + "napari-console>=0.0.9", + "napari-plugin-engine>=0.1.9", + "napari-svg>=0.1.8", + "npe2>=0.7.2", + "numpy>=1.22.2,<2", + "numpydoc>=0.9.2", + "pandas>=1.3.0", + "Pillow>=9.0", + "pint>=0.17", + "psutil>=5.0", + "psygnal>=0.5.0", + "pydantic>=1.9.0", + "pygments>=2.6.0", + "PyOpenGL>=3.1.0", + "PyYAML>=5.1", + "qtpy>=1.10.0", + "scikit-image[data]>=0.19.1", + "scipy>=1.5.4", + "superqt>=0.5.0", + "tifffile>=2022.4.8", + "toolz>=0.10.0", + "tqdm>=4.56.0", + "typing_extensions>=4.2.0", + "vispy>=0.14.1,<0.15", + "wrapt>=1.11.1", +] +dynamic = [ + "version", +] + +[project.license] +text = "BSD 3-Clause" + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[project.urls] +Homepage = "https://napari.org" +Download = "https://github.com/napari/napari" +"Bug Tracker" = "https://github.com/napari/napari/issues" +Documentation = "https://napari.org" +"Source Code" = "https://github.com/napari/napari" + +[project.optional-dependencies] +pyside2 = [ + "PySide2>=5.13.2,!=5.15.0 ; python_version < '3.12'", +] +pyside6_experimental = [ + "PySide6 < 6.5 ; python_version < '3.12'" +] +pyqt6_experimental = [ + "PyQt6", + "PyQt6 != 6.6.1 ; platform_system == 'Darwin'" +] +pyside = [ + "napari[pyside2]" +] +pyqt5 = [ + "PyQt5>=5.12.3,!=5.15.0", +] +pyqt = [ + "napari[pyqt5]" +] +qt = [ + "napari[pyqt]" +] +all = [ + "napari[pyqt,optional]", + "napari-plugin-manager >=0.1.0a1, <0.2.0", +] +optional = [ + "triangle ; platform_machine != 'arm64'", + "numba>=0.57.1", + "zarr>=2.12.0", # needed by `builtins` (dask.array.from_zarr) to open zarr +] +testing = [ + "babel>=2.9.0", + "fsspec>=2023.10.0", + "hypothesis>=6.8.0", + "lxml>5", + "matplotlib >= 3.6.1", + "numba>=0.57.1", + "pooch>=1.6.0", + "coverage>7", + "pretend>=1.0.9", + "pyautogui>=0.9.54", + "pytest-qt>=4.3.1", + "pytest-pretty>=1.1.0", + "pytest>=8.0.0", + "tensorstore>=0.1.13", + "virtualenv>=20.17", + "xarray>=0.16.2", + "zarr>=2.12.0", + "IPython>=7.25.0", + "qtconsole>=4.5.1", + "rich>=12.0.0", + "napari-plugin-manager >=0.1.0a2, <0.2.0", +] +testing_extra = [ + "torch>=1.7", +] +release = [ + "PyGithub>=1.44.1", + "twine>=3.1.1", + "gitpython>=3.1.0", + "requests-cache>=0.9.2", +] +dev = [ + "black", + "check-manifest>=0.42", + "pre-commit>=2.9.0", + "pydantic[dotenv]", + "napari[testing]", +] +build = [ + "black", + "ruff", + "pyqt5", +] + +[project.entry-points.pytest11] +napari = "napari.utils._testsupport" + +[project.entry-points."napari.manifest"] +napari_builtins = "napari_builtins:builtins.yaml" + +[project.scripts] +napari = "napari.__main__:main" + +[tool.setuptools] +zip-safe = false +include-package-data = true +license-files = [ + "LICENSE", +] + +[tool.setuptools.packages.find] +namespaces = false + +[tool.setuptools.package-data] +"*" = [ + "*.pyi", +] +napari_builtins = [ + "builtins.yaml", +] + + [tool.setuptools_scm] write_to = "napari/_version.py" [tool.black] -target-version = ['py38', 'py39', 'py310'] +target-version = ['py39', 'py310', 'py311', 'py312'] skip-string-normalization = true line-length = 79 exclude = ''' @@ -55,6 +243,33 @@ ignore = [ [tool.ruff] line-length = 79 + +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".mypy_cache", + ".pants.d", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "*vendored*", + "*_vendor*", +] + +target-version = "py39" +fix = true + +[tool.ruff.lint] select = [ "E", "F", "W", #flake8 "UP", # pyupgrade @@ -81,45 +296,19 @@ select = [ "RUF", # ruff specyfic rules ] ignore = [ - "E501", "UP006", "UP007", "TCH001", "TCH002", "TCH003", + "E501", "TCH001", "TCH002", "TCH003", "A003", # flake8-builtins - we have class attributes violating these rule "COM812", # flake8-commas - we don't like adding comma on single line of arguments "SIM117", # flake8-simplify - we some of merged with statements are not looking great with black, reanble after drop python 3.9 - "Q000", "RET504", # not fixed yet https://github.com/charliermarsh/ruff/issues/2950 "TRY003", # require implement multiple exception class "RUF005", # problem with numpy compatybility, see https://github.com/charliermarsh/ruff/issues/2142#issuecomment-1451038741 "B028", # need to be fixed "PYI015", # it produces bad looking files (@jni opinion) - -] - -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".mypy_cache", - ".pants.d", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "venv", - "*vendored*", - "*_vendor*", ] -target-version = "py38" -fix = true -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "napari/_vispy/__init__.py" = ["E402"] "**/_tests/*.py" = ["B011", "INP001", "TRY301", "B018", "RUF012"] "napari/utils/_testsupport.py" = ["B011"] @@ -129,19 +318,24 @@ fix = true "**/vendored/**" = ["TID"] "napari/benchmarks/**" = ["RUF012", "TID252"] -[tool.ruff.flake8-quotes] +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.ruff.lint.flake8-quotes] docstring-quotes = "double" +inline-quotes = "single" +multiline-quotes = "double" -[tool.ruff.flake8-tidy-imports] +[tool.ruff.lint.flake8-tidy-imports] # Disallow all relative imports. ban-relative-imports = "all" -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party=['napari'] combine-as-imports = true -[tool.ruff.flake8-import-conventions] -[tool.ruff.flake8-import-conventions.extend-aliases] +[tool.ruff.lint.flake8-import-conventions] +[tool.ruff.lint.flake8-import-conventions.extend-aliases] # Declare a custom alias for the `matplotlib` module. "dask.array" = "da" xarray = "xr" @@ -323,7 +517,6 @@ module = [ 'napari.layers.points.points', 'napari.layers.shapes._shapes_mouse_bindings', 'napari.layers.shapes.shapes', - 'napari.layers.utils._link_layers', 'napari.layers.utils.color_encoding', 'napari.layers.utils.color_manager', 'napari.layers.utils.stack_utils', @@ -367,17 +560,18 @@ module = [ "napari._vispy.visuals.markers", "napari._vispy.visuals.surface", "napari.layers._data_protocols", - "napari.layers._source", "napari.layers.shapes._shapes_models.path", "napari.layers.shapes._shapes_models.polygon", - "napari.layers.utils.interactivity_utils", - "napari.layers.vectors._vector_utils", + "napari.layers.shapes._shapes_models._polgyon_base", + "napari.layers.shapes._shapes_models.ellipse", + "napari.layers.shapes._shapes_models.line", + "napari.layers.shapes._shapes_models.rectangle", + "napari.layers.shapes._shapes_models.shape", "napari.resources._icons", "napari.utils.color", "napari.utils.events.containers._dict", "napari.utils.events.event_utils", "napari.utils.migrations", - "napari.utils.perf._patcher", "napari.utils.validators", "napari.window" ] @@ -386,11 +580,6 @@ disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ - "napari.settings._utils", - "napari.settings._appearance", - "napari.settings._shortcuts", - "napari.settings._application", - "napari._app_model.actions._view_actions", "napari._event_loop", "napari._vispy.utils.quaternion", "napari._vispy.visuals.bounding_box", @@ -402,10 +591,7 @@ module = [ "napari.components._viewer_mouse_bindings", "napari.components.overlays.base", "napari.components.overlays.interaction_box", - "napari.layers.base._base_constants", "napari.utils.colormaps.categorical_colormap_utils", - "napari.utils.perf._event", - "napari.utils.perf._trace_file", ] disallow_untyped_defs = false @@ -425,6 +611,7 @@ module = [ "napari._qt._qapp_model.qactions._view", "napari._vispy.camera", "napari._vispy.layers.image", + "napari._vispy.layers.scalar_field", "napari._vispy.layers.tracks", "napari._vispy.layers.vectors", "napari._vispy.overlays.axes", @@ -433,25 +620,21 @@ module = [ "napari._vispy.overlays.scale_bar", "napari._vispy.overlays.text", "napari.layers.labels._labels_key_bindings", - "napari.layers.shapes._mesh", "napari.layers.surface._surface_key_bindings", "napari.layers.tracks._tracks_key_bindings", "napari.layers.utils._slice_input", - "napari.layers.vectors._vectors_key_bindings", "napari.utils._register", "napari.utils.colormaps.categorical_colormap", "napari.utils.colormaps.standardize_color", - "napari.utils.events.containers._set", "napari.utils.geometry", "napari.utils.io", "napari.utils.notebook_display", - "napari.utils.perf._config", "napari.utils.transforms.transform_utils", "napari.utils.translations", "napari.utils.tree.node", "napari.viewer", - "napari.layers.shapes._shapes_key_bindings", - "napari.layers.shapes._shape_list" + "napari.layers.shapes._shape_list", + "napari.layers.vectors.vectors", ] disallow_incomplete_defs = false disallow_untyped_calls = false @@ -464,7 +647,6 @@ module = [ "napari._vispy.visuals.axes", "napari.layers.labels._labels_mouse_bindings", "napari.layers.utils.color_manager_utils", - "napari.layers.vectors._slice", "napari.utils.colormaps.vendored._cm", "napari.utils.colormaps.vendored.cm", "napari.utils.status_messages", @@ -476,7 +658,6 @@ disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari._app_model._app", - "napari.utils.events.containers._selection", "napari.utils.theme", ] disallow_incomplete_defs = false @@ -501,16 +682,13 @@ module = [ "napari._vispy.layers.shapes", "napari._vispy.layers.surface", "napari.components._viewer_key_bindings", - "napari.layers.image.image", "napari.layers.labels.labels", "napari.layers.surface.surface", "napari.layers.tracks.tracks", "napari.layers.utils.layer_utils", - "napari.layers.vectors.vectors", "napari.utils._dtype", "napari.utils.colormaps.colormap_utils", "napari.utils.misc", - "napari.utils.perf._timers" ] check_untyped_defs = false disallow_incomplete_defs = false @@ -524,14 +702,8 @@ module = [ "napari.conftest", "napari.layers.labels._labels_utils", "napari.layers.points._points_mouse_bindings", - "napari.layers.shapes._shapes_models._polgyon_base", - "napari.layers.shapes._shapes_models.ellipse", - "napari.layers.shapes._shapes_models.line", - "napari.layers.shapes._shapes_models.rectangle", - "napari.layers.shapes._shapes_models.shape", "napari.layers.tracks._track_utils", "napari.utils.colormaps.colormap", - "napari.utils.events.containers._selectable_list", "napari.utils.notifications", ] check_untyped_defs = false @@ -541,7 +713,6 @@ disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ "napari.utils.events.containers._typed", - "napari.layers.base.base", ] check_untyped_defs = false disallow_incomplete_defs = false @@ -552,7 +723,9 @@ warn_unused_ignores = false [[tool.mypy.overrides]] module = [ "napari.__main__", - "napari.utils.colormaps.vendored.colors" + "napari.utils.colormaps.vendored.colors", + "napari.layers.image.image", + "napari.layers._scalar_field.scalar_field", ] check_untyped_defs = false disallow_untyped_calls = false @@ -569,7 +742,8 @@ disallow_untyped_calls = false [[tool.mypy.overrides]] module = [ - "napari._qt.containers.qt_layer_list" + "napari._qt.containers.qt_layer_list", + "napari.layers.base.base" ] check_untyped_defs = false disallow_untyped_calls = false @@ -580,8 +754,78 @@ warn_unused_ignores = false module = [ "napari._vispy.overlays.bounding_box", "napari._vispy.overlays.brush_circle", - "napari.layers.base._base_mouse_bindings", "napari.utils._test_utils", ] check_untyped_defs = false disallow_untyped_defs = false + + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError()", + "except ImportError:", + "^ +\\.\\.\\.$", +] + +[tool.coverage.run] +parallel = true +omit = [ + "*/_vendor/*", + "*/_version.py", + "*/benchmarks/*", + "napari/utils/indexing.py", +] +source = [ + "napari", + "napari_builtins", +] + +[tool.coverage.paths] +source = [ + "napari/", + "D:\\a\\napari\\napari\\napari", + "/home/runner/work/napari/napari/napari", + "/Users/runner/work/napari/napari/napari", +] +builtins = [ + "napari_builtins/", + "D:\\a\\napari\\napari\\napari_builtins", + "/home/runner/work/napari/napari/napari_builtins", + "/Users/runner/work/napari/napari/napari_builtins", +] + +[tool.importlinter] +root_package = "napari" +include_external_packages = true + +[[tool.importlinter.contracts]] +name = "Forbid import PyQt and PySide" +type = "forbidden" +source_modules = "napari" +forbidden_modules = ["PyQt5", "PySide2", "PyQt6", "PySide6"] +ignore_imports = [ + "napari._qt -> PySide2", + "napari.plugins._npe2 -> napari._qt._qplugins", +] + +[[tool.importlinter.contracts]] +name = "Block import from qt module in napari.layers" +type = "layers" +layers = ["napari.qt","napari.layers"] +ignore_imports = [ + "napari.plugins._npe2 -> napari._qt._qplugins", + # TODO: remove once npe1 deprecated + "napari._qt.qt_main_window -> napari._qt._qplugins", +] + +[[tool.importlinter.contracts]] +name = "Block import from qt module in napari.components" +type = "layers" +layers = ["napari.qt","napari.components"] +ignore_imports = [ + "napari.plugins._npe2 -> napari._qt._qplugins", + # TODO: remove once npe1 deprecated + "napari._qt.qt_main_window -> napari._qt._qplugins", +] diff --git a/resources/constraints/constraints_py3.10.txt b/resources/constraints/constraints_py3.10.txt index 5f9b9263fca..cd246c16e33 100644 --- a/resources/constraints/constraints_py3.10.txt +++ b/resources/constraints/constraints_py3.10.txt @@ -2,17 +2,17 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10.txt --strip-extras napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt # alabaster==0.7.16 # via sphinx annotated-types==0.6.0 # via pydantic -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -25,15 +25,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # sphinx -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -43,27 +43,25 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx @@ -81,44 +79,44 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via dask -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole -ipython==8.21.0 +ipython==8.22.2 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -128,14 +126,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -146,22 +144,22 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -170,20 +168,20 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 @@ -192,20 +190,20 @@ networkx==3.2.1 # torch npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.26.3 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # numba # numcodecs @@ -219,7 +217,7 @@ numpy==1.26.3 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -245,13 +243,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -263,12 +261,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -280,94 +277,109 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt -pydantic==2.6.0 +pydantic==2.6.4 # via # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model -pydantic-core==2.16.1 +pydantic-core==2.16.3 # via pydantic +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.15.2.1 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 ; python_version >= "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -377,23 +389,29 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -402,17 +420,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -421,22 +439,20 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image + # via napari (napari_repo/pyproject.toml) scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image shiboken2==5.15.2.1 # via pyside2 @@ -469,22 +485,22 @@ sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2024.1.30 + # napari (napari_repo/pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.0.1 # via @@ -499,17 +515,17 @@ tomli-w==1.0.0 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -519,42 +535,41 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # pydantic-core # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/resources/constraints/constraints_py3.10_docs.txt b/resources/constraints/constraints_py3.10_docs.txt index a0433b04f33..af215fde273 100644 --- a/resources/constraints/constraints_py3.10_docs.txt +++ b/resources/constraints/constraints_py3.10_docs.txt @@ -2,17 +2,17 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10_docs.txt --strip-extras docs/requirements.txt napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg resources/constraints/version_denylist_examples.txt +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10_docs.txt --strip-extras docs/requirements.txt napari_repo/pyproject.toml napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt # accessible-pygments==0.0.4 # via pydata-sphinx-theme alabaster==0.7.16 # via sphinx -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -26,18 +26,18 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pydata-sphinx-theme # sphinx beautifulsoup4==4.12.3 # via pydata-sphinx-theme -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -51,27 +51,25 @@ cloudpickle==3.0.0 # via dask colorama==0.4.6 # via sphinx-autobuild -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via @@ -95,14 +93,14 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch greenlet==3.0.3 # via sqlalchemy @@ -110,38 +108,38 @@ heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imageio-ffmpeg==0.4.9 # via -r docs/requirements.txt imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via # dask # jupyter-cache # myst-nb -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # myst-nb # napari-console # qtconsole -ipython==8.21.0 +ipython==8.22.2 # via # ipykernel # myst-nb - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -157,18 +155,18 @@ joblib==1.3.2 # scikit-learn jsonschema==4.21.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # nbformat jsonschema-specifications==2023.12.1 # via jsonschema jupyter-cache==1.0.0 # via myst-nb -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # nbclient # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -181,7 +179,7 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image livereload==2.6.3 # via sphinx-autobuild @@ -192,10 +190,10 @@ locket==1.0.0 lxml==5.1.0 # via # -r docs/requirements.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # nilearn -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via # mdit-py-plugins @@ -203,10 +201,10 @@ markdown-it-py==3.0.0 # rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 +matplotlib==3.8.3 # via # -r docs/requirements.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -217,31 +215,31 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal myst-nb==1.0.0 # via -r docs/requirements.txt myst-parser==2.0.0 # via myst-nb napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-sphinx-theme==0.3.2 # via -r docs/requirements.txt napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) -nbclient==0.9.0 + # via napari (napari_repo/pyproject.toml) +nbclient==0.10.0 # via # jupyter-cache # myst-nb -nbformat==5.9.2 +nbformat==5.10.3 # via # jupyter-cache # myst-nb @@ -252,16 +250,16 @@ networkx==3.2.1 # via # scikit-image # torch -nibabel==5.2.0 +nibabel==5.2.1 # via nilearn nilearn==0.10.3 # via -r resources/constraints/version_denylist_examples.txt npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr numpy==1.23.5 @@ -272,7 +270,7 @@ numpy==1.23.5 # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # nibabel # nilearn @@ -289,7 +287,7 @@ numpy==1.23.5 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -315,13 +313,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # -r resources/constraints/version_denylist_examples.txt # build @@ -338,12 +336,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # nilearn # xarray parso==0.8.3 @@ -356,99 +353,114 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image # sphinx-gallery pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt pydantic==1.10.14 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model pydata-sphinx-theme==0.14.4 # via napari-sphinx-theme +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # accessible-pygments # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pydata-sphinx-theme # qtconsole # rich # sphinx # sphinx-tabs # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.15.2.1 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 ; python_version >= "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -458,17 +470,23 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 @@ -477,7 +495,7 @@ pyyaml==6.0.1 # jupyter-cache # myst-nb # myst-parser - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # sphinx-external-toc pyzmq==25.1.2 @@ -487,17 +505,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -507,24 +525,22 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image -scikit-learn==1.4.0 + # via napari (napari_repo/pyproject.toml) +scikit-learn==1.4.1.post1 # via nilearn scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # nilearn # scikit-image # scikit-learn @@ -591,14 +607,14 @@ sphinxcontrib-qthelp==1.0.7 # via sphinx sphinxcontrib-serializinghtml==1.1.10 # via sphinx -sqlalchemy==2.0.25 +sqlalchemy==2.0.28 # via jupyter-cache stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch @@ -606,15 +622,15 @@ tabulate==0.9.0 # via # jupyter-cache # numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -threadpoolctl==3.2.0 + # napari (napari_repo/pyproject.toml) +threadpoolctl==3.3.0 # via scikit-learn -tifffile==2024.1.30 +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.0.1 # via @@ -629,18 +645,18 @@ tomli-w==1.0.0 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client # livereload -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -652,48 +668,47 @@ traitlets==5.14.1 # nbformat # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # magicgui # myst-nb - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # pydata-sphinx-theme # sqlalchemy # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: pip==24.0 # via napari-plugin-manager -setuptools==69.0.3 +setuptools==69.2.0 # via imageio-ffmpeg diff --git a/resources/constraints/constraints_py3.10_pydantic_1.txt b/resources/constraints/constraints_py3.10_pydantic_1.txt index cfd6a0ff9fe..e711880039a 100644 --- a/resources/constraints/constraints_py3.10_pydantic_1.txt +++ b/resources/constraints/constraints_py3.10_pydantic_1.txt @@ -2,15 +2,15 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.10_pydantic_1.txt --strip-extras napari_repo/pyproject.toml napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt # alabaster==0.7.16 # via sphinx -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -23,15 +23,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # sphinx -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -41,27 +41,25 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx @@ -79,44 +77,44 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via dask -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole -ipython==8.21.0 +ipython==8.22.2 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -126,14 +124,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -144,22 +142,22 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -168,20 +166,20 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 @@ -190,20 +188,20 @@ networkx==3.2.1 # torch npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.26.3 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # numba # numcodecs @@ -217,7 +215,7 @@ numpy==1.26.3 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -243,13 +241,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -261,12 +259,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -278,93 +275,108 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt pydantic==1.10.14 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.15.2.1 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 ; python_version >= "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -374,23 +386,29 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -399,17 +417,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -418,22 +436,20 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image + # via napari (napari_repo/pyproject.toml) scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image shiboken2==5.15.2.1 # via pyside2 @@ -466,22 +482,22 @@ sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2024.1.30 + # napari (napari_repo/pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.0.1 # via @@ -496,17 +512,17 @@ tomli-w==1.0.0 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -516,41 +532,40 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/resources/constraints/constraints_py3.11.txt b/resources/constraints/constraints_py3.11.txt index 7cc9463395a..18029725fc6 100644 --- a/resources/constraints/constraints_py3.11.txt +++ b/resources/constraints/constraints_py3.11.txt @@ -2,17 +2,17 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.11.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.11.txt --strip-extras napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt # alabaster==0.7.16 # via sphinx annotated-types==0.6.0 # via pydantic -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -25,15 +25,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # sphinx -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -43,27 +43,25 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx @@ -76,44 +74,44 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via dask -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole -ipython==8.21.0 +ipython==8.22.2 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -123,14 +121,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -141,22 +139,22 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -165,20 +163,20 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 @@ -187,20 +185,20 @@ networkx==3.2.1 # torch npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.26.3 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # numba # numcodecs @@ -214,7 +212,7 @@ numpy==1.26.3 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -240,13 +238,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -258,12 +256,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -275,94 +272,109 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt -pydantic==2.6.0 +pydantic==2.6.4 # via # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model -pydantic-core==2.16.1 +pydantic-core==2.16.3 # via pydantic +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.13.2 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 ; python_version >= "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -372,23 +384,29 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -397,17 +415,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -416,22 +434,20 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image + # via napari (napari_repo/pyproject.toml) scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image shiboken2==5.13.2 # via pyside2 @@ -464,39 +480,39 @@ sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2024.1.30 + # napari (napari_repo/pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli-w==1.0.0 # via npe2 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -506,42 +522,41 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # pydantic-core # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/resources/constraints/constraints_py3.11_pydantic_1.txt b/resources/constraints/constraints_py3.11_pydantic_1.txt index 5189afa325c..864e4d4728b 100644 --- a/resources/constraints/constraints_py3.11_pydantic_1.txt +++ b/resources/constraints/constraints_py3.11_pydantic_1.txt @@ -2,15 +2,15 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.11_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.11_pydantic_1.txt --strip-extras napari_repo/pyproject.toml napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt # alabaster==0.7.16 # via sphinx -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -23,15 +23,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # sphinx -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -41,27 +41,25 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx @@ -74,44 +72,44 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via dask -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole -ipython==8.21.0 +ipython==8.22.2 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -121,14 +119,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -139,22 +137,22 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -163,20 +161,20 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 @@ -185,20 +183,20 @@ networkx==3.2.1 # torch npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.26.3 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # numba # numcodecs @@ -212,7 +210,7 @@ numpy==1.26.3 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -238,13 +236,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -256,12 +254,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -273,93 +270,108 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt pydantic==1.10.14 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.13.2 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.4.2 ; python_version >= "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.4.2 # via pyside6 pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -369,23 +381,29 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -394,17 +412,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -413,22 +431,20 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image + # via napari (napari_repo/pyproject.toml) scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image shiboken2==5.13.2 # via pyside2 @@ -461,39 +477,39 @@ sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2024.1.30 + # napari (napari_repo/pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli-w==1.0.0 # via npe2 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -503,41 +519,40 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/resources/constraints/constraints_py3.8.txt b/resources/constraints/constraints_py3.12.txt similarity index 57% rename from resources/constraints/constraints_py3.8.txt rename to resources/constraints/constraints_py3.12.txt index d1051320843..1ec8cd0f93a 100644 --- a/resources/constraints/constraints_py3.8.txt +++ b/resources/constraints/constraints_py3.12.txt @@ -1,18 +1,18 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.8.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=resources/constraints/constraints_py3.12.txt --strip-extras pyproject.toml resources/constraints/version_denylist.txt # -alabaster==0.7.13 +alabaster==0.7.16 # via sphinx annotated-types==0.6.0 # via pydantic -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -25,17 +25,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # sphinx -backcall==0.2.0 - # via ipython -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -45,34 +43,28 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel -contourpy==1.1.1 +contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2023.5.0 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx -exceptiongroup==1.2.0 - # via - # hypothesis - # pytest executing==2.0.1 # via stack-data fasteners==0.19 @@ -82,54 +74,44 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.11 + # via napari (pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 - # via - # build - # dask - # jupyter-client - # numba - # sphinx -importlib-resources==6.1.1 - # via - # jsonschema - # jsonschema-specifications - # matplotlib -in-n-out==0.1.9 +importlib-metadata==7.1.0 + # via dask +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole -ipython==8.12.3 +ipython==8.22.2 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -139,14 +121,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -157,68 +139,70 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # scikit-image -llvmlite==0.41.1 +llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) +magicgui==0.8.2 + # via napari (pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.7.4 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py +ml-dtypes==0.3.2 + # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) nest-asyncio==1.6.0 # via ipykernel -networkx==3.1 +networkx==3.2.1 # via # scikit-image # torch -npe2==0.7.4 +npe2==0.7.5 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-plugin-manager -numba==0.58.1 - # via napari (napari_repo/setup.cfg) +numba==0.59.1 + # via napari (pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.24.4 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # ml-dtypes + # napari (pyproject.toml) # napari-svg # numba # numcodecs # pandas - # pywavelets # scikit-image # scipy # tensorstore @@ -228,7 +212,7 @@ numpy==1.24.4 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -254,13 +238,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -272,12 +256,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.0.3 ; python_version < "3.9" +pandas==2.2.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -285,132 +268,145 @@ partd==1.4.1 # via dask pexpect==4.9.0 # via ipython -pickleshare==0.7.5 - # via ipython pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) + # pyscreeze # scikit-image -pint==0.21.1 - # via napari (napari_repo/setup.cfg) -pkgutil-resolve-name==1.3.10 - # via jsonschema +pint==0.23 + # via napari (pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (pyproject.toml) pyconify==0.1.6 # via superqt -pydantic==2.6.0 +pydantic==2.6.4 # via # app-model - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model -pydantic-core==2.16.1 +pydantic-core==2.16.3 # via pydantic +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # -r resources/constraints/version_denylist.txt + # napari (pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 -pyside2==5.15.2.1 ; python_version == "3.8" - # via napari (napari_repo/setup.cfg) -pyside6==6.3.1 ; python_version < "3.10" - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyside6-addons==6.3.1 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui +pyside2==5.13.2 ; python_version < "3.12" + # via napari (pyproject.toml) +pyside6==6.4.2 ; python_version >= "3.10" + # via + # -r resources/constraints/version_denylist.txt + # napari (pyproject.toml) +pyside6-addons==6.4.2 # via pyside6 -pyside6-essentials==6.3.1 +pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via -r napari_repo/resources/constraints/version_denylist.txt + # via -r resources/constraints/version_denylist.txt pytest-json-report==1.5.0 - # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 + # via -r resources/constraints/version_denylist.txt +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (pyproject.toml) +pytest-qt==4.4.0 + # via napari (pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas -pytz==2024.1 +python3-xlib==0.15 # via - # babel - # pandas -pywavelets==1.4.1 - # via scikit-image + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui +pytz==2024.1 + # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -419,17 +415,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -438,26 +434,24 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing -scikit-image==0.21.0 +scikit-image==0.22.0 + # via napari (pyproject.toml) +scipy==1.12.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # scikit-image -scipy==1.10.1 ; python_version < "3.9" - # via - # napari (napari_repo/setup.cfg) - # scikit-image -shiboken2==5.15.2.1 +shiboken2==5.13.2 # via pyside2 -shiboken6==6.3.1 +shiboken6==6.4.2 # via # pyside6 # pyside6-addons @@ -470,63 +464,55 @@ snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis -sphinx==7.1.2 +sphinx==7.2.6 # via numpydoc -sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-applehelp==1.0.8 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==1.0.6 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.0.5 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==1.0.7 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.45 +tensorstore==0.1.56 # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2023.7.10 + # -r resources/constraints/version_denylist.txt + # napari (pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # scikit-image -tomli==2.0.1 - # via - # build - # coverage - # npe2 - # numpydoc - # pyproject-hooks - # pytest tomli-w==1.0.0 # via npe2 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -536,47 +522,42 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via - # annotated-types # app-model - # ipython # magicgui - # napari (napari_repo/setup.cfg) - # psygnal + # napari (pyproject.toml) + # pint # pydantic # pydantic-core - # rich # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2023.1.0 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 - # via - # importlib-metadata - # importlib-resources + # via napari (pyproject.toml) +xarray==2024.2.0 + # via napari (pyproject.toml) +zarr==2.17.1 + # via napari (pyproject.toml) +zipp==3.18.1 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: pip==24.0 diff --git a/resources/constraints/constraints_py3.8_pydantic_1.txt b/resources/constraints/constraints_py3.12_pydantic_1.txt similarity index 56% rename from resources/constraints/constraints_py3.8_pydantic_1.txt rename to resources/constraints/constraints_py3.12_pydantic_1.txt index dfd40824ca6..88be631396c 100644 --- a/resources/constraints/constraints_py3.8_pydantic_1.txt +++ b/resources/constraints/constraints_py3.12_pydantic_1.txt @@ -1,16 +1,16 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.8_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=resources/constraints/constraints_py3.12_pydantic1.txt --strip-extras pyproject.toml resources/constraints/pydantic_le_2.txt resources/constraints/version_denylist.txt # -alabaster==0.7.13 +alabaster==0.7.16 # via sphinx -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -23,17 +23,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # sphinx -backcall==0.2.0 - # via ipython -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -43,34 +41,28 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel -contourpy==1.1.1 +contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2023.5.0 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx -exceptiongroup==1.2.0 - # via - # hypothesis - # pytest executing==2.0.1 # via stack-data fasteners==0.19 @@ -80,54 +72,44 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.11 + # via napari (pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 - # via - # build - # dask - # jupyter-client - # numba - # sphinx -importlib-resources==6.1.1 - # via - # jsonschema - # jsonschema-specifications - # matplotlib -in-n-out==0.1.9 +importlib-metadata==7.1.0 + # via dask +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole -ipython==8.12.3 +ipython==8.22.2 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -137,14 +119,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -155,68 +137,70 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # scikit-image -llvmlite==0.41.1 +llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) +magicgui==0.8.2 + # via napari (pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.7.4 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py +ml-dtypes==0.3.2 + # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) nest-asyncio==1.6.0 # via ipykernel -networkx==3.1 +networkx==3.2.1 # via # scikit-image # torch -npe2==0.7.4 +npe2==0.7.5 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-plugin-manager -numba==0.58.1 - # via napari (napari_repo/setup.cfg) +numba==0.59.1 + # via napari (pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.24.4 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # ml-dtypes + # napari (pyproject.toml) # napari-svg # numba # numcodecs # pandas - # pywavelets # scikit-image # scipy # tensorstore @@ -226,7 +210,7 @@ numpy==1.24.4 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -252,13 +236,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -270,12 +254,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.0.3 ; python_version < "3.9" +pandas==2.2.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -283,131 +266,144 @@ partd==1.4.1 # via dask pexpect==4.9.0 # via ipython -pickleshare==0.7.5 - # via ipython pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) + # pyscreeze # scikit-image -pint==0.21.1 - # via napari (napari_repo/setup.cfg) -pkgutil-resolve-name==1.3.10 - # via jsonschema +pint==0.23 + # via napari (pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (pyproject.toml) pyconify==0.1.6 # via superqt pydantic==1.10.14 # via - # -r napari_repo/resources/constraints/pydantic_le_2.txt + # -r resources/constraints/pydantic_le_2.txt # app-model - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # -r resources/constraints/version_denylist.txt + # napari (pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 -pyside2==5.15.2.1 ; python_version == "3.8" - # via napari (napari_repo/setup.cfg) -pyside6==6.3.1 ; python_version < "3.10" - # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyside6-addons==6.3.1 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui +pyside2==5.13.2 ; python_version < "3.12" + # via napari (pyproject.toml) +pyside6==6.4.2 ; python_version >= "3.10" + # via + # -r resources/constraints/version_denylist.txt + # napari (pyproject.toml) +pyside6-addons==6.4.2 # via pyside6 -pyside6-essentials==6.3.1 +pyside6-essentials==6.4.2 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata # pytest-pretty # pytest-qt pytest-cov==4.1.0 - # via -r napari_repo/resources/constraints/version_denylist.txt + # via -r resources/constraints/version_denylist.txt pytest-json-report==1.5.0 - # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 + # via -r resources/constraints/version_denylist.txt +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (pyproject.toml) +pytest-qt==4.4.0 + # via napari (pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas -pytz==2024.1 +python3-xlib==0.15 # via - # babel - # pandas -pywavelets==1.4.1 - # via scikit-image + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui +pytz==2024.1 + # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -416,17 +412,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -435,26 +431,24 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing -scikit-image==0.21.0 +scikit-image==0.22.0 + # via napari (pyproject.toml) +scipy==1.12.0 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # scikit-image -scipy==1.10.1 ; python_version < "3.9" - # via - # napari (napari_repo/setup.cfg) - # scikit-image -shiboken2==5.15.2.1 +shiboken2==5.13.2 # via pyside2 -shiboken6==6.3.1 +shiboken6==6.4.2 # via # pyside6 # pyside6-addons @@ -467,63 +461,55 @@ snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via hypothesis -sphinx==7.1.2 +sphinx==7.2.6 # via numpydoc -sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-applehelp==1.0.8 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==1.0.6 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.0.5 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==1.0.7 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.45 +tensorstore==0.1.56 # via - # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2023.7.10 + # -r resources/constraints/version_denylist.txt + # napari (pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # scikit-image -tomli==2.0.1 - # via - # build - # coverage - # npe2 - # numpydoc - # pyproject-hooks - # pytest tomli-w==1.0.0 # via npe2 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -533,45 +519,41 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model - # ipython # magicgui - # napari (napari_repo/setup.cfg) - # psygnal + # napari (pyproject.toml) + # pint # pydantic - # rich # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2023.1.0 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 - # via - # importlib-metadata - # importlib-resources + # via napari (pyproject.toml) +xarray==2024.2.0 + # via napari (pyproject.toml) +zarr==2.17.1 + # via napari (pyproject.toml) +zipp==3.18.1 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: pip==24.0 diff --git a/resources/constraints/constraints_py3.8_min_req.txt b/resources/constraints/constraints_py3.8_min_req.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/resources/constraints/constraints_py3.9.txt b/resources/constraints/constraints_py3.9.txt index 83b1f26a400..fc62213de6e 100644 --- a/resources/constraints/constraints_py3.9.txt +++ b/resources/constraints/constraints_py3.9.txt @@ -2,17 +2,17 @@ # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9.txt --strip-extras napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt # alabaster==0.7.16 # via sphinx annotated-types==0.6.0 # via pydantic -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -25,15 +25,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # sphinx -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -43,27 +43,25 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx @@ -81,50 +79,50 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via # build # dask # jupyter-client # sphinx -importlib-resources==6.1.1 +importlib-resources==6.3.1 # via matplotlib -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole ipython==8.18.1 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -134,14 +132,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -152,22 +150,22 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -176,20 +174,20 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 @@ -198,20 +196,20 @@ networkx==3.2.1 # torch npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.26.3 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # numba # numcodecs @@ -225,7 +223,7 @@ numpy==1.26.3 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -251,13 +249,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -269,12 +267,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -286,94 +283,109 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt -pydantic==2.6.0 +pydantic==2.6.4 # via # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model -pydantic-core==2.16.1 +pydantic-core==2.16.3 # via pydantic +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.15.2.1 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.3.1 ; python_version < "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.3.1 # via pyside6 pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -383,23 +395,29 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -408,17 +426,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -427,22 +445,20 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image + # via napari (napari_repo/pyproject.toml) scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image shiboken2==5.15.2.1 # via pyside2 @@ -475,22 +491,22 @@ sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2024.1.30 + # napari (napari_repo/pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.0.1 # via @@ -505,17 +521,17 @@ tomli-w==1.0.0 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -525,43 +541,42 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # ipython # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # pydantic-core # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via # importlib-metadata # importlib-resources diff --git a/resources/constraints/constraints_py3.9_examples.txt b/resources/constraints/constraints_py3.9_examples.txt index 641f4da6416..b08193228d6 100644 --- a/resources/constraints/constraints_py3.9_examples.txt +++ b/resources/constraints/constraints_py3.9_examples.txt @@ -2,17 +2,17 @@ # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9_examples.txt --strip-extras napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg resources/constraints/version_denylist_examples.txt +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9_examples.txt --strip-extras napari_repo/pyproject.toml napari_repo/resources/constraints/version_denylist.txt resources/constraints/version_denylist_examples.txt # alabaster==0.7.16 # via sphinx annotated-types==0.6.0 # via pydantic -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -25,15 +25,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # sphinx -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -43,27 +43,25 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx @@ -81,50 +79,50 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via # build # dask # jupyter-client # sphinx -importlib-resources==6.1.1 +importlib-resources==6.3.1 # via matplotlib -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole ipython==8.18.1 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -138,14 +136,14 @@ joblib==1.3.2 # nilearn # scikit-learn jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -156,7 +154,7 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.42.0 # via numba @@ -164,16 +162,16 @@ locket==1.0.0 # via partd lxml==5.1.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # nilearn -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -182,36 +180,36 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 # via # scikit-image # torch -nibabel==5.2.0 +nibabel==5.2.1 # via nilearn nilearn==0.10.3 # via -r resources/constraints/version_denylist_examples.txt npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr numpy==1.23.5 @@ -222,7 +220,7 @@ numpy==1.23.5 # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # nibabel # nilearn @@ -239,7 +237,7 @@ numpy==1.23.5 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -265,13 +263,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # -r resources/constraints/version_denylist_examples.txt # build @@ -286,12 +284,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # nilearn # xarray parso==0.8.3 @@ -304,94 +301,109 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt -pydantic==2.6.0 +pydantic==2.6.4 # via # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model -pydantic-core==2.16.1 +pydantic-core==2.16.3 # via pydantic +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.15.2.1 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.3.1 ; python_version < "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.3.1 # via pyside6 pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -401,23 +413,29 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -426,17 +444,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -446,24 +464,22 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image -scikit-learn==1.4.0 + # via napari (napari_repo/pyproject.toml) +scikit-learn==1.4.1.post1 # via nilearn scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # nilearn # scikit-image # scikit-learn @@ -498,24 +514,24 @@ sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -threadpoolctl==3.2.0 + # napari (napari_repo/pyproject.toml) +threadpoolctl==3.3.0 # via scikit-learn -tifffile==2024.1.30 +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.0.1 # via @@ -530,17 +546,17 @@ tomli-w==1.0.0 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -550,43 +566,42 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # ipython # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # pydantic-core # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via # importlib-metadata # importlib-resources diff --git a/napari/_vendor/experimental/humanize/src/__init__.py b/resources/constraints/constraints_py3.9_min_req.txt similarity index 100% rename from napari/_vendor/experimental/humanize/src/__init__.py rename to resources/constraints/constraints_py3.9_min_req.txt diff --git a/resources/constraints/constraints_py3.9_pydantic_1.txt b/resources/constraints/constraints_py3.9_pydantic_1.txt index 540c170a241..bacd9dc66f1 100644 --- a/resources/constraints/constraints_py3.9_pydantic_1.txt +++ b/resources/constraints/constraints_py3.9_pydantic_1.txt @@ -2,15 +2,15 @@ # This file is autogenerated by pip-compile with Python 3.9 # by the following command: # -# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9_pydantic_1.txt --strip-extras napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt napari_repo/setup.cfg +# pip-compile --allow-unsafe --extra=optional --extra=pyqt5 --extra=pyqt6_experimental --extra=pyside2 --extra=pyside6_experimental --extra=testing --extra=testing_extra --output-file=napari_repo/resources/constraints/constraints_py3.9_pydantic_1.txt --strip-extras napari_repo/pyproject.toml napari_repo/resources/constraints/pydantic_le_2.txt napari_repo/resources/constraints/version_denylist.txt # alabaster==0.7.16 # via sphinx -app-model==0.2.4 - # via napari (napari_repo/setup.cfg) +app-model==0.2.5 + # via napari (napari_repo/pyproject.toml) appdirs==1.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 asciitree==0.3.3 # via zarr @@ -23,15 +23,15 @@ attrs==23.2.0 # referencing babel==2.14.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # sphinx -build==1.0.3 +build==1.1.1 # via npe2 cachey==0.2.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) certifi==2024.2.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # requests charset-normalizer==3.3.2 # via requests @@ -41,27 +41,25 @@ click==8.1.7 # typer cloudpickle==3.0.0 # via dask -comm==0.2.1 +comm==0.2.2 # via ipykernel contourpy==1.2.0 # via matplotlib -coverage==7.4.1 +coverage==7.4.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov cycler==0.12.1 # via matplotlib -dask==2024.1.1 - # via - # dask - # napari (napari_repo/setup.cfg) -debugpy==1.8.0 +dask==2024.3.1 + # via napari (napari_repo/pyproject.toml) +debugpy==1.8.1 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv -docstring-parser==0.15 +docstring-parser==0.16 # via magicgui docutils==0.20.1 # via sphinx @@ -79,50 +77,50 @@ filelock==3.13.1 # torch # triton # virtualenv -fonttools==4.47.2 +fonttools==4.50.0 # via matplotlib freetype-py==2.4.0 # via vispy -fsspec==2024.2.0 +fsspec==2024.3.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # torch heapdict==1.0.1 # via cachey hsluv==5.0.4 # via vispy -hypothesis==6.98.1 - # via napari (napari_repo/setup.cfg) +hypothesis==6.99.8 + # via napari (napari_repo/pyproject.toml) idna==3.6 # via requests -imageio==2.33.1 +imageio==2.34.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.0.2 # via # build # dask # jupyter-client # sphinx -importlib-resources==6.1.1 +importlib-resources==6.3.1 # via matplotlib -in-n-out==0.1.9 +in-n-out==0.2.0 # via app-model iniconfig==2.0.0 # via pytest -ipykernel==6.29.0 +ipykernel==6.29.3 # via # napari-console # qtconsole ipython==8.18.1 # via # ipykernel - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console jedi==0.19.1 # via ipython @@ -132,14 +130,14 @@ jinja2==3.1.3 # sphinx # torch jsonschema==4.21.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.0 +jupyter-client==8.6.1 # via # ipykernel # qtconsole -jupyter-core==5.7.1 +jupyter-core==5.7.2 # via # ipykernel # jupyter-client @@ -150,22 +148,22 @@ kiwisolver==1.4.5 # vispy lazy-loader==0.3 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image llvmlite==0.42.0 # via numba locket==1.0.0 # via partd lxml==5.1.0 - # via napari (napari_repo/setup.cfg) -magicgui==0.8.1 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) +magicgui==0.8.2 + # via napari (napari_repo/pyproject.toml) markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via jinja2 -matplotlib==3.8.2 - # via napari (napari_repo/setup.cfg) +matplotlib==3.8.3 + # via napari (napari_repo/pyproject.toml) matplotlib-inline==0.1.6 # via # ipykernel @@ -174,20 +172,20 @@ mdurl==0.1.2 # via markdown-it-py ml-dtypes==0.3.2 # via tensorstore +mouseinfo==0.1.3 + # via pyautogui mpmath==1.3.0 # via sympy -mypy-extensions==1.0.0 - # via psygnal napari-console==0.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-plugin-engine==0.2.0 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg napari-plugin-manager==0.1.0a2 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) napari-svg==0.1.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nest-asyncio==1.6.0 # via ipykernel networkx==3.2.1 @@ -196,20 +194,20 @@ networkx==3.2.1 # torch npe2==0.7.4 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager numba==0.59.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) numcodecs==0.12.1 # via zarr -numpy==1.26.3 +numpy==1.26.4 # via # contourpy # dask # imageio # matplotlib # ml-dtypes - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg # numba # numcodecs @@ -223,7 +221,7 @@ numpy==1.26.3 # xarray # zarr numpydoc==1.6.0 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) nvidia-cublas-cu12==12.1.3.1 # via # nvidia-cudnn-cu12 @@ -249,13 +247,13 @@ nvidia-cusparse-cu12==12.1.0.106 # torch nvidia-nccl-cu12==2.19.3 # via torch -nvidia-nvjitlink-cu12==12.3.101 +nvidia-nvjitlink-cu12==12.4.99 # via # nvidia-cusolver-cu12 # nvidia-cusparse-cu12 nvidia-nvtx-cu12==12.1.105 # via torch -packaging==23.2 +packaging==24.0 # via # build # dask @@ -267,12 +265,11 @@ packaging==23.2 # qtpy # scikit-image # sphinx - # superqt # vispy # xarray -pandas==2.2.0 ; python_version >= "3.9" +pandas==2.2.1 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # xarray parso==0.8.3 # via jedi @@ -284,93 +281,108 @@ pillow==10.2.0 # via # imageio # matplotlib - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) + # pyscreeze # scikit-image pint==0.23 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) platformdirs==4.2.0 # via # jupyter-core # pooch # virtualenv pluggy==1.4.0 - # via pytest -pooch==1.8.0 # via - # napari (napari_repo/setup.cfg) + # pytest + # pytest-qt +pooch==1.8.1 + # via + # napari (napari_repo/pyproject.toml) # scikit-image pretend==1.0.9 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) prompt-toolkit==3.0.43 # via ipython psutil==5.9.8 # via # ipykernel - # napari (napari_repo/setup.cfg) -psygnal==0.9.5 + # napari (napari_repo/pyproject.toml) +psygnal==0.10.2 # via # app-model # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data +pyautogui==0.9.54 + # via napari (napari_repo/pyproject.toml) pyconify==0.1.6 # via superqt pydantic==1.10.14 # via # -r napari_repo/resources/constraints/pydantic_le_2.txt # app-model - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pydantic-compat pydantic-compat==0.1.2 # via app-model +pygetwindow==0.0.9 + # via pyautogui pygments==2.17.2 # via # ipython - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # qtconsole # rich # sphinx # superqt +pymsgbox==1.0.9 + # via pyautogui pyopengl==3.1.6 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -pyparsing==3.1.1 + # napari (napari_repo/pyproject.toml) +pyparsing==3.1.2 # via matplotlib +pyperclip==1.8.2 + # via mouseinfo pyproject-hooks==1.0.0 # via build pyqt5==5.15.10 - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyqt5-qt5==5.15.2 # via pyqt5 pyqt5-sip==12.13.0 # via pyqt5 pyqt6==6.6.1 - # via napari (napari_repo/setup.cfg) -pyqt6-qt6==6.6.1 + # via napari (napari_repo/pyproject.toml) +pyqt6-qt6==6.6.2 # via pyqt6 pyqt6-sip==13.6.0 # via pyqt6 +pyrect==0.2.0 + # via pygetwindow +pyscreeze==0.1.30 + # via pyautogui pyside2==5.15.2.1 ; python_version != "3.8" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) pyside6==6.3.1 ; python_version < "3.10" # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) pyside6-addons==6.3.1 # via pyside6 pyside6-essentials==6.3.1 # via # pyside6 # pyside6-addons -pytest==8.0.0 +pytest==8.1.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pytest-cov # pytest-json-report # pytest-metadata @@ -380,23 +392,29 @@ pytest-cov==4.1.0 # via -r napari_repo/resources/constraints/version_denylist.txt pytest-json-report==1.5.0 # via -r napari_repo/resources/constraints/version_denylist.txt -pytest-metadata==3.1.0 +pytest-metadata==3.1.1 # via pytest-json-report pytest-pretty==1.2.0 - # via napari (napari_repo/setup.cfg) -pytest-qt==4.3.1 - # via napari (napari_repo/setup.cfg) -python-dateutil==2.8.2 + # via napari (napari_repo/pyproject.toml) +pytest-qt==4.4.0 + # via napari (napari_repo/pyproject.toml) +python-dateutil==2.9.0.post0 # via # jupyter-client # matplotlib # pandas +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui pytz==2024.1 # via pandas pyyaml==6.0.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 pyzmq==25.1.2 # via @@ -405,17 +423,17 @@ pyzmq==25.1.2 # qtconsole qtconsole==5.5.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console qtpy==2.4.1 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-console # napari-plugin-manager # qtconsole # superqt -referencing==0.33.0 +referencing==0.34.0 # via # jsonschema # jsonschema-specifications @@ -424,22 +442,20 @@ requests==2.31.0 # pooch # pyconify # sphinx -rich==13.7.0 +rich==13.7.1 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # npe2 # pytest-pretty -rpds-py==0.17.1 +rpds-py==0.18.0 # via # jsonschema # referencing scikit-image==0.22.0 - # via - # napari (napari_repo/setup.cfg) - # scikit-image + # via napari (napari_repo/pyproject.toml) scipy==1.12.0 ; python_version >= "3.9" # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image shiboken2==5.15.2.1 # via pyside2 @@ -472,22 +488,22 @@ sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython -superqt==0.6.1 +superqt==0.6.2 # via # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-plugin-manager sympy==1.12 # via torch tabulate==0.9.0 # via numpydoc -tensorstore==0.1.53 +tensorstore==0.1.56 # via # -r napari_repo/resources/constraints/version_denylist.txt - # napari (napari_repo/setup.cfg) -tifffile==2024.1.30 + # napari (napari_repo/pyproject.toml) +tifffile==2024.2.12 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # scikit-image tomli==2.0.1 # via @@ -502,17 +518,17 @@ tomli-w==1.0.0 toolz==0.12.1 # via # dask - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # partd -torch==2.2.0 - # via napari (napari_repo/setup.cfg) +torch==2.2.1 + # via napari (napari_repo/pyproject.toml) tornado==6.4 # via # ipykernel # jupyter-client -tqdm==4.66.1 - # via napari (napari_repo/setup.cfg) -traitlets==5.14.1 +tqdm==4.66.2 + # via napari (napari_repo/pyproject.toml) +traitlets==5.14.2 # via # comm # ipykernel @@ -522,42 +538,41 @@ traitlets==5.14.1 # matplotlib-inline # qtconsole triangle==20230923 ; platform_machine != "arm64" - # via napari (napari_repo/setup.cfg) + # via napari (napari_repo/pyproject.toml) triton==2.2.0 # via torch typer==0.9.0 # via npe2 -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # app-model # ipython # magicgui - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # pint - # psygnal # pydantic # superqt # torch # typer -tzdata==2023.4 +tzdata==2024.1 # via pandas -urllib3==2.2.0 +urllib3==2.2.1 # via requests -virtualenv==20.25.0 - # via napari (napari_repo/setup.cfg) -vispy==0.14.1 +virtualenv==20.25.1 + # via napari (napari_repo/pyproject.toml) +vispy==0.14.2 # via - # napari (napari_repo/setup.cfg) + # napari (napari_repo/pyproject.toml) # napari-svg wcwidth==0.2.13 # via prompt-toolkit wrapt==1.16.0 - # via napari (napari_repo/setup.cfg) -xarray==2024.1.1 - # via napari (napari_repo/setup.cfg) -zarr==2.16.1 - # via napari (napari_repo/setup.cfg) -zipp==3.17.0 + # via napari (napari_repo/pyproject.toml) +xarray==2024.2.0 + # via napari (napari_repo/pyproject.toml) +zarr==2.17.1 + # via napari (napari_repo/pyproject.toml) +zipp==3.18.1 # via # importlib-metadata # importlib-resources diff --git a/resources/constraints/version_denylist.txt b/resources/constraints/version_denylist.txt index fbb323324ef..7f930fed497 100644 --- a/resources/constraints/version_denylist.txt +++ b/resources/constraints/version_denylist.txt @@ -1,6 +1,6 @@ pytest-cov PySide6 < 6.3.2 ; python_version < '3.10' -PySide6 != 6.4.3, !=6.5.0, !=6.5.1, !=6.5.1.1, !=6.5.2, != 6.5.3, != 6.6.0, != 6.6.1 ; python_version >= '3.10' +PySide6 != 6.4.3, !=6.5.0, !=6.5.1, !=6.5.1.1, !=6.5.2, != 6.5.3, != 6.6.0, != 6.6.1, != 6.6.2 ; python_version >= '3.10' and python_version < '3.12' pytest-json-report pyopengl!=3.1.7 tensorstore!=0.1.38 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 19529eca80c..00000000000 --- a/setup.cfg +++ /dev/null @@ -1,235 +0,0 @@ -[metadata] -name = napari -url = https://napari.org -download_url = https://github.com/napari/napari -license = BSD 3-Clause -license_files = LICENSE -description = n-dimensional array viewer in Python -long_description = file: README.md -long_description_content_type = text/markdown -author = napari team -author_email = napari-steering-council@googlegroups.com -project_urls = - Bug Tracker = https://github.com/napari/napari/issues - Documentation = https://napari.org - Source Code = https://github.com/napari/napari -classifiers = - Development Status :: 3 - Alpha - Environment :: X11 Applications :: Qt - Intended Audience :: Education - Intended Audience :: Science/Research - License :: OSI Approved :: BSD License - Programming Language :: C - Programming Language :: Python - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Scientific/Engineering - Topic :: Scientific/Engineering :: Visualization - Topic :: Scientific/Engineering :: Information Analysis - Topic :: Scientific/Engineering :: Bio-Informatics - Topic :: Utilities - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Operating System :: Unix - Operating System :: MacOS - - -[options] -zip_safe = False -packages = find: -python_requires = >=3.8 -include_package_data = True -install_requires = - appdirs>=1.4.4 - app-model>=0.2.2,<0.3.0 # as per @czaki request. app-model v0.3.0 can drop napari v0.4.17 - cachey>=0.2.1 - certifi>=2018.1.18 - dask[array]>=2.15.0,!=2.28.0 # https://github.com/napari/napari/issues/1656 - imageio>=2.20,!=2.22.1 - jsonschema>=3.2.0 - lazy_loader>=0.2 - magicgui>=0.7.0 - napari-console>=0.0.9 - napari-plugin-engine>=0.1.9 - napari-svg>=0.1.8 - npe2>=0.7.2 - numpy>=1.22.2,<2 - numpydoc>=0.9.2 - pandas>=1.1.0 ; python_version < '3.9' - pandas>=1.3.0 ; python_version >= '3.9' - Pillow!=7.1.0,!=7.1.1 # not a direct dependency, but 7.1.0 and 7.1.1 broke imageio - pint>=0.17 - psutil>=5.0 - psygnal>=0.5.0 - pydantic>=1.9.0 - pygments>=2.6.0 - PyOpenGL>=3.1.0 - PyYAML>=5.1 - qtpy>=1.10.0 - scikit-image[data]>=0.19.1 # just `pooch`, but needed by `builtins` to provide all scikit-image.data samples - scipy>=1.4.1 ; python_version < '3.9' - scipy>=1.5.4 ; python_version >= '3.9' - superqt>=0.5.0 - tifffile>=2020.2.16 - toolz>=0.10.0 - tqdm>=4.56.0 - typing_extensions>=4.2.0 - vispy>=0.14.1,<0.15 - wrapt>=1.11.1 - napari-graph>=0.2.0 - - -[options.package_data] -* = *.pyi -napari_builtins = - builtins.yaml - - -# for explanation of %(extra)s syntax see: -# https://github.com/pypa/setuptools/issues/1260#issuecomment-438187625 -# this syntax may change in the future - -[options.extras_require] -pyside2 = - PySide2>=5.13.2,!=5.15.0 ; python_version != '3.8' - PySide2>=5.14.2,!=5.15.0 ; python_version == '3.8' -pyside6_experimental = - PySide6 -pyqt6_experimental = - PyQt6 -pyside = # alias for pyside2 - %(pyside2)s -pyqt5 = - PyQt5>=5.12.3,!=5.15.0 -pyqt = # alias for pyqt5 - %(pyqt5)s -qt = # alias for pyqt5 - %(pyqt5)s -# all is the full "batteries included" extra. -all = - %(pyqt5)s - %(optional)s - napari-plugin-manager >=0.1.0a1, <0.2.0 -# optional (i.e. opt-in) packages, see https://github.com/napari/napari/pull/3867#discussion_r864354854 -optional = - triangle ; platform_machine != 'arm64' # no wheels - numba>=0.57.1 -testing = - babel>=2.9.0 - fsspec - hypothesis>=6.8.0 - lxml - matplotlib >= 3.6.1 - networkx>=2.7.0 - numba>=0.57.1 - pooch>=1.6.0 - coverage - pretend - pytest-qt - pytest-pretty>=1.1.0 - pytest>=7.0.0 - tensorstore>=0.1.13 - virtualenv - xarray>=0.16.2 - zarr>=2.12.0 - IPython>=7.25.0 - qtconsole>=4.5.1 - rich>=12.0.0 - napari-plugin-manager >=0.1.0a2, <0.2.0 -testing_extra = - torch>=1.7 -release = - PyGithub>=1.44.1 - twine>=3.1.1 - gitpython>=3.1.0 - requests-cache>=0.9.2 -dev = - black - check-manifest>=0.42 - pre-commit>=2.9.0 - pydantic[dotenv] - rich - %(testing)s -build = - black - ruff - pyqt5 - - -[options.entry_points] -console_scripts = - napari = napari.__main__:main -pytest11 = - napari = napari.utils._testsupport -napari.manifest = - napari_builtins = napari_builtins:builtins.yaml - - -[coverage:report] -exclude_lines = - pragma: no cover - if TYPE_CHECKING: - raise NotImplementedError() - except ImportError: - ^ +\.\.\.$ - - -[coverage:run] -parallel = true -omit = - */_vendor/* - */_version.py - */benchmarks/* - napari/utils/indexing.py -source = - napari - napari_builtins - -[coverage:paths] -source = - napari/ - D:\a\napari\napari\napari - /home/runner/work/napari/napari/napari - /Users/runner/work/napari/napari/napari -builtins = - napari_builtins/ - D:\a\napari\napari\napari_builtins - /home/runner/work/napari/napari/napari_builtins - /Users/runner/work/napari/napari/napari_builtins - - - -[importlinter] -root_package = napari -include_external_packages=True - - -[importlinter:contract:1] -name = "Forbid import PyQt and PySide" -type = forbidden -source_modules = - napari -forbidden_modules = - PyQt5 - PySide2 -ignore_imports = - napari._qt -> PySide2 - - -[importlinter:contract:2] -name = "Block import from qt module in abstract ones" -type = layers -layers= - napari.qt - napari.layers - - -[importlinter:contract:3] -name = "Block import from qt module in abstract ones" -type = layers -layers= - napari.qt - napari.components diff --git a/tools/check_updated_packages.py b/tools/check_updated_packages.py index ece57ab0e00..fa2cc53ab71 100644 --- a/tools/check_updated_packages.py +++ b/tools/check_updated_packages.py @@ -6,31 +6,32 @@ import re import subprocess # nosec import sys -from configparser import ConfigParser from pathlib import Path from typing import Optional +from tomllib import loads + REPO_DIR = Path(__file__).parent.parent -DEFAULT_NAME = "auto-dependency-upgrades" +DEFAULT_NAME = 'auto-dependency-upgrades' def get_base_branch_name(ref_name, event): if ref_name == DEFAULT_NAME: - return "main" + return 'main' if ref_name.startswith(DEFAULT_NAME): - if event in {"pull_request", "pull_request_target"}: - return os.environ.get("GITHUB_BASE_REF") + if event in {'pull_request', 'pull_request_target'}: + return os.environ.get('GITHUB_BASE_REF') return ref_name[len(DEFAULT_NAME) + 1 :] return ref_name def main(): parser = argparse.ArgumentParser() - parser.add_argument("--main-packages", action="store_true") + parser.add_argument('--main-packages', action='store_true') args = parser.parse_args() ref_name = get_ref_name() - event = os.environ.get("GITHUB_EVENT_NAME", "") + event = os.environ.get('GITHUB_EVENT_NAME', '') base_branch = get_base_branch_name(ref_name, event) @@ -41,11 +42,11 @@ def main(): sys.exit(1) if args.main_packages: - print("\n".join(f" * {x}" for x in sorted(res))) + print('\n'.join(f' * {x}' for x in sorted(res))) elif res: - print(", ".join(f"`{x}`" for x in res)) + print(', '.join(f'`{x}`' for x in res)) else: - print("only indirect updates") + print('only indirect updates') def get_branches() -> list[str]: @@ -53,11 +54,11 @@ def get_branches() -> list[str]: Get all branches from the repository. """ out = subprocess.run( # nosec - ["git", "branch", "--list", "--format", "%(refname:short)", "-a"], + ['git', 'branch', '--list', '--format', '%(refname:short)', '-a'], capture_output=True, check=True, ) - return out.stdout.decode().split("\n") + return out.stdout.decode().split('\n') def calc_changed_packages( @@ -80,20 +81,20 @@ def calc_changed_packages( list[str] list of changed packages """ - changed_name_re = re.compile(r"\+([\w-]+)") + changed_name_re = re.compile(r'\+([\w-]+)') command = [ - "git", - "diff", + 'git', + 'diff', base_branch, str( src_dir - / "resources" - / "constraints" - / f"constraints_py{python_version}.txt" + / 'resources' + / 'constraints' + / f'constraints_py{python_version}.txt' ), ] - logging.info("Git diff call: %s", " ".join(command)) + logging.info('Git diff call: %s', ' '.join(command)) try: out = subprocess.run( # nosec command, @@ -102,14 +103,14 @@ def calc_changed_packages( ) except subprocess.CalledProcessError as e: raise ValueError( - f"git diff failed with return code {e.returncode}" - " stderr: {e.stderr.decode()!r}" - " stdout: {e.stdout.decode()!r}" + f'git diff failed with return code {e.returncode}' + ' stderr: {e.stderr.decode()!r}' + ' stdout: {e.stdout.decode()!r}' ) from e return [ changed_name_re.match(x)[1].lower() - for x in out.stdout.decode().split("\n") + for x in out.stdout.decode().split('\n') if changed_name_re.match(x) ] @@ -118,11 +119,11 @@ def get_ref_name() -> str: """ Get the name of the current branch. """ - ref_name = os.environ.get("GITHUB_REF_NAME") + ref_name = os.environ.get('GITHUB_REF_NAME') if ref_name: return ref_name out = subprocess.run( # nosec - ["git", "rev-parse", "--abbrev-ref", "HEAD"], + ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], capture_output=True, check=True, ) @@ -132,18 +133,19 @@ def get_ref_name() -> str: def calc_only_direct_updates( changed_packages: list[str], src_dir: Path ) -> list[str]: - name_re = re.compile(r"[\w-]+") + name_re = re.compile(r'[\w-]+') + + metadata = loads((src_dir / 'pyproject.toml').read_text())['project'] + optional_dependencies = metadata['optional-dependencies'] - config = ConfigParser() - config.read(src_dir / "setup.cfg") packages = ( - config["options"]["install_requires"].split("\n") - + config["options.extras_require"]["pyqt5"].split("\n") - + config["options.extras_require"]["pyqt6_experimental"].split("\n") - + config["options.extras_require"]["pyside2"].split("\n") - + config["options.extras_require"]["pyside6_experimental"].split("\n") - + config["options.extras_require"]["testing"].split("\n") - + config["options.extras_require"]["all"].split("\n") + metadata['dependencies'] + + optional_dependencies['pyqt5'] + + optional_dependencies['pyqt6_experimental'] + + optional_dependencies['pyside2'] + + optional_dependencies['pyside6_experimental'] + + optional_dependencies['testing'] + + optional_dependencies['all'] ) packages = [ name_re.match(package).group().lower() @@ -156,7 +158,7 @@ def calc_only_direct_updates( def get_changed_dependencies( base_branch: str, all_packages=False, - python_version="3.10", + python_version='3.10', src_dir: Optional[Path] = None, ): """ @@ -171,11 +173,11 @@ def get_changed_dependencies( branches = get_branches() if base_branch not in branches: - if f"origin/{base_branch}" not in branches: + if f'origin/{base_branch}' not in branches: raise ValueError( - f"base branch {base_branch} not found in {branches!r}" + f'base branch {base_branch} not found in {branches!r}' ) - base_branch = f"origin/{base_branch}" + base_branch = f'origin/{base_branch}' changed_packages = calc_changed_packages( base_branch, src_dir, python_version=python_version @@ -187,5 +189,5 @@ def get_changed_dependencies( return calc_only_direct_updates(changed_packages, src_dir) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/tools/create_pr_or_update_existing_one.py b/tools/create_pr_or_update_existing_one.py index 8b98f41cad8..9d8d12903ba 100644 --- a/tools/create_pr_or_update_existing_one.py +++ b/tools/create_pr_or_update_existing_one.py @@ -12,10 +12,10 @@ import requests from check_updated_packages import get_changed_dependencies -REPO_DIR = Path(__file__).parent.parent / "napari_repo" +REPO_DIR = Path(__file__).parent.parent / 'napari_repo' # GitHub API base URL -BASE_URL = "https://api.github.com" -DEFAULT_BRANCH_NAME = "auto-update-dependencies" +BASE_URL = 'https://api.github.com' +DEFAULT_BRANCH_NAME = 'auto-update-dependencies' @contextmanager @@ -33,29 +33,29 @@ def cd(path: Path): def _setup_git_author(): subprocess.run( - ["git", "config", "--global", "user.name", "napari-bot"], check=True + ['git', 'config', '--global', 'user.name', 'napari-bot'], check=True ) # nosec subprocess.run( [ - "git", - "config", - "--global", - "user.email", - "napari-bot@users.noreply.github.com", + 'git', + 'config', + '--global', + 'user.email', + 'napari-bot@users.noreply.github.com', ], check=True, ) # nosec -def create_commit(message: str, branch_name: str = ""): +def create_commit(message: str, branch_name: str = ''): """ Create a commit calling git. """ with cd(REPO_DIR): if branch_name: - subprocess.run(["git", "checkout", "-B", branch_name], check=True) - subprocess.run(["git", "add", "-u"], check=True) # nosec - subprocess.run(["git", "commit", "-m", message], check=True) # nosec + subprocess.run(['git', 'checkout', '-B', branch_name], check=True) + subprocess.run(['git', 'add', '-u'], check=True) # nosec + subprocess.run(['git', 'commit', '-m', message], check=True) # nosec def push(branch_name: str, update: bool = False): @@ -63,30 +63,30 @@ def push(branch_name: str, update: bool = False): Push the current branch to the remote. """ with cd(REPO_DIR): - logging.info("go to dir %s", REPO_DIR) + logging.info('go to dir %s', REPO_DIR) if update: - logging.info("Pushing to %s", branch_name) + logging.info('Pushing to %s', branch_name) subprocess.run( [ - "git", - "push", - "--force", - "--set-upstream", - "napari-bot", + 'git', + 'push', + '--force', + '--set-upstream', + 'napari-bot', branch_name, ], check=True, capture_output=True, ) else: - logging.info("Force pushing to %s", branch_name) + logging.info('Force pushing to %s', branch_name) subprocess.run( [ - "git", - "push", - "--force", - "--set-upstream", - "origin", + 'git', + 'push', + '--force', + '--set-upstream', + 'origin', branch_name, ], check=True, @@ -99,12 +99,12 @@ def commit_message(branch_name) -> str: changed_direct = get_changed_dependencies( all_packages=False, base_branch=branch_name, - python_version="3.11", + python_version='3.11', src_dir=REPO_DIR, ) if not changed_direct: - return "Update indirect dependencies" - return "Update " + ", ".join(f"`{x}`" for x in changed_direct) + return 'Update indirect dependencies' + return 'Update ' + ', '.join(f'`{x}`' for x in changed_direct) def long_description(branch_name: str) -> str: @@ -112,29 +112,29 @@ def long_description(branch_name: str) -> str: all_changed = get_changed_dependencies( all_packages=True, base_branch=branch_name, - python_version="3.11", + python_version='3.11', src_dir=REPO_DIR, ) - return "Updated packages: " + ", ".join(f"`{x}`" for x in all_changed) + return 'Updated packages: ' + ', '.join(f'`{x}`' for x in all_changed) -def create_pr_with_push(branch_name: str, access_token: str, repo=""): +def create_pr_with_push(branch_name: str, access_token: str, repo=''): """ Create a PR. """ - if branch_name == "main": + if branch_name == 'main': new_branch_name = DEFAULT_BRANCH_NAME else: - new_branch_name = f"{DEFAULT_BRANCH_NAME}-{branch_name}" + new_branch_name = f'{DEFAULT_BRANCH_NAME}-{branch_name}' if not repo: - repo = os.environ.get("GITHUB_REPOSITORY", "napari/napari") + repo = os.environ.get('GITHUB_REPOSITORY', 'napari/napari') with cd(REPO_DIR): - subprocess.run(["git", "checkout", "-B", new_branch_name], check=True) + subprocess.run(['git', 'checkout', '-B', new_branch_name], check=True) create_commit(commit_message(branch_name)) push(new_branch_name) - logging.info("Create PR for branch %s", new_branch_name) + logging.info('Create PR for branch %s', new_branch_name) if pr_number := list_pr_for_branch( new_branch_name, access_token, repo=repo ): @@ -149,23 +149,23 @@ def create_pr_with_push(branch_name: str, access_token: str, repo=""): def update_own_pr(pr_number: int, access_token: str, base_branch: str, repo): - headers = {"Authorization": f"token {access_token}"} + headers = {'Authorization': f'token {access_token}'} payload = { - "title": commit_message(base_branch), - "body": long_description(base_branch), + 'title': commit_message(base_branch), + 'body': long_description(base_branch), } - url = f"{BASE_URL}/repos/{repo}/pulls/{pr_number}" - logging.info("Update PR with payload: %s in %s", str(payload), url) + url = f'{BASE_URL}/repos/{repo}/pulls/{pr_number}' + logging.info('Update PR with payload: %s in %s', str(payload), url) response = requests.post(url, headers=headers, json=payload) response.raise_for_status() -def list_pr_for_branch(branch_name: str, access_token: str, repo=""): +def list_pr_for_branch(branch_name: str, access_token: str, repo=''): """ check if PR for branch exists """ org_name = repo.split('/')[0] - url = f"{BASE_URL}/repos/{repo}/pulls?state=open&head={org_name}:{branch_name}" + url = f'{BASE_URL}/repos/{repo}/pulls?state=open&head={org_name}:{branch_name}' response = requests.get(url) response.raise_for_status() if response.json(): @@ -174,57 +174,57 @@ def list_pr_for_branch(branch_name: str, access_token: str, repo=""): def create_pr( - base_branch: str, new_branch: str, access_token: str, repo, source_user="" + base_branch: str, new_branch: str, access_token: str, repo, source_user='' ): # Prepare the headers with the access token - headers = {"Authorization": f"token {access_token}"} + headers = {'Authorization': f'token {access_token}'} # publish the comment payload = { - "title": commit_message(base_branch), - "body": long_description(base_branch), - "head": new_branch, - "base": base_branch, - "maintainer_can_modify": True, + 'title': commit_message(base_branch), + 'body': long_description(base_branch), + 'head': new_branch, + 'base': base_branch, + 'maintainer_can_modify': True, } if source_user: - payload["head"] = f"{source_user}:{new_branch}" - pull_request_url = f"{BASE_URL}/repos/{repo}/pulls" + payload['head'] = f'{source_user}:{new_branch}' + pull_request_url = f'{BASE_URL}/repos/{repo}/pulls' logging.info( - "Create PR with payload: %s in %s", str(payload), pull_request_url + 'Create PR with payload: %s in %s', str(payload), pull_request_url ) response = requests.post(pull_request_url, headers=headers, json=payload) response.raise_for_status() - logging.info("PR created: %s", response.json()["html_url"]) - add_label(repo, response.json()["number"], "maintenance", access_token) + logging.info('PR created: %s', response.json()['html_url']) + add_label(repo, response.json()['number'], 'maintenance', access_token) def add_label(repo, pr_num, label, access_token): - pull_request_url = f"{BASE_URL}/repos/{repo}/issues/{pr_num}/labels" - headers = {"Authorization": f"token {access_token}"} - payload = {"labels": [label]} + pull_request_url = f'{BASE_URL}/repos/{repo}/issues/{pr_num}/labels' + headers = {'Authorization': f'token {access_token}'} + payload = {'labels': [label]} - logging.info("Add labels: %s in %s", str(payload), pull_request_url) + logging.info('Add labels: %s in %s', str(payload), pull_request_url) response = requests.post(pull_request_url, headers=headers, json=payload) response.raise_for_status() - logging.info("Labels added: %s", response.json()) + logging.info('Labels added: %s', response.json()) def add_comment_to_pr( pull_request_number: int, message: str, - repo="napari/napari", + repo='napari/napari', ): """ Add a comment to an existing PR. """ # Prepare the headers with the access token - headers = {"Authorization": f"token {os.environ.get('GITHUB_TOKEN')}"} + headers = {'Authorization': f"token {os.environ.get('GITHUB_TOKEN')}"} # publish the comment - payload = {"body": message} + payload = {'body': message} comment_url = ( - f"{BASE_URL}/repos/{repo}/issues/{pull_request_number}/comments" + f'{BASE_URL}/repos/{repo}/issues/{pull_request_number}/comments' ) response = requests.post(comment_url, headers=headers, json=payload) response.raise_for_status() @@ -238,26 +238,26 @@ def update_pr(branch_name: str): target_repo = os.environ.get('FULL_NAME') - new_branch_name = f"auto-update-dependencies/{target_repo}/{branch_name}" + new_branch_name = f'auto-update-dependencies/{target_repo}/{branch_name}' if ( - target_repo == os.environ.get("GITHUB_REPOSITORY", "napari/napari") + target_repo == os.environ.get('GITHUB_REPOSITORY', 'napari/napari') and branch_name == DEFAULT_BRANCH_NAME ): new_branch_name = DEFAULT_BRANCH_NAME create_commit(commit_message(branch_name), branch_name=new_branch_name) - comment_content = long_description(f"origin/{branch_name}") + comment_content = long_description(f'origin/{branch_name}') try: push(new_branch_name, update=branch_name != DEFAULT_BRANCH_NAME) except subprocess.CalledProcessError as e: - if "create or update workflow" in e.stderr.decode(): - logging.info("Workflow file changed. Skip PR create.") + if 'create or update workflow' in e.stderr.decode(): + logging.info('Workflow file changed. Skip PR create.') comment_content += ( - "\n\n This PR contains changes to the workflow file. " + '\n\n This PR contains changes to the workflow file. ' ) - comment_content += "Please download the artifact and update the constraints files manually. " + comment_content += 'Please download the artifact and update the constraints files manually. ' comment_content += f"Artifact: https://github.com/{os.environ.get('GITHUB_REPOSITORY', 'napari/napari')}/actions/runs/{os.environ.get('GITHUB_RUN_ID')}" else: raise @@ -270,21 +270,21 @@ def update_pr(branch_name: str): add_comment_to_pr( pr_number, comment_content, - repo=os.environ.get("GITHUB_REPOSITORY", "napari/napari"), + repo=os.environ.get('GITHUB_REPOSITORY', 'napari/napari'), ) - logging.info("PR updated: %s", pr_number) + logging.info('PR updated: %s', pr_number) def update_external_pr_comment( target_repo: str, branch_name: str, new_branch_name: str ) -> str: - comment = "\n\nThis workflow cannot automatically update your PR or create PR to your repository. " - comment += "But you could open such PR by clicking the link: " - comment += f"https://github.com/{target_repo}/compare/{branch_name}...napari-bot:{new_branch_name}." - comment += "\n\n" - comment += "You could also get the updated files from the " - comment += f"https://github.com/napari-bot/napari/tree/{new_branch_name}/resources/constraints. " - comment += "Or ask the maintainers to provide you the contents of the constraints artifact " + comment = '\n\nThis workflow cannot automatically update your PR or create PR to your repository. ' + comment += 'But you could open such PR by clicking the link: ' + comment += f'https://github.com/{target_repo}/compare/{branch_name}...napari-bot:{new_branch_name}.' + comment += '\n\n' + comment += 'You could also get the updated files from the ' + comment += f'https://github.com/napari-bot/napari/tree/{new_branch_name}/resources/constraints. ' + comment += 'Or ask the maintainers to provide you the contents of the constraints artifact ' comment += f"from the run https://github.com/{os.environ.get('GITHUB_REPOSITORY', 'napari/napari')}/actions/runs/{os.environ.get('GITHUB_RUN_ID')}" return comment @@ -297,36 +297,36 @@ def get_pr_number() -> int: ------- pr number: int """ - pr_number = environ.get("PR_NUMBER") - logging.info("PR_NUMBER: %s", pr_number) + pr_number = environ.get('PR_NUMBER') + logging.info('PR_NUMBER: %s', pr_number) return int(pr_number) def main(): - event_name = environ.get("GITHUB_EVENT_NAME") - branch_name = environ.get("BRANCH") + event_name = environ.get('GITHUB_EVENT_NAME') + branch_name = environ.get('BRANCH') - access_token = environ.get("GHA_TOKEN_MAIN_REPO") + access_token = environ.get('GHA_TOKEN_MAIN_REPO') _setup_git_author() logging.basicConfig(level=logging.INFO) - logging.info("Branch name: %s", branch_name) - logging.info("Event name: %s", event_name) + logging.info('Branch name: %s', branch_name) + logging.info('Event name: %s', event_name) - if event_name in {"schedule", "workflow_dispatch"}: - logging.info("Creating PR") + if event_name in {'schedule', 'workflow_dispatch'}: + logging.info('Creating PR') create_pr_with_push(branch_name, access_token) - elif event_name == "issue_comment": - logging.info("Updating PR") + elif event_name == 'issue_comment': + logging.info('Updating PR') update_pr(branch_name) - elif event_name == "pull_request": + elif event_name == 'pull_request': logging.info( - "Pull request run. We cannot add comment or create PR. Please download the artifact." + 'Pull request run. We cannot add comment or create PR. Please download the artifact.' ) else: - raise ValueError(f"Unknown event name: {event_name}") + raise ValueError(f'Unknown event name: {event_name}') -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/tools/perfmon/compare_callable.py b/tools/perfmon/compare_callable.py index 11c5c8e2dba..65cdd3571c6 100644 --- a/tools/perfmon/compare_callable.py +++ b/tools/perfmon/compare_callable.py @@ -34,8 +34,8 @@ args = parser.parse_args() logging.info( - "Running compare_callable.py with the following arguments.\n{args_}", - extra={"args_": args}, + 'Running compare_callable.py with the following arguments.\n{args_}', + extra={'args_': args}, ) perfmon_dir = pathlib.Path(__file__).parent.resolve(strict=True) diff --git a/tools/perfmon/plot_callable.py b/tools/perfmon/plot_callable.py index 30bc9561bf0..98ee9a72a88 100644 --- a/tools/perfmon/plot_callable.py +++ b/tools/perfmon/plot_callable.py @@ -27,8 +27,8 @@ args = parser.parse_args() logging.info( - "Running plot_callable.py with the following arguments.\n{args_}", - extra={"args_": args}, + 'Running plot_callable.py with the following arguments.\n{args_}', + extra={'args_': args}, ) perfmon_dir = pathlib.Path(__file__).parent.resolve(strict=True) diff --git a/tools/perfmon/run.py b/tools/perfmon/run.py index a7eb2e52a2c..b875b659543 100644 --- a/tools/perfmon/run.py +++ b/tools/perfmon/run.py @@ -28,8 +28,8 @@ args = parser.parse_args() logging.info( - "Running run.py with the following arguments.\n{args_}", - extra={"args_": args}, + 'Running run.py with the following arguments.\n{args_}', + extra={'args_': args}, ) perfmon_dir = pathlib.Path(__file__).parent.resolve(strict=True) diff --git a/tools/remove_html_comments_from_pr.py b/tools/remove_html_comments_from_pr.py index 1089afcdd16..a0860cfd7e7 100644 --- a/tools/remove_html_comments_from_pr.py +++ b/tools/remove_html_comments_from_pr.py @@ -16,48 +16,48 @@ def remove_html_comments(text): # Regular expression to remove HTML comments # [^\S\r\n] is whitespace but not new line - html_comment_pattern = r"[^\S\r\n]*[^\S\r\n]*\s*" - return re.sub(html_comment_pattern, "\n", text, flags=re.DOTALL) + html_comment_pattern = r'[^\S\r\n]*[^\S\r\n]*\s*' + return re.sub(html_comment_pattern, '\n', text, flags=re.DOTALL) def edit_pull_request_description(repo, pull_request_number, access_token): # GitHub API base URL - base_url = "https://api.github.com" + base_url = 'https://api.github.com' # Prepare the headers with the access token - headers = {"Authorization": f"token {access_token}"} + headers = {'Authorization': f'token {access_token}'} # Get the current pull request description - pr_url = f"{base_url}/repos/{repo}/pulls/{pull_request_number}" + pr_url = f'{base_url}/repos/{repo}/pulls/{pull_request_number}' response = requests.get(pr_url, headers=headers) response.raise_for_status() response_json = response.json() - current_description = response_json["body"] + current_description = response_json['body'] # Remove HTML comments from the description edited_description = remove_html_comments(current_description) if edited_description == current_description: - print("No HTML comments found in the pull request description") + print('No HTML comments found in the pull request description') return # Update the pull request description - update_pr_url = f"{base_url}/repos/{repo}/pulls/{pull_request_number}" - payload = {"body": edited_description} + update_pr_url = f'{base_url}/repos/{repo}/pulls/{pull_request_number}' + payload = {'body': edited_description} response = requests.patch(update_pr_url, json=payload, headers=headers) response.raise_for_status() if response.status_code == 200: print( - f"Pull request #{pull_request_number} description has been updated successfully!" + f'Pull request #{pull_request_number} description has been updated successfully!' ) else: print( - f"Failed to update pull request description. Status code: {response.status_code}" + f'Failed to update pull request description. Status code: {response.status_code}' ) -if __name__ == "__main__": +if __name__ == '__main__': print('Will inspect PR description to remove html comments.') # note that the env between pull_request and pull_request_target are different @@ -68,7 +68,7 @@ def edit_pull_request_description(repo, pull_request_number, access_token): # - github.event.repository.name is not the full slug, but just the name # - github.event.repository.org is empty if the repo is a normal user. - repository_url = environ.get("GH_REPO_URL") + repository_url = environ.get('GH_REPO_URL') print(f'Current repository is {repository_url}') repository_parts = repository_url.split('/')[-2:] @@ -79,12 +79,12 @@ def edit_pull_request_description(repo, pull_request_number, access_token): sys.exit(0) # get current PR number from github actions - number = environ.get("GH_PR_NUMBER") + number = environ.get('GH_PR_NUMBER') print(f'Current PR number is {number}') - access_token = environ.get("GH_TOKEN") + access_token = environ.get('GH_TOKEN') if access_token is None: - print("No access token found in the environment variables") + print('No access token found in the environment variables') # we still don't want fail status sys.exit(0) edit_pull_request_description(slug, number, access_token) diff --git a/tools/split_qt_backend.py b/tools/split_qt_backend.py index 11e26d1fce9..34493313d8d 100644 --- a/tools/split_qt_backend.py +++ b/tools/split_qt_backend.py @@ -1,10 +1,10 @@ import sys -names = ["MAIN", "SECOND", "THIRD", "FOURTH"] +names = ['MAIN', 'SECOND', 'THIRD', 'FOURTH'] num = int(sys.argv[1]) -values = sys.argv[2].split(",") +values = sys.argv[2].split(',') if num < len(values): - print(f"{names[num]}={values[num]}") + print(f'{names[num]}={values[num]}') else: - print(f"{names[num]}=none") + print(f'{names[num]}=none') diff --git a/tools/string_list.json b/tools/string_list.json index c34ff94f26e..2666df5a3ac 100644 --- a/tools/string_list.json +++ b/tools/string_list.json @@ -1835,6 +1835,12 @@ "border_width_is_relative", "current_value", "data", + "border", + "border_color", + "border_color_cycle", + "border_colormap", + "border_contrast_limits", + "border_width", "face", "face_color", "face_color_cycle", @@ -1866,6 +1872,7 @@ "shown", "{k}: {v[value]}", "dimgray", + "border_width_is_relative", "antialiasing", "canvas_size_limits", "coordinates" @@ -1917,12 +1924,12 @@ "black", "cross", "data", - "edge", - "edge_color", - "edge_color_cycle", - "edge_colormap", - "edge_contrast_limits", - "edge_width", + "border", + "border_color", + "border_color_cycle", + "border_colormap", + "border_contrast_limits", + "border_width", "face", "face_color", "face_color_cycle", diff --git a/tools/validate_strings.py b/tools/validate_strings.py index 8a778aed455..b9eaa679720 100644 --- a/tools/validate_strings.py +++ b/tools/validate_strings.py @@ -30,7 +30,7 @@ from contextlib import suppress from pathlib import Path from types import ModuleType -from typing import Dict, List, Optional, Set, Tuple +from typing import Optional import pytest from strings_list import ( @@ -41,12 +41,12 @@ ) REPO_ROOT = Path(__file__).resolve() -NAPARI_MODULE = (REPO_ROOT / "napari").relative_to(REPO_ROOT) +NAPARI_MODULE = (REPO_ROOT / 'napari').relative_to(REPO_ROOT) # Types -StringIssuesDict = Dict[str, List[Tuple[int, str]]] -OutdatedStringsDict = Dict[str, List[str]] -TranslationErrorsDict = Dict[str, List[Tuple[str, str]]] +StringIssuesDict = dict[str, list[tuple[int, str]]] +OutdatedStringsDict = dict[str, list[str]] +TranslationErrorsDict = dict[str, list[tuple[str, str]]] class FindTransStrings(ast.NodeVisitor): @@ -75,12 +75,12 @@ def _check_vars(self, method_name, args, kwargs): kwargs : kwargs List of keyword arguments passed to translation method. """ - singular_kwargs = set(kwargs) - set({"n"}) + singular_kwargs = set(kwargs) - set({'n'}) plural_kwargs = set(kwargs) # If using trans methods with `context`, remove it since we are # only interested in the singular and plural strings (if any) - if method_name in ["_p", "_np"]: + if method_name in ['_p', '_np']: args = args[1:] # Iterate on strings passed to the trans method. Could be just a @@ -113,9 +113,9 @@ def _check_vars(self, method_name, args, kwargs): self._trans_errors.append(error) def visit_Call(self, node): - method_name, args, kwargs = "", [], [] + method_name, args, kwargs = '', [], [] with suppress(AttributeError): - if node.func.value.id == "trans": + if node.func.value.id == 'trans': method_name = node.func.attr # Args for item in [arg.value for arg in node.args]: @@ -123,7 +123,7 @@ def visit_Call(self, node): self._found.add(item) # Kwargs kwargs = [ - kw.arg for kw in node.keywords if kw.arg != "deferred" + kw.arg for kw in node.keywords if kw.arg != 'deferred' ] if method_name: @@ -141,8 +141,8 @@ def reset(self): def _find_func_definitions( - node: ast.AST, defs: Optional[List[ast.FunctionDef]] = None -) -> List[ast.FunctionDef]: + node: ast.AST, defs: Optional[list[ast.FunctionDef]] = None +) -> list[ast.FunctionDef]: """Find all functions definition recrusively. This also find functions nested inside other functions. @@ -179,8 +179,8 @@ def find_files( path: str, skip_folders: tuple, skip_files: tuple, - extensions: tuple = (".py",), -) -> List[str]: + extensions: tuple = ('.py',), +) -> list[str]: """Find recursively all files in path. Parameters @@ -216,7 +216,7 @@ def find_files( return sorted(found_files) -def find_docstrings(fpath: str) -> Dict[str, str]: +def find_docstrings(fpath: str) -> dict[str, str]: """Find all docstrings in file path. Parameters @@ -258,7 +258,7 @@ def find_docstrings(fpath: str) -> Dict[str, str]: results = {} for doc in docstrings: - key = " ".join([it for it in doc.split() if it != ""]) + key = ' '.join([it for it in doc.split() if it != '']) results[key] = doc return results @@ -339,7 +339,7 @@ def compress_str(gen): yield tokenize.STRING, nt, acc_line -def find_strings(fpath: str) -> Dict[Tuple[int, str], Tuple[int, str]]: +def find_strings(fpath: str) -> dict[tuple[int, str], tuple[int, str]]: """Find all strings (and f-strings) for the given file. Parameters @@ -366,7 +366,7 @@ def find_strings(fpath: str) -> Dict[Tuple[int, str], Tuple[int, str]]: string = eval(tokstr[1:]) if isinstance(string, str): - key = " ".join([it for it in string.split() if it != ""]) + key = ' '.join([it for it in string.split() if it != '']) strings[(lineno, key)] = (lineno, string) return strings @@ -374,7 +374,7 @@ def find_strings(fpath: str) -> Dict[Tuple[int, str], Tuple[int, str]]: def find_trans_strings( fpath: str, -) -> Tuple[Dict[str, str], List[Tuple[str, Set[str]]]]: +) -> tuple[dict[str, str], list[tuple[str, set[str]]]]: """Find all translation strings for the given file. Parameters @@ -396,7 +396,7 @@ def find_trans_strings( trans_strings = {} show_trans_strings.visit(module) for string in show_trans_strings._found: - key = " ".join(list(string.split())) + key = ' '.join(list(string.split())) trans_strings[key] = string errors = list(show_trans_strings._trans_errors) @@ -419,8 +419,8 @@ def import_module_by_path(fpath: str) -> Optional[ModuleType]: """ import importlib.util - fpath = fpath.replace("\\", "/") - module_name = fpath.replace(".py", "").replace("/", ".") + fpath = fpath.replace('\\', '/') + module_name = fpath.replace('.py', '').replace('/', '.') try: module = importlib.import_module(module_name) @@ -431,8 +431,8 @@ def import_module_by_path(fpath: str) -> Optional[ModuleType]: def find_issues( - paths: List[str], skip_words: List[str] -) -> Tuple[StringIssuesDict, OutdatedStringsDict, TranslationErrorsDict]: + paths: list[str], skip_words: list[str] +) -> tuple[StringIssuesDict, OutdatedStringsDict, TranslationErrorsDict]: """Find strings that have not been translated, and errors in translations. This will not raise errors but return a list with found issues wo they @@ -468,7 +468,7 @@ def find_issues( skip_words_for_file_check = skip_words_for_file[:] module = import_module_by_path(fpath) if module is None: - raise RuntimeError(f"Error loading {fpath}") + raise RuntimeError(f'Error loading {fpath}') try: __all__strings = module.__all__ @@ -486,8 +486,8 @@ def find_issues( and string not in trans_strings and value not in skip_words_for_file and value not in __all__strings - and string != "" - and string.strip() != "" + and string != '' + and string.strip() != '' and value not in SKIP_WORDS_GLOBAL ): issues[fpath].append((_lineno, value)) @@ -514,7 +514,7 @@ def _checks(): return issues, outdated_strings, trans_errors -@pytest.fixture(scope="module") +@pytest.fixture(scope='module') def checks(): return _checks() @@ -524,35 +524,35 @@ def checks(): def test_missing_translations(checks): issues, _, _ = checks print( - "\nSome strings on the following files might need to be translated " - "or added to the skip list.\nSkip list is located at " - "`tools/strings_list.py` file.\n\n" + '\nSome strings on the following files might need to be translated ' + 'or added to the skip list.\nSkip list is located at ' + '`tools/strings_list.py` file.\n\n' ) for fpath, values in issues.items(): print(f"{fpath}\n{'*' * len(fpath)}") unique_values = set() for line, value in values: unique_values.add(value) - print(f"{line}:\t{value!r}") + print(f'{line}:\t{value!r}') - print("\n") + print('\n') if fpath in SKIP_WORDS: print( f"List below can be copied directly to `tools/strings_list.py` file inside the '{fpath}' key:\n" ) for value in sorted(unique_values): - print(f" {value!r},") + print(f' {value!r},') else: print( - "List below can be copied directly to `tools/strings_list.py` file:\n" + 'List below can be copied directly to `tools/strings_list.py` file:\n' ) - print(f" {fpath!r}: [") + print(f' {fpath!r}: [') for value in sorted(unique_values): - print(f" {value!r},") - print(" ],") + print(f' {value!r},') + print(' ],') - print("\n") + print('\n') no_issues = not issues assert no_issues @@ -561,13 +561,13 @@ def test_missing_translations(checks): def test_outdated_string_skips(checks): _, outdated_strings, _ = checks print( - "\nSome strings on the skip list on the `tools/strings_list.py` are " - "outdated.\nPlease remove them from the skip list.\n\n" + '\nSome strings on the skip list on the `tools/strings_list.py` are ' + 'outdated.\nPlease remove them from the skip list.\n\n' ) for fpath, values in outdated_strings.items(): print(f"{fpath}\n{'*' * len(fpath)}") - print(", ".join(repr(value) for value in values)) - print("") + print(', '.join(repr(value) for value in values)) + print('') no_outdated_strings = not outdated_strings assert no_outdated_strings @@ -576,19 +576,19 @@ def test_outdated_string_skips(checks): def test_translation_errors(checks): _, _, trans_errors = checks print( - "\nThe following translation strings do not provide some " - "interpolation variables:\n\n" + '\nThe following translation strings do not provide some ' + 'interpolation variables:\n\n' ) for fpath, errors in trans_errors.items(): print(f"{fpath}\n{'*' * len(fpath)}") for string, variables in errors: - print(f"String:\t\t{string!r}") + print(f'String:\t\t{string!r}') print( f"Variables:\t{', '.join(repr(value) for value in variables)}" ) - print("") + print('') - print("") + print('') no_trans_errors = not trans_errors assert no_trans_errors @@ -605,24 +605,24 @@ def getch(): return ch -GREEN = "\x1b[1;32m" -RED = "\x1b[1;31m" -NORMAL = "\x1b[1;0m" +GREEN = '\x1b[1;32m' +RED = '\x1b[1;31m' +NORMAL = '\x1b[1;0m' def print_colored_diff(old, new): lines = list(difflib.unified_diff(old.splitlines(), new.splitlines())) for line in lines[2:]: if line.startswith('-'): - print(f"{RED}{line}{NORMAL}") + print(f'{RED}{line}{NORMAL}') elif line.startswith('+'): - print(f"{GREEN}{line}{NORMAL}") + print(f'{GREEN}{line}{NORMAL}') else: print(line) def clear_screen(): - print(chr(27) + "[2J") + print(chr(27) + '[2J') def _compute_autosugg(raw_code, text): @@ -677,15 +677,15 @@ def _compute_autosugg(raw_code, text): clear_screen() print( - f"{RED}=== About {n_issues} items in {len(issues)} files to review ==={NORMAL}" + f'{RED}=== About {n_issues} items in {len(issues)} files to review ==={NORMAL}' ) print() - print(f"{RED}{file}:{line}{NORMAL}", GREEN, repr(text), NORMAL) + print(f'{RED}{file}:{line}{NORMAL}', GREEN, repr(text), NORMAL) if autosugg: print_colored_diff(raw_code, sugg) else: - print(f"{RED}f-string nedds manual intervention{NORMAL}") + print(f'{RED}f-string nedds manual intervention{NORMAL}') for lt in code[line - 3 : line - 1]: print(' ', lt) print('>', code[line - 1].replace(text, GREEN + text + NORMAL)) @@ -695,20 +695,20 @@ def _compute_autosugg(raw_code, text): print() print( - f"{RED}i{NORMAL} : ignore - add to ignored localised strings" + f'{RED}i{NORMAL} : ignore - add to ignored localised strings' ) - print(f"{RED}c{NORMAL} : continue - go to next") + print(f'{RED}c{NORMAL} : continue - go to next') if autosugg: - print(f"{RED}a{NORMAL} : Apply Auto suggestion") + print(f'{RED}a{NORMAL} : Apply Auto suggestion') else: - print("- : Auto suggestion not available here") + print('- : Auto suggestion not available here') if edit_cmd: - print(f"{RED}e{NORMAL} : EDIT - using {edit_cmd!r}") + print(f'{RED}e{NORMAL} : EDIT - using {edit_cmd!r}') else: print( "- : Edit not available, call with python tools/validate_strings.py '$COMMAND {filename} {linenumber} '" ) - print(f"{RED}s{NORMAL} : save and quit") + print(f'{RED}s{NORMAL} : save and quit') print('> ', end='') sys.stdout.flush() val = getch() diff --git a/tox.ini b/tox.ini index c34a35528d8..197fbc05420 100644 --- a/tox.ini +++ b/tox.ini @@ -15,17 +15,16 @@ # "tox -e py38-macos-pyqt" will test python3.8 with pyqt on macos # (even if a combination of factors is not in the default envlist # you can run it manually... like py39-linux-pyside2) -envlist = py{38,39,310,311}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6,headless}-{cov,no_cov},mypy +envlist = py{39,310,311,312}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6,headless}-{cov,no_cov},mypy isolated_build = true toxworkdir={env:TOX_WORK_DIR:/tmp/.tox} [gh-actions] python = - 3.8: py38 3.9: py39 - 3.9.0: py390 3.10: py310 3.11: py311 + 3.12: py312 fail_on_no_env = True # This section turns environment variables from github actions @@ -107,9 +106,9 @@ commands = -[testenv:py{38,39,310,311}-{linux,macos,windows}-headless-{cov,no_cov}] +[testenv:py{39,310,311,312}-{linux,macos,windows}-headless-{cov,no_cov}] commands_pre = - pip uninstall -y pytest-qt qtpy pyqt5 pyside2 pyside6 pyqt6 + pip uninstall -y pyautogui pytest-qt pyqt5 pyside2 pyside6 pyqt6 commands = cov: coverage run \ @@ -118,7 +117,7 @@ commands = --ignore napari/_qt --ignore napari/_tests --ignore tools \ --json-report --json-report-file={toxinidir}/report-{envname}.json {posargs} -[testenv:py{38,39,310,311}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}-examples-{cov,no_cov}] +[testenv:py{39,310,311,312}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}-examples-{cov,no_cov}] deps = # For surface_timeseries_.py example nilearn From 34725fce6e67cae315a2ee65c580dd911d2466ae Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 Mar 2024 02:56:12 +0000 Subject: [PATCH 095/105] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/add-graph-networkx.py | 2 +- examples/add_graph.py | 2 +- .../_vispy/_tests/test_vispy_graph_layer.py | 10 +++---- napari/layers/graph/_slice.py | 3 +- napari/layers/graph/_tests/test_graph.py | 28 ++++++++--------- napari/layers/graph/graph.py | 30 +++++++++++-------- 6 files changed, 40 insertions(+), 35 deletions(-) diff --git a/examples/add-graph-networkx.py b/examples/add-graph-networkx.py index 2c334c1ea8b..f481b83dfcc 100644 --- a/examples/add-graph-networkx.py +++ b/examples/add-graph-networkx.py @@ -19,5 +19,5 @@ viewer = napari.Viewer() layer = viewer.add_graph(hex_grid_ints, size=1) -if __name__ == "__main__": +if __name__ == '__main__': napari.run() diff --git a/examples/add_graph.py b/examples/add_graph.py index 9d3abfd6268..0bf22d6b6b1 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -34,6 +34,6 @@ def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: layer = viewer.add_graph(graph, out_of_slice_display=True, size=5, projection_mode='all') -if __name__ == "__main__": +if __name__ == '__main__': napari.run() diff --git a/napari/_vispy/_tests/test_vispy_graph_layer.py b/napari/_vispy/_tests/test_vispy_graph_layer.py index 30017353d39..1a1e81355f6 100644 --- a/napari/_vispy/_tests/test_vispy_graph_layer.py +++ b/napari/_vispy/_tests/test_vispy_graph_layer.py @@ -12,7 +12,7 @@ from napari.layers import Graph -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_vispy_graph_layer(graph_class: Type[BaseGraph]) -> None: edges = np.asarray([[0, 1], [1, 2]]) coords = np.asarray([[0, 0, 0, -1], [0, 0, 1, 2], [1, 0, 2, 3]]) @@ -25,7 +25,7 @@ def test_vispy_graph_layer(graph_class: Type[BaseGraph]) -> None: # checking nodes positions assert np.all( coords[:2, 1:] - == np.flip(visual.node._subvisuals[0]._data["a_position"], axis=-1) + == np.flip(visual.node._subvisuals[0]._data['a_position'], axis=-1) ) # checking edges positions @@ -34,7 +34,7 @@ def test_vispy_graph_layer(graph_class: Type[BaseGraph]) -> None: ) -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_vispy_graph_layer_removal(graph_class: Type[BaseGraph]) -> None: edges = np.asarray([[0, 1], [1, 2]]) coords = np.asarray([[0, 0, 0, -1], [0, 0, 1, 2], [0, 0, 2, 3]]) @@ -47,7 +47,7 @@ def test_vispy_graph_layer_removal(graph_class: Type[BaseGraph]) -> None: # checking nodes positions assert np.all( coords[:, 1:] - == np.flip(visual.node._subvisuals[0]._data["a_position"], axis=-1) + == np.flip(visual.node._subvisuals[0]._data['a_position'], axis=-1) ) # checking first edge @@ -66,7 +66,7 @@ def test_vispy_graph_layer_removal(graph_class: Type[BaseGraph]) -> None: # checking remaining nodes positions assert np.all( coords[1:, 1:] - == np.flip(visual.node._subvisuals[0]._data["a_position"], axis=-1) + == np.flip(visual.node._subvisuals[0]._data['a_position'], axis=-1) ) # checking single edge diff --git a/napari/layers/graph/_slice.py b/napari/layers/graph/_slice.py index b2ab2f34389..262d4fad320 100644 --- a/napari/layers/graph/_slice.py +++ b/napari/layers/graph/_slice.py @@ -1,5 +1,6 @@ +from collections.abc import Sequence from dataclasses import dataclass, field -from typing import Any, Sequence, Tuple +from typing import Any, Tuple import numpy as np from napari_graph import BaseGraph diff --git a/napari/layers/graph/_tests/test_graph.py b/napari/layers/graph/_tests/test_graph.py index ddca8458ee3..8be746137a9 100644 --- a/napari/layers/graph/_tests/test_graph.py +++ b/napari/layers/graph/_tests/test_graph.py @@ -58,7 +58,7 @@ def test_networkx_graph() -> None: mapping = {} for i, j in graph.nodes: - graph.nodes[i, j]["pos"] = (i, j) + graph.nodes[i, j]['pos'] = (i, j) mapping[i, j] = i * m + j nx.relabel_nodes(graph, mapping, copy=False) @@ -81,7 +81,7 @@ def test_networkx_nonspatial_graph() -> None: Graph(graph) -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_changing_graph(graph_class: Type[BaseGraph]) -> None: graph_a = graph_class(edges=[[0, 1]], coords=[[0, 0], [1, 1]]) graph_b = graph_class(coords=[[0, 0, 0]]) @@ -93,7 +93,7 @@ def test_changing_graph(graph_class: Type[BaseGraph]) -> None: assert layer.ndim == graph_b.ndim -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_move(graph_class: Type[BaseGraph]) -> None: start_coords = np.asarray([[0, 0], [1, 1], [2, 2]]) graph = graph_class(edges=[[0, 1], [1, 2]], coords=start_coords) @@ -116,7 +116,7 @@ def test_move(graph_class: Type[BaseGraph]) -> None: assert np.all(layer._points_data[1:2] == start_coords[1:2] + [-3, 4]) -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_add_nodes(graph_class: Type[BaseGraph]) -> None: # it also tests if original graph object is changed inplace. coords = np.asarray([[0, 0], [1, 1]]) @@ -146,7 +146,7 @@ def test_add_nodes(graph_class: Type[BaseGraph]) -> None: assert set(layer.selected_data) == {4, 5} -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_remove_selected_nodes(graph_class: Type[BaseGraph]) -> None: # it also tests if original graph object is changed inplace. coords = np.asarray([[0, 0], [1, 1], [2, 2]]) @@ -175,7 +175,7 @@ def test_remove_selected_nodes(graph_class: Type[BaseGraph]) -> None: assert graph.n_nodes == 0 -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_remove_nodes(graph_class: Type[BaseGraph]) -> None: # it also tests if original graph object is changed inplace. coords = np.asarray([[0, 0], [1, 1], [2, 2]]) @@ -196,7 +196,7 @@ def test_remove_nodes(graph_class: Type[BaseGraph]) -> None: assert graph.n_nodes == 0 -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_remove_nodes_non_sequential_indexing( graph_class: Type[BaseGraph], ) -> None: @@ -221,7 +221,7 @@ def test_remove_nodes_non_sequential_indexing( assert graph.n_nodes == 0 -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_graph_out_of_slice_display(graph_class: Type[BaseGraph]) -> None: coords = np.asarray([[0, 0, 0, 0], [1, 1, 1, 1], [2, 2, 2, 2]]) @@ -231,14 +231,14 @@ def test_graph_out_of_slice_display(graph_class: Type[BaseGraph]) -> None: def test_graph_from_data_tuple() -> None: - layer = Graph(name="graph") + layer = Graph(name='graph') new_layer = Graph.create(*layer.as_layer_data_tuple()) assert layer.name == new_layer.name assert len(layer.data) == len(new_layer.data) assert layer.ndim == new_layer.ndim -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_graph_from_data_tuple_non_empty(graph_class: Type[BaseGraph]) -> None: indices = np.asarray([5, 3, 1]) coords = np.asarray([[0, 0], [1, 1], [2, 2]]) @@ -253,7 +253,7 @@ def test_graph_from_data_tuple_non_empty(graph_class: Type[BaseGraph]) -> None: assert layer.ndim == new_layer.ndim -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_add_nodes_buffer_resize(graph_class): coords = np.asarray([(0, 0, 0)]) @@ -266,7 +266,7 @@ def test_add_nodes_buffer_resize(graph_class): assert graph.n_nodes == coords.shape[0] + 1 -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_add_data_event(graph_class): coords = np.asarray([[0, 0], [1, 1]]) @@ -301,7 +301,7 @@ def test_add_data_event(graph_class): assert last_call[1]['data_indices'] == (8, 9) -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_remove_data_event(graph_class): coords = np.asarray([[0, 0], [1, 1], [2, 2], [3, 3]]) @@ -328,7 +328,7 @@ def test_remove_data_event(graph_class): assert last_call[1]['data_indices'] == (2, 3) -@pytest.mark.parametrize("graph_class", [UndirectedGraph, DirectedGraph]) +@pytest.mark.parametrize('graph_class', [UndirectedGraph, DirectedGraph]) def test_remove_selected_data_event(graph_class): coords = np.asarray([[0, 0], [1, 1], [2, 2], [3, 3]]) diff --git a/napari/layers/graph/graph.py b/napari/layers/graph/graph.py index 449d78fb5e1..29258c30378 100644 --- a/napari/layers/graph/graph.py +++ b/napari/layers/graph/graph.py @@ -342,7 +342,7 @@ def _fix_data( if not data.is_spatial(): raise ValueError( trans._( - "Graph layer must be a spatial graph, have the `coords` attribute (`pos` in NetworkX)." + 'Graph layer must be a spatial graph, have the `coords` attribute (`pos` in NetworkX).' ) ) return data @@ -410,7 +410,7 @@ def add( if indices.ndim > 1: raise ValueError( trans._( - "Indices for removal must be 1-dim. Found {ndim}", + 'Indices for removal must be 1-dim. Found {ndim}', ndim=indices.ndim, ) ) @@ -463,7 +463,7 @@ def _remove_nodes( if indices.ndim > 1: raise ValueError( trans._( - "Indices for removal must be 1-dim. Found {ndim}", + 'Indices for removal must be 1-dim. Found {ndim}', ndim=indices.ndim, ) ) @@ -515,7 +515,11 @@ def _move_points( def _update_props_and_style(self, data_size: int, prev_size: int) -> None: # Add/remove property and style values based on the number of new points. - with self.events.blocker_all(), self._border.events.blocker_all(), self._face.events.blocker_all(): + with ( + self.events.blocker_all(), + self._border.events.blocker_all(), + self._face.events.blocker_all(), + ): self._feature_table.resize(data_size) self.text.apply(self.features) if data_size < prev_size: @@ -546,20 +550,20 @@ def _update_props_and_style(self, data_size: int, prev_size: int) -> None: # ensure each attribute is updated before refreshing with self._block_refresh(): for attribute in ( - "shown", - "size", - "symbol", - "border_width", + 'shown', + 'size', + 'symbol', + 'border_width', ): - if attribute == "shown": + if attribute == 'shown': default_value = True else: default_value = getattr( - self, f"current_{attribute}" + self, f'current_{attribute}' ) new_values = np.repeat([default_value], adding, axis=0) values = np.concatenate( - (getattr(self, f"_{attribute}"), new_values), + (getattr(self, f'_{attribute}'), new_values), axis=0, ) setattr(self, attribute, values) @@ -571,6 +575,6 @@ def _data_changed(self, prev_size: int) -> None: def _get_state(self) -> Dict[str, Any]: # FIXME: this method can be removed once 'properties' argument is deprecreated. state = super()._get_state() - state.pop("properties", None) - state.pop("property_choices", None) + state.pop('properties', None) + state.pop('property_choices', None) return state From e1998eb3e3b7843359700fbc9fad68f5ebde2465 Mon Sep 17 00:00:00 2001 From: Draga Doncila Date: Tue, 26 Mar 2024 14:01:45 +1100 Subject: [PATCH 096/105] Bring back napari graph dep --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index b559249acb5..01b42506688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "lazy_loader>=0.2", "magicgui>=0.7.0", "napari-console>=0.0.9", + "napari-graph>=0.2.2", "napari-plugin-engine>=0.1.9", "napari-svg>=0.1.8", "npe2>=0.7.2", From fec6bbbf8736da33cb5a80e4154ac51bb46d4dc5 Mon Sep 17 00:00:00 2001 From: Draga Doncila Date: Wed, 27 Mar 2024 11:44:48 +1100 Subject: [PATCH 097/105] Fix points layer --- napari/layers/points/points.py | 129 +++++++++++---------------------- 1 file changed, 43 insertions(+), 86 deletions(-) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index cf5b5fef262..9a102cad385 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -103,36 +103,6 @@ class _BasePoints(Layer): # If more points are present then they are randomly subsampled _max_points_thumbnail = 1024 - @rename_argument( - 'edge_width', 'border_width', since_version='0.5.0', version='0.6.0' - ) - @rename_argument( - 'edge_width_is_relative', - 'border_width_is_relative', - since_version='0.5.0', - version='0.6.0', - ) - @rename_argument( - 'edge_color', 'border_color', since_version='0.5.0', version='0.6.0' - ) - @rename_argument( - 'edge_color_cycle', - 'border_color_cycle', - since_version='0.5.0', - version='0.6.0', - ) - @rename_argument( - 'edge_colormap', - 'border_colormap', - since_version='0.5.0', - version='0.6.0', - ) - @rename_argument( - 'edge_contrast_limits', - 'border_contrast_limits', - since_version='0.5.0', - version='0.6.0', - ) def __init__( self, data=None, @@ -241,31 +211,6 @@ def __init__( feature_defaults=Event, ) - deprecated_events = {} - for attr in [ - '{}_width', - 'current_{}_width', - '{}_width_is_relative', - '{}_color', - 'current_{}_color', - ]: - old_attr = attr.format('edge') - new_attr = attr.format('border') - old_emitter = deprecation_warning_event( - 'layer.events', - old_attr, - new_attr, - since_version='0.5.0', - version='0.6.0', - ) - getattr(self.events, new_attr).connect(old_emitter) - deprecated_events[old_attr] = old_emitter - - self.events.add(**deprecated_events) - - # Save the point coordinates - self._data = np.asarray(data) - self._feature_table = _FeatureTable.from_layer( features=features, feature_defaults=feature_defaults, @@ -312,6 +257,13 @@ def __init__( if len(self._points_data) else self._feature_table.currents() ) + + if n_dimensional is not None: + self._out_of_slice_display = n_dimensional + else: + self._out_of_slice_display = out_of_slice_display + + # Save the point style params self._border = ColorManager._from_layer_kwargs( n_colors=len(data), colors=border_color, @@ -328,13 +280,6 @@ def __init__( categorical_colormap=face_color_cycle, properties=color_properties, ) - - if n_dimensional is not None: - self._out_of_slice_display = n_dimensional - else: - self._out_of_slice_display = out_of_slice_display - - # Save the point style params self.size = size self.shown = shown self.symbol = symbol @@ -348,30 +293,6 @@ def __init__( # Trigger generation of view slice and thumbnail self.refresh() - @classmethod - def _add_deprecated_properties(cls) -> None: - """Adds deprecated properties to class.""" - deprecated_properties = [ - 'edge_width', - 'edge_width_is_relative', - 'current_edge_width', - 'edge_color', - 'edge_color_cycle', - 'edge_colormap', - 'edge_contrast_limits', - 'current_edge_color', - 'edge_color_mode', - ] - for old_property in deprecated_properties: - new_property = old_property.replace('edge', 'border') - add_deprecated_property( - cls, - old_property, - new_property, - since_version='0.5.0', - version='0.6.0', - ) - @property def _points_data(self) -> np.ndarray: """Spatially distributed coordinates.""" @@ -384,6 +305,42 @@ def data(self) -> Any: def _set_data(self, data: Any) -> None: raise NotImplementedError + @data.setter + def data(self, data: Optional[np.ndarray]) -> None: + """Set the data array and emit a corresponding event.""" + prior_data = len(self.data) > 0 + data_not_empty = ( + data is not None + and (isinstance(data, np.ndarray) and data.size > 0) + or (isinstance(data, list) and len(data) > 0) + ) + kwargs = { + 'value': self.data, + 'vertex_indices': ((),), + 'data_indices': tuple(i for i in range(len(self.data))), + } + if prior_data and data_not_empty: + kwargs['action'] = ActionType.CHANGING + elif data_not_empty: + kwargs['action'] = ActionType.ADDING + kwargs['data_indices'] = tuple(i for i in range(len(data))) + else: + kwargs['action'] = ActionType.REMOVING + + self.events.data(**kwargs) + self._set_data(data) + kwargs['data_indices'] = tuple(i for i in range(len(self.data))) + kwargs['value'] = self.data + + if prior_data and data_not_empty: + kwargs['action'] = ActionType.CHANGED + elif data_not_empty: + kwargs['data_indices'] = tuple(i for i in range(len(data))) + kwargs['action'] = ActionType.ADDED + else: + kwargs['action'] = ActionType.REMOVED + self.events.data(**kwargs) + def _on_selection(self, selected): if selected: self._set_highlight() From 05395f663d383ad310e407fb37c005834e2a5e9a Mon Sep 17 00:00:00 2001 From: Draga Doncila Date: Fri, 29 Mar 2024 09:18:22 +1100 Subject: [PATCH 098/105] Bring back og example --- examples/add_graph.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/examples/add_graph.py b/examples/add_graph.py index 9d3abfd6268..e76d81c7bba 100644 --- a/examples/add_graph.py +++ b/examples/add_graph.py @@ -7,6 +7,7 @@ .. tags:: visualization-basic """ +import numpy as np import pandas as pd from napari_graph import UndirectedGraph @@ -14,26 +15,24 @@ def build_graph(n_nodes: int, n_neighbors: int) -> UndirectedGraph: - edges = [[0, 2], [3, 4]] + neighbors = np.random.randint(n_nodes, size=(n_nodes * n_neighbors)) + edges = np.stack([np.repeat(np.arange(n_nodes), n_neighbors), neighbors], axis=1) nodes_df = pd.DataFrame( - { - 't': [0, 1, 2, 3, 4], - 'y': [0, 20, 45, 70, 90], - 'x': [0, 20, 45, 70, 90] - } + 400 * np.random.uniform(size=(n_nodes, 4)), + columns=['t', 'z', 'y', 'x'], ) graph = UndirectedGraph(edges=edges, coords=nodes_df) return graph -graph = build_graph(n_nodes=100, n_neighbors=5) +graph = build_graph(n_nodes=1_000_000, n_neighbors=5) viewer = napari.Viewer() -layer = viewer.add_graph(graph, out_of_slice_display=True, size=5, projection_mode='all') +layer = viewer.add_graph(graph, out_of_slice_display=True) -if __name__ == "__main__": +if __name__ == '__main__': napari.run() From 6c21fc8421f973257e3c0bbe3434950a2d641aed Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Wed, 24 Apr 2024 11:47:16 +1000 Subject: [PATCH 099/105] Fix points tests --- napari/_vispy/layers/points.py | 2 +- napari/layers/points/points.py | 48 +++++++++++++++++----------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index b74a70c1e98..b9499bfa2b0 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -14,7 +14,7 @@ class VispyPointsLayer(VispyBaseLayer): node: PointsVisual def __init__(self, layer) -> None: - node = self._visual() + node = PointsVisual() super().__init__(layer, node) self.layer.events.symbol.connect(self._on_data_change) diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 874ca502577..a7767f2717a 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -2185,44 +2185,44 @@ class Points(_BasePoints): def __init__( self, data=None, - *, ndim=None, - features=None, - feature_defaults=None, - properties=None, - text=None, - symbol='o', - size=10, - border_width=0.05, - border_width_is_relative=True, + *, + affine=None, + antialiasing=1, + blending='translucent', border_color='dimgray', border_color_cycle=None, border_colormap='viridis', border_contrast_limits=None, + border_width=0.05, + border_width_is_relative=True, + cache=True, + canvas_size_limits=(2, 10000), + experimental_clipping_planes=None, face_color='white', face_color_cycle=None, face_colormap='viridis', face_contrast_limits=None, - out_of_slice_display=False, + feature_defaults=None, + features=None, + metadata=None, n_dimensional=None, name=None, - metadata=None, - scale=None, - translate=None, - rotate=None, - shear=None, - affine=None, - opacity=1, - blending='translucent', - visible=True, - cache=True, + opacity=1.0, + out_of_slice_display=False, + projection_mode='none', + properties=None, property_choices=None, - experimental_clipping_planes=None, + rotate=None, + scale=None, shading='none', - canvas_size_limits=(2, 10000), - antialiasing=1, + shear=None, shown=True, - projection_mode='none', + size=10, + symbol='o', + text=None, + translate=None, + visible=True, ) -> None: if ndim is None: if scale is not None: From 31b5703eb96bc501a235f699e9ac1a2e5eeb0162 Mon Sep 17 00:00:00 2001 From: Clement Caporal <94049435+ClementCaporal@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:08:21 +0200 Subject: [PATCH 100/105] Use node attribute to create node This will be needed for the VispyGraphLayer --- napari/_vispy/layers/points.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index b9499bfa2b0..557bfeed60c 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -14,7 +14,7 @@ class VispyPointsLayer(VispyBaseLayer): node: PointsVisual def __init__(self, layer) -> None: - node = PointsVisual() + node = self.node() super().__init__(layer, node) self.layer.events.symbol.connect(self._on_data_change) From 565934b69fdd21652b9354e9d905a247d21a4d48 Mon Sep 17 00:00:00 2001 From: Clement Caporal <94049435+ClementCaporal@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:11:08 +0200 Subject: [PATCH 101/105] Rename _visual to node for GraphVisual --- napari/_vispy/layers/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/_vispy/layers/graph.py b/napari/_vispy/layers/graph.py index 378e3382315..e2faea8e10c 100644 --- a/napari/_vispy/layers/graph.py +++ b/napari/_vispy/layers/graph.py @@ -5,7 +5,7 @@ class VispyGraphLayer(VispyPointsLayer): - _visual = GraphVisual + node = GraphVisual def _on_data_change(self) -> None: self._set_graph_edges_data() From 6369a31136f6d966069797619659e17e756b6c60 Mon Sep 17 00:00:00 2001 From: Clement Caporal <94049435+ClementCaporal@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:23:12 +0200 Subject: [PATCH 102/105] Update points.py to allow VispyPointsLayer.node access --- napari/_vispy/layers/points.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index 557bfeed60c..ebea34136ff 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -11,7 +11,7 @@ class VispyPointsLayer(VispyBaseLayer): - node: PointsVisual + node = PointsVisual def __init__(self, layer) -> None: node = self.node() From 83ec8d50df3bc4f143d978caa3a4cdc16180bc6f Mon Sep 17 00:00:00 2001 From: Clement Caporal <94049435+ClementCaporal@users.noreply.github.com> Date: Sat, 4 May 2024 08:56:53 +0200 Subject: [PATCH 103/105] Fix node point layer typing issue --- napari/_vispy/layers/points.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index ebea34136ff..13771c53992 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -11,10 +11,11 @@ class VispyPointsLayer(VispyBaseLayer): - node = PointsVisual + _visual = PointsVisual + node: PointVisual def __init__(self, layer) -> None: - node = self.node() + node = self._visual() super().__init__(layer, node) self.layer.events.symbol.connect(self._on_data_change) From cebfffcc439d15b4095c5f3cd04f917b4fd2b1e2 Mon Sep 17 00:00:00 2001 From: Clement Caporal <94049435+ClementCaporal@users.noreply.github.com> Date: Sat, 4 May 2024 08:57:56 +0200 Subject: [PATCH 104/105] Fix graph node typing issue --- napari/_vispy/layers/graph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/napari/_vispy/layers/graph.py b/napari/_vispy/layers/graph.py index e2faea8e10c..79b517ffa31 100644 --- a/napari/_vispy/layers/graph.py +++ b/napari/_vispy/layers/graph.py @@ -5,7 +5,8 @@ class VispyGraphLayer(VispyPointsLayer): - node = GraphVisual + _visual = GraphVisual + node: GraphVisual def _on_data_change(self) -> None: self._set_graph_edges_data() From 5d5830af8c497b0d44fa3008db56e5dad33d90d0 Mon Sep 17 00:00:00 2001 From: Draga Doncila Pop Date: Wed, 22 May 2024 11:06:53 +1000 Subject: [PATCH 105/105] fix typo --- napari/_vispy/layers/points.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/napari/_vispy/layers/points.py b/napari/_vispy/layers/points.py index 13771c53992..00670782b66 100644 --- a/napari/_vispy/layers/points.py +++ b/napari/_vispy/layers/points.py @@ -12,7 +12,7 @@ class VispyPointsLayer(VispyBaseLayer): _visual = PointsVisual - node: PointVisual + node: PointsVisual def __init__(self, layer) -> None: node = self._visual()