Skip to content

Commit 60b7653

Browse files
committed
Add format() method for transformations and measurements
Adds a format() method for converting transformations and measurements into human-readable strings. Most components use a default implementation based on inspecting each class to get its public properties, but some (most notably any component with multiple children) use custom formatting logic. Also adds a `tmlt.core.utils.format` module containing shared helpers used during formatting. #31
1 parent ef98efe commit 60b7653

43 files changed

Lines changed: 1695 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
Changelog
44
=========
55

6-
76
Unreleased
87
----------
98

9+
Added
10+
~~~~~
11+
12+
- :class:`.Transformation`\s and :class:`.Measurement`\ s have a new ``format`` method, which renders a human-readable string showing the structure of the transformation/measurement to aid in visualization and debugging.
13+
1014
.. _v0.19.0:
1115

1216
0.19.0 - 2026-05-22

src/tmlt/core/measurements/base.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44
# Copyright Tumult Labs 2026
55
from abc import ABC, abstractmethod
6-
from typing import Any
6+
from typing import Any, FrozenSet
77

88
from typeguard import typechecked
99

@@ -15,6 +15,11 @@
1515
class Measurement(ABC):
1616
"""Abstract base class for measurements."""
1717

18+
_FORMAT_EXCLUDED_ATTRS: FrozenSet[str] = frozenset(
19+
{"input_domain", "input_metric", "output_measure", "is_interactive"}
20+
)
21+
"""Fields hidden from output when formatting this measurement."""
22+
1823
@typechecked
1924
def __init__(
2025
self,
@@ -96,3 +101,33 @@ def privacy_relation(self, d_in: Any, d_out: Any) -> bool:
96101
@abstractmethod
97102
def __call__(self, data: Any) -> Any:
98103
"""Performs measurement."""
104+
105+
def format(self) -> str:
106+
"""Return a human-readable multi-line description of this measurement.
107+
108+
The default implementation assembles :meth:`_format_head` and
109+
:meth:`_format_children`; subclasses can override either of these
110+
hooks (or :meth:`format` itself) to customize the rendering.
111+
"""
112+
head = self._format_head()
113+
children = self._format_children()
114+
if not children:
115+
return head
116+
return f"{head}\n{children}"
117+
118+
def _format_head(self) -> str:
119+
"""Render this measurement's head line: class name followed by its attrs."""
120+
from tmlt.core.utils.format import default_format_attrs # noqa: PLC0415
121+
122+
parts = [type(self).__name__]
123+
parts.extend(
124+
f"{name}={value}"
125+
for name, value in default_format_attrs(self, self._FORMAT_EXCLUDED_ATTRS)
126+
)
127+
return " ".join(parts)
128+
129+
def _format_children(self) -> str:
130+
"""Return the rendered block for nested transformations/measurements."""
131+
from tmlt.core.utils.format import default_format_children # noqa: PLC0415
132+
133+
return default_format_children(self)

src/tmlt/core/measurements/chaining.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,12 @@ def privacy_relation(self, d_in: Any, d_out: Any) -> bool:
140140
def __call__(self, data: Any) -> Any:
141141
"""Computes measurement after applying transformation on input data."""
142142
return self._measurement(self._transformation(data))
143+
144+
def format(self) -> str:
145+
"""Return a human-readable multi-line description of this measurement."""
146+
from tmlt.core.utils.format import ( # noqa: PLC0415
147+
format_chain,
148+
get_chain_children,
149+
)
150+
151+
return format_chain(get_chain_children(self))

src/tmlt/core/measurements/composition.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,8 @@ def privacy_relation(self, d_in: Any, d_out: Any) -> bool:
178178
def __call__(self, data: Any) -> List:
179179
"""Return answers to composed measurements."""
180180
return [measurement(data) for measurement in self._measurements]
181+
182+
def _format_children(self) -> str:
183+
from tmlt.core.utils.format import format_siblings # noqa: PLC0415
184+
185+
return format_siblings(self._measurements)

src/tmlt/core/measurements/interactive_measurements.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,11 @@ def __call__(self, data: Any) -> ParallelQueryable:
720720
"""Returns a :class:`~.ParallelQueryable`."""
721721
return ParallelQueryable(data, self._measurements)
722722

723+
def _format_children(self) -> str:
724+
from tmlt.core.utils.format import format_siblings # noqa: PLC0415
725+
726+
return format_siblings(self._measurements)
727+
723728

724729
class MakeInteractive(Measurement):
725730
"""Creates a :class:`~.GetAnswerQueryable`.

src/tmlt/core/measurements/pandas_measurements/dataframe.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,3 +273,16 @@ def __call__(self, df: pd.DataFrame) -> pd.DataFrame:
273273
for column_name, aggregation in self.column_to_aggregation.items()
274274
}
275275
)
276+
277+
def format(self) -> str:
278+
"""Return a human-readable multi-line description of this measurement.
279+
280+
The per-column aggregations are rendered as labeled sibling children
281+
(the ``output_schema`` is derivable from them, so it is not shown).
282+
"""
283+
from tmlt.core.utils.format import format_labeled_siblings # noqa: PLC0415
284+
285+
return (
286+
f"{type(self).__name__}\n"
287+
f"{format_labeled_siblings(self.column_to_aggregation.items())}"
288+
)

src/tmlt/core/transformations/base.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from __future__ import annotations
77

88
from abc import ABC, abstractmethod
9-
from typing import Any, Union, overload
9+
from typing import Any, FrozenSet, Union, overload
1010

1111
from typeguard import check_type, typechecked
1212

@@ -18,6 +18,11 @@
1818
class Transformation(ABC):
1919
"""Abstract base class for transformations."""
2020

21+
_FORMAT_EXCLUDED_ATTRS: FrozenSet[str] = frozenset(
22+
{"input_domain", "input_metric", "output_domain", "output_metric"}
23+
)
24+
"""Fields hidden from output when formatting this transformation."""
25+
2126
@typechecked
2227
def __init__(
2328
self,
@@ -125,3 +130,33 @@ def __or__(self, other: Any) -> Union[Measurement, Transformation]:
125130
@abstractmethod
126131
def __call__(self, data: Any) -> Any:
127132
"""Perform transformation."""
133+
134+
def format(self) -> str:
135+
"""Return a human-readable multi-line description of this transformation.
136+
137+
The default implementation assembles :meth:`_format_head` and
138+
:meth:`_format_children`; subclasses can override either of these
139+
hooks (or :meth:`format` itself) to customize the rendering.
140+
"""
141+
head = self._format_head()
142+
children = self._format_children()
143+
if not children:
144+
return head
145+
return f"{head}\n{children}"
146+
147+
def _format_head(self) -> str:
148+
"""Render this component's head line: class name followed by its attrs."""
149+
from tmlt.core.utils.format import default_format_attrs # noqa: PLC0415
150+
151+
parts = [type(self).__name__]
152+
parts.extend(
153+
f"{name}={value}"
154+
for name, value in default_format_attrs(self, self._FORMAT_EXCLUDED_ATTRS)
155+
)
156+
return " ".join(parts)
157+
158+
def _format_children(self) -> str:
159+
"""Return the rendered block for nested transformations."""
160+
from tmlt.core.utils.format import default_format_children # noqa: PLC0415
161+
162+
return default_format_children(self)

src/tmlt/core/transformations/chaining.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,12 @@ def transformation2(self) -> Transformation:
126126
def __call__(self, data: Any) -> Any:
127127
"""Performs transformation1 followed by transformation2."""
128128
return self._transformation2(self._transformation1(data))
129+
130+
def format(self) -> str:
131+
"""Return a human-readable multi-line description of this measurement."""
132+
from tmlt.core.utils.format import ( # noqa: PLC0415
133+
format_chain,
134+
get_chain_children,
135+
)
136+
137+
return format_chain(get_chain_children(self))

src/tmlt/core/transformations/spark_transformations/groupby.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ class GroupBy(Transformation):
123123
1
124124
""" # noqa: E501
125125

126+
# When formatted, group_keys provides no information that isn't in groupby_columns
127+
_FORMAT_EXCLUDED_ATTRS = Transformation._FORMAT_EXCLUDED_ATTRS | { # noqa: SLF001
128+
"group_keys"
129+
}
130+
126131
@typechecked
127132
def __init__(
128133
self,

0 commit comments

Comments
 (0)