Repository: napari/napari

Issue:
Napari does not open file with uppercase extension
## 🐛 Bug

<!-- A clear and concise description of what the bug is. -->

When an extension of the file is uppercase, then napari fails to open it with a message that there is no proper reader. 

## To Reproduce

Steps to reproduce the behavior:

1. Take any file that you could open in napari (tested on `tif`). 
2. Change its extension to uppercase (`.tif` -> `.TIF`)
3. try to open it.

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

```
NoAvailableReaderError                    Traceback (most recent call last)
~/Projekty/napari/napari/_qt/qt_viewer.py in dropEvent(self=<napari._qt.qt_viewer.QtViewer object>, event=<PyQt5.QtGui.QDropEvent object>)
   1162 
   1163         for filename in filenames:
-> 1164             self._qt_open([filename], stack=bool(shift_down))
        self._qt_open = <bound method QtViewer._qt_open of <napari._qt.qt_viewer.QtViewer object at 0x7f7f11ce0c10>>
        filename = '/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF'
        global stack = undefined
        global bool = undefined
        shift_down = <PyQt5.QtCore.Qt.KeyboardModifiers object at 0x7f7f03762a50>
   1165 
   1166     def closeEvent(self, event):

~/Projekty/napari/napari/_qt/qt_viewer.py in _qt_open(self=<napari._qt.qt_viewer.QtViewer object>, filenames=['/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF'], stack=False, plugin=None, layer_type=None, **kwargs={})
    809         """
    810         try:
--> 811             self.viewer.open(
        self.viewer.open = <bound method ViewerModel.open of Viewer(axes=Axes(visible=False, labels=True, colored=True, dashed=False, arrows=True), camera=Camera(center=(0.0, 719.5, 959.5), zoom=0.3760416666666666, angles=(0.0, 0.0, 90.0), perspective=0.0, interactive=True), cursor=Cursor(position=(937.5609125632585, 10.137119076801035), scaled=True, size=1, style=<CursorStyle.STANDARD: 'standard'>), dims=Dims(ndim=2, ndisplay=2, last_used=0, range=((0.0, 1440.0, 1.0), (0.0, 1920.0, 1.0)), current_step=(720, 960), order=(0, 1), axis_labels=('1', '2')), grid=GridCanvas(stride=1, shape=(-1, -1), enabled=False), layers=[<Image layer 'Ecoli neu_60min 1 1E-1N002 layer 0' at 0x7f7f0cd52460>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 1' at 0x7f7f0cd7dfd0>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 2' at 0x7f7f03b5dbb0>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 3' at 0x7f7f03b00d00>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 4' at 0x7f7f03b21820>], scale_bar=ScaleBar(visible=False, colored=False, color=<class 'numpy.ndarray'> (4,) float32, ticks=True, position=<Position.BOTTOM_RIGHT: 'bottom_right'>, font_size=10.0, box=False, box_color=<class 'numpy.ndarray'> (4,) float32, unit=None), text_overlay=TextOverlay(visible=False, color=<class 'numpy.ndarray'> (4,) float32, font_size=10.0, position=<TextOverlayPosition.TOP_LEFT: 'top_left'>, text=''), overlays=Overlays(interaction_box=InteractionBox(points=None, show=False, show_handle=False, show_vertices=False, selection_box_drag=None, selection_box_final=None, transform_start=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d871c0>, transform_drag=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d87220>, transform_final=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d87280>, transform=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d872e0>, allow_new_selection=True, selected_vertex=None)), help='', status='Ready', tooltip=Tooltip(visible=True, text=''), theme='dark', title='napari', mouse_over_canvas=True, mouse_move_callbacks=[<function InteractionBoxMouseBindings.initialize_mouse_events.<locals>.mouse_move at 0x7f7f0bdf6c10>], mouse_drag_callbacks=[<function InteractionBoxMouseBindings.initialize_mouse_events.<locals>.mouse_drag at 0x7f7f0bdf6670>], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[<function dims_scroll at 0x7f7f22d83dc0>], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, keymap={'Shift': <function InteractionBoxMouseBindings.initialize_key_events.<locals>.hold_to_lock_aspect_ratio at 0x7f7f0bdf64c0>, 'Control-Shift-R': <function InteractionBoxMouseBindings._reset_active_layer_affine at 0x7f7f0bd9f790>, 'Control-Shift-A': <function InteractionBoxMouseBindings._transform_active_layer at 0x7f7f0bd9f8b0>})>
        filenames = ['/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF']
        stack = False
        plugin = None
        layer_type = None
        kwargs = {}
    812                 filenames,
    813                 stack=stack,

~/Projekty/napari/napari/components/viewer_model.py in open(self=Viewer(axes=Axes(visible=False, labels=True, col...ings._transform_active_layer at 0x7f7f0bd9f8b0>}), path=['/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF'], stack=False, plugin=None, layer_type=None, **kwargs={})
   1024                 # no plugin choice was made
   1025                 else:
-> 1026                     layers = self._open_or_raise_error(
        layers = undefined
        self._open_or_raise_error = <bound method ViewerModel._open_or_raise_error of Viewer(axes=Axes(visible=False, labels=True, colored=True, dashed=False, arrows=True), camera=Camera(center=(0.0, 719.5, 959.5), zoom=0.3760416666666666, angles=(0.0, 0.0, 90.0), perspective=0.0, interactive=True), cursor=Cursor(position=(937.5609125632585, 10.137119076801035), scaled=True, size=1, style=<CursorStyle.STANDARD: 'standard'>), dims=Dims(ndim=2, ndisplay=2, last_used=0, range=((0.0, 1440.0, 1.0), (0.0, 1920.0, 1.0)), current_step=(720, 960), order=(0, 1), axis_labels=('1', '2')), grid=GridCanvas(stride=1, shape=(-1, -1), enabled=False), layers=[<Image layer 'Ecoli neu_60min 1 1E-1N002 layer 0' at 0x7f7f0cd52460>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 1' at 0x7f7f0cd7dfd0>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 2' at 0x7f7f03b5dbb0>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 3' at 0x7f7f03b00d00>, <Image layer 'Ecoli neu_60min 1 1E-1N002 layer 4' at 0x7f7f03b21820>], scale_bar=ScaleBar(visible=False, colored=False, color=<class 'numpy.ndarray'> (4,) float32, ticks=True, position=<Position.BOTTOM_RIGHT: 'bottom_right'>, font_size=10.0, box=False, box_color=<class 'numpy.ndarray'> (4,) float32, unit=None), text_overlay=TextOverlay(visible=False, color=<class 'numpy.ndarray'> (4,) float32, font_size=10.0, position=<TextOverlayPosition.TOP_LEFT: 'top_left'>, text=''), overlays=Overlays(interaction_box=InteractionBox(points=None, show=False, show_handle=False, show_vertices=False, selection_box_drag=None, selection_box_final=None, transform_start=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d871c0>, transform_drag=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d87220>, transform_final=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d87280>, transform=<napari.utils.transforms.transforms.Affine object at 0x7f7f11d872e0>, allow_new_selection=True, selected_vertex=None)), help='', status='Ready', tooltip=Tooltip(visible=True, text=''), theme='dark', title='napari', mouse_over_canvas=True, mouse_move_callbacks=[<function InteractionBoxMouseBindings.initialize_mouse_events.<locals>.mouse_move at 0x7f7f0bdf6c10>], mouse_drag_callbacks=[<function InteractionBoxMouseBindings.initialize_mouse_events.<locals>.mouse_drag at 0x7f7f0bdf6670>], mouse_double_click_callbacks=[], mouse_wheel_callbacks=[<function dims_scroll at 0x7f7f22d83dc0>], _persisted_mouse_event={}, _mouse_drag_gen={}, _mouse_wheel_gen={}, keymap={'Shift': <function InteractionBoxMouseBindings.initialize_key_events.<locals>.hold_to_lock_aspect_ratio at 0x7f7f0bdf64c0>, 'Control-Shift-R': <function InteractionBoxMouseBindings._reset_active_layer_affine at 0x7f7f0bd9f790>, 'Control-Shift-A': <function InteractionBoxMouseBindings._transform_active_layer at 0x7f7f0bd9f8b0>})>
        _path = ['/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF']
        kwargs = {}
        layer_type = None
        _stack = False
   1027                         _path, kwargs, layer_type, _stack
   1028                     )

~/Projekty/napari/napari/components/viewer_model.py in _open_or_raise_error(self=Viewer(axes=Axes(visible=False, labels=True, col...ings._transform_active_layer at 0x7f7f0bd9f8b0>}), paths=['/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF'], kwargs={}, layer_type=None, stack=False)
   1096         readers = get_potential_readers(_path)
   1097         if not readers:
-> 1098             raise NoAvailableReaderError(
        global NoAvailableReaderError = <class 'napari.errors.reader_errors.NoAvailableReaderError'>
        global trans._ = <bound method TranslationBundle._ of <napari.utils.translations.TranslationBundle object at 0x7f7f8ea5d2b0>>
        path_message = '/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF'
        global deferred = undefined
        paths = ['/home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF']
   1099                 trans._(
   1100                     'No plugin found capable of reading {path_message}.',

NoAvailableReaderError: No plugin found capable of reading /home/czaki/Pobrane/tmp/Ecoli neu_60min 1 1E-1N002.TIF.

```

## Expected behavior

The file should be properly handled (for example, the extension should be lowered, or the list of extensions from npe2 should be capitalized. 

<!-- A clear and concise description of what you expected to happen. -->

## Environment

current main


## Additional context

<!-- Add any other context about the problem here. -->



Discussion:
@Czaki thanks for raising, we hadn't thought about this tbh. My feeling is that we should standardize by lowercasing the extensions when we read - I feel like I *mostly* see lowercase extensions? Pinging @chili-chiu who has a lot of background on file opening stuff
File extensions should be case insensitive, so changing example.tif to example.TIF should still allow the file to be opened the same way. 

Resolving patch:
diff --git a/napari/plugins/utils.py b/napari/plugins/utils.py
--- a/napari/plugins/utils.py
+++ b/napari/plugins/utils.py
@@ -4,6 +4,7 @@
 from enum import IntFlag
 from fnmatch import fnmatch
 from functools import lru_cache
+from pathlib import Path
 from typing import Dict, Iterable, List, Optional, Set, Tuple, Union
 
 from npe2 import PluginManifest
@@ -130,6 +131,9 @@ def get_potential_readers(filename: str) -> Dict[str, str]:
     """
     readers = {}
     hook_caller = plugin_manager.hook.napari_get_reader
+    # lower case file extension
+    ext = str(Path(filename).suffix).lower()
+    filename = str(Path(filename).with_suffix(ext))
     for impl in hook_caller.get_hookimpls():
         reader = hook_caller._call_plugin(impl.plugin_name, path=filename)
         if callable(reader):


Test patch:
diff --git a/napari/plugins/_tests/test_utils.py b/napari/plugins/_tests/test_utils.py
--- a/napari/plugins/_tests/test_utils.py
+++ b/napari/plugins/_tests/test_utils.py
@@ -198,6 +198,15 @@ def read_all(path):
     assert len(readers) == 2
 
 
+def test_get_potential_readers_extension_case(tmp_plugin: DynamicPlugin):
+    @tmp_plugin.contribute.reader(filename_patterns=['*.tif'])
+    def read_tif(path):
+        ...
+
+    readers = get_potential_readers('my_file.TIF')
+    assert len(readers) == 1
+
+
 def test_get_potential_readers_none_available():
     assert not get_potential_readers('my_file.fake')