Skip to content

Commit 61b391b

Browse files
authored
Merge pull request #119 from ArcanaFramework/comprehensive-mime-like
Optional loading of extras dependencies
2 parents 7137bac + fa032e1 commit 61b391b

File tree

17 files changed

+287
-78
lines changed

17 files changed

+287
-78
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ extensions and magic numbers. As such, many of the formats in the library have n
3737
tested on real data and so should be treated with some caution. If you encounter any issues with an implemented file
3838
type, please raise an issue in the [GitHub tracker](https://github.com/ArcanaFramework/fileformats/issues).
3939

40-
Adding support for vendor formats is planned for v1.0.
40+
A small selection of vendor-specific types can be found under `fileformats.vendor.*`. Support for additional vendor-specific
41+
formats can be added via plugin (see the
42+
[extension template](https://github.com/ArcanaFramework/fileformats-extension-template)).
4143

4244
## Installation
4345

extras/fileformats/extras/application/medical.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import sys
22
import typing as ty
33
from pathlib import Path
4-
import pydicom.tag
54
from fileformats.core import FileSet, extra_implementation
5+
from fileformats.extras.core import check_optional_dependency
66
from fileformats.application import Dicom
7-
import medimages4tests.dummy.dicom.mri.t1w.siemens.skyra.syngo_d13c
87
from fileformats.core import SampleFileGenerator
98

9+
try:
10+
import pydicom.tag
11+
except ImportError:
12+
pydicom = None # type: ignore[assignment]
13+
try:
14+
import medimages4tests.dummy.dicom.mri.t1w.siemens.skyra.syngo_d13c
15+
except ImportError:
16+
medimages4tests = None # type: ignore[assignment]
17+
1018
if sys.version_info <= (3, 11):
1119
from typing_extensions import TypeAlias
1220
else:
@@ -16,7 +24,7 @@
1624
ty.List[int],
1725
ty.List[str],
1826
ty.List[ty.Tuple[int, int]],
19-
ty.List[pydicom.tag.BaseTag],
27+
ty.List["pydicom.tag.BaseTag"],
2028
]
2129

2230

@@ -26,6 +34,7 @@ def dicom_read_metadata(
2634
metadata_keys: ty.Optional[TagListType] = None,
2735
**kwargs: ty.Any,
2836
) -> ty.Mapping[str, ty.Any]:
37+
check_optional_dependency(pydicom)
2938
dcm = pydicom.dcmread(
3039
dicom.fspath, specific_tags=metadata_keys, stop_before_pixels=True
3140
)
@@ -37,6 +46,7 @@ def dicom_generate_sample_data(
3746
dicom: Dicom,
3847
generator: SampleFileGenerator,
3948
) -> ty.List[Path]:
49+
check_optional_dependency(medimages4tests)
4050
return next(
4151
medimages4tests.dummy.dicom.mri.t1w.siemens.skyra.syngo_d13c.get_image(
4252
out_dir=generator.dest_dir
@@ -49,15 +59,17 @@ def dicom_load(
4959
dicom: Dicom,
5060
specific_tags: ty.Optional[TagListType] = None,
5161
**kwargs: ty.Any,
52-
) -> pydicom.FileDataset:
62+
) -> "pydicom.FileDataset":
63+
check_optional_dependency(pydicom)
5364
return pydicom.dcmread(dicom.fspath, specific_tags=specific_tags)
5465

5566

5667
@extra_implementation(FileSet.save)
5768
def dicom_save(
5869
dicom: Dicom,
59-
data: pydicom.FileDataset,
70+
data: "pydicom.FileDataset",
6071
write_like_original: bool = False,
6172
**kwargs: ty.Any,
6273
) -> None:
74+
check_optional_dependency(pydicom)
6375
pydicom.dcmwrite(dicom.fspath, data, write_like_original=write_like_original)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
11
"""Only required to hold the automatically generated version for the "core" extras"""
22

3+
import typing as ty
4+
import types
5+
import inspect
36
from ._version import __version__
7+
8+
9+
def check_optional_dependency(module: ty.Optional[types.ModuleType]) -> None:
10+
if module is None:
11+
frame = inspect.currentframe()
12+
while frame:
13+
# Find the frame where the decorated_extra method was called
14+
if frame.f_code.co_name == "decorated_extra":
15+
extras_module = frame.f_locals["extras"][0].pkg.split(".")[-1]
16+
break
17+
frame = frame.f_back
18+
raise ImportError(
19+
f"The optional dependencies are not installed for '{extras_module}', please include when "
20+
f"installing fileformats-extras, e.g. `pip install 'fileformats-extras[{extras_module}]'`"
21+
)
22+
23+
24+
__all__ = ["__version__"]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from fileformats.testing import MyFormat
2+
import pytest
3+
4+
5+
def test_check_optional_dependency_fail():
6+
7+
with pytest.raises(
8+
ImportError, match="The optional dependencies are not installed for 'testing'"
9+
):
10+
MyFormat.sample().dummy_extra()
Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
1-
import imageio
21
import typing as ty
3-
import numpy # noqa: F401
4-
import typing # noqa: F401
2+
3+
4+
# import numpy # noqa: F401
5+
# import typing # noqa: F401
6+
from fileformats.extras.core import check_optional_dependency
57
from fileformats.core import FileSet, extra_implementation
68
from fileformats.image.raster import RasterImage, DataArrayType
79

10+
try:
11+
import imageio # noqa: F401
12+
except ImportError:
13+
imageio = None # type: ignore
14+
815

916
@extra_implementation(FileSet.load)
1017
def read_raster_data(image: RasterImage, **kwargs: ty.Any) -> DataArrayType:
18+
check_optional_dependency(imageio)
19+
1120
return imageio.imread(image.fspath) # type: ignore
1221

1322

1423
@extra_implementation(FileSet.save)
1524
def write_raster_data(
1625
image: RasterImage, data: DataArrayType, **kwargs: ty.Any
1726
) -> None:
27+
check_optional_dependency(imageio)
28+
1829
imageio.imwrite(image.fspath, data, **kwargs)

extras/fileformats/extras/image/tests/test_image_converters.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1-
# import sys
21
import pytest
3-
from imageio.core.fetching import get_remote_file
42
from fileformats.image import Bitmap, Gif, Jpeg, Png, Tiff
3+
from imageio.core.fetching import get_remote_file
54

65

76
@pytest.fixture(scope="session")
87
def jpg() -> Jpeg:
9-
# imageio.imread("imageio:bricks.jpg")
108
return Jpeg(get_remote_file("images/bricks.jpg"))
119

1210

1311
@pytest.fixture(scope="session")
1412
def png() -> Png:
13+
1514
return Png(get_remote_file("images/chelsea.png"))
1615

1716

extras/fileformats/extras/testing/__init__.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from pydra.compose import python
22

3-
from fileformats.core import converter
4-
from fileformats.testing import AbstractFile, ConvertibleToFile, EncodedText
3+
from fileformats.core import converter, extra_implementation
4+
from fileformats.extras.core import check_optional_dependency
5+
from fileformats.testing import AbstractFile, ConvertibleToFile, EncodedText, MyFormat
56
from fileformats.text import TextFile
67

8+
dummy_import = None
9+
710

811
@converter # pyright: ignore[reportArgumentType]
912
@python.define(outputs=["out_file"]) # type: ignore[misc]
@@ -31,3 +34,9 @@ def EncodedToTextConverter(in_file: EncodedText) -> TextFile:
3134
out_file = TextFile.sample()
3235
out_file.write_text(decoded_contents)
3336
return out_file
37+
38+
39+
@extra_implementation(MyFormat.dummy_extra)
40+
def my_format_dummy_extra(my_format: MyFormat) -> int:
41+
check_optional_dependency(dummy_import)
42+
return 42
Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import typing as ty
22

3-
from docx import Document
4-
from docx.document import Document as DocumentObject
3+
try:
4+
import docx.document
5+
except ImportError:
6+
docx = None # type: ignore[assignment]
57

68
from fileformats.core import FileSet, extra_implementation
9+
from fileformats.extras.core import check_optional_dependency
710
from fileformats.vendor.openxmlformats_officedocument.application import (
811
Wordprocessingml_Document as MswordX,
912
)
1013

1114

1215
@extra_implementation(FileSet.load)
13-
def load_docx(doc: MswordX, **kwargs: ty.Any) -> DocumentObject:
14-
return Document(str(doc)) # type: ignore[no-any-return]
16+
def load_docx(doc: MswordX, **kwargs: ty.Any) -> "docx.document.Document":
17+
check_optional_dependency(docx)
18+
return docx.Document(str(doc)) # type: ignore[no-any-return]
1519

1620

1721
@extra_implementation(FileSet.save)
18-
def save_docx(doc: MswordX, data: DocumentObject, **kwargs: ty.Any) -> None:
22+
def save_docx(doc: MswordX, data: "docx.document.Document", **kwargs: ty.Any) -> None:
23+
check_optional_dependency(docx)
24+
if not isinstance(data, docx.document.Document):
25+
raise TypeError(f"Expected a 'docx.document.Document' object, got {type(data)}")
1926
data.save(str(doc))

extras/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ test = [
3737
"pytest-env>=0.6.2",
3838
"pytest-cov>=2.12.1",
3939
"codecov",
40-
"medimages4tests",
4140
]
4241
application = [
4342
"PyYAML>=6.0",

fileformats/core/extras.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def extra(method: ExtraMethod) -> "ExtraMethod":
3434
dispatch_method: ty.Callable[..., ty.Any] = functools.singledispatch(method)
3535

3636
@functools.wraps(method)
37-
def decorated(obj: DataType, *args: ty.Any, **kwargs: ty.Any) -> ty.Any:
37+
def decorated_extra(obj: DataType, *args: ty.Any, **kwargs: ty.Any) -> ty.Any:
3838
cls = type(obj)
3939
extras = []
4040
for tp in cls.referenced_types(): # type: ignore[attr-defined]
@@ -61,8 +61,8 @@ def decorated(obj: DataType, *args: ty.Any, **kwargs: ty.Any) -> ty.Any:
6161

6262
# Store single dispatch method on the decorated function so we can register
6363
# implementations to it later
64-
decorated._dispatch = dispatch_method # type: ignore[attr-defined]
65-
return decorated # type: ignore[return-value]
64+
decorated_extra._dispatch = dispatch_method # type: ignore[attr-defined]
65+
return decorated_extra # type: ignore[return-value]
6666

6767

6868
def extra_implementation(
@@ -78,7 +78,9 @@ def extra_implementation(
7878
"an implementation"
7979
)
8080

81-
def decorator(implementation: ExtraImplementation) -> ExtraImplementation:
81+
def extra_implementation_decorator(
82+
implementation: ExtraImplementation,
83+
) -> ExtraImplementation:
8284
msig = inspect.signature(method)
8385
fsig = inspect.signature(implementation)
8486
msig_args = list(msig.parameters.values())[1:]
@@ -188,7 +190,7 @@ def type_match(mtype: ty.Union[str, type], ftype: ty.Union[str, type]) -> bool:
188190
dispatch_method.register(implementation)
189191
return implementation
190192

191-
return decorator
193+
return extra_implementation_decorator
192194

193195

194196
WrappedTask = ty.TypeVar("WrappedTask", bound=ty.Callable[..., ty.Any])

0 commit comments

Comments
 (0)