From d93f6f8541fc30405e8a3c843d3c05a2f4a88b7f Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 13:22:39 +1000 Subject: [PATCH 1/9] blurred logo --- docs/source/_static/images/logo_small.png | Bin 12480 -> 23755 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/source/_static/images/logo_small.png b/docs/source/_static/images/logo_small.png index 3f3e885f054ba5be4b168616b733370991b0d22d..101b6aa2f338aad4ebaa4fbcd7e098517ec4c2da 100644 GIT binary patch literal 23755 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 ZgB^ZaFn_E38Q+G`VS5+U6I;I<{{sRQJKg{Q literal 12480 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 H+CKds-*%X@ From 496741b4f34f439a9f8ba3d76fce4aba2ac6e084 Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 14:14:54 +1000 Subject: [PATCH 2/9] added mtime_cached_property decorator --- fileformats/core/fileset.py | 8 +++- fileformats/core/tests/test_utils.py | 24 ++++++++++ fileformats/core/utils.py | 66 ++++++++++++++++++++++++---- fileformats/generic/file.py | 4 +- fileformats/generic/set.py | 3 +- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/fileformats/core/fileset.py b/fileformats/core/fileset.py index 5d87abb..21d5d60 100644 --- a/fileformats/core/fileset.py +++ b/fileformats/core/fileset.py @@ -17,6 +17,7 @@ from fileformats.core.typing import Self from .utils import ( classproperty, + mtime_cached_property, fspaths_converter, describe_task, matching_source, @@ -179,6 +180,11 @@ def relative_fspaths(self) -> ty.Iterator[Path]: "Paths for all top-level paths in the file-set relative to the common parent directory" return (p.relative_to(self.parent) for p in self.fspaths) + @property + def mtimes(self) -> ty.Tuple[ty.Tuple[Path, float], ...]: + """Modification times of all files in the file-set""" + return tuple((p, p.stat().st_mtime) for p in sorted(self.fspaths)) + @classproperty def mime_type(cls) -> str: """Generates a MIME type (IANA) identifier from a format class. If an official @@ -225,7 +231,7 @@ def possible_exts(cls) -> ty.List[ty.Optional[str]]: pass return possible - @property + @mtime_cached_property def metadata(self) -> ty.Mapping[str, ty.Any]: """Lazily load metadata from `read_metadata` extra if implemented, returning an empty metadata array if not""" diff --git a/fileformats/core/tests/test_utils.py b/fileformats/core/tests/test_utils.py index 071fdf7..83d0920 100644 --- a/fileformats/core/tests/test_utils.py +++ b/fileformats/core/tests/test_utils.py @@ -8,6 +8,7 @@ from fileformats.generic import File, Directory, FsObject from fileformats.core.mixin import WithSeparateHeader from fileformats.core.exceptions import UnsatisfiableCopyModeError +from fileformats.core.utils import mtime_cached_property from conftest import write_test_file @@ -365,3 +366,26 @@ def test_hash_files(fsobject: FsObject, work_dir: Path, dest_dir: Path): ) cpy = fsobject.copy(dest_dir) assert cpy.hash_files() == fsobject.hash_files() + + +class MtimeTestFile(File): + + flag: int + + @mtime_cached_property + def cached_prop(self): + return self.flag + + +def test_mtime_cached_property(tmp_path: Path): + fspath = tmp_path / "file_1.txt" + fspath.write_text("hello") + + file = MtimeTestFile(fspath) + + file.flag = 0 + assert file.cached_prop == 0 + file.flag = 1 + assert file.cached_prop == 0 + fspath.write_text("world") + assert file.cached_prop == 1 diff --git a/fileformats/core/utils.py b/fileformats/core/utils.py index 98c7101..95184d3 100644 --- a/fileformats/core/utils.py +++ b/fileformats/core/utils.py @@ -6,6 +6,7 @@ from types import ModuleType import urllib.request import urllib.error +from threading import RLock import os import logging import pkgutil @@ -97,7 +98,14 @@ def fspaths_converter(fspaths: FspathsInputType) -> ty.FrozenSet[Path]: PropReturn = ty.TypeVar("PropReturn") -if sys.version_info[:2] < (3, 9): +if sys.version_info[:2] >= (3, 9): + + def classproperty(meth: ty.Callable[..., PropReturn]) -> PropReturn: + """Access a @classmethod like a @property.""" + # mypy doesn't understand class properties yet: https://github.com/python/mypy/issues/2563 + return classmethod(property(meth)) # type: ignore + +else: class classproperty(object): def __init__(self, f: ty.Callable[[ty.Type[ty.Any]], ty.Any]): @@ -106,13 +114,6 @@ def __init__(self, f: ty.Callable[[ty.Type[ty.Any]], ty.Any]): def __get__(self, obj: ty.Any, owner: ty.Any) -> ty.Any: return self.f(owner) -else: - - def classproperty(meth: ty.Callable[..., PropReturn]) -> PropReturn: - """Access a @classmethod like a @property.""" - # mypy doesn't understand class properties yet: https://github.com/python/mypy/issues/2563 - return classmethod(property(meth)) # type: ignore - def add_exc_note(e: Exception, note: str) -> Exception: """Adds a note to an exception in a Python <3.11 compatible way @@ -248,3 +249,52 @@ def import_extras_module(klass: ty.Type["fileformats.core.DataType"]) -> ExtrasM else: extras_imported = True return ExtrasModule(extras_imported, extras_pkg, extras_pypi) + + +ReturnType = ty.TypeVar("ReturnType") + + +class mtime_cached_property: + """A property that is cached until the mtimes of the files in the fileset are changed""" + + def __init__(self, func: ty.Callable[..., ty.Any]): + self.func = func + self.__doc__ = func.__doc__ + self.lock = RLock() + + @property + def _cache_name(self) -> str: + return f"_{self.func.__name__}_mtime_cache" + + def __get__( + self, + instance: ty.Optional["fileformats.core.FileSet"], + owner: ty.Optional[ty.Type["fileformats.core.FileSet"]] = None, + ) -> ty.Any: + if instance is None: # if accessing property from class not instance + return self + assert isinstance(instance, fileformats.core.FileSet), ( + "Cannot use mtime_cached_property instance with " + f"{type(instance).__name__!r} object, only FileSet objects." + ) + try: + mtimes, value = instance.__dict__[self._cache_name] + except KeyError: + pass + else: + if instance.mtimes == mtimes: + return value + with self.lock: + # check if another thread filled cache while we awaited lock + try: + mtimes, value = instance.__dict__[self._cache_name] + except KeyError: + pass + else: + if instance.mtimes == mtimes: + return value + value = self.func(instance) + instance.__dict__[self._cache_name] = (instance.mtimes, value) + return value + + __class_getitem__ = classmethod(ty.GenericAlias) # type: ignore[attr-defined, var-annotated] diff --git a/fileformats/generic/file.py b/fileformats/generic/file.py index 22e5b6e..34e995c 100644 --- a/fileformats/generic/file.py +++ b/fileformats/generic/file.py @@ -5,7 +5,7 @@ FormatMismatchError, UnconstrainedExtensionException, ) -from fileformats.core.utils import classproperty +from fileformats.core.utils import classproperty, mtime_cached_property from .fsobject import FsObject @@ -79,7 +79,7 @@ def copy_ext( ) return Path(new_path).with_suffix(suffix) - @property + @mtime_cached_property def contents(self) -> ty.Union[str, bytes]: return self.read_contents() diff --git a/fileformats/generic/set.py b/fileformats/generic/set.py index 200038d..2da783b 100644 --- a/fileformats/generic/set.py +++ b/fileformats/generic/set.py @@ -3,6 +3,7 @@ from fileformats.core.exceptions import ( FormatMismatchError, ) +from fileformats.core.utils import mtime_cached_property from fileformats.core.mixin import WithClassifiers @@ -12,7 +13,7 @@ class TypedSet(FileSet): content_types: ty.Tuple[ty.Type[FileSet], ...] = () - @property + @mtime_cached_property def contents(self) -> ty.Iterable[FileSet]: for content_type in self.content_types: for p in self.fspaths: From 0ea911cf0ba858e93c347f8481bdf77a2f033a71 Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 15:10:27 +1000 Subject: [PATCH 3/9] fixed up issues accessing contents of mtime_cached properties --- fileformats/core/fileset.py | 58 +++++++++++-------------- fileformats/core/tests/test_metadata.py | 38 +++++++++++----- fileformats/testing/classifiers/file.py | 3 +- 3 files changed, 56 insertions(+), 43 deletions(-) diff --git a/fileformats/core/fileset.py b/fileformats/core/fileset.py index 21d5d60..aefb021 100644 --- a/fileformats/core/fileset.py +++ b/fileformats/core/fileset.py @@ -68,7 +68,10 @@ class FileSet(DataType): a set of file-system paths pointing to all the resources in the file-set metadata : dict[str, Any] metadata associated with the file-set, typically lazily loaded via `read_metadata` - extra hook + extra hook but can be provided directly at the time of instantiation + metadata_keys : list[str] + the keys of the metadata to load when the `metadata` property is called. Provided + to allow for selective loading of metadata fields for performance reasons. """ # Class attributes @@ -88,14 +91,17 @@ class FileSet(DataType): # Member attributes fspaths: ty.FrozenSet[Path] - _metadata: ty.Union[ty.Mapping[str, ty.Any], bool, None] + _explicit_metadata: ty.Optional[ty.Mapping[str, ty.Any]] + _metadata_keys: ty.Optional[ty.List[str]] def __init__( self, fspaths: FspathsInputType, - metadata: ty.Union[ty.Dict[str, ty.Any], bool, None] = False, + metadata: ty.Optional[ty.Dict[str, ty.Any]] = None, + metadata_keys: ty.Optional[ty.List[str]] = None, ): - self._metadata = metadata + self._explicit_metadata = metadata + self._metadata_keys = metadata_keys self._validate_class() self.fspaths = fspaths_converter(fspaths) self._validate_fspaths() @@ -181,9 +187,16 @@ def relative_fspaths(self) -> ty.Iterator[Path]: return (p.relative_to(self.parent) for p in self.fspaths) @property - def mtimes(self) -> ty.Tuple[ty.Tuple[Path, float], ...]: - """Modification times of all files in the file-set""" - return tuple((p, p.stat().st_mtime) for p in sorted(self.fspaths)) + def mtimes(self) -> ty.Tuple[ty.Tuple[str, float], ...]: + """Modification times of all fspaths in the file-set + + Returns + ------- + tuple[tuple[str, float], ...] + a tuple of tuples containing the file paths and the modification time sorted + by the file path + """ + return tuple((str(p), p.stat().st_mtime) for p in sorted(self.fspaths)) @classproperty def mime_type(cls) -> str: @@ -235,37 +248,18 @@ def possible_exts(cls) -> ty.List[ty.Optional[str]]: def metadata(self) -> ty.Mapping[str, ty.Any]: """Lazily load metadata from `read_metadata` extra if implemented, returning an empty metadata array if not""" - if self._metadata is not False: - assert self._metadata is not True - return self._metadata if self._metadata else {} + if self._explicit_metadata is not None: + return self._explicit_metadata try: - self._metadata = self.read_metadata() + metadata = self.read_metadata(selected_keys=self._metadata_keys) except FileFormatsExtrasPkgUninstalledError: raise except FileFormatsExtrasPkgNotCheckedError as e: logger.warning(str(e)) - self._metadata = None + metadata = {} except FileFormatsExtrasError: - self._metadata = None - return self._metadata if self._metadata else {} - - def select_metadata( - self, selected_keys: ty.Union[ty.Sequence[str], None] = None - ) -> None: - """Selects a subset of the metadata to be read and stored instead all available - (i.e for performance reasons). - - Parameters - ---------- - selected_keys : Union[Sequence[str], None] - the keys of the values to load. If None, all values are loaded - """ - if not ( - isinstance(self._metadata, dict) - and selected_keys is not None - and set(selected_keys).issubset(self._metadata) - ): - self._metadata = dict(self.read_metadata(selected_keys)) + metadata = {} + return metadata @extra def read_metadata( diff --git a/fileformats/core/tests/test_metadata.py b/fileformats/core/tests/test_metadata.py index 98cc0a2..1fdc92b 100644 --- a/fileformats/core/tests/test_metadata.py +++ b/fileformats/core/tests/test_metadata.py @@ -21,7 +21,7 @@ def aformat_read_metadata( @pytest.fixture -def file_with_metadata(tmp_path): +def file_with_metadata_fspath(tmp_path): metadata = { "a": 1, "b": 2, @@ -32,27 +32,45 @@ def file_with_metadata(tmp_path): fspath = tmp_path / "metadata-file.mf" with open(fspath, "w") as f: f.write("\n".join("{}:{}".format(*t) for t in metadata.items())) - return FileWithMetadata(fspath) + return fspath -def test_metadata(file_with_metadata): +def test_metadata(file_with_metadata_fspath): + file_with_metadata = FileWithMetadata(file_with_metadata_fspath) assert file_with_metadata.metadata["a"] == "1" assert sorted(file_with_metadata.metadata) == ["a", "b", "c", "d", "e"] -def test_select_metadata(file_with_metadata): - file_with_metadata.select_metadata(["a", "b", "c"]) +def test_select_metadata(file_with_metadata_fspath): + file_with_metadata = FileWithMetadata( + file_with_metadata_fspath, metadata_keys=["a", "b", "c"] + ) assert file_with_metadata.metadata["a"] == "1" assert sorted(file_with_metadata.metadata) == ["a", "b", "c"] -def test_select_metadata_reload(file_with_metadata): - file_with_metadata.select_metadata(["a", "b", "c"]) +def test_explicit_metadata(file_with_metadata_fspath): + file_with_metadata = FileWithMetadata( + file_with_metadata_fspath, + metadata={ + "a": 1, + "b": 2, + "c": 3, + }, + ) + # Check that we use the explicitly provided metadata and not one from the file + # contents assert sorted(file_with_metadata.metadata) == ["a", "b", "c"] - # add new metadata line to check that it isn't loaded + # add new metadata line to check and check that it isn't reloaded with open(file_with_metadata, "a") as f: f.write("\nf:6") - file_with_metadata.select_metadata(["a", "b"]) assert sorted(file_with_metadata.metadata) == ["a", "b", "c"] - file_with_metadata.select_metadata(None) + + +def test_metadata_reload(file_with_metadata_fspath): + file_with_metadata = FileWithMetadata(file_with_metadata_fspath) + assert sorted(file_with_metadata.metadata) == ["a", "b", "c", "d", "e"] + # add new metadata line to check and check that it is reloaded + with open(file_with_metadata, "a") as f: + f.write("\nf:6") assert sorted(file_with_metadata.metadata) == ["a", "b", "c", "d", "e", "f"] diff --git a/fileformats/testing/classifiers/file.py b/fileformats/testing/classifiers/file.py index a71b778..955dd33 100644 --- a/fileformats/testing/classifiers/file.py +++ b/fileformats/testing/classifiers/file.py @@ -84,9 +84,10 @@ class N(WithClassifiers, File): @attrs.define -class TestField(Singular[ty.Any, ty.Any]): +class TestField(Singular[ty.Any, str]): value: ty.Any + primitive = str class P(WithClassifiers, File): From 2ec11a86b76caa4735c708fe08256667a9823ead Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 15:23:02 +1000 Subject: [PATCH 4/9] added sleeps for mtime cache invalidation --- fileformats/core/tests/test_metadata.py | 2 ++ fileformats/core/tests/test_utils.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/fileformats/core/tests/test_metadata.py b/fileformats/core/tests/test_metadata.py index 1fdc92b..0e9de81 100644 --- a/fileformats/core/tests/test_metadata.py +++ b/fileformats/core/tests/test_metadata.py @@ -1,5 +1,6 @@ import typing as ty import pytest +import time from fileformats.core import FileSet, extra_implementation from fileformats.generic import File @@ -73,4 +74,5 @@ def test_metadata_reload(file_with_metadata_fspath): # add new metadata line to check and check that it is reloaded with open(file_with_metadata, "a") as f: f.write("\nf:6") + time.sleep(2) assert sorted(file_with_metadata.metadata) == ["a", "b", "c", "d", "e", "f"] diff --git a/fileformats/core/tests/test_utils.py b/fileformats/core/tests/test_utils.py index 83d0920..531075c 100644 --- a/fileformats/core/tests/test_utils.py +++ b/fileformats/core/tests/test_utils.py @@ -385,7 +385,9 @@ def test_mtime_cached_property(tmp_path: Path): file.flag = 0 assert file.cached_prop == 0 + time.sleep(2) file.flag = 1 assert file.cached_prop == 0 + time.sleep(2) fspath.write_text("world") assert file.cached_prop == 1 From df5bb4a0e34bf23fca8a4af4a7a61bdcc4dda7d1 Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 15:27:02 +1000 Subject: [PATCH 5/9] added method to forcibly clear the cach of a mtime_cached_property --- fileformats/core/tests/test_utils.py | 13 +++++++++++++ fileformats/core/utils.py | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/fileformats/core/tests/test_utils.py b/fileformats/core/tests/test_utils.py index 531075c..521f000 100644 --- a/fileformats/core/tests/test_utils.py +++ b/fileformats/core/tests/test_utils.py @@ -391,3 +391,16 @@ def test_mtime_cached_property(tmp_path: Path): time.sleep(2) fspath.write_text("world") assert file.cached_prop == 1 + + +def test_mtime_cached_property_force_clear(tmp_path: Path): + fspath = tmp_path / "file_1.txt" + fspath.write_text("hello") + + file = MtimeTestFile(fspath) + + file.flag = 0 + assert file.cached_prop == 0 + file.flag = 1 + MtimeTestFile.cached_prop.clear(file) + assert file.cached_prop == 1 diff --git a/fileformats/core/utils.py b/fileformats/core/utils.py index 95184d3..45a42d2 100644 --- a/fileformats/core/utils.py +++ b/fileformats/core/utils.py @@ -266,6 +266,10 @@ def __init__(self, func: ty.Callable[..., ty.Any]): def _cache_name(self) -> str: return f"_{self.func.__name__}_mtime_cache" + def clear(self, instance: "fileformats.core.FileSet") -> None: + """Forcibly clear the cache""" + del instance.__dict__[self._cache_name] + def __get__( self, instance: ty.Optional["fileformats.core.FileSet"], From a2ac20819ee4fc6b63c292d726e8235af62321f8 Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 15:29:18 +1000 Subject: [PATCH 6/9] comment string --- fileformats/core/tests/test_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fileformats/core/tests/test_utils.py b/fileformats/core/tests/test_utils.py index 521f000..21ce5fc 100644 --- a/fileformats/core/tests/test_utils.py +++ b/fileformats/core/tests/test_utils.py @@ -385,6 +385,9 @@ def test_mtime_cached_property(tmp_path: Path): file.flag = 0 assert file.cached_prop == 0 + # Need a long delay to ensure the mtime changes on Ubuntu and particularly on Windows + # On MacOS, the mtime resolution is much higher so not usually an issue. Use + # explicitly cache clearing if needed time.sleep(2) file.flag = 1 assert file.cached_prop == 0 From 1f58cea824b67d84c671f8d3a3cec9eb5c17fe79 Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 15:33:11 +1000 Subject: [PATCH 7/9] removed troublesome class_getitem from mtime_cached_property --- fileformats/core/typing.py | 8 +++++++- fileformats/core/utils.py | 2 -- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/fileformats/core/typing.py b/fileformats/core/typing.py index d7de7b6..c3bc219 100644 --- a/fileformats/core/typing.py +++ b/fileformats/core/typing.py @@ -22,4 +22,10 @@ PathType: TypeAlias = ty.Union[str, Path] -__all__ = ["CryptoMethod", "FspathsInputType", "PathType", "TypeAlias", "Self"] +__all__ = [ + "CryptoMethod", + "FspathsInputType", + "PathType", + "TypeAlias", + "Self", +] diff --git a/fileformats/core/utils.py b/fileformats/core/utils.py index 45a42d2..2221343 100644 --- a/fileformats/core/utils.py +++ b/fileformats/core/utils.py @@ -300,5 +300,3 @@ def __get__( value = self.func(instance) instance.__dict__[self._cache_name] = (instance.mtimes, value) return value - - __class_getitem__ = classmethod(ty.GenericAlias) # type: ignore[attr-defined, var-annotated] From 3e467b88a34204c0b713e73368e8b03320cc631e Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 16:07:28 +1000 Subject: [PATCH 8/9] removed unrequired ignores --- extras/fileformats/extras/application/archive.py | 2 +- .../extras/application/serialization.py | 2 +- extras/fileformats/extras/image/converters.py | 2 +- fileformats/core/fileset.py | 2 +- fileformats/core/utils.py | 14 +++++++------- fileformats/generic/file.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/extras/fileformats/extras/application/archive.py b/extras/fileformats/extras/application/archive.py index 1d7bff1..5ca5bba 100644 --- a/extras/fileformats/extras/application/archive.py +++ b/extras/fileformats/extras/application/archive.py @@ -238,4 +238,4 @@ def relative_path(path: PathType, base_dir: PathType) -> str: f"Cannot add {path} to archive as it is not a " f"subdirectory of {base_dir}" ) - return relpath # type: ignore[no-any-return] + return relpath diff --git a/extras/fileformats/extras/application/serialization.py b/extras/fileformats/extras/application/serialization.py index 65999d9..0fdb189 100644 --- a/extras/fileformats/extras/application/serialization.py +++ b/extras/fileformats/extras/application/serialization.py @@ -24,7 +24,7 @@ def convert_data_serialization( output_path = out_dir / ( in_file.fspath.stem + (output_format.ext if output_format.ext else "") ) - return output_format.save_new(output_path, dct) # type: ignore[no-any-return] + return output_format.save_new(output_path, dct) @extra_implementation(DataSerialization.load) diff --git a/extras/fileformats/extras/image/converters.py b/extras/fileformats/extras/image/converters.py index 89266b9..db01286 100644 --- a/extras/fileformats/extras/image/converters.py +++ b/extras/fileformats/extras/image/converters.py @@ -25,4 +25,4 @@ def convert_image( output_path = out_dir / ( in_file.fspath.stem + (output_format.ext if output_format.ext else "") ) - return output_format.save_new(output_path, data_array) # type: ignore[no-any-return] + return output_format.save_new(output_path, data_array) diff --git a/fileformats/core/fileset.py b/fileformats/core/fileset.py index aefb021..7b27475 100644 --- a/fileformats/core/fileset.py +++ b/fileformats/core/fileset.py @@ -672,7 +672,7 @@ def formats_by_iana_mime(cls) -> ty.Dict[str, ty.Type["FileSet"]]: """a dictionary containing all formats by their IANA MIME type (if applicable)""" if cls._formats_by_iana_mime is None: cls._formats_by_iana_mime = { - f.iana_mime: f # type: ignore + f.iana_mime: f # type: ignore[misc] for f in FileSet.all_formats if f.__dict__.get("iana_mime") is not None } diff --git a/fileformats/core/utils.py b/fileformats/core/utils.py index 2221343..e7a5845 100644 --- a/fileformats/core/utils.py +++ b/fileformats/core/utils.py @@ -98,16 +98,16 @@ def fspaths_converter(fspaths: FspathsInputType) -> ty.FrozenSet[Path]: PropReturn = ty.TypeVar("PropReturn") -if sys.version_info[:2] >= (3, 9): - def classproperty(meth: ty.Callable[..., PropReturn]) -> PropReturn: - """Access a @classmethod like a @property.""" - # mypy doesn't understand class properties yet: https://github.com/python/mypy/issues/2563 - return classmethod(property(meth)) # type: ignore +def classproperty(meth: ty.Callable[..., PropReturn]) -> PropReturn: + """Access a @classmethod like a @property.""" + # mypy doesn't understand class properties yet: https://github.com/python/mypy/issues/2563 + return classmethod(property(meth)) # type: ignore -else: - class classproperty(object): +if sys.version_info[:2] < (3, 9): + + class classproperty(object): # type: ignore[no-redef] # noqa def __init__(self, f: ty.Callable[[ty.Type[ty.Any]], ty.Any]): self.f = f diff --git a/fileformats/generic/file.py b/fileformats/generic/file.py index 34e995c..ba096eb 100644 --- a/fileformats/generic/file.py +++ b/fileformats/generic/file.py @@ -125,7 +125,7 @@ def actual_ext(self) -> str: "(i.e. matches the None extension)" ) # Return the longest matching extension, useful for optional extensions - return sorted(matching, key=len)[-1] # type: ignore[no-any-return] + return sorted(matching, key=len)[-1] @property def stem(self) -> str: From 53e50d369df0d3a582ecb11b7f1df99ffe9c34ec Mon Sep 17 00:00:00 2001 From: Tom Close Date: Wed, 4 Sep 2024 16:09:32 +1000 Subject: [PATCH 9/9] moved sleep statement in metadata reload step --- fileformats/core/tests/test_metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fileformats/core/tests/test_metadata.py b/fileformats/core/tests/test_metadata.py index 0e9de81..90a1674 100644 --- a/fileformats/core/tests/test_metadata.py +++ b/fileformats/core/tests/test_metadata.py @@ -72,7 +72,7 @@ def test_metadata_reload(file_with_metadata_fspath): file_with_metadata = FileWithMetadata(file_with_metadata_fspath) assert sorted(file_with_metadata.metadata) == ["a", "b", "c", "d", "e"] # add new metadata line to check and check that it is reloaded + time.sleep(2) with open(file_with_metadata, "a") as f: f.write("\nf:6") - time.sleep(2) assert sorted(file_with_metadata.metadata) == ["a", "b", "c", "d", "e", "f"]