Skip to content

Commit 38e0e49

Browse files
committed
core: ensure function return type can be class of string
1 parent 2910d80 commit 38e0e49

2 files changed

Lines changed: 62 additions & 10 deletions

File tree

‎src/damast/core/decorators.py‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@
1717
from .transformations import PipelineElement
1818

1919

20+
def _require_annotated_dataframe_return(func, *, decorator_name: str) -> None:
21+
"""
22+
Ensure a decorated function is annotated as returning an :class:`AnnotatedDataFrame`.
23+
24+
The annotation is accepted both as the class itself and as the string ``'AnnotatedDataFrame'``,
25+
since a module using ``from __future__ import annotations`` (PEP 563) only ever hands over
26+
strings - the transformer is perfectly valid in that case.
27+
28+
:param func: the function being decorated
29+
:param decorator_name: name of the calling decorator, for the error message
30+
:raise RuntimeError: if the function is not annotated as returning an ``AnnotatedDataFrame``
31+
"""
32+
return_type = inspect.signature(func).return_annotation
33+
if return_type not in (AnnotatedDataFrame, "AnnotatedDataFrame"):
34+
raise RuntimeError(
35+
f"{decorator_name}: decorator requires 'AnnotatedDataFrame' to be returned by"
36+
f" function - but was '{return_type}'"
37+
)
38+
39+
2040
def _get_dataframe(*args, **kwargs) -> AnnotatedDataFrame:
2141
"""
2242
Extract the dataframe from positional or keyword arguments
@@ -169,11 +189,7 @@ def output(requirements: dict[str, Any], exclusive: bool = False):
169189
)
170190

171191
def decorator(func):
172-
return_type = inspect.signature(func).return_annotation
173-
if return_type != AnnotatedDataFrame and return_type != 'AnnotatedDataFrame':
174-
raise RuntimeError(
175-
f"output: decorator requires 'AnnotatedDataFrame' to be returned by function - but was '{return_type}'"
176-
)
192+
_require_annotated_dataframe_return(func, decorator_name="output")
177193

178194
setattr(func, DECORATED_OUTPUT_SPECS, required_output_specs)
179195
setattr(func, DECORATED_OUTPUT_EXCLUSIVE, exclusive)
@@ -262,11 +278,7 @@ def artifacts(requirements: dict[str, Any]):
262278
required_artifact_specs = ArtifactSpecification(requirements=requirements)
263279

264280
def decorator(func):
265-
return_type = inspect.signature(func).return_annotation
266-
if return_type != AnnotatedDataFrame:
267-
raise RuntimeError(
268-
"artifacts: decorator requires 'AnnotatedDataFrame' to be returned by function"
269-
)
281+
_require_annotated_dataframe_return(func, decorator_name="artifacts")
270282

271283
setattr(func, DECORATED_ARTIFACT_SPECS, required_artifact_specs)
272284

‎tests/damast/core/test_dataprocessing.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,46 @@ def transform(self, df: AnnotatedDataFrame) -> AnnotatedDataFrame:
702702
p.transform(df=adf)
703703

704704

705+
def test_decorators_accept_string_return_annotation(tmp_path):
706+
"""
707+
A module using 'from __future__ import annotations' (PEP 563) hands the decorators the
708+
return annotation as a string - the transformer is valid all the same.
709+
"""
710+
df = polars.LazyFrame([["10000000", 0]], ["mmsi", "status"], orient="row")
711+
adf = AnnotatedDataFrame(df, MetaData(columns=[DataSpecification(name="mmsi"),
712+
DataSpecification(name="status", unit=units.deg)]))
713+
714+
class TransformX(PipelineElement):
715+
716+
@damast.describe("Generic transform of x")
717+
@damast.input({"x": {"unit": units.deg}})
718+
@damast.output({})
719+
@damast.artifacts({"file": "string-annotation.damast"})
720+
def transform(self, df: AnnotatedDataFrame) -> "AnnotatedDataFrame":
721+
(self.parent_pipeline.base_dir / "string-annotation.damast").write_text("test")
722+
return df
723+
724+
pipeline = DataProcessingPipeline(name="string-annotation-test", base_dir=tmp_path) \
725+
.add(name="status-transform", transformer=TransformX(), name_mappings={"x": "status"})
726+
pipeline.transform(df=adf)
727+
728+
assert (tmp_path / "string-annotation.damast").exists()
729+
730+
731+
@pytest.mark.parametrize("decorator", [damast.output({}), damast.artifacts({"file": "test.damast"})])
732+
def test_decorators_reject_other_return_annotation(decorator):
733+
"""Any other return annotation - string or not - remains an error."""
734+
with pytest.raises(RuntimeError, match="AnnotatedDataFrame"):
735+
@decorator
736+
def transform(self, df: AnnotatedDataFrame) -> polars.LazyFrame:
737+
return df
738+
739+
with pytest.raises(RuntimeError, match="AnnotatedDataFrame"):
740+
@decorator
741+
def transform_by_name(self, df: AnnotatedDataFrame) -> "polars.LazyFrame":
742+
return df
743+
744+
705745
def test_save(tmp_path):
706746
pipeline = DataProcessingPipeline(name="abc",
707747
base_dir=tmp_path) \

0 commit comments

Comments
 (0)