jobflow 0.2.1__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jobflow/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Jobflow is a package for writing dynamic and connected workflows."""
2
2
 
3
3
  from jobflow._version import __version__
4
- from jobflow.core.flow import Flow, JobOrder
4
+ from jobflow.core.flow import Flow, JobOrder, flow
5
5
  from jobflow.core.job import Job, JobConfig, Response, job
6
6
  from jobflow.core.maker import Maker
7
7
  from jobflow.core.reference import OnMissing, OutputReference
jobflow/core/flow.py CHANGED
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import warnings
7
+ from contextlib import contextmanager
8
+ from contextvars import ContextVar
7
9
  from copy import deepcopy
8
10
  from typing import TYPE_CHECKING
9
11
 
@@ -155,6 +157,12 @@ class Flow(MSONable):
155
157
  self.add_jobs(jobs)
156
158
  self.output = output
157
159
 
160
+ # If we're running inside a `DecoratedFlow`, add *this* Flow to the
161
+ # context.
162
+ current_flow_children_list = _current_flow_context.get()
163
+ if current_flow_children_list is not None:
164
+ current_flow_children_list.append(self)
165
+
158
166
  def __len__(self) -> int:
159
167
  """Get the number of jobs or subflows in the flow."""
160
168
  return len(self.jobs)
@@ -828,7 +836,7 @@ class Flow(MSONable):
828
836
  if job.host is not None and job.host != self.uuid:
829
837
  raise ValueError(
830
838
  f"{type(job).__name__} {job.name} ({job.uuid}) already belongs "
831
- f"to another flow."
839
+ f"to another flow: {job.host}."
832
840
  )
833
841
  if job.uuid in job_ids:
834
842
  raise ValueError(
@@ -921,3 +929,104 @@ def get_flow(
921
929
  )
922
930
 
923
931
  return flow
932
+
933
+
934
+ class DecoratedFlow(Flow):
935
+ """A DecoratedFlow is a Flow that is returned on using the @flow decorator."""
936
+
937
+ def __init__(self, fn, *args, **kwargs):
938
+ from jobflow import Maker
939
+
940
+ self.fn = fn
941
+ self.args = args
942
+ self.kwargs = kwargs
943
+
944
+ # Collect the jobs and flows that are used in the function
945
+ children_list = []
946
+ with flow_build_context(children_list):
947
+ output = self.fn(*self.args, **self.kwargs)
948
+
949
+ # From the collected items, remove those that have already been assigned
950
+ # to another Flow during the call of the function.
951
+ # This handles the case where a Flow object is instantiated inside
952
+ # the decorated function
953
+ children_list = [c for c in children_list if c.host is None]
954
+
955
+ name = getattr(self.fn, "__qualname__", self.fn.__name__)
956
+
957
+ # if decorates a make() in a Maker use that as a name
958
+ if (
959
+ len(self.args) > 0
960
+ and name.split(".")[-1] == "make"
961
+ and getattr(args[0], self.fn.__name__, None)
962
+ and isinstance(args[0], Maker)
963
+ ):
964
+ name = args[0].name
965
+
966
+ if isinstance(output, (jobflow.Job, jobflow.Flow)):
967
+ warnings.warn(
968
+ f"@flow decorated function '{name}' contains a Flow or"
969
+ f"Job as an output. Usually the output should be the output of"
970
+ f"a Job or another Flow (e.g. job.output). Replacing the"
971
+ f"output of the @flow with the output of the Flow/Job."
972
+ f"If this message is unexpected then double check the outputs"
973
+ f"of your @flow decorated function.",
974
+ stacklevel=2,
975
+ )
976
+ output = output.output
977
+
978
+ super().__init__(name=name, jobs=children_list, output=output)
979
+
980
+
981
+ def flow(fn):
982
+ """
983
+ Turn a function into a DecoratedFlow object.
984
+
985
+ Parameters
986
+ ----------
987
+ fn (Callable): The function to be wrapped in a DecoratedFlow object.
988
+
989
+ Returns
990
+ -------
991
+ Callable: A wrapper function that, when called, creates and returns
992
+ an instance of DecoratedFlow initialized with the provided function
993
+ and its arguments.
994
+ """
995
+ from functools import wraps
996
+
997
+ @wraps(fn)
998
+ def wrapper(*args, **kwargs):
999
+ return DecoratedFlow(fn, *args, **kwargs)
1000
+
1001
+ return wrapper
1002
+
1003
+
1004
+ @contextmanager
1005
+ def flow_build_context(children_list):
1006
+ """Provide a context manager for flows.
1007
+
1008
+ Provides a context manager for setting and resetting the `Job` and `Flow`
1009
+ objects in the current flow context.
1010
+
1011
+ Parameters
1012
+ ----------
1013
+ children_list: The `Job` or `Flow` objects that are part of the current
1014
+ flow context.
1015
+
1016
+ Yields
1017
+ ------
1018
+ None: Temporarily sets the provided `Job` or `Flow` objects as
1019
+ belonging to the current flow context within the managed block.
1020
+
1021
+ Raises
1022
+ ------
1023
+ None
1024
+ """
1025
+ token = _current_flow_context.set(children_list)
1026
+ try:
1027
+ yield
1028
+ finally:
1029
+ _current_flow_context.reset(token)
1030
+
1031
+
1032
+ _current_flow_context = ContextVar("current_flow_context", default=None)
jobflow/core/job.py CHANGED
@@ -11,6 +11,7 @@ from typing import cast, overload
11
11
  from monty.json import MSONable, jsanitize
12
12
  from typing_extensions import Self
13
13
 
14
+ from jobflow.core.flow import _current_flow_context
14
15
  from jobflow.core.reference import OnMissing, OutputReference
15
16
  from jobflow.utils.uid import suid
16
17
 
@@ -384,6 +385,30 @@ class Job(MSONable):
384
385
  stacklevel=2,
385
386
  )
386
387
 
388
+ # If we're running inside a `DecoratedFlow`, add *this* Job to the
389
+ # context.
390
+ current_flow_children_list = _current_flow_context.get()
391
+ if current_flow_children_list is not None:
392
+ current_flow_children_list.append(self)
393
+
394
+ def __getitem__(self, key: Any) -> OutputReference:
395
+ """
396
+ Get the corresponding `OutputReference` for the `Job`.
397
+
398
+ This is for when it is indexed like a dictionary or list.
399
+
400
+ Parameters
401
+ ----------
402
+ key
403
+ The index/key.
404
+
405
+ Returns
406
+ -------
407
+ OutputReference
408
+ The equivalent of `Job.output[k]`
409
+ """
410
+ return self.output[key]
411
+
387
412
  def __repr__(self):
388
413
  """Get a string representation of the job."""
389
414
  name, uuid = self.name, self.uuid
@@ -1279,6 +1304,21 @@ class Response(typing.Generic[T]):
1279
1304
  Response
1280
1305
  The job response controlling the data to store and flow execution options.
1281
1306
  """
1307
+ # If the Job returns another Job, or something that can be interpreted
1308
+ # as an iterable of jobs, interpret it as a replace.
1309
+ from jobflow import Flow
1310
+
1311
+ def is_job_or_flow(x):
1312
+ return isinstance(x, Job | Flow)
1313
+
1314
+ should_replace = is_job_or_flow(job_returns)
1315
+ if job_returns and isinstance(job_returns, (list, tuple)):
1316
+ should_replace = all(is_job_or_flow(resp) for resp in job_returns)
1317
+
1318
+ if should_replace:
1319
+ job_returns = Response(replace=job_returns)
1320
+
1321
+ # only apply output schema if there is no replace.
1282
1322
  if isinstance(job_returns, Response):
1283
1323
  if job_returns.replace is None:
1284
1324
  # only apply output schema if there is no replace.
jobflow/core/reference.py CHANGED
@@ -514,12 +514,47 @@ def validate_schema_access(
514
514
  The BaseModel class associated with the item, if any.
515
515
  """
516
516
  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:
518
520
  raise AttributeError(f"{schema.__name__} does not have attribute '{item}'.")
519
521
 
520
522
  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
524
527
 
525
528
  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__")
jobflow/core/store.py CHANGED
@@ -21,9 +21,17 @@ if typing.TYPE_CHECKING:
21
21
 
22
22
  from jobflow.core.schemas import JobStoreDocument
23
23
 
24
- obj_type = Union[str, Enum, type[MSONable], list[Union[Enum, str, type[MSONable]]]]
25
- save_type = Optional[dict[str, obj_type]]
26
- load_type = Union[bool, dict[str, Union[bool, obj_type]]]
24
+ obj_save_type = Union[
25
+ str,
26
+ Enum,
27
+ type[MSONable],
28
+ list[Union[Enum, str, type[MSONable], list[Union[str, type[MSONable]]]]],
29
+ ]
30
+ save_type = Optional[dict[str, obj_save_type]]
31
+ obj_load_type = Union[
32
+ str, Enum, type[MSONable], list[Union[Enum, str, type[MSONable]]]
33
+ ]
34
+ load_type = Union[bool, dict[str, Union[bool, obj_load_type]]]
27
35
 
28
36
 
29
37
  T = typing.TypeVar("T", bound="JobStore")
@@ -43,6 +51,9 @@ class JobStore(Store):
43
51
  Which items to save in additional stores when uploading documents. Given as a
44
52
  mapping of ``{store name: store_type}`` where ``store_type`` can be a dictionary
45
53
  key (string or enum), an :obj:`.MSONable` class, or a list of keys/classes.
54
+ If the list of keys/classes itself contains a list/tuple, this will be treated
55
+ as the path to the save key. Can be used if a key is duplicate in the output
56
+ and only a single occurrence shall be put in the additional store.
46
57
  load
47
58
  Which items to load from additional stores when querying documents. Given as a
48
59
  mapping of ``{store name: store_type}`` where ``store_type`` can be `True``, in
@@ -304,7 +315,10 @@ class JobStore(Store):
304
315
  locations = []
305
316
  for store_name, store_save in save_keys.items():
306
317
  for save_key in store_save:
307
- locations.extend(find_key(doc, save_key, include_end=True))
318
+ if isinstance(save_key, (list, tuple)):
319
+ locations.append(list(save_key))
320
+ else:
321
+ locations.extend(find_key(doc, save_key, include_end=True))
308
322
 
309
323
  locations = get_root_locations(locations)
310
324
  objects = [get(doc, list(loc)) for loc in locations]
@@ -726,7 +740,7 @@ def _prepare_load(
726
740
 
727
741
  def _prepare_save(
728
742
  save: bool | save_type,
729
- ) -> dict[str, list[str | type[MSONable]]]:
743
+ ) -> dict[str, list[str | type[MSONable] | list[str | type[MSONable]]]]:
730
744
  """Standardize save type."""
731
745
  from enum import Enum
732
746
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jobflow
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: jobflow is a library for writing computational workflows
5
5
  Author-email: Alex Ganose <a.ganose@imperial.ac.uk>
6
6
  License: modified BSD
@@ -36,35 +36,35 @@ Provides-Extra: ulid
36
36
  Requires-Dist: python-ulid; extra == "ulid"
37
37
  Provides-Extra: docs
38
38
  Requires-Dist: autodoc_pydantic==2.2.0; extra == "docs"
39
- Requires-Dist: furo==2025.9.25; extra == "docs"
40
- Requires-Dist: ipython==9.3.0; extra == "docs"
39
+ Requires-Dist: furo==2025.12.19; extra == "docs"
40
+ Requires-Dist: ipython==9.9.0; extra == "docs"
41
41
  Requires-Dist: myst_parser==4.0.1; extra == "docs"
42
- Requires-Dist: nbsphinx==0.9.7; extra == "docs"
42
+ Requires-Dist: nbsphinx==0.9.8; extra == "docs"
43
43
  Requires-Dist: sphinx-copybutton==0.5.2; extra == "docs"
44
44
  Requires-Dist: sphinx==8.1.3; extra == "docs"
45
45
  Provides-Extra: dev
46
46
  Requires-Dist: pre-commit>=2.12.1; extra == "dev"
47
47
  Requires-Dist: typing_extensions; python_version < "3.11" and extra == "dev"
48
48
  Provides-Extra: tests
49
- Requires-Dist: moto==5.1.16; extra == "tests"
49
+ Requires-Dist: moto==5.1.20; extra == "tests"
50
50
  Requires-Dist: pytest-cov==7.0.0; extra == "tests"
51
- Requires-Dist: pytest==8.4.2; extra == "tests"
51
+ Requires-Dist: pytest==9.0.2; extra == "tests"
52
52
  Provides-Extra: vis
53
53
  Requires-Dist: matplotlib; extra == "vis"
54
54
  Requires-Dist: pydot; extra == "vis"
55
55
  Provides-Extra: fireworks
56
56
  Requires-Dist: FireWorks; extra == "fireworks"
57
57
  Provides-Extra: strict
58
- Requires-Dist: FireWorks==2.0.6; extra == "strict"
58
+ Requires-Dist: FireWorks==2.0.8; extra == "strict"
59
59
  Requires-Dist: PyYAML==6.0.3; extra == "strict"
60
60
  Requires-Dist: maggma==0.72.0; extra == "strict"
61
- Requires-Dist: matplotlib==3.10.7; extra == "strict"
61
+ Requires-Dist: matplotlib==3.10.8; extra == "strict"
62
62
  Requires-Dist: monty==2025.3.3; extra == "strict"
63
- Requires-Dist: moto==5.1.16; extra == "strict"
63
+ Requires-Dist: moto==5.1.20; extra == "strict"
64
64
  Requires-Dist: networkx==3.4.2; extra == "strict"
65
- Requires-Dist: pydantic-settings==2.11.0; extra == "strict"
66
- Requires-Dist: pydantic==2.12.3; extra == "strict"
67
- Requires-Dist: pydash==8.0.5; extra == "strict"
65
+ Requires-Dist: pydantic-settings==2.12.0; extra == "strict"
66
+ Requires-Dist: pydantic==2.12.5; extra == "strict"
67
+ Requires-Dist: pydash==8.0.6; extra == "strict"
68
68
  Requires-Dist: pydot==4.0.1; extra == "strict"
69
69
  Requires-Dist: python-ulid==3.1.0; extra == "strict"
70
70
  Requires-Dist: typing-extensions==4.15.0; extra == "strict"
@@ -154,7 +154,7 @@ the jobs is determined automatically and can be visualised using the flow graph.
154
154
 
155
155
  ## Installation
156
156
 
157
- `jobflow` is a Python 3.9+ library and can be installed using `pip`.
157
+ `jobflow` is a Python 3.10+ library and can be installed using `pip`.
158
158
 
159
159
  ```bash
160
160
  pip install jobflow
@@ -1,15 +1,15 @@
1
- jobflow/__init__.py,sha256=l7o10BaqEQWw5aZziWRg40PsIAgQ4lrlluXs9hIv2mg,570
1
+ jobflow/__init__.py,sha256=gKxdw3Yi4gjq6218fMBTHSBnVq-kZ4uZ9fLoKbC1xr0,576
2
2
  jobflow/_version.py,sha256=Ym07PBD7sAmpqVpX8tuzWma3P_Hv6KXbDKXWkw8OwaI,205
3
3
  jobflow/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  jobflow/settings.py,sha256=yinHpix-DPwzcBhQCO8zDXFuv048rmBgpYMPa9wcs8c,6094
5
5
  jobflow/core/__init__.py,sha256=3sx5t1Gysejc4c_fPrhvCjPUg0p_384Zko8ms2c_NnY,98
6
- jobflow/core/flow.py,sha256=AWseNK4P42CiiPykl4kud0e-SJYxDJoqM6I_n77w4_M,30854
7
- jobflow/core/job.py,sha256=ogKour02EF6uvSYZz4F4zaqiM6bUiaCwwtboF0WprPk,49002
6
+ jobflow/core/flow.py,sha256=ernJoGkts5hi3ruReJHDqioB5N2wpl6zjiwCQqhqGYM,34337
7
+ jobflow/core/job.py,sha256=s_5N4utkpoPWJnj5OU1fMpE5IXIbUzRwnFjnbF9Aeec,50335
8
8
  jobflow/core/maker.py,sha256=WhsYw2wDNVIyAEeRUoikOQMzXzHuXfFVwXrJpwGCD1E,11162
9
- jobflow/core/reference.py,sha256=2xqNwyo57wFPLMLPprXa5h8f_cOWcyCHmDsfVEl5pRk,17101
9
+ jobflow/core/reference.py,sha256=ZATBhaB9HDhPujsfscOcHw7nJf7QfKvozvII47_q1UU,18012
10
10
  jobflow/core/schemas.py,sha256=Oi5-PnZpI8S9jSY7Q4f8H7xUybbRZDXlgugeVewVsrA,968
11
11
  jobflow/core/state.py,sha256=IGJTtmpotDKEcgDEnsT5x20ZeyvQT68Mr3teTjkgYnM,709
12
- jobflow/core/store.py,sha256=PAtAhZTLU1b17OD66rqlo8_JP1p-IzH8c2WJwWd1Q2o,26997
12
+ jobflow/core/store.py,sha256=njeKh_F-sHGt-hk0lsETmgK9H1I7hAqCeJY4LzStAxo,27627
13
13
  jobflow/managers/__init__.py,sha256=KkA5cVDe2os2_2aTa8eiB9SnkGLZNybcci-Lo4tbaWM,55
14
14
  jobflow/managers/fireworks.py,sha256=xKPcL1BX59jnF6LpyEwcsxvCCShLqep-9UHRyv9FmYE,7146
15
15
  jobflow/managers/local.py,sha256=iI1QI6Dg7P7C3vVd4Mt1xXAmcQ-o37Ox_gQRnafsBHE,6142
@@ -21,8 +21,8 @@ jobflow/utils/graph.py,sha256=kweAowEzv8ZGjJ9KZvJ-G5ueAqGPWbU0z7Xd4e-q8no,6596
21
21
  jobflow/utils/log.py,sha256=4-_1OUSQ8I4Q6CgQ4pxNBeveBdpXla7nibZNF7Vk3pw,1110
22
22
  jobflow/utils/uid.py,sha256=hNkpJ5AYhKd_sPWE_iGPcn6YD_AyizKX_swWksFr-_M,2537
23
23
  jobflow/utils/uuid.py,sha256=m0fInOs1yklpavf7jId85luDOHjZqfIIUzEtd89Bk_s,440
24
- jobflow-0.2.1.dist-info/licenses/LICENSE,sha256=jUEiENfZNQZh9RE9ixtUWgVkLRD85ScZ6iv1WREf19w,2418
25
- jobflow-0.2.1.dist-info/METADATA,sha256=4B39RQEMtBLt7_K2rghR9VfZ4GfxgFHIig9iixQRdaw,10039
26
- jobflow-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
27
- jobflow-0.2.1.dist-info/top_level.txt,sha256=IanNooU88OupQPDrWnT0rbL3E27P2wEy7Jsfx9_j8zc,8
28
- jobflow-0.2.1.dist-info/RECORD,,
24
+ jobflow-0.3.1.dist-info/licenses/LICENSE,sha256=jUEiENfZNQZh9RE9ixtUWgVkLRD85ScZ6iv1WREf19w,2418
25
+ jobflow-0.3.1.dist-info/METADATA,sha256=T1K52p9RjBXK-ZRRcuFXpxbptqbEUjBX3WVw6z9LBvE,10041
26
+ jobflow-0.3.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
27
+ jobflow-0.3.1.dist-info/top_level.txt,sha256=IanNooU88OupQPDrWnT0rbL3E27P2wEy7Jsfx9_j8zc,8
28
+ jobflow-0.3.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5