Skip to content

Commit 93c8a60

Browse files
authored
Lazy import only required plugin: open 2.3-15.6x & save 2.2-9x faster (#9398)
2 parents b617830 + d568c8d commit 93c8a60

File tree

4 files changed

+143
-18
lines changed

4 files changed

+143
-18
lines changed

Tests/test_file_spider.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
TEST_FILE = "Tests/images/hopper.spider"
1515

1616

17+
def teardown_module() -> None:
18+
del Image.EXTENSION[".spider"]
19+
20+
1721
def test_sanity() -> None:
1822
with Image.open(TEST_FILE) as im:
1923
im.load()

Tests/test_image.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,9 @@ def test_registered_extensions_uninitialized(self) -> None:
466466
# Assert
467467
assert Image._initialized == 2
468468

469+
for extension in Image.EXTENSION:
470+
assert extension in Image._EXTENSION_PLUGIN
471+
469472
def test_registered_extensions(self) -> None:
470473
# Arrange
471474
# Open an image to trigger plugin registration

src/PIL/Image.py

Lines changed: 133 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,108 @@ def getmodebands(mode: str) -> int:
323323

324324
_initialized = 0
325325

326+
# Mapping from file extension to plugin module name for lazy importing
327+
_EXTENSION_PLUGIN: dict[str, str] = {
328+
# Common formats (preinit)
329+
".bmp": "BmpImagePlugin",
330+
".dib": "BmpImagePlugin",
331+
".gif": "GifImagePlugin",
332+
".jfif": "JpegImagePlugin",
333+
".jpe": "JpegImagePlugin",
334+
".jpg": "JpegImagePlugin",
335+
".jpeg": "JpegImagePlugin",
336+
".pbm": "PpmImagePlugin",
337+
".pgm": "PpmImagePlugin",
338+
".pnm": "PpmImagePlugin",
339+
".ppm": "PpmImagePlugin",
340+
".pfm": "PpmImagePlugin",
341+
".png": "PngImagePlugin",
342+
".apng": "PngImagePlugin",
343+
# Less common formats (init)
344+
".avif": "AvifImagePlugin",
345+
".avifs": "AvifImagePlugin",
346+
".blp": "BlpImagePlugin",
347+
".bufr": "BufrStubImagePlugin",
348+
".cur": "CurImagePlugin",
349+
".dcx": "DcxImagePlugin",
350+
".dds": "DdsImagePlugin",
351+
".ps": "EpsImagePlugin",
352+
".eps": "EpsImagePlugin",
353+
".fit": "FitsImagePlugin",
354+
".fits": "FitsImagePlugin",
355+
".fli": "FliImagePlugin",
356+
".flc": "FliImagePlugin",
357+
".fpx": "FpxImagePlugin",
358+
".ftc": "FtexImagePlugin",
359+
".ftu": "FtexImagePlugin",
360+
".gbr": "GbrImagePlugin",
361+
".grib": "GribStubImagePlugin",
362+
".h5": "Hdf5StubImagePlugin",
363+
".hdf": "Hdf5StubImagePlugin",
364+
".icns": "IcnsImagePlugin",
365+
".ico": "IcoImagePlugin",
366+
".im": "ImImagePlugin",
367+
".iim": "IptcImagePlugin",
368+
".jp2": "Jpeg2KImagePlugin",
369+
".j2k": "Jpeg2KImagePlugin",
370+
".jpc": "Jpeg2KImagePlugin",
371+
".jpf": "Jpeg2KImagePlugin",
372+
".jpx": "Jpeg2KImagePlugin",
373+
".j2c": "Jpeg2KImagePlugin",
374+
".mic": "MicImagePlugin",
375+
".mpg": "MpegImagePlugin",
376+
".mpeg": "MpegImagePlugin",
377+
".mpo": "MpoImagePlugin",
378+
".msp": "MspImagePlugin",
379+
".palm": "PalmImagePlugin",
380+
".pcd": "PcdImagePlugin",
381+
".pcx": "PcxImagePlugin",
382+
".pdf": "PdfImagePlugin",
383+
".pxr": "PixarImagePlugin",
384+
".psd": "PsdImagePlugin",
385+
".qoi": "QoiImagePlugin",
386+
".bw": "SgiImagePlugin",
387+
".rgb": "SgiImagePlugin",
388+
".rgba": "SgiImagePlugin",
389+
".sgi": "SgiImagePlugin",
390+
".ras": "SunImagePlugin",
391+
".tga": "TgaImagePlugin",
392+
".icb": "TgaImagePlugin",
393+
".vda": "TgaImagePlugin",
394+
".vst": "TgaImagePlugin",
395+
".tif": "TiffImagePlugin",
396+
".tiff": "TiffImagePlugin",
397+
".webp": "WebPImagePlugin",
398+
".wmf": "WmfImagePlugin",
399+
".emf": "WmfImagePlugin",
400+
".xbm": "XbmImagePlugin",
401+
".xpm": "XpmImagePlugin",
402+
}
403+
404+
405+
def _import_plugin_for_extension(ext: str | bytes) -> bool:
406+
"""Import only the plugin needed for a specific file extension."""
407+
if not ext:
408+
return False
409+
410+
if isinstance(ext, bytes):
411+
ext = ext.decode()
412+
ext = ext.lower()
413+
if ext in EXTENSION:
414+
return True
415+
416+
plugin = _EXTENSION_PLUGIN.get(ext)
417+
if plugin is None:
418+
return False
419+
420+
try:
421+
logger.debug("Importing %s", plugin)
422+
__import__(f"{__spec__.parent}.{plugin}", globals(), locals(), [])
423+
return True
424+
except ImportError as e:
425+
logger.debug("Image: failed to import %s: %s", plugin, e)
426+
return False
427+
326428

327429
def preinit() -> None:
328430
"""
@@ -382,11 +484,10 @@ def init() -> bool:
382484
if _initialized >= 2:
383485
return False
384486

385-
parent_name = __name__.rpartition(".")[0]
386487
for plugin in _plugins:
387488
try:
388489
logger.debug("Importing %s", plugin)
389-
__import__(f"{parent_name}.{plugin}", globals(), locals(), [])
490+
__import__(f"{__spec__.parent}.{plugin}", globals(), locals(), [])
390491
except ImportError as e:
391492
logger.debug("Image: failed to import %s: %s", plugin, e)
392493

@@ -2535,12 +2636,20 @@ def save(
25352636
# only set the name for metadata purposes
25362637
filename = os.fspath(fp.name)
25372638

2538-
preinit()
2639+
if format:
2640+
preinit()
2641+
else:
2642+
filename_ext = os.path.splitext(filename)[1].lower()
2643+
ext = (
2644+
filename_ext.decode()
2645+
if isinstance(filename_ext, bytes)
2646+
else filename_ext
2647+
)
25392648

2540-
filename_ext = os.path.splitext(filename)[1].lower()
2541-
ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext
2649+
# Try importing only the plugin for this extension first
2650+
if not _import_plugin_for_extension(ext):
2651+
preinit()
25422652

2543-
if not format:
25442653
if ext not in EXTENSION:
25452654
init()
25462655
try:
@@ -3524,7 +3633,11 @@ def open(
35243633

35253634
prefix = fp.read(16)
35263635

3527-
preinit()
3636+
# Try to import just the plugin needed for this file extension
3637+
# before falling back to preinit() which imports common plugins
3638+
ext = os.path.splitext(filename)[1] if filename else ""
3639+
if not _import_plugin_for_extension(ext):
3640+
preinit()
35283641

35293642
warning_messages: list[str] = []
35303643

@@ -3560,14 +3673,19 @@ def _open_core(
35603673
im = _open_core(fp, filename, prefix, formats)
35613674

35623675
if im is None and formats is ID:
3563-
checked_formats = ID.copy()
3564-
if init():
3565-
im = _open_core(
3566-
fp,
3567-
filename,
3568-
prefix,
3569-
tuple(format for format in formats if format not in checked_formats),
3570-
)
3676+
# Try preinit (few common plugins) then init (all plugins)
3677+
for loader in (preinit, init):
3678+
checked_formats = ID.copy()
3679+
loader()
3680+
if formats != checked_formats:
3681+
im = _open_core(
3682+
fp,
3683+
filename,
3684+
prefix,
3685+
tuple(f for f in formats if f not in checked_formats),
3686+
)
3687+
if im is not None:
3688+
break
35713689

35723690
if im:
35733691
im._exclusive_fp = exclusive_fp

src/PIL/SpiderImagePlugin.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
290290

291291
def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
292292
# get the filename extension and register it with Image
293-
filename_ext = os.path.splitext(filename)[1]
294-
ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext
295-
Image.register_extension(SpiderImageFile.format, ext)
293+
if filename_ext := os.path.splitext(filename)[1]:
294+
ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext
295+
Image.register_extension(SpiderImageFile.format, ext)
296296
_save(im, fp, filename)
297297

298298

0 commit comments

Comments
 (0)