From cf17cff973426b050998db3254a24c29a9eda9e2 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Thu, 19 Dec 2019 08:37:23 -0800 Subject: [PATCH] Registry: Implement plugin-based architecture for loading files --- ultratrace2/model/files/__init__.py | 32 ++++++++ ultratrace2/model/files/adt.py | 59 -------------- ultratrace2/model/files/bundle.py | 61 ++++++++------ ultratrace2/model/files/impls/__init__.py | 12 --- ultratrace2/model/files/impls/dicom.py | 65 --------------- ultratrace2/model/files/impls/flac.py | 8 -- ultratrace2/model/files/impls/measurement.py | 10 --- ultratrace2/model/files/impls/mp3.py | 8 -- ultratrace2/model/files/impls/ogg.py | 8 -- ultratrace2/model/files/impls/textgrid.py | 7 -- ultratrace2/model/files/impls/wav.py | 6 -- ultratrace2/model/files/loaders/__init__.py | 12 +++ ultratrace2/model/files/loaders/base.py | 58 ++++++++++++++ ultratrace2/model/files/loaders/dicom.py | 79 +++++++++++++++++++ ultratrace2/model/files/loaders/flac.py | 7 ++ .../model/files/loaders/measurement.py | 9 +++ ultratrace2/model/files/loaders/mp3.py | 7 ++ ultratrace2/model/files/loaders/ogg.py | 7 ++ ultratrace2/model/files/loaders/textgrid.py | 7 ++ ultratrace2/model/files/loaders/wav.py | 7 ++ ultratrace2/model/files/registry.py | 67 ++++++++++++++++ ultratrace2/model/project.py | 2 +- ultratrace2/model/tests/test_project.py | 14 ++++ 23 files changed, 342 insertions(+), 210 deletions(-) delete mode 100644 ultratrace2/model/files/adt.py delete mode 100644 ultratrace2/model/files/impls/__init__.py delete mode 100644 ultratrace2/model/files/impls/dicom.py delete mode 100644 ultratrace2/model/files/impls/flac.py delete mode 100644 ultratrace2/model/files/impls/measurement.py delete mode 100644 ultratrace2/model/files/impls/mp3.py delete mode 100644 ultratrace2/model/files/impls/ogg.py delete mode 100644 ultratrace2/model/files/impls/textgrid.py delete mode 100644 ultratrace2/model/files/impls/wav.py create mode 100644 ultratrace2/model/files/loaders/__init__.py create mode 100644 ultratrace2/model/files/loaders/base.py create mode 100644 ultratrace2/model/files/loaders/dicom.py create mode 100644 ultratrace2/model/files/loaders/flac.py create mode 100644 ultratrace2/model/files/loaders/measurement.py create mode 100644 ultratrace2/model/files/loaders/mp3.py create mode 100644 ultratrace2/model/files/loaders/ogg.py create mode 100644 ultratrace2/model/files/loaders/textgrid.py create mode 100644 ultratrace2/model/files/loaders/wav.py create mode 100644 ultratrace2/model/files/registry.py diff --git a/ultratrace2/model/files/__init__.py b/ultratrace2/model/files/__init__.py index e69de29..b3b2552 100644 --- a/ultratrace2/model/files/__init__.py +++ b/ultratrace2/model/files/__init__.py @@ -0,0 +1,32 @@ +from .registry import register_loader_for_extensions_and_mime_types as __register + +from .loaders import DICOMLoader +from .loaders import FLACLoader +from .loaders import MeasurementLoader +from .loaders import MP3Loader +from .loaders import OggLoader +from .loaders import TextGridLoader +from .loaders import WAVLoader + + +__register( + [".dicom", ".dcm"], ["application/dicom"], DICOMLoader, +) +__register( + [], [], FLACLoader, +) +__register( + [], [], MeasurementLoader, +) +__register( + [], [], MP3Loader, +) +__register( + [], [], OggLoader, +) +__register( + [".textgrid"], ["text/plain"], TextGridLoader, +) +__register( + [".wav"], ["audio/x-wav", "audio/wav"], WAVLoader, +) diff --git a/ultratrace2/model/files/adt.py b/ultratrace2/model/files/adt.py deleted file mode 100644 index 07b0931..0000000 --- a/ultratrace2/model/files/adt.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import mimetypes -import os - -from abc import ABC, abstractmethod -from typing import ClassVar, Sequence - - -logger = logging.getLogger(__name__) - - -class FileLoadError(Exception): - pass - - -class TypedFile(ABC): - - extensions: ClassVar[Sequence[str]] - mimetypes: ClassVar[Sequence[str]] - - _path: str - - @classmethod - def is_valid(cls, path: str) -> bool: - _, extension = os.path.splitext(path.lower()) - mimetype, _ = mimetypes.guess_type(path) - return extension in cls.extensions and mimetype in cls.mimetypes - - @property - @abstractmethod - def path(self) -> str: - return self._path - - def __new__(cls, path: str): - try: - return cls.from_file(path) - except FileLoadError as e: - logger.error(e) - return None - - def __repr__(self): - return f"{type(self).__name__}({self.path})" - - @classmethod - @abstractmethod - def from_file(cls, path: str) -> "TypedFile": - pass - - -class AlignmentFile(TypedFile): - pass - - -class ImageSetFile(TypedFile): - pass - - -class SoundFile(TypedFile): - pass diff --git a/ultratrace2/model/files/bundle.py b/ultratrace2/model/files/bundle.py index bbbf632..e2a5f96 100644 --- a/ultratrace2/model/files/bundle.py +++ b/ultratrace2/model/files/bundle.py @@ -3,7 +3,13 @@ from typing import Dict, FrozenSet, Optional, Sequence, Type -from .adt import AlignmentFile, ImageSetFile, SoundFile, TypedFile +from .loaders.base import ( + AlignmentFileLoader, + ImageSetFileLoader, + SoundFileLoader, + FileLoaderBase, +) +from .registry import get_loader_for logger = logging.getLogger(__name__) @@ -13,32 +19,38 @@ class FileBundle: def __init__( self, name: str, - alignment_file: Optional[AlignmentFile] = None, - image_file: Optional[ImageSetFile] = None, - sound_file: Optional[SoundFile] = None, + alignment_file: Optional[AlignmentFileLoader] = None, + image_set_file: Optional[ImageSetFileLoader] = None, + sound_file: Optional[SoundFileLoader] = None, ): self.name = name self.alignment_file = alignment_file - self.image_file = image_file + self.image_set_file = image_set_file self.sound_file = sound_file def has_impl(self) -> bool: return any( f is not None - for f in [self.alignment_file, self.image_file, self.sound_file] + for f in [self.alignment_file, self.image_set_file, self.sound_file] ) - def set_alignment_file(self, alignment_file: AlignmentFile) -> None: + def set_alignment_file(self, alignment_file: AlignmentFileLoader) -> None: + if self.alignment_file is not None: + logger.warning("Overwriting existing alignment file") self.alignment_file = alignment_file - def set_image_file(self, image_file: ImageSetFile) -> None: - self.image_file = image_file + def set_image_set_file(self, image_set_file: ImageSetFileLoader) -> None: + if self.image_set_file is not None: + logger.warning("Overwriting existing image-set file") + self.image_set_file = image_set_file - def set_sound_file(self, sound_file: SoundFile) -> None: + def set_sound_file(self, sound_file: SoundFileLoader) -> None: + if self.sound_file is not None: + logger.warning("Overwriting existing sound file") self.sound_file = sound_file def __repr__(self): - return f'Bundle("{self.name}",{self.alignment_file},{self.image_file},{self.sound_file})' + return f'Bundle("{self.name}",{self.alignment_file},{self.image_set_file},{self.sound_file})' class FileBundleList: @@ -59,12 +71,12 @@ def __init__(self, bundles: Dict[str, FileBundle]): self.bundles: Dict[str, FileBundle] = bundles self.has_alignment_impl: bool = False - self.has_image_impl: bool = False + self.has_image_set_impl: bool = False self.has_sound_impl: bool = False for bundle in bundles.values(): self.has_alignment_impl |= bundle.alignment_file is not None - self.has_image_impl |= bundle.image_file is not None + self.has_image_set_impl |= bundle.image_set_file is not None self.has_sound_impl |= bundle.sound_file is not None @classmethod @@ -94,24 +106,21 @@ def build_from_dir( ) continue - impl_cls = guess_file_type(filepath) - if impl_cls is None: + file_loader: Optional[Type[FileLoaderBase]] = get_loader_for(filepath) + if file_loader is None: logger.warning(f"unrecognized filetype: {filepath}") continue if name not in bundles: bundles[name] = FileBundle(filepath) - file_impl = impl_cls(filepath) - if isinstance(file_impl, AlignmentFile): - bundles[name].set_alignment_file(file_impl) - elif isinstance(file_impl, ImageSetFile): - bundles[name].set_image_file(file_impl) - elif isinstance(file_impl, SoundFile): - bundles[name].set_sound_file(file_impl) + loaded_file = file_loader(filepath) + if loaded_file is not None: + if isinstance(loaded_file, AlignmentFileLoader): + bundles[name].set_alignment_file(loaded_file) + elif isinstance(loaded_file, ImageSetFileLoader): + bundles[name].set_image_set_file(loaded_file) + elif isinstance(loaded_file, SoundFileLoader): + bundles[name].set_sound_file(loaded_file) return cls(bundles) - - -def guess_file_type(path: str) -> Optional[Type[TypedFile]]: - raise NotImplementedError() diff --git a/ultratrace2/model/files/impls/__init__.py b/ultratrace2/model/files/impls/__init__.py deleted file mode 100644 index e1cf37e..0000000 --- a/ultratrace2/model/files/impls/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# alignment files -from .measurement import Measurement # noqa: F401 -from .textgrid import TextGrid # noqa: F401 - -# imageset files -from .dicom import DICOM # noqa: F401 - -# sound files -from .flac import FLAC # noqa: F401 -from .mp3 import MP3 # noqa: F401 -from .ogg import Ogg # noqa: F401 -from .wav import WAV # noqa: F401 diff --git a/ultratrace2/model/files/impls/dicom.py b/ultratrace2/model/files/impls/dicom.py deleted file mode 100644 index b23e2ac..0000000 --- a/ultratrace2/model/files/impls/dicom.py +++ /dev/null @@ -1,65 +0,0 @@ -import numpy as np -import os -import PIL # type: ignore -import pydicom # type: ignore - -from tqdm import tqdm # type: ignore - -from ..adt import FileLoadError, TypedFile - - -class DICOM(TypedFile): - - mimetypes = ["application/dicom"] - extensions = [".dicom", ".dcm"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # FIXME: make this more granular - self.png_path = f"{self.path}-frames" - - def load(self) -> None: - if os.path.exists(self.png_path): - return - - try: - dicom = pydicom.read_file(self.path) - except pydicom.errors.InvalidDicomError as e: - raise FileLoadError(str(e)) - - pixels: np.ndarray = dicom.pixel_array - - # check encoding, manipulate array if we need to - if len(pixels.shape) == 3: - is_greyscale = True - frames, rows, columns = pixels.shape - - elif len(pixels.shape) == 4: - # full-color RGB - is_greyscale = False - if pixels.shape[0] == 3: - # RGB-first - rgb, frames, rows, columns = pixels.shape - pixels.reshape((frames, rows, columns, rgb)) - elif pixels.shape[3] == 3: - # RGB-last - frames, rows, columns, rgb = pixels.shape - else: - raise FileLoadError( - "Invalid DICOM ({self.path}), unknown shape {pixels.shape}" - ) - - else: - raise FileLoadError( - "Invalid DICOM ({self.path}), unknown shape {pixels.shape}" - ) - - os.mkdir(self.png_path, mode=0o755) - for i in tqdm(range(frames), desc="converting to PNG"): - filename = os.path.join(self.png_path, f"{i:06}.png") - arr = pixels[i, :, :] if is_greyscale else pixels[i, :, :, :] - img = PIL.Image.fromarray(arr) - img.save(filename, format="PNG", compress_level=1) - - def convert_to_png(self, *args, **kwargs): - # FIXME: implement this function, signatures, etc. - print("converting") diff --git a/ultratrace2/model/files/impls/flac.py b/ultratrace2/model/files/impls/flac.py deleted file mode 100644 index fd18158..0000000 --- a/ultratrace2/model/files/impls/flac.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import ClassVar, Sequence - -from ..adt import TypedFile - - -class FLAC(TypedFile): - mimetypes: ClassVar[Sequence[str]] = [] - extensions: ClassVar[Sequence[str]] = [] diff --git a/ultratrace2/model/files/impls/measurement.py b/ultratrace2/model/files/impls/measurement.py deleted file mode 100644 index 0229efe..0000000 --- a/ultratrace2/model/files/impls/measurement.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import ClassVar, Sequence - -from ..adt import AlignmentFile - - -class Measurement(AlignmentFile): - # FIXME: what is this? do we need to support it? - - mimetypes: ClassVar[Sequence[str]] = [] - extensions: ClassVar[Sequence[str]] = [] diff --git a/ultratrace2/model/files/impls/mp3.py b/ultratrace2/model/files/impls/mp3.py deleted file mode 100644 index 79ebf70..0000000 --- a/ultratrace2/model/files/impls/mp3.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import ClassVar, Sequence - -from ..adt import TypedFile - - -class MP3(TypedFile): - mimetypes: ClassVar[Sequence[str]] = [] - extensions: ClassVar[Sequence[str]] = [] diff --git a/ultratrace2/model/files/impls/ogg.py b/ultratrace2/model/files/impls/ogg.py deleted file mode 100644 index 17b4b3a..0000000 --- a/ultratrace2/model/files/impls/ogg.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import ClassVar, Sequence - -from ..adt import TypedFile - - -class Ogg(TypedFile): - mimetypes: ClassVar[Sequence[str]] = [] - extensions: ClassVar[Sequence[str]] = [] diff --git a/ultratrace2/model/files/impls/textgrid.py b/ultratrace2/model/files/impls/textgrid.py deleted file mode 100644 index 3a8aa12..0000000 --- a/ultratrace2/model/files/impls/textgrid.py +++ /dev/null @@ -1,7 +0,0 @@ -from ..adt import AlignmentFile - - -class TextGrid(AlignmentFile): - - mimetypes = ["text/plain"] - extensions = [".textgrid"] diff --git a/ultratrace2/model/files/impls/wav.py b/ultratrace2/model/files/impls/wav.py deleted file mode 100644 index 7130e70..0000000 --- a/ultratrace2/model/files/impls/wav.py +++ /dev/null @@ -1,6 +0,0 @@ -from ..adt import TypedFile - - -class WAV(TypedFile): - mimetypes = ["audio/x-wav", "audio/wav"] - extensions = ["wav"] diff --git a/ultratrace2/model/files/loaders/__init__.py b/ultratrace2/model/files/loaders/__init__.py new file mode 100644 index 0000000..c27a3dd --- /dev/null +++ b/ultratrace2/model/files/loaders/__init__.py @@ -0,0 +1,12 @@ +# alignment files +from .measurement import MeasurementLoader # noqa: F401 +from .textgrid import TextGridLoader # noqa: F401 + +# imageset files +from .dicom import DICOMLoader # noqa: F401 + +# sound files +from .flac import FLACLoader # noqa: F401 +from .mp3 import MP3Loader # noqa: F401 +from .ogg import OggLoader # noqa: F401 +from .wav import WAVLoader # noqa: F401 diff --git a/ultratrace2/model/files/loaders/base.py b/ultratrace2/model/files/loaders/base.py new file mode 100644 index 0000000..ccc8585 --- /dev/null +++ b/ultratrace2/model/files/loaders/base.py @@ -0,0 +1,58 @@ +import logging + +from abc import ABC, abstractmethod +from PIL import Image # type: ignore + + +logger = logging.getLogger(__name__) + + +class FileLoadError(Exception): + pass + + +class FileLoaderBase(ABC): + def __new__(cls, path: str): + try: + return cls.from_file(path) + except FileLoadError as e: + logger.error(e) + return None + + def __init__(self, path: str): + self.path = path + + def __repr__(self): + return f"{type(self).__name__}({self.path})" + + @classmethod + @abstractmethod + def from_file(cls, path: str) -> "FileLoaderBase": + # NB: If this concrete method fails to load the data at the given path, then + # it should throw a `FileLoadError`. + pass + + +class AlignmentFileLoader(FileLoaderBase): + pass + + +class ImageSetFileLoader(FileLoaderBase): + @abstractmethod + def __len__(self) -> int: + """ImageSets should have some notion of their length. + + For example, for DICOM files, this is equal to the number of frames. This + number can then be used to "slice up" any accompanying Alignment or Sound + files. + """ + pass + + @abstractmethod + def get_frame(self, i: int) -> Image.Image: + """ImageSets should support random access of frames.""" + pass + + +class SoundFileLoader(FileLoaderBase): + pass diff --git a/ultratrace2/model/files/loaders/dicom.py b/ultratrace2/model/files/loaders/dicom.py new file mode 100644 index 0000000..498d76a --- /dev/null +++ b/ultratrace2/model/files/loaders/dicom.py @@ -0,0 +1,79 @@ +from PIL import Image # type: ignore + +import numpy as np +import os +import pydicom # type: ignore + +from .base import FileLoadError, ImageSetFileLoader + + +class DICOMLoader(ImageSetFileLoader): + def __init__(self, path: str, pixels: np.ndarray): + """Construct the DICOMLoader from a pixel array. + + The `shape` of the pixel array should be `(n_frames, n_rows, n_columns)` for greyscale + images and `(n_frames, n_rows, n_columns, rgb_data)` for full-color images.""" + super().__init__(path) + self.pixels = pixels + # FIXME: these should be in the `.ultratrace/` dir + self.png_dir = f"{self.path}-frames" + if not os.path.exists(self.png_dir): + os.mkdir(self.png_dir, mode=0o755) + + def is_greyscale(self) -> bool: + return len(self.pixels) == 3 + + def __len__(self) -> int: + return self.pixels.shape[0] + + def get_png_filepath_for_frame(self, i: int) -> str: + return os.path.join(self.png_dir, f"{i:06}.png") + + def get_frame(self, i: int) -> Image.Image: + png_filepath = self.get_png_filepath_for_frame(i) + if os.path.exists(png_filepath): + return Image.open(png_filepath) + else: + arr = ( + self.pixels[i, :, :] if self.is_greyscale() else self.pixels[i, :, :, :] + ) + img = Image.fromarray(arr) + img.save(png_filepath, format="PNG", compress_level=1) + return img + + @classmethod + def from_file(cls, path: str) -> "DICOMLoader": + + if not os.path.exists(path): + raise FileLoadError("Cannot load from path: '{path}': cannot find") + + try: + dicom = pydicom.read_file(path) + except pydicom.errors.InvalidDicomError as e: + raise FileLoadError(str(e)) + + pixels: np.ndarray = dicom.pixel_array + + # check encoding, manipulate array if we need to + if len(pixels.shape) == 3: + n_frames, n_rows, n_columns = pixels.shape + return cls(path, pixels) + + elif len(pixels.shape) == 4: + # full-color RGB + if pixels.shape[0] == 3: + # RGB-first + rgb, n_frames, n_rows, n_columns = pixels.shape + pixels.reshape((n_frames, n_rows, n_columns, rgb)) + return cls(path, pixels) + + elif pixels.shape[3] == 3: + # RGB-last + n_frames, n_rows, n_columns, rgb = pixels.shape + return cls(path, pixels) + + raise FileLoadError("Invalid DICOM ({path}), unknown shape {pixels.shape}") + + def convert_to_png(self, *args, **kwargs): + # FIXME: implement this as a helper function + raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/flac.py b/ultratrace2/model/files/loaders/flac.py new file mode 100644 index 0000000..fdd9b75 --- /dev/null +++ b/ultratrace2/model/files/loaders/flac.py @@ -0,0 +1,7 @@ +from .base import SoundFileLoader + + +class FLACLoader(SoundFileLoader): + @classmethod + def from_file(cls, path: str) -> "FLACLoader": + raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/measurement.py b/ultratrace2/model/files/loaders/measurement.py new file mode 100644 index 0000000..a586179 --- /dev/null +++ b/ultratrace2/model/files/loaders/measurement.py @@ -0,0 +1,9 @@ +from .base import AlignmentFileLoader + + +class MeasurementLoader(AlignmentFileLoader): + # FIXME: what is this? do we need to support it? + + @classmethod + def from_file(cls, path: str) -> "MeasurementLoader": + raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/mp3.py b/ultratrace2/model/files/loaders/mp3.py new file mode 100644 index 0000000..b183616 --- /dev/null +++ b/ultratrace2/model/files/loaders/mp3.py @@ -0,0 +1,7 @@ +from .base import SoundFileLoader + + +class MP3Loader(SoundFileLoader): + @classmethod + def from_file(cls, path: str) -> "MP3Loader": + raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/ogg.py b/ultratrace2/model/files/loaders/ogg.py new file mode 100644 index 0000000..d70794c --- /dev/null +++ b/ultratrace2/model/files/loaders/ogg.py @@ -0,0 +1,7 @@ +from .base import SoundFileLoader + + +class OggLoader(SoundFileLoader): + @classmethod + def from_file(cls, path: str) -> "OggLoader": + raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/textgrid.py b/ultratrace2/model/files/loaders/textgrid.py new file mode 100644 index 0000000..b93a6f3 --- /dev/null +++ b/ultratrace2/model/files/loaders/textgrid.py @@ -0,0 +1,7 @@ +from .base import AlignmentFileLoader + + +class TextGridLoader(AlignmentFileLoader): + @classmethod + def from_file(cls, path: str) -> "TextGridLoader": + raise NotImplementedError() diff --git a/ultratrace2/model/files/loaders/wav.py b/ultratrace2/model/files/loaders/wav.py new file mode 100644 index 0000000..4cb593e --- /dev/null +++ b/ultratrace2/model/files/loaders/wav.py @@ -0,0 +1,7 @@ +from .base import SoundFileLoader + + +class WAVLoader(SoundFileLoader): + @classmethod + def from_file(cls, path: str) -> "WAVLoader": + raise NotImplementedError() diff --git a/ultratrace2/model/files/registry.py b/ultratrace2/model/files/registry.py new file mode 100644 index 0000000..f8d3da0 --- /dev/null +++ b/ultratrace2/model/files/registry.py @@ -0,0 +1,67 @@ +import mimetypes +import os + +from collections import defaultdict +from typing import DefaultDict, Optional, Sequence, Set, Type + +from .loaders.base import FileLoaderBase + + +# global maps +__extension_to_loaders_map: DefaultDict[str, Set[Type[FileLoaderBase]]] = defaultdict( + set +) +__mime_type_to_loaders_map: DefaultDict[str, Set[Type[FileLoaderBase]]] = defaultdict( + set +) + + +def register_loader_for_extensions_and_mime_types( + extensions: Sequence[str], + mime_types: Sequence[str], + loader_cls: Type[FileLoaderBase], +) -> None: + """Register a loader which recognizes a file in the format indicated by the given + extensions and MIME types. + + Parameters: + extensions: A set of file extension, e.g. [".wav"], indicating the file format + mime_types: A set of MIME types, e.g. ["audio/x-wav", "audio/wav"], also indicating the file format + loader_cls: A file loader which knows how to load files with the given file extensions and MIME types + """ + + global __extension_to_loaders_map + global __mime_type_to_loaders_map + + for extension in extensions: + __extension_to_loaders_map[extension].add(loader_cls) + + for mime_type in mime_types: + __mime_type_to_loaders_map[mime_type].add(loader_cls) + + +def get_loader_for(path: str) -> Optional[Type[FileLoaderBase]]: + + global __extension_to_loaders_map + global __mime_type_to_loaders_map + + _, extension = os.path.splitext(path.lower()) + mime_type, _ = mimetypes.guess_type(path) + if mime_type is None: + # Early return since we can't possibly match anymore + return None + + loader_clses_by_extension = __extension_to_loaders_map[extension] + loader_clses_by_mime_type = __mime_type_to_loaders_map[mime_type] + + # NB: Use set-intersection (could potentially use set-union instead). + loader_clses = loader_clses_by_extension & loader_clses_by_mime_type + + if len(loader_clses) == 0: + return None + elif len(loader_clses) == 1: + return loader_clses.pop() + else: + raise NotImplementedError( + f"Found multiple Loaders for path '{path}': {','.join(map(str, loader_clses))}" + ) diff --git a/ultratrace2/model/project.py b/ultratrace2/model/project.py index c95a419..9dd59bd 100644 --- a/ultratrace2/model/project.py +++ b/ultratrace2/model/project.py @@ -82,7 +82,7 @@ def has_alignment_impl(self) -> bool: return self.files.has_alignment_impl def has_image_impl(self) -> bool: - return self.files.has_image_impl + return self.files.has_image_set_impl def has_sound_impl(self) -> bool: return self.files.has_sound_impl diff --git a/ultratrace2/model/tests/test_project.py b/ultratrace2/model/tests/test_project.py index 07de4b7..d46c396 100644 --- a/ultratrace2/model/tests/test_project.py +++ b/ultratrace2/model/tests/test_project.py @@ -1,6 +1,8 @@ import pytest from ..project import Project +from ..trace import TraceList +from ..files.bundle import FileBundle, FileBundleList @pytest.mark.parametrize( @@ -22,3 +24,15 @@ def test_load_project_invalid(save_file: str, error: Exception) -> None: def test_load_project_valid(save_file) -> None: pass """ + + +@pytest.mark.parametrize( + "traces,files", + [ + (TraceList(), FileBundleList({})), + (TraceList(), FileBundleList({"x": FileBundle("x")})), + ], +) +def test_init_project(traces: TraceList, files: FileBundleList) -> None: + p = Project(traces, files) + assert isinstance(p, Project)