Repository: python-pillow/Pillow

Issue:
Reading FITS images
Whenever I try to run PIL.FitsStubImagePlugin.register_handler()
it gives me an error saying PIL doesn't have any FitsStubImagePlugin module.
How do I open my fits file as it says I need a handler to operate .fits file


Discussion:

Resolving patch:
diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py
new file mode 100644
--- /dev/null
+++ b/src/PIL/FitsImagePlugin.py
@@ -0,0 +1,71 @@
+#
+# The Python Imaging Library
+# $Id$
+#
+# FITS file handling
+#
+# Copyright (c) 1998-2003 by Fredrik Lundh
+#
+# See the README file for information on usage and redistribution.
+#
+
+import math
+
+from . import Image, ImageFile
+
+
+def _accept(prefix):
+    return prefix[:6] == b"SIMPLE"
+
+
+class FitsImageFile(ImageFile.ImageFile):
+
+    format = "FITS"
+    format_description = "FITS"
+
+    def _open(self):
+        headers = {}
+        while True:
+            header = self.fp.read(80)
+            if not header:
+                raise OSError("Truncated FITS file")
+            keyword = header[:8].strip()
+            if keyword == b"END":
+                break
+            value = header[8:].strip()
+            if value.startswith(b"="):
+                value = value[1:].strip()
+            if not headers and (not _accept(keyword) or value != b"T"):
+                raise SyntaxError("Not a FITS file")
+            headers[keyword] = value
+
+        naxis = int(headers[b"NAXIS"])
+        if naxis == 0:
+            raise ValueError("No image data")
+        elif naxis == 1:
+            self._size = 1, int(headers[b"NAXIS1"])
+        else:
+            self._size = int(headers[b"NAXIS1"]), int(headers[b"NAXIS2"])
+
+        number_of_bits = int(headers[b"BITPIX"])
+        if number_of_bits == 8:
+            self.mode = "L"
+        elif number_of_bits == 16:
+            self.mode = "I"
+            # rawmode = "I;16S"
+        elif number_of_bits == 32:
+            self.mode = "I"
+        elif number_of_bits in (-32, -64):
+            self.mode = "F"
+            # rawmode = "F" if number_of_bits == -32 else "F;64F"
+
+        offset = math.ceil(self.fp.tell() / 2880) * 2880
+        self.tile = [("raw", (0, 0) + self.size, offset, (self.mode, 0, -1))]
+
+
+# --------------------------------------------------------------------
+# Registry
+
+Image.register_open(FitsImageFile.format, FitsImageFile, _accept)
+
+Image.register_extensions(FitsImageFile.format, [".fit", ".fits"])
diff --git a/src/PIL/FitsStubImagePlugin.py b/src/PIL/FitsStubImagePlugin.py
--- a/src/PIL/FitsStubImagePlugin.py
+++ b/src/PIL/FitsStubImagePlugin.py
@@ -9,7 +9,9 @@
 # See the README file for information on usage and redistribution.
 #
 
-from . import Image, ImageFile
+import warnings
+
+from . import FitsImagePlugin, Image, ImageFile
 
 _handler = None
 
@@ -23,57 +25,37 @@ def register_handler(handler):
     global _handler
     _handler = handler
 
+    warnings.warn(
+        "FitsStubImagePlugin is deprecated and will be removed in Pillow "
+        "10 (2023-07-01). FITS images can now be read without a handler through "
+        "FitsImagePlugin instead.",
+        DeprecationWarning,
+    )
 
-# --------------------------------------------------------------------
-# Image adapter
-
+    # Override FitsImagePlugin with this handler
+    # for backwards compatibility
+    try:
+        Image.ID.remove(FITSStubImageFile.format)
+    except ValueError:
+        pass
 
-def _accept(prefix):
-    return prefix[:6] == b"SIMPLE"
+    Image.register_open(
+        FITSStubImageFile.format, FITSStubImageFile, FitsImagePlugin._accept
+    )
 
 
 class FITSStubImageFile(ImageFile.StubImageFile):
 
-    format = "FITS"
-    format_description = "FITS"
+    format = FitsImagePlugin.FitsImageFile.format
+    format_description = FitsImagePlugin.FitsImageFile.format_description
 
     def _open(self):
         offset = self.fp.tell()
 
-        headers = {}
-        while True:
-            header = self.fp.read(80)
-            if not header:
-                raise OSError("Truncated FITS file")
-            keyword = header[:8].strip()
-            if keyword == b"END":
-                break
-            value = header[8:].strip()
-            if value.startswith(b"="):
-                value = value[1:].strip()
-            if not headers and (not _accept(keyword) or value != b"T"):
-                raise SyntaxError("Not a FITS file")
-            headers[keyword] = value
-
-        naxis = int(headers[b"NAXIS"])
-        if naxis == 0:
-            raise ValueError("No image data")
-        elif naxis == 1:
-            self._size = 1, int(headers[b"NAXIS1"])
-        else:
-            self._size = int(headers[b"NAXIS1"]), int(headers[b"NAXIS2"])
-
-        number_of_bits = int(headers[b"BITPIX"])
-        if number_of_bits == 8:
-            self.mode = "L"
-        elif number_of_bits == 16:
-            self.mode = "I"
-            # rawmode = "I;16S"
-        elif number_of_bits == 32:
-            self.mode = "I"
-        elif number_of_bits in (-32, -64):
-            self.mode = "F"
-            # rawmode = "F" if number_of_bits == -32 else "F;64F"
+        im = FitsImagePlugin.FitsImageFile(self.fp)
+        self._size = im.size
+        self.mode = im.mode
+        self.tile = []
 
         self.fp.seek(offset)
 
@@ -86,15 +68,10 @@ def _load(self):
 
 
 def _save(im, fp, filename):
-    if _handler is None or not hasattr("_handler", "save"):
-        raise OSError("FITS save handler not installed")
-    _handler.save(im, fp, filename)
+    raise OSError("FITS save handler not installed")
 
 
 # --------------------------------------------------------------------
 # Registry
 
-Image.register_open(FITSStubImageFile.format, FITSStubImageFile, _accept)
 Image.register_save(FITSStubImageFile.format, _save)
-
-Image.register_extensions(FITSStubImageFile.format, [".fit", ".fits"])
diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py
--- a/src/PIL/__init__.py
+++ b/src/PIL/__init__.py
@@ -30,6 +30,7 @@
     "DcxImagePlugin",
     "DdsImagePlugin",
     "EpsImagePlugin",
+    "FitsImagePlugin",
     "FitsStubImagePlugin",
     "FliImagePlugin",
     "FpxImagePlugin",


Test patch:
diff --git a/Tests/images/hopper.fits b/Tests/images/hopper.fits
Binary files a/Tests/images/hopper.fits and b/Tests/images/hopper.fits differ
diff --git a/Tests/test_file_fits.py b/Tests/test_file_fits.py
new file mode 100644
--- /dev/null
+++ b/Tests/test_file_fits.py
@@ -0,0 +1,80 @@
+from io import BytesIO
+
+import pytest
+
+from PIL import FitsImagePlugin, FitsStubImagePlugin, Image
+
+from .helper import assert_image_equal, hopper
+
+TEST_FILE = "Tests/images/hopper.fits"
+
+
+def test_open():
+    # Act
+    with Image.open(TEST_FILE) as im:
+
+        # Assert
+        assert im.format == "FITS"
+        assert im.size == (128, 128)
+        assert im.mode == "L"
+
+        assert_image_equal(im, hopper("L"))
+
+
+def test_invalid_file():
+    # Arrange
+    invalid_file = "Tests/images/flower.jpg"
+
+    # Act / Assert
+    with pytest.raises(SyntaxError):
+        FitsImagePlugin.FitsImageFile(invalid_file)
+
+
+def test_truncated_fits():
+    # No END to headers
+    image_data = b"SIMPLE  =                    T" + b" " * 50 + b"TRUNCATE"
+    with pytest.raises(OSError):
+        FitsImagePlugin.FitsImageFile(BytesIO(image_data))
+
+
+def test_naxis_zero():
+    # This test image has been manually hexedited
+    # to set the number of data axes to zero
+    with pytest.raises(ValueError):
+        with Image.open("Tests/images/hopper_naxis_zero.fits"):
+            pass
+
+
+def test_stub_deprecated():
+    class Handler:
+        opened = False
+        loaded = False
+
+        def open(self, im):
+            self.opened = True
+
+        def load(self, im):
+            self.loaded = True
+            return Image.new("RGB", (1, 1))
+
+    handler = Handler()
+    with pytest.warns(DeprecationWarning):
+        FitsStubImagePlugin.register_handler(handler)
+
+    with Image.open(TEST_FILE) as im:
+        assert im.format == "FITS"
+        assert im.size == (128, 128)
+        assert im.mode == "L"
+
+        assert handler.opened
+        assert not handler.loaded
+
+        im.load()
+        assert handler.loaded
+
+    FitsStubImagePlugin._handler = None
+    Image.register_open(
+        FitsImagePlugin.FitsImageFile.format,
+        FitsImagePlugin.FitsImageFile,
+        FitsImagePlugin._accept,
+    )
diff --git a/Tests/test_file_fitsstub.py b/Tests/test_file_fitsstub.py
deleted file mode 100644
--- a/Tests/test_file_fitsstub.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from io import BytesIO
-
-import pytest
-
-from PIL import FitsStubImagePlugin, Image
-
-TEST_FILE = "Tests/images/hopper.fits"
-
-
-def test_open():
-    # Act
-    with Image.open(TEST_FILE) as im:
-
-        # Assert
-        assert im.format == "FITS"
-        assert im.size == (128, 128)
-        assert im.mode == "L"
-
-
-def test_invalid_file():
-    # Arrange
-    invalid_file = "Tests/images/flower.jpg"
-
-    # Act / Assert
-    with pytest.raises(SyntaxError):
-        FitsStubImagePlugin.FITSStubImageFile(invalid_file)
-
-
-def test_load():
-    # Arrange
-    with Image.open(TEST_FILE) as im:
-
-        # Act / Assert: stub cannot load without an implemented handler
-        with pytest.raises(OSError):
-            im.load()
-
-
-def test_truncated_fits():
-    # No END to headers
-    image_data = b"SIMPLE  =                    T" + b" " * 50 + b"TRUNCATE"
-    with pytest.raises(OSError):
-        FitsStubImagePlugin.FITSStubImageFile(BytesIO(image_data))
-
-
-def test_naxis_zero():
-    # This test image has been manually hexedited
-    # to set the number of data axes to zero
-    with pytest.raises(ValueError):
-        with Image.open("Tests/images/hopper_naxis_zero.fits"):
-            pass
-
-
-def test_save():
-    # Arrange
-    with Image.open(TEST_FILE) as im:
-        dummy_fp = None
-        dummy_filename = "dummy.filename"
-
-        # Act / Assert: stub cannot save without an implemented handler
-        with pytest.raises(OSError):
-            im.save(dummy_filename)
-        with pytest.raises(OSError):
-            FitsStubImagePlugin._save(im, dummy_fp, dummy_filename)