From 3abc1ce72a3e0c3a53caeea1851de97e342e4999 Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Mon, 9 Oct 2023 17:27:16 -0700 Subject: [PATCH 01/16] Add __getitem__ magic method to `Job` --- src/jobflow/core/job.py | 16 ++++++++++++++++ tests/core/test_flow.py | 21 +++++++++++++++++++++ tests/core/test_job.py | 7 +++++++ 3 files changed, 44 insertions(+) diff --git a/src/jobflow/core/job.py b/src/jobflow/core/job.py index 99873595..c3d4e708 100644 --- a/src/jobflow/core/job.py +++ b/src/jobflow/core/job.py @@ -403,6 +403,22 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: """Get the hash of the job.""" return hash(self.uuid) + + def __getitem__(self, k: Any) -> OutputReference: + """ + Convenience function for parsing the output of a Job. + + Parameters + ---------- + k + The index key or index. + + Returns + ------- + OutputReference + The equivalent of `Job.output[k]` + """ + return self.output[k] @property def input_references(self) -> tuple[jobflow.OutputReference, ...]: diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index d34fc2ae..1f9d0a53 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1012,3 +1012,24 @@ def test_flow_repr(): assert len(lines) == len(flow_repr) for expected, line in zip(lines, flow_repr): assert line.startswith(expected), f"{line=} doesn't start with {expected=}" + +def test_get_item(): + from jobflow import Flow, job, run_locally + + @job + def make_str(s): + return {"hello": s} + + @job + def capitalize(s): + return s.upper() + + + job1 = make_str("world") + job2 = capitalize(job1["hello"]) + + + flow = Flow([job1, job2]) + + responses = run_locally(flow, ensure_success=True) + assert responses[job2.uuid][1].output == "WORLD" \ No newline at end of file diff --git a/tests/core/test_job.py b/tests/core/test_job.py index 3d862565..1143ddd9 100644 --- a/tests/core/test_job.py +++ b/tests/core/test_job.py @@ -1272,6 +1272,7 @@ def use_maker(maker): def test_job_magic_methods(): from jobflow import Job + from jobflow.reference import OutputReference # prepare test jobs job1 = Job(function=sum, function_args=([1, 2],)) @@ -1296,3 +1297,9 @@ def test_job_magic_methods(): # test __hash__ assert hash(job1) != hash(job2) != hash(job3) + + # test __getitem__ + assert isinstance(job1["test"], OutputReference) + assert isinstance(job1[1], OutputReference) + assert job1["test"].attributes == (("i", "test")) + assert job1[1].attributes == (("a", 1)) From f509b0f8e9f8b69e9ffdeb606031980acacb7489 Mon Sep 17 00:00:00 2001 From: "Andrew S. Rosen" Date: Mon, 9 Oct 2023 17:33:09 -0700 Subject: [PATCH 02/16] Add newline --- tests/core/test_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index 1f9d0a53..48cf8862 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1032,4 +1032,4 @@ def capitalize(s): flow = Flow([job1, job2]) responses = run_locally(flow, ensure_success=True) - assert responses[job2.uuid][1].output == "WORLD" \ No newline at end of file + assert responses[job2.uuid][1].output == "WORLD" From 3e40fd98c5f859070a3a5707dadaf2386af08121 Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Mon, 9 Oct 2023 17:35:08 -0700 Subject: [PATCH 03/16] `black` --- tests/core/test_flow.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index 1f9d0a53..4108c3f2 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1013,23 +1013,22 @@ def test_flow_repr(): for expected, line in zip(lines, flow_repr): assert line.startswith(expected), f"{line=} doesn't start with {expected=}" + def test_get_item(): from jobflow import Flow, job, run_locally @job def make_str(s): return {"hello": s} - + @job def capitalize(s): return s.upper() - job1 = make_str("world") job2 = capitalize(job1["hello"]) - - + flow = Flow([job1, job2]) - + responses = run_locally(flow, ensure_success=True) - assert responses[job2.uuid][1].output == "WORLD" \ No newline at end of file + assert responses[job2.uuid][1].output == "WORLD" From 3e55bc51bb96d4850e36265c236e41621a40281d Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Mon, 9 Oct 2023 17:36:45 -0700 Subject: [PATCH 04/16] Fix import --- tests/core/test_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_job.py b/tests/core/test_job.py index 1143ddd9..91574b28 100644 --- a/tests/core/test_job.py +++ b/tests/core/test_job.py @@ -1272,7 +1272,7 @@ def use_maker(maker): def test_job_magic_methods(): from jobflow import Job - from jobflow.reference import OutputReference + from jobflow.core.reference import OutputReference # prepare test jobs job1 = Job(function=sum, function_args=([1, 2],)) From cfd867d5145ca54f5b05e3a58692056dfb23a8ec Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Mon, 9 Oct 2023 17:38:56 -0700 Subject: [PATCH 05/16] ruff --- src/jobflow/core/job.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/jobflow/core/job.py b/src/jobflow/core/job.py index c3d4e708..0fd443b6 100644 --- a/src/jobflow/core/job.py +++ b/src/jobflow/core/job.py @@ -403,16 +403,16 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: """Get the hash of the job.""" return hash(self.uuid) - + def __getitem__(self, k: Any) -> OutputReference: """ - Convenience function for parsing the output of a Job. + Get the corresponding `OutputReference` for the `Job`. Parameters ---------- k The index key or index. - + Returns ------- OutputReference From 132a38d62f8ec2002bb63daf0fc4587b63e1e50e Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Mon, 9 Oct 2023 17:41:40 -0700 Subject: [PATCH 06/16] patch --- tests/core/test_job.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_job.py b/tests/core/test_job.py index 91574b28..dbf7cad5 100644 --- a/tests/core/test_job.py +++ b/tests/core/test_job.py @@ -1301,5 +1301,5 @@ def test_job_magic_methods(): # test __getitem__ assert isinstance(job1["test"], OutputReference) assert isinstance(job1[1], OutputReference) - assert job1["test"].attributes == (("i", "test")) - assert job1[1].attributes == (("a", 1)) + assert job1["test"].attributes == (("i", "test"),) + assert job1[1].attributes == (("a", 1),) From 94a15116abae19322f252f078de49f0672dfff4f Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Mon, 9 Oct 2023 17:44:22 -0700 Subject: [PATCH 07/16] patch --- tests/core/test_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_job.py b/tests/core/test_job.py index dbf7cad5..999557b7 100644 --- a/tests/core/test_job.py +++ b/tests/core/test_job.py @@ -1302,4 +1302,4 @@ def test_job_magic_methods(): assert isinstance(job1["test"], OutputReference) assert isinstance(job1[1], OutputReference) assert job1["test"].attributes == (("i", "test"),) - assert job1[1].attributes == (("a", 1),) + assert job1[1].attributes == (("i", 1),) From 184d86cb1cd30766724740874402259b3fb8061f Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Thu, 12 Oct 2023 14:39:36 -0700 Subject: [PATCH 08/16] Add `__getattr__` support Job-in-Job support --- src/jobflow/core/job.py | 41 ++++++++++++++--------- src/jobflow/utils/find.py | 4 +-- tests/core/test_flow.py | 70 +++++++++++++++++++++++++-------------- tests/core/test_job.py | 8 ++--- 4 files changed, 78 insertions(+), 45 deletions(-) diff --git a/src/jobflow/core/job.py b/src/jobflow/core/job.py index 8b9c0bca..e522c876 100644 --- a/src/jobflow/core/job.py +++ b/src/jobflow/core/job.py @@ -351,16 +351,9 @@ def __init__( self.output = OutputReference(self.uuid, output_schema=self.output_schema) - # check to see if job or flow is included in the job args - # this is a possible situation but likely a mistake - all_args = tuple(self.function_args) + tuple(self.function_kwargs.values()) - if contains_flow_or_job(all_args): - warnings.warn( - f"Job '{self.name}' contains an Flow or Job as an input. " - f"Usually inputs should be the output of a Job or an Flow (e.g. " - f"job.output). If this message is unexpected then double check the " - f"inputs to your Job." - ) + # check to see if job is included in the job args + self.function_args = tuple([arg.output if isinstance(arg, Job) else arg for arg in list(self.function_args)]) + self.function_kwargs = {arg: v.output if isinstance(v, Job) else v for arg, v in self.function_kwargs.items()} def __repr__(self): """Get a string representation of the job.""" @@ -405,21 +398,39 @@ def __hash__(self) -> int: """Get the hash of the job.""" return hash(self.uuid) - def __getitem__(self, k: Any) -> OutputReference: + def __getitem__(self, key: Any) -> OutputReference: """ - Get the corresponding `OutputReference` for the `Job`. + Get the corresponding `OutputReference` for the `Job` + when indexed like a dictionary or list. Parameters ---------- - k - The index key or index. + key + The index/key. Returns ------- OutputReference The equivalent of `Job.output[k]` """ - return self.output[k] + return self.output[key] + + def __getattr__(self, name: str) -> OutputReference: + """ + Get the corresponding `OutputReference` for the `Job` + when indexed list a class attribute. + + Parameters + ---------- + name + The name of the attribute. + + Returns + ------- + OutputReference + The equivalent of `Job.output.name` + """ + return getattr(self.output, name) @property def input_references(self) -> tuple[jobflow.OutputReference, ...]: diff --git a/src/jobflow/utils/find.py b/src/jobflow/utils/find.py index cb1a2b82..31ca4a5e 100644 --- a/src/jobflow/utils/find.py +++ b/src/jobflow/utils/find.py @@ -199,11 +199,11 @@ def contains_flow_or_job(obj: Any) -> bool: from jobflow.core.job import Job if isinstance(obj, (Flow, Job)): - # if the argument is an flow or job then stop there + # if the argument is a flow or job then stop there return True elif isinstance(obj, (float, int, str, bool)): - # argument is a primitive, we won't find an flow or job here + # argument is a primitive, we won't find a flow or job here return False obj = jsanitize(obj, strict=True, allow_bson=True) diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index 4108c3f2..22a1eb5f 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -101,27 +101,17 @@ def test_flow_of_jobs_init(): flow = Flow([add_job], output=add_job.output) assert flow.output == add_job.output - # # test multi job and list multi outputs + # test multi job and list multi outputs add_job1 = get_test_job() add_job2 = get_test_job() flow = Flow([add_job1, add_job2], output=[add_job1.output, add_job2.output]) assert flow.output[1] == add_job2.output - # # test all jobs included needed to generate outputs + # test all jobs included needed to generate outputs add_job = get_test_job() with pytest.raises(ValueError): Flow([], output=add_job.output) - # test job given rather than outputs - add_job = get_test_job() - with pytest.warns(UserWarning): - Flow([add_job], output=add_job) - - # test complex object containing job given rather than outputs - add_job = get_test_job() - with pytest.warns(UserWarning): - Flow([add_job], output={1: [[{"a": add_job}]]}) - # test job already belongs to another flow add_job = get_test_job() Flow([add_job]) @@ -197,18 +187,6 @@ def test_flow_of_flows_init(): with pytest.raises(ValueError): Flow([], output=subflow.output) - # test flow given rather than outputs - add_job = get_test_job() - subflow = Flow([add_job], output=add_job.output) - with pytest.warns(UserWarning): - Flow([subflow], output=subflow) - - # test complex object containing job given rather than outputs - add_job = get_test_job() - subflow = Flow([add_job], output=add_job.output) - with pytest.warns(UserWarning): - Flow([subflow], output={1: [[{"a": subflow}]]}) - # test flow already belongs to another flow add_job = get_test_job() subflow = Flow([add_job], output=add_job.output) @@ -1032,3 +1010,47 @@ def capitalize(s): responses = run_locally(flow, ensure_success=True) assert responses[job2.uuid][1].output == "WORLD" + +def test_get_item_job(): + from jobflow import Flow, job, run_locally + + @job + def make_str(s): + return s + + @job + def capitalize(s): + return s.upper() + + job1 = make_str("world") + job2 = capitalize(job1) + + flow = Flow([job1, job2]) + + responses = run_locally(flow, ensure_success=True) + assert responses[job2.uuid][1].output == "WORLD" + +# def test_get_attr(): +# from jobflow import Flow, job, run_locally +# from dataclasses import dataclass + +# @job +# def make_str(s): + +# @dataclass +# class MyClass: +# hello: str = s + +# return MyClass + +# @job +# def capitalize(s): +# return s.upper() + +# job1 = make_str("world") +# job2 = capitalize(job1.hello) + +# flow = Flow([job1, job2]) + +# responses = run_locally(flow, ensure_success=True) +# assert responses[job2.uuid][1].output == "WORLD" diff --git a/tests/core/test_job.py b/tests/core/test_job.py index 999557b7..77fae782 100644 --- a/tests/core/test_job.py +++ b/tests/core/test_job.py @@ -32,10 +32,6 @@ def test_job_init(): assert test_job.uuid is not None assert test_job.output.uuid == test_job.uuid - # test job as another job as input - with pytest.warns(UserWarning): - Job(function=add, function_args=(test_job,)) - # test init with kwargs test_job = Job(function=add, function_args=(1,), function_kwargs={"b": 2}) assert test_job @@ -1303,3 +1299,7 @@ def test_job_magic_methods(): assert isinstance(job1[1], OutputReference) assert job1["test"].attributes == (("i", "test"),) assert job1[1].attributes == (("i", 1),) + + # test __getattr__ + assert isinstance(job1.test, OutputReference) + assert job1.test.attributes == (("a", "test"),) From 8818241ae2796eecfa32fa133c5e64bced11ede0 Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Thu, 12 Oct 2023 14:40:57 -0700 Subject: [PATCH 09/16] black --- src/jobflow/core/job.py | 12 ++++++++++-- tests/core/test_flow.py | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/jobflow/core/job.py b/src/jobflow/core/job.py index e522c876..b80ce319 100644 --- a/src/jobflow/core/job.py +++ b/src/jobflow/core/job.py @@ -352,8 +352,16 @@ def __init__( self.output = OutputReference(self.uuid, output_schema=self.output_schema) # check to see if job is included in the job args - self.function_args = tuple([arg.output if isinstance(arg, Job) else arg for arg in list(self.function_args)]) - self.function_kwargs = {arg: v.output if isinstance(v, Job) else v for arg, v in self.function_kwargs.items()} + self.function_args = tuple( + [ + arg.output if isinstance(arg, Job) else arg + for arg in list(self.function_args) + ] + ) + self.function_kwargs = { + arg: v.output if isinstance(v, Job) else v + for arg, v in self.function_kwargs.items() + } def __repr__(self): """Get a string representation of the job.""" diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index 22a1eb5f..c618c2f7 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1011,6 +1011,7 @@ def capitalize(s): responses = run_locally(flow, ensure_success=True) assert responses[job2.uuid][1].output == "WORLD" + def test_get_item_job(): from jobflow import Flow, job, run_locally @@ -1030,6 +1031,7 @@ def capitalize(s): responses = run_locally(flow, ensure_success=True) assert responses[job2.uuid][1].output == "WORLD" + # def test_get_attr(): # from jobflow import Flow, job, run_locally # from dataclasses import dataclass From 8363c14e1faf67070002406698dc352b6c5b8471 Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Thu, 12 Oct 2023 14:48:07 -0700 Subject: [PATCH 10/16] update --- src/jobflow/core/job.py | 3 --- tests/core/test_flow.py | 34 +++++++++++++++++----------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/jobflow/core/job.py b/src/jobflow/core/job.py index b80ce319..98b98ec9 100644 --- a/src/jobflow/core/job.py +++ b/src/jobflow/core/job.py @@ -4,7 +4,6 @@ import logging import typing -import warnings from dataclasses import dataclass, field from monty.json import MSONable, jsanitize @@ -317,8 +316,6 @@ def __init__( ): from copy import deepcopy - from jobflow.utils.find import contains_flow_or_job - function_args = () if function_args is None else function_args function_kwargs = {} if function_kwargs is None else function_kwargs uuid = suuid() if uuid is None else uuid diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index c618c2f7..b68f2447 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1032,27 +1032,27 @@ def capitalize(s): assert responses[job2.uuid][1].output == "WORLD" -# def test_get_attr(): -# from jobflow import Flow, job, run_locally -# from dataclasses import dataclass +def test_get_attr(): + from jobflow import Flow, job, run_locally + from dataclasses import dataclass -# @job -# def make_str(s): + @job + def make_str(s): -# @dataclass -# class MyClass: -# hello: str = s + @dataclass + class MyClass: + hello: str = s -# return MyClass + return MyClass -# @job -# def capitalize(s): -# return s.upper() + @job + def capitalize(s): + return s.upper() -# job1 = make_str("world") -# job2 = capitalize(job1.hello) + job1 = make_str("world") + job2 = capitalize(job1.hello) -# flow = Flow([job1, job2]) + flow = Flow([job1, job2]) -# responses = run_locally(flow, ensure_success=True) -# assert responses[job2.uuid][1].output == "WORLD" + responses = run_locally(flow, ensure_success=True) + assert responses[job2.uuid][1].output == "WORLD" From 18f75a7fdfbc477a51d03db16d5b73911db804d8 Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Thu, 12 Oct 2023 14:50:40 -0700 Subject: [PATCH 11/16] black --- tests/core/test_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index b68f2447..63d9baee 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1038,7 +1038,6 @@ def test_get_attr(): @job def make_str(s): - @dataclass class MyClass: hello: str = s From 6893d7f15a7bb98a323caff7342e6b4eab602a5a Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Thu, 12 Oct 2023 14:53:58 -0700 Subject: [PATCH 12/16] lint --- tests/core/test_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index 63d9baee..fbbb0f41 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1033,9 +1033,10 @@ def capitalize(s): def test_get_attr(): - from jobflow import Flow, job, run_locally from dataclasses import dataclass + from jobflow import Flow, job, run_locally + @job def make_str(s): @dataclass From 7c835d3882230bff5b3ec2d483cc9933fd427a5c Mon Sep 17 00:00:00 2001 From: "Andrew S. Rosen" Date: Fri, 13 Oct 2023 06:35:22 -0700 Subject: [PATCH 13/16] Update test_flow.py --- tests/core/test_flow.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index fbbb0f41..2cfe6f40 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1037,22 +1037,22 @@ def test_get_attr(): from jobflow import Flow, job, run_locally + @dataclass + class MyClass: + hello: str + @job def make_str(s): - @dataclass - class MyClass: - hello: str = s - - return MyClass - + return MyClass(hello=s) + @job def capitalize(s): return s.upper() - + job1 = make_str("world") job2 = capitalize(job1.hello) - + flow = Flow([job1, job2]) - + responses = run_locally(flow, ensure_success=True) assert responses[job2.uuid][1].output == "WORLD" From 2a8483616db43bea5da41800fec69abebd1b1f48 Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Fri, 13 Oct 2023 06:37:30 -0700 Subject: [PATCH 14/16] black --- tests/core/test_flow.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py index 2cfe6f40..4899137f 100644 --- a/tests/core/test_flow.py +++ b/tests/core/test_flow.py @@ -1039,20 +1039,20 @@ def test_get_attr(): @dataclass class MyClass: - hello: str - + hello: str + @job def make_str(s): return MyClass(hello=s) - + @job def capitalize(s): return s.upper() - + job1 = make_str("world") job2 = capitalize(job1.hello) - + flow = Flow([job1, job2]) - + responses = run_locally(flow, ensure_success=True) assert responses[job2.uuid][1].output == "WORLD" From 6e1c45d5afb60bd62bc9a41377175d9f701981f6 Mon Sep 17 00:00:00 2001 From: Andrew Rosen Date: Fri, 13 Oct 2023 06:46:07 -0700 Subject: [PATCH 15/16] lint --- src/jobflow/core/job.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/jobflow/core/job.py b/src/jobflow/core/job.py index 98b98ec9..4e4d903b 100644 --- a/src/jobflow/core/job.py +++ b/src/jobflow/core/job.py @@ -405,8 +405,9 @@ def __hash__(self) -> int: def __getitem__(self, key: Any) -> OutputReference: """ - Get the corresponding `OutputReference` for the `Job` - when indexed like a dictionary or list. + Get the corresponding `OutputReference` for the `Job`. + + This is for when it is indexed like a dictionary or list. Parameters ---------- @@ -422,8 +423,9 @@ def __getitem__(self, key: Any) -> OutputReference: def __getattr__(self, name: str) -> OutputReference: """ - Get the corresponding `OutputReference` for the `Job` - when indexed list a class attribute. + Get the corresponding `OutputReference` for the `Job`. + + This is for when it is indexed like a class attribute. Parameters ---------- From 57402ee44bd79c1c8262536d60033a3b1de4ae4e Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Fri, 13 Oct 2023 08:55:36 -0700 Subject: [PATCH 16/16] Job.__getattr__ raise AttributeError if attribute is not found in the job's output --- src/jobflow/core/job.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jobflow/core/job.py b/src/jobflow/core/job.py index 4e4d903b..437d06ca 100644 --- a/src/jobflow/core/job.py +++ b/src/jobflow/core/job.py @@ -437,7 +437,9 @@ def __getattr__(self, name: str) -> OutputReference: OutputReference The equivalent of `Job.output.name` """ - return getattr(self.output, name) + if attr := getattr(self.output, name, None): + return attr + raise AttributeError(f"{type(self).__name__} has no attribute {name!r}") @property def input_references(self) -> tuple[jobflow.OutputReference, ...]: