Skip to content

Commit a33894d

Browse files
authored
Merge pull request #839 from gpetretto/validate_properties
Validate properties attribute in output_schema
2 parents f88be30 + 815c816 commit a33894d

File tree

2 files changed

+111
-6
lines changed

2 files changed

+111
-6
lines changed

src/jobflow/core/reference.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -514,12 +514,47 @@ def validate_schema_access(
514514
The BaseModel class associated with the item, if any.
515515
"""
516516
schema_dict = schema.model_json_schema()
517-
if item not in schema_dict["properties"]:
517+
item_in_schema = item in schema_dict["properties"]
518+
property_like = isinstance(item, str) and has_property_like(schema, item)
519+
if not item_in_schema and not property_like:
518520
raise AttributeError(f"{schema.__name__} does not have attribute '{item}'.")
519521

520522
subschema = None
521-
item_type = schema.model_fields[item].annotation
522-
if lenient_issubclass(item_type, BaseModel):
523-
subschema = item_type
523+
if item_in_schema:
524+
item_type = schema.model_fields[item].annotation
525+
if lenient_issubclass(item_type, BaseModel):
526+
subschema = item_type
524527

525528
return True, subschema
529+
530+
531+
def has_property_like(obj_type: type, name: str) -> bool:
532+
"""
533+
Check if a class has an attribute and if it is property-like.
534+
535+
Parameters
536+
----------
537+
obj_type
538+
The class that needs to be checked
539+
name
540+
The name of the attribute to be verified
541+
542+
Returns
543+
-------
544+
bool
545+
True if the property corresponding to the name is property-like.
546+
"""
547+
if not hasattr(obj_type, name):
548+
return False
549+
550+
attr = getattr(obj_type, name)
551+
552+
if isinstance(attr, property):
553+
return True
554+
555+
if callable(attr):
556+
return False
557+
558+
# Check for custom property-like descriptors with __get__ but not callable
559+
# If not, is not property-like.
560+
return hasattr(attr, "__get__")

tests/core/test_reference.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ def test_set_uuid():
153153

154154

155155
def test_schema():
156+
from functools import cached_property
157+
156158
from pydantic import BaseModel
157159

158160
from jobflow import OutputReference
@@ -173,6 +175,17 @@ class MySchema(BaseModel):
173175
name: str
174176
nested: MediumSchema
175177

178+
@property
179+
def b(self) -> int:
180+
return self.number
181+
182+
@cached_property
183+
def c(self) -> int:
184+
return self.number
185+
186+
def some_method(self) -> int:
187+
return self.number
188+
176189
ref = OutputReference("123", output_schema=MySchema)
177190
assert ref.attributes == ()
178191

@@ -185,15 +198,26 @@ class MySchema(BaseModel):
185198
assert new_ref.uuid == "123"
186199
assert new_ref.output_schema is None
187200

188-
with pytest.raises(AttributeError):
201+
with pytest.raises(AttributeError, match="does not have attribute 'a'"):
189202
_ = ref.a.uuid
190203

191-
with pytest.raises(AttributeError):
204+
with pytest.raises(AttributeError, match="does not have attribute 'a'"):
192205
_ = ref["a"].uuid
193206

194207
with pytest.raises(AttributeError):
195208
_ = ref[1].uuid
196209

210+
assert ref.b
211+
assert ref["b"]
212+
assert ref.c
213+
assert ref["c"]
214+
215+
with pytest.raises(AttributeError, match="does not have attribute 'some_method'"):
216+
_ = ref.some_method
217+
218+
with pytest.raises(AttributeError, match="does not have attribute 'some_method'"):
219+
_ = ref["some_method"]
220+
197221
# check valid nested schemas
198222
assert ref.nested.s.uuid == "123"
199223
with pytest.raises(AttributeError):
@@ -518,3 +542,49 @@ def test_not_iterable():
518542
with pytest.raises(TypeError):
519543
for _ in ref:
520544
pass
545+
546+
547+
def test_has_property_like():
548+
from functools import cached_property
549+
550+
from monty.functools import lazy_property
551+
from pydantic import BaseModel, ConfigDict
552+
553+
from jobflow.core.reference import has_property_like
554+
555+
class TestModel(BaseModel):
556+
model_config = ConfigDict(ignored_types=(lazy_property,))
557+
558+
x: str = "x"
559+
y: int = 1
560+
561+
def method(self):
562+
return self.x
563+
564+
@staticmethod
565+
def static_method():
566+
return 1
567+
568+
@classmethod
569+
def class_method(cls):
570+
return cls.y
571+
572+
@property
573+
def standard_property(self):
574+
return self.x
575+
576+
@lazy_property
577+
def monty_lazy_property(self):
578+
return self.x
579+
580+
@cached_property
581+
def functools_cached_property(self):
582+
return self.x
583+
584+
assert not has_property_like(TestModel, "method")
585+
assert not has_property_like(TestModel, "static_method")
586+
assert not has_property_like(TestModel, "class_method")
587+
assert has_property_like(TestModel, "standard_property")
588+
assert has_property_like(TestModel, "monty_lazy_property")
589+
assert has_property_like(TestModel, "functools_cached_property")
590+
assert not has_property_like(TestModel, "not_existing_prop")

0 commit comments

Comments
 (0)