From 6b9de40533cf4ec60fcbb6c7351eed417dd2b042 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:59:28 +0200 Subject: [PATCH 01/12] Lazy import only required plugin --- src/PIL/Image.py | 126 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index eb5616117..cd1abb935 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -323,6 +323,99 @@ def getmodebands(mode: str) -> int: _initialized = 0 +# Mapping from file extension to plugin module name for lazy loading +_EXTENSION_PLUGIN: dict[str, str] = { + # Common formats (preinit) + ".bmp": "BmpImagePlugin", + ".dib": "BmpImagePlugin", + ".gif": "GifImagePlugin", + ".jfif": "JpegImagePlugin", + ".jpe": "JpegImagePlugin", + ".jpg": "JpegImagePlugin", + ".jpeg": "JpegImagePlugin", + ".pbm": "PpmImagePlugin", + ".pgm": "PpmImagePlugin", + ".pnm": "PpmImagePlugin", + ".ppm": "PpmImagePlugin", + ".pfm": "PpmImagePlugin", + ".png": "PngImagePlugin", + ".apng": "PngImagePlugin", + # Less common formats (init) + ".avif": "AvifImagePlugin", + ".avifs": "AvifImagePlugin", + ".blp": "BlpImagePlugin", + ".bufr": "BufrStubImagePlugin", + ".cur": "CurImagePlugin", + ".dcx": "DcxImagePlugin", + ".dds": "DdsImagePlugin", + ".ps": "EpsImagePlugin", + ".eps": "EpsImagePlugin", + ".fit": "FitsImagePlugin", + ".fits": "FitsImagePlugin", + ".fli": "FliImagePlugin", + ".flc": "FliImagePlugin", + ".fpx": "FpxImagePlugin", + ".ftc": "FtexImagePlugin", + ".ftu": "FtexImagePlugin", + ".gbr": "GbrImagePlugin", + ".grib": "GribStubImagePlugin", + ".h5": "Hdf5StubImagePlugin", + ".hdf": "Hdf5StubImagePlugin", + ".icns": "IcnsImagePlugin", + ".ico": "IcoImagePlugin", + ".im": "ImImagePlugin", + ".iim": "IptcImagePlugin", + ".jp2": "Jpeg2KImagePlugin", + ".j2k": "Jpeg2KImagePlugin", + ".jpc": "Jpeg2KImagePlugin", + ".jpf": "Jpeg2KImagePlugin", + ".jpx": "Jpeg2KImagePlugin", + ".j2c": "Jpeg2KImagePlugin", + ".mic": "MicImagePlugin", + ".mpg": "MpegImagePlugin", + ".mpeg": "MpegImagePlugin", + ".mpo": "MpoImagePlugin", + ".msp": "MspImagePlugin", + ".palm": "PalmImagePlugin", + ".pcd": "PcdImagePlugin", + ".pcx": "PcxImagePlugin", + ".pdf": "PdfImagePlugin", + ".pxr": "PixarImagePlugin", + ".psd": "PsdImagePlugin", + ".qoi": "QoiImagePlugin", + ".bw": "SgiImagePlugin", + ".rgb": "SgiImagePlugin", + ".rgba": "SgiImagePlugin", + ".sgi": "SgiImagePlugin", + ".ras": "SunImagePlugin", + ".tga": "TgaImagePlugin", + ".icb": "TgaImagePlugin", + ".vda": "TgaImagePlugin", + ".vst": "TgaImagePlugin", + ".tif": "TiffImagePlugin", + ".tiff": "TiffImagePlugin", + ".webp": "WebPImagePlugin", + ".wmf": "WmfImagePlugin", + ".emf": "WmfImagePlugin", + ".xbm": "XbmImagePlugin", + ".xpm": "XpmImagePlugin", +} + + +def _load_plugin_for_extension(ext: str | bytes) -> bool: + """Load only the plugin needed for a specific file extension.""" + if isinstance(ext, bytes): + ext = ext.decode() + plugin = _EXTENSION_PLUGIN.get(ext.lower()) + if plugin is None: + return False + + try: + __import__(f"PIL.{plugin}", globals(), locals(), []) + return True + except ImportError: + return False + def preinit() -> None: """ @@ -2535,11 +2628,13 @@ class Image: # only set the name for metadata purposes filename = os.fspath(fp.name) - preinit() - filename_ext = os.path.splitext(filename)[1].lower() ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext + # Try loading only the plugin for this extension first + if not _load_plugin_for_extension(ext): + preinit() + if not format: if ext not in EXTENSION: init() @@ -3524,7 +3619,11 @@ def open( prefix = fp.read(16) - preinit() + # Try to load just the plugin needed for this file extension + # before falling back to preinit() which loads common plugins + ext = os.path.splitext(filename)[1] if filename else "" + if not (ext and _load_plugin_for_extension(ext)): + preinit() warning_messages: list[str] = [] @@ -3560,14 +3659,19 @@ def open( im = _open_core(fp, filename, prefix, formats) if im is None and formats is ID: - checked_formats = ID.copy() - if init(): - im = _open_core( - fp, - filename, - prefix, - tuple(format for format in formats if format not in checked_formats), - ) + # Try preinit (few common plugins) then init (all plugins) + for loader in (preinit, init): + checked_formats = ID.copy() + loader() + if formats != checked_formats: + im = _open_core( + fp, + filename, + prefix, + tuple(f for f in formats if f not in checked_formats), + ) + if im is not None: + break if im: im._exclusive_fp = exclusive_fp From 1baf141146fdc46262460d8ca8617f1e46d29969 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 19 Jan 2026 17:13:43 +1100 Subject: [PATCH 02/12] Check that _EXTENSION_PLUGIN contains all registered extensions --- Tests/test_image.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/test_image.py b/Tests/test_image.py index afc6e8e16..c064939e8 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -466,6 +466,9 @@ class TestImage: # Assert assert Image._initialized == 2 + for extension in Image.EXTENSION: + assert extension in Image._EXTENSION_PLUGIN + def test_registered_extensions(self) -> None: # Arrange # Open an image to trigger plugin registration From 9c8059fdea6d4272b9eacc2eeb05dc32efe40d4f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 19 Jan 2026 17:18:30 +1100 Subject: [PATCH 03/12] Cleanup .spider extension registered by test code during save --- Tests/test_file_spider.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 3b3c3b4a5..03494523b 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -14,6 +14,10 @@ from .helper import assert_image_equal, hopper, is_pypy TEST_FILE = "Tests/images/hopper.spider" +def teardown_module() -> None: + del Image.EXTENSION[".spider"] + + def test_sanity() -> None: with Image.open(TEST_FILE) as im: im.load() From b06118c2b3287d8061162c3bb90f2b672a5713e3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 19 Jan 2026 17:24:28 +1100 Subject: [PATCH 04/12] Do not register empty extension --- src/PIL/SpiderImagePlugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 866292243..848dccda5 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -290,9 +290,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # get the filename extension and register it with Image - filename_ext = os.path.splitext(filename)[1] - ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext - Image.register_extension(SpiderImageFile.format, ext) + if filename_ext := os.path.splitext(filename)[1]: + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext + Image.register_extension(SpiderImageFile.format, ext) _save(im, fp, filename) From 096c479cfb2f71fecbb38a9b2dc12e5a49518ed8 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:28:42 +0200 Subject: [PATCH 05/12] If plugin has already been imported and registered the extension, return early Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/Image.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index cd1abb935..dfd64ef19 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -406,6 +406,9 @@ def _load_plugin_for_extension(ext: str | bytes) -> bool: """Load only the plugin needed for a specific file extension.""" if isinstance(ext, bytes): ext = ext.decode() + if ext in EXTENSION: + return True + plugin = _EXTENSION_PLUGIN.get(ext.lower()) if plugin is None: return False From a0f51493ca8ce7e90169a41d96d037ef413b659f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 22 Jan 2026 00:23:58 +1100 Subject: [PATCH 06/12] Refer to lazy importing, as lazy loading of images is separate --- src/PIL/Image.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index dfd64ef19..88f79a792 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -323,7 +323,7 @@ def getmodebands(mode: str) -> int: _initialized = 0 -# Mapping from file extension to plugin module name for lazy loading +# Mapping from file extension to plugin module name for lazy importing _EXTENSION_PLUGIN: dict[str, str] = { # Common formats (preinit) ".bmp": "BmpImagePlugin", @@ -402,8 +402,8 @@ _EXTENSION_PLUGIN: dict[str, str] = { } -def _load_plugin_for_extension(ext: str | bytes) -> bool: - """Load only the plugin needed for a specific file extension.""" +def _import_plugin_for_extension(ext: str | bytes) -> bool: + """Import only the plugin needed for a specific file extension.""" if isinstance(ext, bytes): ext = ext.decode() if ext in EXTENSION: @@ -2634,8 +2634,8 @@ class Image: filename_ext = os.path.splitext(filename)[1].lower() ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext - # Try loading only the plugin for this extension first - if not _load_plugin_for_extension(ext): + # Try importing only the plugin for this extension first + if not _import_plugin_for_extension(ext): preinit() if not format: @@ -3625,7 +3625,7 @@ def open( # Try to load just the plugin needed for this file extension # before falling back to preinit() which loads common plugins ext = os.path.splitext(filename)[1] if filename else "" - if not (ext and _load_plugin_for_extension(ext)): + if not (ext and _import_plugin_for_extension(ext)): preinit() warning_messages: list[str] = [] From a6b36f0b6bd91a899b6a4038b7f875ce5382776b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 22 Jan 2026 00:28:26 +1100 Subject: [PATCH 07/12] format overrides file extension when saving --- src/PIL/Image.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 88f79a792..e7357cc27 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2631,14 +2631,20 @@ class Image: # only set the name for metadata purposes filename = os.fspath(fp.name) - filename_ext = os.path.splitext(filename)[1].lower() - ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext - - # Try importing only the plugin for this extension first - if not _import_plugin_for_extension(ext): + if format: preinit() + else: + filename_ext = os.path.splitext(filename)[1].lower() + ext = ( + filename_ext.decode() + if isinstance(filename_ext, bytes) + else filename_ext + ) + + # Try importing only the plugin for this extension first + if not _import_plugin_for_extension(ext): + preinit() - if not format: if ext not in EXTENSION: init() try: From 76d3116ef030e9abb960f6544a272d3060ed2353 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 22 Jan 2026 18:48:38 +1100 Subject: [PATCH 08/12] Added logger messages to match init() --- src/PIL/Image.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index e7357cc27..7d007718d 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -414,9 +414,11 @@ def _import_plugin_for_extension(ext: str | bytes) -> bool: return False try: + logger.debug("Importing %s", plugin) __import__(f"PIL.{plugin}", globals(), locals(), []) return True - except ImportError: + except ImportError as e: + logger.debug("Image: failed to import %s: %s", plugin, e) return False From 34814d8d2fbcace1f31ad5857e1d61d540100812 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 24 Jan 2026 12:52:49 +0200 Subject: [PATCH 09/12] Improve wording Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/Image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 7d007718d..9ed251496 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -3630,8 +3630,8 @@ def open( prefix = fp.read(16) - # Try to load just the plugin needed for this file extension - # before falling back to preinit() which loads common plugins + # Try to import just the plugin needed for this file extension + # before falling back to preinit() which imports common plugins ext = os.path.splitext(filename)[1] if filename else "" if not (ext and _import_plugin_for_extension(ext)): preinit() From c03618551492c55e98425254496112a3bee501b1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:48:41 +0200 Subject: [PATCH 10/12] Ensure lower before checking if ext in EXTENSION Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/Image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 9ed251496..743632e1f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -406,10 +406,11 @@ def _import_plugin_for_extension(ext: str | bytes) -> bool: """Import only the plugin needed for a specific file extension.""" if isinstance(ext, bytes): ext = ext.decode() + ext = ext.lower() if ext in EXTENSION: return True - plugin = _EXTENSION_PLUGIN.get(ext.lower()) + plugin = _EXTENSION_PLUGIN.get(ext) if plugin is None: return False From 2b186fceb8b3da3104e1ad7635b522aca481663f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:02:39 +0200 Subject: [PATCH 11/12] Use __spec__.parent instead of calculating each time --- src/PIL/Image.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 743632e1f..3e37754e1 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -416,7 +416,7 @@ def _import_plugin_for_extension(ext: str | bytes) -> bool: try: logger.debug("Importing %s", plugin) - __import__(f"PIL.{plugin}", globals(), locals(), []) + __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) return True except ImportError as e: logger.debug("Image: failed to import %s: %s", plugin, e) @@ -481,11 +481,10 @@ def init() -> bool: if _initialized >= 2: return False - parent_name = __name__.rpartition(".")[0] for plugin in _plugins: try: logger.debug("Importing %s", plugin) - __import__(f"{parent_name}.{plugin}", globals(), locals(), []) + __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) except ImportError as e: logger.debug("Image: failed to import %s: %s", plugin, e) From d08d7ee99e4f5f986f37709ab7308dcbc8c4a1d8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 25 Jan 2026 22:55:19 +1100 Subject: [PATCH 12/12] Check ext is not empty during save --- src/PIL/Image.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 3e37754e1..8a28b56bd 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -404,6 +404,9 @@ _EXTENSION_PLUGIN: dict[str, str] = { def _import_plugin_for_extension(ext: str | bytes) -> bool: """Import only the plugin needed for a specific file extension.""" + if not ext: + return False + if isinstance(ext, bytes): ext = ext.decode() ext = ext.lower() @@ -3633,7 +3636,7 @@ def open( # Try to import just the plugin needed for this file extension # before falling back to preinit() which imports common plugins ext = os.path.splitext(filename)[1] if filename else "" - if not (ext and _import_plugin_for_extension(ext)): + if not _import_plugin_for_extension(ext): preinit() warning_messages: list[str] = []