Convert `FileFormatModel` from `ABC` to `typing.Protocol` (#3692)
Closes #3100
## Rationale for this change
`FileFormatModel` had only abstract methods (no shared implementation),
so an `ABC` was heavier than needed. This PR converts it to
`typing.Protocol`, which is the more Pythonic pattern for a pure
interface. `FileFormatWriter` remains an `ABC` since it has shared
`__enter__`/`__exit__`/`result` implementations that inheritance carries
for free. `FileFormatFactory` is unchanged - it matches the existing
`AuthManagerFactory` pattern.
## Changes
New format implementations can now conform structurally without
inheriting from `FileFormatModel`. Net: 2 `ABC`s → 1 `ABC` + 1
`Protocol`. No signature change for existing callers;
`ParquetFormatModel(FileFormatModel)` inheritance is retained
(`Protocol` supports explicit conformance).
## Are these changes tested?
Yes. `tests/io/test_fileformat.py` adds two tests using a shared
`_StructuralModel` helper:
- `test_file_format_model_is_protocol` - a structurally-conforming class
(no inheritance) passes `isinstance()` against `FileFormatModel`.
- `test_structural_model_works_with_factory` - a structurally-conforming
class can be registered and retrieved via `FileFormatFactory`
end-to-end.
## Are there any user-facing changes?
No. Default behavior is unchanged.
diff --git a/pyiceberg/io/fileformat.py b/pyiceberg/io/fileformat.py
index 337e698..65c0cbe 100644
--- a/pyiceberg/io/fileformat.py
+++ b/pyiceberg/io/fileformat.py
@@ -21,7 +21,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from pyiceberg.io import OutputFile
from pyiceberg.manifest import FileFormat
@@ -143,18 +143,17 @@
self._result = self.close()
-class FileFormatModel(ABC):
+@runtime_checkable
+class FileFormatModel(Protocol):
"""Represents a file format's capabilities. Creates writers."""
@property
- @abstractmethod
def format(self) -> FileFormat: ...
- @abstractmethod
def file_extension(self) -> str:
"""Return file extension without dot, e.g. 'parquet', 'orc'."""
+ ...
- @abstractmethod
def create_writer(
self,
output_file: OutputFile,
@@ -162,9 +161,9 @@
properties: Properties,
) -> FileFormatWriter: ...
- @abstractmethod
def add_field_metadata(self, field: NestedField, metadata: dict[bytes, bytes], include_field_ids: bool) -> None:
"""Add format-specific Arrow field metadata."""
+ ...
class FileFormatFactory:
diff --git a/tests/io/test_fileformat.py b/tests/io/test_fileformat.py
index 328fee9..d5d487f 100644
--- a/tests/io/test_fileformat.py
+++ b/tests/io/test_fileformat.py
@@ -77,3 +77,36 @@
writer = _DummyWriter()
with pytest.raises(RuntimeError, match="Writer has not been closed yet"):
writer.result()
+
+
+class _StructuralModel:
+ """Non-inheriting class that structurally conforms to the FileFormatModel Protocol."""
+
+ @property
+ def format(self) -> FileFormat:
+ return FileFormat.ORC
+
+ def file_extension(self) -> str:
+ return "orc"
+
+ def create_writer(self, output_file: Any, file_schema: Any, properties: Any) -> Any:
+ raise NotImplementedError
+
+ def add_field_metadata(self, field: Any, metadata: Any, include_field_ids: bool) -> None:
+ pass
+
+
+def test_file_format_model_is_protocol() -> None:
+ """A structurally-conforming class (no inheritance) passes isinstance() against FileFormatModel."""
+ assert isinstance(_StructuralModel(), FileFormatModel)
+
+
+def test_structural_model_works_with_factory() -> None:
+ """A structurally-conforming class (no inheritance) can be registered and retrieved via FileFormatFactory."""
+ original = dict(FileFormatFactory._registry)
+ try:
+ model = _StructuralModel()
+ FileFormatFactory.register(model)
+ assert FileFormatFactory.get(FileFormat.ORC) is model
+ finally:
+ FileFormatFactory._registry = original