Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ Annotations for data loaded from a file format (see :ref:`Loaded data`)
.. autofunction:: fileformats.core.check_loaded


Convertible-format annotations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Annotation for the formats that can be converted to a file format (see
:ref:`Convertible formats`)

.. autoclass:: fileformats.core.ConvertibleTo

.. autoclass:: fileformats.core.ConvertibleToMarker
:members: from_hint, resolve, hint


Extra hooks
~~~~~~~~~~~

Expand Down
50 changes: 50 additions & 0 deletions docs/source/read_write.rst
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,56 @@ classmethod
)
...

Convertible formats
~~~~~~~~~~~~~~~~~~~

The formats that can be converted to a format, with the converters that are installed
in the current environment, are returned by the ``convertible_from`` classmethod as a
union that includes the format itself, annotated with a marker recording the format it
was created from

.. code-block:: python

>>> from fileformats.image import Png
>>> Png.convertible_from()
typing.Annotated[fileformats.image.raster.Png | fileformats.image.raster.RasterImage, ConvertibleToMarker(format=<class 'fileformats.image.raster.Png'>)]

Functions that accept any format that can be converted to the one they need can be
annotated with ``ConvertibleTo[<format>]``. At runtime this evaluates to
``<format>.convertible_from()``, so the format it was created from can be recovered in
the same way as for :ref:`Loaded data`

.. code-block:: python

import inspect
from fileformats.core import ConvertibleTo, ConvertibleToMarker
from fileformats.image import Png

def thumbnail(image: ConvertibleTo[Png]) -> Png:
...

hint = inspect.signature(thumbnail, eval_str=True).parameters["image"].annotation
marker = ConvertibleToMarker.from_hint(hint)
marker.format # -> Png
png = marker.format.convert(image)

The union is resolved when the annotation is evaluated, so it doesn't include converters
that are registered afterwards. ``marker.hint()`` returns the annotated union, and ``marker.resolve()`` the plain
union, for the converters that are registered at the time they are called.

In "MIME-like" strings (see :ref:`MIME types`) it is written as the format it was
created from with a "+convertible-to" suffix instead of as the union, so the formats
are resolved again from the converters that are installed in the environment that the
string is read in

.. code-block:: python

>>> from fileformats.core import to_mime, from_mime
>>> to_mime(ConvertibleTo[Png], official=False)
"image/png+convertible-to"
>>> from_mime("image/png+convertible-to") == ConvertibleTo[Png]
True



.. _Pydra: https://pydra.readthedocs.io
Expand Down
8 changes: 6 additions & 2 deletions extras/fileformats/extras/core/tests/test_converter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import typing as ty
from pathlib import Path

import attrs
import pytest
from pydra.compose import python, shell

from conftest import write_test_file
from fileformats.core import converter
from fileformats.core import ConvertibleToMarker, converter
from fileformats.core.exceptions import FormatConversionError
from fileformats.generic import File, FileSet
from fileformats.testing import (
Expand Down Expand Up @@ -114,4 +115,7 @@ def test_convert_mapped_conversion(work_dir):
],
)
def test_convertible_from(klass: type[FileSet], convertible_from: type[FileSet]):
assert klass.convertible_from() == convertible_from
assert (
klass.convertible_from()
== ty.Annotated[convertible_from, ConvertibleToMarker(klass)]
)
97 changes: 97 additions & 0 deletions extras/fileformats/extras/core/tests/test_convertible.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import inspect
import typing as ty

import pytest

# Registers the converters between the testing formats
import test_converter # noqa: F401

from fileformats.core import (
ConvertibleTo,
ConvertibleToMarker,
LoadedMarker,
from_mime,
to_mime,
)
from fileformats.core.exceptions import FormatRecognitionError
from fileformats.image import Png, RasterImage
from fileformats.testing import Bar, Baz, Foo, Qux


def test_convertible_to_hint():
hint = ConvertibleTo[Bar]
assert ty.get_origin(hint) is ty.Annotated
assert ty.get_args(hint)[0] == Bar | Baz | Foo
marker = ConvertibleToMarker.from_hint(hint)
assert marker == ConvertibleToMarker(Bar)
assert marker.format is Bar
assert hint == Bar.convertible_from()
assert marker.hint() == hint
assert marker.resolve() == Bar | Baz | Foo
assert LoadedMarker.from_hint(hint) is None


def test_convertible_no_converters():
hint = ConvertibleTo[Baz]
assert ty.get_args(hint)[0] is Baz
assert ConvertibleToMarker.from_hint(hint).format is Baz


def test_convertible_in_signature():
def func(in_file: ConvertibleTo[Bar] | None = None) -> Foo:
raise NotImplementedError

sig = inspect.signature(func, eval_str=True)
in_marker = ConvertibleToMarker.from_hint(sig.parameters["in_file"].annotation)
assert in_marker.format is Bar
assert ConvertibleToMarker.from_hint(sig.return_annotation) is None


def test_convertible_requires_format():
with pytest.raises(TypeError, match="requires a FileSet subclass"):
ConvertibleTo[int]
with pytest.raises(TypeError, match="requires a FileSet subclass"):
ConvertibleTo[Foo | Bar]
with pytest.raises(TypeError, match="only for annotations"):
ConvertibleTo() # type: ignore[operator]


@pytest.mark.parametrize(
["hint", "mime"],
[
[ConvertibleTo[Bar], "testing/bar+convertible-to"],
[ConvertibleTo[Foo], "testing/foo+convertible-to"],
[ConvertibleTo[Baz], "testing/baz+convertible-to"],
[ConvertibleTo[Bar] | None, "testing/bar+convertible-to?"],
[list[ConvertibleTo[Bar]], "testing/bar+convertible-to+list-of"],
[ConvertibleTo[Bar] | Qux, "testing/bar+convertible-to|testing/qux"],
],
)
def test_convertible_mime_roundtrip(hint: ty.Any, mime: str):
assert to_mime(hint, official=False) == mime
assert from_mime(mime) == hint


def test_convertible_mime_official():
with pytest.raises(TypeError, match="official"):
to_mime(ConvertibleTo[Bar])


def test_convertible_mime_not_format():
with pytest.raises(FormatRecognitionError):
from_mime("testing/not-a-format+convertible-to")


def test_convertible_sorted_by_specificity():
"""Subclasses come before their base classes so that the first type in the union
that matches a file-set is the most specific one"""
for hint in (Bar.convertible_from(), Png.convertible_from()):
args = ty.get_args(ty.get_args(hint)[0])
assert len(args) > 1
for i, tp in enumerate(args):
assert not any(issubclass(later, tp) for later in args[i + 1 :])
assert ty.get_args(ty.get_args(Png.convertible_from())[0]) == (Png, RasterImage)
by_name = ty.get_args(
ty.get_args(Bar.convertible_from(union_sort_key=lambda t: t.__name__))[0]
)
assert by_name == (Bar, Baz, Foo)
44 changes: 21 additions & 23 deletions fileformats/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,40 @@
from ._version import __version__
from .classifier import Classifier
from .convertible import ConvertibleTo, ConvertibleToMarker
from .datatype import DataType, FieldPrimitive
from .mock import MockMixin
from .fileset import FileSet, FileSetMetadata, FileSetPrimitive
from .decorators import mtime_cached_property, validated_property
from .extras import converter, extra, extra_implementation, find_extra_implementation
from .field import Field
from .identification import (
to_mime,
from_mime,
find_matching,
from_paths,
)
from .sampling import SampleFileGenerator
from .extras import extra, extra_implementation, find_extra_implementation, converter
from .fileset import FileSet, FileSetMetadata, FileSetPrimitive
from .identification import find_matching, from_mime, from_paths, to_mime
from .loaded import Loaded, LoadedMarker, check_loaded
from .decorators import validated_property, mtime_cached_property
from .mock import MockMixin
from .sampling import SampleFileGenerator

__all__ = [
"__version__",
"Classifier",
"converter",
"ConvertibleTo",
"ConvertibleToMarker",
"DataType",
"Field",
"FieldPrimitive",
"FileSet",
"FileSetMetadata",
"FieldPrimitive",
"FileSetPrimitive",
"MockMixin",
"Field",
"to_mime",
"from_mime",
"find_extra_implementation",
"find_matching",
"from_mime",
"from_paths",
"SampleFileGenerator",
"extra",
"extra_implementation",
"find_extra_implementation",
"converter",
"Loaded",
"LoadedMarker",
"check_loaded",
"validated_property",
"MockMixin",
"mtime_cached_property",
"SampleFileGenerator",
"to_mime",
"validated_property",
"check_loaded",
"extra",
"extra_implementation",
]
76 changes: 76 additions & 0 deletions fileformats/core/convertible.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""`ConvertibleTo[Format]` annotation for the formats that can be converted to a file
format.

``ConvertibleTo[Png]`` evaluates at runtime to ``Png.convertible_from()``, which is::

Annotated[<union of formats>, ConvertibleToMarker(Png)]

i.e. the union of the formats that can be converted to ``Png`` by the converters
installed in the current environment, while anything that reads signatures can recover
the target format with ``ConvertibleToMarker.from_hint(hint).format``. Static type
checkers see it as ``Any``.

It is written to MIME-like strings as "<namespace>/<format>+convertible-to" by
`to_mime`, so the union is resolved again from the converters that are installed in the
environment the string is read in.
"""

import inspect
import typing as ty
from dataclasses import dataclass

from .loaded import FormatMarker

if ty.TYPE_CHECKING:
from typing_extensions import TypeAliasType

__all__ = ["ConvertibleTo", "ConvertibleToMarker"]


@dataclass(frozen=True)
class ConvertibleToMarker(FormatMarker):
"""Metadata attached to an ``Annotated`` hint recording the format that the
annotated types can be converted to"""

# Appended to the MIME-like string of the format by `to_mime`
mime_suffix: ty.ClassVar[str] = "+convertible-to"

def hint(self) -> ty.Any:
"""The hint for the marker, resolved from the converters that are currently
registered, which may have changed since the hint was created"""
return self.format.convertible_from()

def resolve(self) -> ty.Any:
"""The format, or union of formats, that can be converted to the format with
the converters that are currently registered, i.e. `hint` without the
annotation"""
return ty.get_args(self.hint())[0]


if ty.TYPE_CHECKING:
# Checkers can't evaluate __class_getitem__, so give them a generic alias to Any,
# see `Loaded`
FormatT = ty.TypeVar("FormatT")
ConvertibleTo = TypeAliasType("ConvertibleTo", ty.Any, type_params=(FormatT,))
else:

class ConvertibleTo:
"""``ConvertibleTo[Format]`` -> ``Format.convertible_from()``, i.e.
``Annotated[<union of formats>, ConvertibleToMarker(Format)]``"""

def __new__(cls, *args: ty.Any, **kwargs: ty.Any) -> ty.NoReturn:
raise TypeError(
"ConvertibleTo is only for annotations, use Format.convert()"
)

def __class_getitem__(cls, fmt: ty.Any) -> ty.Any:
if isinstance(fmt, ty.TypeVar):
return ty.Any

from .fileset import FileSet

if not (inspect.isclass(fmt) and issubclass(fmt, FileSet)):
raise TypeError(
f"ConvertibleTo[...] requires a FileSet subclass, not {fmt!r}"
)
return ConvertibleToMarker(fmt).hint()
Loading
Loading