Skip to content

Commit 8b0c5fc

Browse files
committed
tested out optional content types for TypedSet and TypedDirectory
1 parent 2060304 commit 8b0c5fc

File tree

6 files changed

+122
-21
lines changed

6 files changed

+122
-21
lines changed

fileformats/core/collection.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from abc import ABCMeta, abstractproperty
44
from fileformats.core import FileSet, validated_property, mtime_cached_property
55
from fileformats.core.decorators import classproperty
6-
from fileformats.core.exceptions import FormatDefinitionError, FormatMismatchError
6+
from fileformats.core.exceptions import FormatMismatchError
7+
from fileformats.core.utils import get_optional_type
78

89

910
class TypedCollection(FileSet, metaclass=ABCMeta):
@@ -50,16 +51,7 @@ def _validate_required_content_types(self) -> None:
5051
def potential_content_types(cls) -> ty.Tuple[ty.Type[FileSet], ...]:
5152
content_types: ty.List[ty.Type[FileSet]] = []
5253
for content_type in cls.content_types: # type: ignore[assignment]
53-
if ty.get_origin(content_type) is ty.Union:
54-
args = ty.get_args(content_type)
55-
if not len(args) == 2 and None not in args:
56-
raise FormatDefinitionError(
57-
"Only Optional types are allowed in content_type definitions, "
58-
f"not {content_type}"
59-
)
60-
content_types.append(args[0] if args[0] is not None else args[1])
61-
else:
62-
content_types.append(content_type) # type: ignore[arg-type]
54+
content_types.append(get_optional_type(content_type)) # type: ignore[arg-type]
6355
return tuple(content_types)
6456

6557
@classproperty

fileformats/core/mixin.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from .datatype import DataType
66
import fileformats.core
7-
from .utils import describe_task, matching_source
7+
from .utils import describe_task, matching_source, get_optional_type
88
from .decorators import validated_property, classproperty
99
from .identification import to_mime_format_name
1010
from .converter_helpers import SubtypeVar, ConverterSpec
@@ -292,6 +292,7 @@ def my_func(file: MyFormatWithClassifiers[Integer]):
292292
# Default values for class attrs
293293
multiple_classifiers = True
294294
allowed_classifiers: ty.Optional[ty.Tuple[ty.Type[Classifier], ...]] = None
295+
allow_optional_classifiers = False
295296
exclusive_classifiers: ty.Tuple[ty.Type[Classifier], ...] = ()
296297
ordered_classifiers = False
297298
generically_classifiable = False
@@ -320,7 +321,9 @@ def wildcard_classifiers(
320321
) -> ty.FrozenSet[ty.Type[SubtypeVar]]:
321322
if classifiers is None:
322323
classifiers = cls.classifiers if cls.is_classified else ()
323-
return frozenset(t for t in classifiers if issubclass(t, SubtypeVar))
324+
return frozenset(
325+
t for t in classifiers if issubclass(get_optional_type(t), SubtypeVar) # type: ignore[misc]
326+
)
324327

325328
@classmethod
326329
def non_wildcard_classifiers(
@@ -329,7 +332,9 @@ def non_wildcard_classifiers(
329332
if classifiers is None:
330333
classifiers = cls.classifiers if cls.is_classified else ()
331334
assert classifiers is not None
332-
return frozenset(q for q in classifiers if not issubclass(q, SubtypeVar))
335+
return frozenset(
336+
q for q in classifiers if not issubclass(get_optional_type(q), SubtypeVar)
337+
)
333338

334339
@classmethod
335340
def __class_getitem__(
@@ -341,11 +346,15 @@ def __class_getitem__(
341346
classifiers_tuple = tuple(classifiers)
342347
else:
343348
classifiers_tuple = (classifiers,)
349+
classifiers_to_check = tuple(
350+
get_optional_type(c, cls.allow_optional_classifiers)
351+
for c in classifiers_tuple
352+
)
344353

345354
if cls.allowed_classifiers:
346355
not_allowed = [
347356
q
348-
for q in classifiers_tuple
357+
for q in classifiers_to_check
349358
if not any(issubclass(q, t) for t in cls.allowed_classifiers)
350359
]
351360
if not_allowed:
@@ -357,15 +366,17 @@ def __class_getitem__(
357366
if cls.multiple_classifiers:
358367
if not cls.ordered_classifiers:
359368
# Check for duplicate classifiers in the multiple list
360-
if len(classifiers_tuple) > 1:
369+
if len(classifiers_to_check) > 1:
361370
# Sort the classifiers into categories and ensure that there aren't more
362371
# than one type for each category. Otherwise, if the classifier doesn't
363372
# belong to a category, check to see that there aren't multiple sub-classes
364373
# in the classifier set
365374
repetitions: ty.Dict[
366375
ty.Type[Classifier], ty.List[ty.Type[Classifier]]
367-
] = {c: [] for c in cls.exclusive_classifiers + classifiers_tuple}
368-
for classifier in classifiers_tuple:
376+
] = {
377+
c: [] for c in cls.exclusive_classifiers + classifiers_to_check
378+
}
379+
for classifier in classifiers_to_check:
369380
for exc_classifier in repetitions:
370381
if issubclass(classifier, exc_classifier):
371382
repetitions[exc_classifier].append(classifier)
@@ -381,7 +392,10 @@ def __class_getitem__(
381392
)
382393
)
383394
classifiers_tuple = tuple(
384-
sorted(set(classifiers_tuple), key=lambda x: x.__name__)
395+
sorted(
396+
set(classifiers_tuple),
397+
key=lambda x: get_optional_type(x).__name__,
398+
)
385399
)
386400
else:
387401
if len(classifiers_tuple) > 1:

fileformats/core/utils.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from contextlib import contextmanager
1212
from .typing import FspathsInputType
1313
import fileformats.core
14+
from fileformats.core.exceptions import FormatDefinitionError
1415

1516
if ty.TYPE_CHECKING:
1617
import pydra.engine.core
@@ -228,3 +229,39 @@ def import_extras_module(klass: ty.Type["fileformats.core.DataType"]) -> ExtrasM
228229
else:
229230
extras_imported = True
230231
return ExtrasModule(extras_imported, extras_pkg, extras_pypi)
232+
233+
234+
TypeType = ty.TypeVar("TypeType", bound=ty.Type[ty.Any])
235+
236+
237+
def get_optional_type(
238+
type_: ty.Union[TypeType, ty.Type[ty.Optional[TypeType]]], allowed: bool = True
239+
) -> TypeType:
240+
"""Checks if a type is an Optional type
241+
242+
Parameters
243+
----------
244+
type_ : ty.Type
245+
the type to check
246+
allowed : bool
247+
whether Optional types are allowed or not
248+
249+
Returns
250+
-------
251+
bool
252+
whether the type is an Optional type or not
253+
"""
254+
if ty.get_origin(type_) is None:
255+
return type_ # type: ignore[return-value]
256+
if not allowed:
257+
raise FormatDefinitionError(
258+
f"Optional types are not allowed in content_type definitions ({type_}) "
259+
"in this context"
260+
)
261+
args = ty.get_args(type_)
262+
if len(args) != 2 and None in ty.get_args(type_):
263+
raise FormatDefinitionError(
264+
"Only Optional types are allowed in content_type definitions, "
265+
f"not {type_}"
266+
)
267+
return args[0] if args[0] is not None else args[1] # type: ignore[no-any-return]

fileformats/generic/directory.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,5 @@ class DirectoryOf(WithClassifiers, TypedDirectory): # type: ignore[misc]
110110
# WithClassifiers-required class attrs
111111
classifiers_attr_name = "content_types"
112112
allowed_classifiers = (FileSet,)
113+
allow_optional_classifiers = True
113114
generically_classifiable = True

fileformats/generic/set.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import typing as ty
2+
import itertools
23
from pathlib import Path
3-
from fileformats.core import FileSet
4+
from fileformats.core import FileSet, validated_property
45
from fileformats.core.mixin import WithClassifiers
56
from fileformats.core.collection import TypedCollection
7+
from fileformats.core.exceptions import FormatMismatchError
68

79

810
class TypedSet(TypedCollection):
@@ -25,9 +27,21 @@ def __repr__(self) -> str:
2527
paths_repr += ", ..."
2628
return f"{self.type_name}({paths_repr})"
2729

30+
@validated_property
31+
def _all_paths_used(self) -> None:
32+
all_contents_paths = set(itertools.chain(*(c.fspaths for c in self.contents)))
33+
missing = self.fspaths - all_contents_paths
34+
if missing:
35+
contents_str = "\n".join(repr(c) for c in self.contents)
36+
raise FormatMismatchError(
37+
f"Paths {[str(p) for p in missing]} are not used by any of the "
38+
f"contents of {self.type_name}:\n{contents_str}"
39+
)
40+
2841

2942
class SetOf(WithClassifiers, TypedSet): # type: ignore[misc]
3043
# WithClassifiers-required class attrs
3144
classifiers_attr_name = "content_types"
3245
allowed_classifiers = (FileSet,)
46+
allow_optional_classifiers = True
3347
generically_classifiable = True

fileformats/generic/tests/test_directory.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import typing as ty
2+
import pytest
13
from fileformats.generic import Directory, DirectoryOf, SetOf
2-
from fileformats.testing import MyFormatGz
4+
from fileformats.testing import MyFormatGz, YourFormat, EncodedText
5+
from fileformats.core.exceptions import FormatMismatchError
36

47

58
def test_sample_directory():
@@ -16,3 +19,43 @@ def test_sample_set_of():
1619
sample = SetOf[MyFormatGz].sample()
1720
assert isinstance(sample, SetOf)
1821
assert all(isinstance(c, MyFormatGz) for c in sample.contents)
22+
23+
24+
def test_directory_optional_contents(tmp_path):
25+
my_format = MyFormatGz.sample(dest_dir=tmp_path)
26+
sample_dir = DirectoryOf[MyFormatGz](tmp_path)
27+
EncodedText.sample(dest_dir=tmp_path)
28+
assert sample_dir.contents == [my_format]
29+
30+
with pytest.raises(
31+
FormatMismatchError, match="Did not find the required content types"
32+
):
33+
DirectoryOf[MyFormatGz, YourFormat](sample_dir)
34+
35+
optional_dir = DirectoryOf[MyFormatGz, ty.Optional[YourFormat]](sample_dir)
36+
assert optional_dir.contents == [my_format]
37+
your_format = YourFormat.sample(dest_dir=tmp_path)
38+
optional_dir = DirectoryOf[MyFormatGz, ty.Optional[YourFormat]](sample_dir)
39+
assert optional_dir.contents == [my_format, your_format]
40+
required_dir = DirectoryOf[MyFormatGz, YourFormat](sample_dir)
41+
assert required_dir.contents == [my_format, your_format]
42+
43+
44+
def test_set_optional_contents():
45+
my_format = MyFormatGz.sample()
46+
your_format = YourFormat.sample()
47+
48+
sample_set = SetOf[MyFormatGz, YourFormat](my_format, your_format)
49+
assert sample_set.contents == [my_format, your_format]
50+
with pytest.raises(
51+
FormatMismatchError, match="are not used by any of the contents of "
52+
):
53+
SetOf[MyFormatGz](my_format, your_format)
54+
with pytest.raises(
55+
FormatMismatchError, match="Did not find the required content types"
56+
):
57+
SetOf[MyFormatGz, YourFormat](my_format)
58+
sample_set = SetOf[MyFormatGz, ty.Optional[YourFormat]](my_format)
59+
assert sample_set.contents == [my_format]
60+
sample_set = SetOf[MyFormatGz, ty.Optional[YourFormat]](my_format, your_format)
61+
assert sample_set.contents == [my_format, your_format]

0 commit comments

Comments
 (0)