Skip to content

Commit 9a83e73

Browse files
committed
feat: add proper __repr__ for criteria / combinations
1 parent ccd9e7d commit 9a83e73

File tree

6 files changed

+180
-29
lines changed

6 files changed

+180
-29
lines changed

execution_engine/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __repr__(self) -> str:
4646
"""
4747
Get the string representation of the category.
4848
"""
49-
return str(self)
49+
return f"{self.__class__.__name__}.{self.name}"
5050

5151
def __str__(self) -> str:
5252
"""

execution_engine/omop/concepts.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def from_series(series: pd.Series) -> "Concept":
1919
"""Creates a concept from a pandas Series."""
2020
return Concept(**series.to_dict())
2121

22-
def __repr__(self) -> str:
22+
def __str__(self) -> str:
2323
"""
2424
Returns a string representation of the concept.
2525
"""
@@ -30,14 +30,14 @@ def __repr__(self) -> str:
3030

3131
return base
3232

33-
def __str__(self) -> str:
34-
"""
35-
Returns a string representation of the concept.
36-
"""
37-
if self.vocabulary_id == "UCUM":
38-
return str(self.concept_code)
39-
40-
return str(self.concept_name)
33+
# def __str__(self) -> str:
34+
# """
35+
# Returns a string representation of the concept.
36+
# """
37+
# if self.vocabulary_id == "UCUM":
38+
# return str(self.concept_code)
39+
#
40+
# return str(self.concept_name)
4141

4242
def is_custom(self) -> bool:
4343
"""

execution_engine/omop/criterion/abstract.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from execution_engine.constants import CohortCategory
1313
from execution_engine.omop.concepts import Concept
14+
from execution_engine.omop.criterion.meta import SignatureReprABCMeta
1415
from execution_engine.omop.db.base import DateTimeWithTimeZone
1516
from execution_engine.omop.db.celida.tables import IntervalTypeEnum, ResultInterval
1617
from execution_engine.omop.db.omop.tables import (
@@ -88,15 +89,12 @@ def create_conditional_interval_column(condition: ColumnElement) -> ColumnElemen
8889
)
8990

9091

91-
class AbstractCriterion(Serializable, ABC):
92+
class AbstractCriterion(Serializable, ABC, metaclass=SignatureReprABCMeta):
9293
"""
9394
Abstract base class for Criterion and CriterionCombination.
9495
"""
9596

96-
def __init__(
97-
self, category: CohortCategory, id: int | None = None
98-
) -> None:
99-
self._id = id
97+
def __init__(self, category: CohortCategory) -> None:
10098

10199
assert isinstance(
102100
category, CohortCategory
@@ -129,12 +127,6 @@ def description(self) -> str:
129127
"""
130128
raise NotImplementedError()
131129

132-
def __repr__(self) -> str:
133-
"""
134-
Get the representation of the criterion.
135-
"""
136-
return f"{self.type}.{self._category.name}.{self.description()}"
137-
138130
def __str__(self) -> str:
139131
"""
140132
Get the name of the criterion.
@@ -212,10 +204,8 @@ class Criterion(AbstractCriterion):
212204
Flag to indicate whether the filter_datetime function has been called.
213205
"""
214206

215-
def __init__(
216-
self, category: CohortCategory, id: int | None = None
217-
) -> None:
218-
super().__init__(category=category, id=id)
207+
def __init__(self, category: CohortCategory) -> None:
208+
super().__init__(category=category)
219209

220210
def _set_omop_variables_from_domain(self, domain_id: str) -> None:
221211
"""

execution_engine/omop/criterion/combination/logical.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,39 @@ def AllOrNone(
147147
criteria=criteria,
148148
)
149149

150+
def _repr_pretty(self, level: int = 0) -> str:
151+
def snake_to_camel(s: str) -> str:
152+
return "".join(word.capitalize() for word in s.lower().split("_"))
153+
154+
indent = " " * (2 * level)
155+
op = snake_to_camel(self.operator.operator)
156+
157+
lines = []
158+
# Opening line
159+
lines.append(f"{indent}{self.__class__.__name__}.{op}(")
160+
161+
# Each child on its own line, indented by level+1
162+
for c in self._criteria:
163+
if hasattr(c, "_repr_pretty"):
164+
# Assume c is another Combination or something that implements _repr_pretty
165+
lines.append(c._repr_pretty(level + 1) + ",")
166+
else:
167+
# If it's a leaf object (e.g. a ConceptCriterion), just call normal repr
168+
child_indent = " " * (2 * (level + 1))
169+
lines.append(f"{child_indent}{repr(c)},")
170+
171+
# Closing parenthesis at the current indent level
172+
lines.append(f"{indent})")
173+
174+
# Join all lines with newlines
175+
return "\n".join(lines)
176+
177+
def __repr__(self) -> str:
178+
"""
179+
Get the string representation of the criterion combination.
180+
"""
181+
return self._repr_pretty(0)
182+
150183

151184
class NonCommutativeLogicalCriterionCombination(LogicalCriterionCombination):
152185
"""

execution_engine/omop/criterion/concept.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from abc import ABC
21
from typing import Any, Dict, cast
32

43
from sqlalchemy.sql import Select
54

65
from execution_engine.constants import CohortCategory, OMOPConcepts
76
from execution_engine.omop.concepts import Concept
87
from execution_engine.omop.criterion.abstract import Criterion
8+
from execution_engine.omop.criterion.meta import SignatureReprMeta
99
from execution_engine.util.types import Timing
1010
from execution_engine.util.value import Value
1111
from execution_engine.util.value.factory import value_factory
@@ -25,7 +25,7 @@
2525
# TODO: Only use weight etc from the current encounter/visit!
2626

2727

28-
class ConceptCriterion(Criterion, ABC):
28+
class ConceptCriterion(Criterion, metaclass=SignatureReprMeta):
2929
"""
3030
Abstract class for a criterion based on an OMOP concept and optional value.
3131
@@ -43,13 +43,12 @@ def __init__(
4343
self,
4444
category: CohortCategory,
4545
concept: Concept,
46-
id: int | None = None,
4746
value: Value | None = None,
4847
static: bool | None = None,
4948
timing: Timing | None = None,
5049
override_value_required: bool | None = None,
5150
):
52-
super().__init__(category=category, id=id)
51+
super().__init__(category=category)
5352

5453
self._set_omop_variables_from_domain(concept.domain_id)
5554
self._concept = concept
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import abc
2+
import inspect
3+
from typing import Any, Type, TypeVar
4+
5+
T = TypeVar("T", bound="SignatureReprMeta")
6+
7+
8+
class SignatureReprMeta(type):
9+
"""
10+
A metaclass that automatically captures constructor arguments and generates a __repr__ method.
11+
12+
This metaclass wraps the `__init__` method of a class to store the arguments passed at instantiation,
13+
allowing `__repr__` to dynamically generate an informative string representation, omitting default values.
14+
15+
Features:
16+
- Captures constructor arguments at instantiation time (`self._init_args`).
17+
- Automatically generates a `__repr__` if the class does not define one.
18+
- Ensures that `__repr__` only displays arguments that differ from the default values.
19+
- Prevents redundant re-capturing when a parent class’s `__init__` is invoked via `super()`.
20+
- Retains the original `__init__` function signature for accurate introspection.
21+
22+
Usage:
23+
```python
24+
class MyClass(metaclass=SignatureReprMeta):
25+
def __init__(self, x, y=10, z=None):
26+
self.x = x
27+
self.y = y
28+
self.z = z
29+
30+
obj = MyClass(5, z="test")
31+
print(obj) # Output: MyClass(x=5, z='test') (y is omitted since it uses the default 10)
32+
```
33+
34+
"""
35+
36+
def __new__(
37+
mcs: Type[T], name: str, bases: tuple[type, ...], namespace: dict[str, Any]
38+
) -> T:
39+
"""
40+
Wrap the __init__ method and attach a default __repr__ if not defined.
41+
"""
42+
original_init = namespace.get("__init__")
43+
44+
if not original_init:
45+
# Try to find an __init__ in the bases
46+
for base in reversed(bases):
47+
if base.__init__ is not object.__init__:
48+
original_init = base.__init__
49+
break
50+
51+
# We'll define a new __init__ only if there's an actual one to wrap
52+
if original_init:
53+
54+
def __init__(self: object, *args: Any, **kwargs: Any) -> None:
55+
if type(self) is cls:
56+
assert original_init is not None, "No valid __init__ found!"
57+
sig = inspect.signature(original_init)
58+
bound = sig.bind(self, *args, **kwargs)
59+
bound.apply_defaults()
60+
all_args = dict(bound.arguments)
61+
all_args.pop("self", None)
62+
self._init_args = all_args # type: ignore[attr-defined]
63+
64+
original_init(self, *args, **kwargs)
65+
66+
# The rest is basically the same
67+
# But we must create the class first so we can set new_init.__signature__ = ...
68+
cls = super().__new__(mcs, name, bases, namespace)
69+
70+
# If we actually did define new_init, attach it
71+
if original_init:
72+
# Replace/attach the new __init__ to cls
73+
setattr(cls, "__init__", __init__)
74+
75+
# Manually override the function's signature
76+
init_sig = inspect.signature(original_init)
77+
__init__.__signature__ = init_sig # type: ignore[attr-defined]
78+
79+
# If no user-defined __repr__, attach a default
80+
if "__repr__" not in namespace:
81+
82+
def __repr__(self: object) -> str:
83+
if not hasattr(self, "_init_args"):
84+
return super(type(self), self).__repr__()
85+
sig = inspect.signature(original_init) if original_init else None
86+
parts = []
87+
if sig:
88+
for param_name, param in sig.parameters.items():
89+
if param_name == "self":
90+
continue
91+
default = param.default
92+
# Only show params if they differ from the default
93+
if (
94+
param_name in self._init_args
95+
and self._init_args[param_name] != default
96+
):
97+
parts.append(
98+
f"{param_name}={repr(self._init_args[param_name])}"
99+
)
100+
return f"{name}({', '.join(parts)})"
101+
102+
setattr(cls, "__repr__", __repr__)
103+
104+
return cls
105+
106+
107+
class SignatureReprABCMeta(SignatureReprMeta, abc.ABCMeta):
108+
"""
109+
A metaclass combining `SignatureReprMeta` and `ABCMeta`.
110+
111+
This metaclass extends `SignatureReprMeta`, allowing abstract base classes (`ABC`) to inherit
112+
automatic argument capturing and dynamic `__repr__` generation.
113+
114+
Usage:
115+
```python
116+
class AbstractExample(metaclass=SignatureReprABCMeta):
117+
@abc.abstractmethod
118+
def some_method(self):
119+
pass
120+
121+
class ConcreteExample(AbstractExample):
122+
def __init__(self, value, flag=True):
123+
self.value = value
124+
self.flag = flag
125+
126+
obj = ConcreteExample(42)
127+
print(obj) # Output: ConcreteExample(value=42) (flag is omitted since it uses the default True)
128+
```
129+
"""

0 commit comments

Comments
 (0)