metafold 0.6.0__tar.gz → 0.8.dev0__tar.gz
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.
- {metafold-0.6.0 → metafold-0.8.dev0}/PKG-INFO +2 -2
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/__init__.py +3 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/api.py +18 -14
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/assets.py +0 -24
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/client.py +29 -1
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/func.py +75 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/jobs.py +70 -39
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/utils.py +1 -4
- metafold-0.8.dev0/metafold/workflows.py +137 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/PKG-INFO +2 -2
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/SOURCES.txt +3 -1
- {metafold-0.6.0 → metafold-0.8.dev0}/pyproject.toml +1 -1
- {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_assets.py +0 -13
- {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_jobs.py +85 -34
- {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_utils.py +4 -25
- metafold-0.8.dev0/tests/test_workflows.py +151 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/LICENSE +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/README.md +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/auth.py +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/exceptions.py +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/func_types.py +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/nx.py +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold/projects.py +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/dependency_links.txt +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/requires.txt +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/top_level.txt +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/setup.cfg +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_func.py +0 -0
- {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_projects.py +0 -0
|
@@ -2,6 +2,7 @@ from metafold.client import Client
|
|
|
2
2
|
from metafold.projects import ProjectsEndpoint
|
|
3
3
|
from metafold.assets import AssetsEndpoint
|
|
4
4
|
from metafold.jobs import JobsEndpoint
|
|
5
|
+
from metafold.workflows import WorkflowsEndpoint
|
|
5
6
|
from metafold.auth import AuthProvider
|
|
6
7
|
from typing import Optional
|
|
7
8
|
|
|
@@ -17,6 +18,7 @@ class MetafoldClient(Client):
|
|
|
17
18
|
projects: ProjectsEndpoint
|
|
18
19
|
assets: AssetsEndpoint
|
|
19
20
|
jobs: JobsEndpoint
|
|
21
|
+
workflows: WorkflowsEndpoint
|
|
20
22
|
|
|
21
23
|
def __init__(
|
|
22
24
|
self,
|
|
@@ -48,3 +50,4 @@ class MetafoldClient(Client):
|
|
|
48
50
|
self.projects = ProjectsEndpoint(self)
|
|
49
51
|
self.assets = AssetsEndpoint(self)
|
|
50
52
|
self.jobs = JobsEndpoint(self)
|
|
53
|
+
self.workflows = WorkflowsEndpoint(self)
|
|
@@ -2,20 +2,6 @@ from datetime import datetime, timezone
|
|
|
2
2
|
from functools import wraps
|
|
3
3
|
from typing import Any, Callable, Optional, TypeVar, Union
|
|
4
4
|
|
|
5
|
-
T = TypeVar("T")
|
|
6
|
-
U = TypeVar("U")
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def optional(f: Callable[[T], U]) -> Callable[[Optional[T]], Optional[U]]:
|
|
10
|
-
"""Decorator to generate converters that accept optional values."""
|
|
11
|
-
@wraps(f)
|
|
12
|
-
def decorator(v: Optional[T]) -> Optional[U]:
|
|
13
|
-
if v is None:
|
|
14
|
-
return v
|
|
15
|
-
return f(v)
|
|
16
|
-
|
|
17
|
-
return decorator
|
|
18
|
-
|
|
19
5
|
|
|
20
6
|
def asdatetime(s: Union[str, datetime]) -> datetime:
|
|
21
7
|
"""Parse Metafold API datetime.
|
|
@@ -38,3 +24,21 @@ def asdict(**kwargs: Any) -> dict[str, Any]:
|
|
|
38
24
|
if v is not None:
|
|
39
25
|
d[k] = v
|
|
40
26
|
return d
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
U = TypeVar("U")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def optional(f: Callable[[T], U]) -> Callable[[Optional[T]], Optional[U]]:
|
|
34
|
+
"""Decorator to generate converters that accept optional values."""
|
|
35
|
+
@wraps(f)
|
|
36
|
+
def decorator(v: Optional[T]) -> Optional[U]:
|
|
37
|
+
if v is None:
|
|
38
|
+
return None
|
|
39
|
+
return f(v)
|
|
40
|
+
|
|
41
|
+
return decorator
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
optional_datetime = optional(asdatetime)
|
|
@@ -115,30 +115,6 @@ class AssetsEndpoint:
|
|
|
115
115
|
fp.close()
|
|
116
116
|
return Asset(**r.json())
|
|
117
117
|
|
|
118
|
-
def update(
|
|
119
|
-
self, asset_id: str,
|
|
120
|
-
f: Union[str, bytes, PathLike, IO[bytes]],
|
|
121
|
-
project_id: Optional[str] = None,
|
|
122
|
-
) -> Asset:
|
|
123
|
-
"""Update an asset.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
asset_id: ID of asset to update.
|
|
127
|
-
f: File-like object (opened in binary mode) or path to file on disk.
|
|
128
|
-
project_id: Asset project ID.
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
Updated asset resource.
|
|
132
|
-
"""
|
|
133
|
-
project_id = self._client.project_id(project_id)
|
|
134
|
-
fp: IO[bytes] = _open_file(f)
|
|
135
|
-
try:
|
|
136
|
-
url = f"/projects/{project_id}/assets/{asset_id}"
|
|
137
|
-
r: Response = self._client.patch(url, files={"file": fp})
|
|
138
|
-
finally:
|
|
139
|
-
fp.close()
|
|
140
|
-
return Asset(**r.json())
|
|
141
|
-
|
|
142
118
|
def delete(self, asset_id: str, project_id: Optional[str] = None) -> None:
|
|
143
119
|
"""Delete an asset.
|
|
144
120
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from metafold.auth import AuthProvider
|
|
2
|
+
from metafold.exceptions import PollTimeout
|
|
2
3
|
from requests import HTTPError, Response, Session
|
|
3
|
-
from typing import Any, Callable, Optional
|
|
4
|
+
from typing import Any, Callable, Optional, Union
|
|
4
5
|
from urllib.parse import urljoin
|
|
5
6
|
import platform
|
|
7
|
+
import time
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
class Client:
|
|
@@ -74,3 +76,29 @@ class Client:
|
|
|
74
76
|
|
|
75
77
|
def delete(self, url: str, *args: Any, **kwargs: Any) -> Response:
|
|
76
78
|
return self._request(self._session.delete, url, *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
def poll(
|
|
81
|
+
self, url: str,
|
|
82
|
+
timeout: Union[int, float] = 120,
|
|
83
|
+
every: Union[int, float] = 1,
|
|
84
|
+
) -> Response:
|
|
85
|
+
"""Poll the given URL in regular intervals.
|
|
86
|
+
|
|
87
|
+
Helpful for waiting on async processes given a status URL.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
timeout: Time in seconds to wait for a result.
|
|
91
|
+
every: Frequency in seconds.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
HTTP response.
|
|
95
|
+
"""
|
|
96
|
+
t0 = time.monotonic()
|
|
97
|
+
r = self.get(url)
|
|
98
|
+
while r.status_code == 202:
|
|
99
|
+
elapsed = time.monotonic() - t0
|
|
100
|
+
if elapsed >= timeout:
|
|
101
|
+
raise PollTimeout(f"Polling timed out: {url}")
|
|
102
|
+
time.sleep(1)
|
|
103
|
+
r = self.get(url)
|
|
104
|
+
return r
|
|
@@ -100,6 +100,44 @@ class CSG(TypedFunc[Literal[FuncType.FLOAT]]):
|
|
|
100
100
|
return cast(TypedResult[Literal[FuncType.FLOAT]], r)
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
ComputeCurvatures_Enum_spacing_type: TypeAlias = Literal["Continuous", "Discrete"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ComputeCurvatures_Parameters(TypedDict, total=False):
|
|
107
|
+
spacing_type: ComputeCurvatures_Enum_spacing_type
|
|
108
|
+
step_size: float
|
|
109
|
+
volume_size: Vec3f
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ComputeCurvatures(TypedFunc[Literal[FuncType.VEC3F]]):
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
samples: Func,
|
|
116
|
+
parameters: Optional[ComputeCurvatures_Parameters] = None,
|
|
117
|
+
):
|
|
118
|
+
self.inputs: Optional[dict[str, Func]]
|
|
119
|
+
self.inputs = {
|
|
120
|
+
"Samples": samples,
|
|
121
|
+
}
|
|
122
|
+
self.assets: Optional[Assets]
|
|
123
|
+
self.assets = None
|
|
124
|
+
self.parameters = parameters
|
|
125
|
+
|
|
126
|
+
@cache
|
|
127
|
+
def __call__(self, eval_: Evaluator) -> TypedResult[Literal[FuncType.VEC3F]]:
|
|
128
|
+
inputs: Optional[Inputs] = None
|
|
129
|
+
if self.inputs:
|
|
130
|
+
inputs = dict((k, v(eval_)) for k, v in self.inputs.items())
|
|
131
|
+
r = eval_(
|
|
132
|
+
"ComputeCurvatures",
|
|
133
|
+
inputs=inputs,
|
|
134
|
+
assets=self.assets,
|
|
135
|
+
# https://github.com/python/mypy/issues/4976#issuecomment-460971843
|
|
136
|
+
parameters=cast(Optional[Params], self.parameters),
|
|
137
|
+
)
|
|
138
|
+
return cast(TypedResult[Literal[FuncType.VEC3F]], r)
|
|
139
|
+
|
|
140
|
+
|
|
103
141
|
class ComputeNormals_Parameters(TypedDict, total=False):
|
|
104
142
|
volume_offset: Vec3f
|
|
105
143
|
volume_size: Vec3f
|
|
@@ -605,6 +643,43 @@ class SampleLattice(TypedFunc[Literal[FuncType.FLOAT]]):
|
|
|
605
643
|
return cast(TypedResult[Literal[FuncType.FLOAT]], r)
|
|
606
644
|
|
|
607
645
|
|
|
646
|
+
class SampleSpinodoid_Parameters(TypedDict, total=False):
|
|
647
|
+
angles: Vec3f
|
|
648
|
+
density: float
|
|
649
|
+
pore_size: float
|
|
650
|
+
wave_count: int
|
|
651
|
+
xform: Mat4f
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class SampleSpinodoid(TypedFunc[Literal[FuncType.FLOAT]]):
|
|
655
|
+
def __init__(
|
|
656
|
+
self,
|
|
657
|
+
points: TypedFunc[Literal[FuncType.VEC3F]],
|
|
658
|
+
parameters: Optional[SampleSpinodoid_Parameters] = None,
|
|
659
|
+
):
|
|
660
|
+
self.inputs: Optional[dict[str, Func]]
|
|
661
|
+
self.inputs = {
|
|
662
|
+
"Points": points,
|
|
663
|
+
}
|
|
664
|
+
self.assets: Optional[Assets]
|
|
665
|
+
self.assets = None
|
|
666
|
+
self.parameters = parameters
|
|
667
|
+
|
|
668
|
+
@cache
|
|
669
|
+
def __call__(self, eval_: Evaluator) -> TypedResult[Literal[FuncType.FLOAT]]:
|
|
670
|
+
inputs: Optional[Inputs] = None
|
|
671
|
+
if self.inputs:
|
|
672
|
+
inputs = dict((k, v(eval_)) for k, v in self.inputs.items())
|
|
673
|
+
r = eval_(
|
|
674
|
+
"SampleSpinodoid",
|
|
675
|
+
inputs=inputs,
|
|
676
|
+
assets=self.assets,
|
|
677
|
+
# https://github.com/python/mypy/issues/4976#issuecomment-460971843
|
|
678
|
+
parameters=cast(Optional[Params], self.parameters),
|
|
679
|
+
)
|
|
680
|
+
return cast(TypedResult[Literal[FuncType.FLOAT]], r)
|
|
681
|
+
|
|
682
|
+
|
|
608
683
|
SampleSurfaceLattice_Enum_lattice_type: TypeAlias = Literal["CD", "CI2Y", "CP", "CPM_Y", "CS", "CY", "C_Y", "D", "F", "FRD", "Gyroid", "I2Y", "IWP", "None", "P", "PM_Y", "S", "SD1", "Schwarz", "SchwarzD", "SchwarzN", "SchwarzPW", "SchwarzW", "W", "Y"]
|
|
609
684
|
|
|
610
685
|
|
|
@@ -1,18 +1,50 @@
|
|
|
1
1
|
from attrs import field, frozen
|
|
2
2
|
from datetime import datetime
|
|
3
|
-
from metafold.api import asdatetime, asdict, optional
|
|
3
|
+
from metafold.api import asdatetime, asdict, optional, optional_datetime
|
|
4
4
|
from metafold.assets import Asset
|
|
5
5
|
from metafold.client import Client
|
|
6
6
|
from metafold.exceptions import PollTimeout
|
|
7
7
|
from requests import Response
|
|
8
|
-
from typing import Any, Optional, Union
|
|
9
|
-
import
|
|
8
|
+
from typing import Any, Optional, TypedDict, Union
|
|
9
|
+
from typing_extensions import TypeAlias
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _assets(v: list[Union[dict[str, Any], Asset]]) -> list[Asset]:
|
|
13
13
|
return [a if isinstance(a, Asset) else Asset(**a) for a in v]
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
AssetDict: TypeAlias = dict[str, Union[dict[str, Any], Asset]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _assets_dict(v: AssetDict) -> dict[str, Asset]:
|
|
20
|
+
return dict(
|
|
21
|
+
(k, a if isinstance(a, Asset) else Asset(**a))
|
|
22
|
+
for k, a in v.items()
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IODict(TypedDict):
|
|
27
|
+
params: Optional[dict[str, Any]]
|
|
28
|
+
assets: Optional[dict[str, AssetDict]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@frozen(kw_only=True)
|
|
32
|
+
class IO:
|
|
33
|
+
"""Job input/output.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
params: JSON-encoded parameter values.
|
|
37
|
+
assets: Related assets.
|
|
38
|
+
"""
|
|
39
|
+
params: Optional[dict[str, Any]] = None
|
|
40
|
+
assets: Optional[dict[str, Asset]] = field(
|
|
41
|
+
converter=lambda v: optional(_assets_dict)(v), default=None)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def from_dict(d: IODict) -> "IO":
|
|
45
|
+
return IO(params=d.get("params"), assets=d.get("assets"))
|
|
46
|
+
|
|
47
|
+
|
|
16
48
|
@frozen(kw_only=True)
|
|
17
49
|
class Job:
|
|
18
50
|
"""Job resource.
|
|
@@ -21,24 +53,34 @@ class Job:
|
|
|
21
53
|
id: Job ID.
|
|
22
54
|
name: Job name.
|
|
23
55
|
type: Job type.
|
|
24
|
-
|
|
56
|
+
state: Job state. May be one of: pending, started, success, failure, or
|
|
57
|
+
canceled.
|
|
25
58
|
created: Job creation datetime.
|
|
59
|
+
started: Job started datetime.
|
|
26
60
|
finished: Job finished datetime.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
61
|
+
error: Error message for failed jobs.
|
|
62
|
+
inputs: Input assets and parameters.
|
|
63
|
+
outputs: Output assets and parameters.
|
|
64
|
+
assets: (Deprecated) List of generated asset resources.
|
|
65
|
+
parameters: (Deprecated) Job parameters.
|
|
66
|
+
meta: (Deprecated) Additional metadata generated by the job.
|
|
30
67
|
"""
|
|
31
68
|
id: str
|
|
32
69
|
name: Optional[str] = None
|
|
33
70
|
type: str
|
|
34
|
-
|
|
71
|
+
state: str
|
|
35
72
|
created: datetime = field(converter=asdatetime)
|
|
73
|
+
started: Optional[datetime] = field(
|
|
74
|
+
converter=lambda v: optional_datetime(v), default=None)
|
|
36
75
|
finished: Optional[datetime] = field(
|
|
37
|
-
converter=lambda v:
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
76
|
+
converter=lambda v: optional_datetime(v), default=None)
|
|
77
|
+
error: Optional[str] = None
|
|
78
|
+
inputs: IO = field(converter=lambda v: v if isinstance(v, IO) else IO.from_dict(v))
|
|
79
|
+
outputs: IO = field(converter=lambda v: v if isinstance(v, IO) else IO.from_dict(v))
|
|
80
|
+
# NOTE(ryan): Deprecated
|
|
81
|
+
assets: Optional[list[Asset]] = field(
|
|
82
|
+
converter=lambda v: optional(_assets)(v), default=None)
|
|
83
|
+
parameters: dict[str, Any]
|
|
42
84
|
meta: dict[str, Any]
|
|
43
85
|
|
|
44
86
|
|
|
@@ -58,7 +100,8 @@ class JobsEndpoint:
|
|
|
58
100
|
|
|
59
101
|
Args:
|
|
60
102
|
sort: Sort string. For details on syntax see the Metafold API docs.
|
|
61
|
-
Supported sorting fields are: "id", "name",
|
|
103
|
+
Supported sorting fields are: "id", "name", "created", "started", or
|
|
104
|
+
"finished".
|
|
62
105
|
q: Query string. For details on syntax see the Metafold API docs.
|
|
63
106
|
Supported search fields are: "id", "name", "type", and "state".
|
|
64
107
|
project_id: Job project ID.
|
|
@@ -139,31 +182,8 @@ class JobsEndpoint:
|
|
|
139
182
|
r: Response = self._client.post(f"/projects/{project_id}/jobs", json=payload)
|
|
140
183
|
return r.json()["link"]
|
|
141
184
|
|
|
142
|
-
def poll(
|
|
143
|
-
self,
|
|
144
|
-
timeout: Union[int, float] = 120,
|
|
145
|
-
every: Union[int, float] = 1,
|
|
146
|
-
) -> Response:
|
|
147
|
-
"""Poll the given URL in regular intervals.
|
|
148
|
-
|
|
149
|
-
Helpful for waiting on job results given a status URL.
|
|
150
|
-
|
|
151
|
-
Args:
|
|
152
|
-
timeout: Time in seconds to wait for a result.
|
|
153
|
-
every: Frequency in seconds.
|
|
154
|
-
|
|
155
|
-
Returns:
|
|
156
|
-
HTTP response.
|
|
157
|
-
"""
|
|
158
|
-
t0 = time.monotonic()
|
|
159
|
-
r = self._client.get(url)
|
|
160
|
-
while r.status_code == 202:
|
|
161
|
-
elapsed = time.monotonic() - t0
|
|
162
|
-
if elapsed >= timeout:
|
|
163
|
-
raise PollTimeout("Job timed out")
|
|
164
|
-
time.sleep(1)
|
|
165
|
-
r = self._client.get(url)
|
|
166
|
-
return r
|
|
185
|
+
def poll(self, *args, **kwargs):
|
|
186
|
+
return self._client.poll(*args, **kwargs)
|
|
167
187
|
|
|
168
188
|
def update(
|
|
169
189
|
self, job_id: str,
|
|
@@ -185,3 +205,14 @@ class JobsEndpoint:
|
|
|
185
205
|
payload = asdict(name=name)
|
|
186
206
|
r: Response = self._client.patch(url, data=payload)
|
|
187
207
|
return Job(**r.json())
|
|
208
|
+
|
|
209
|
+
def delete(self, job_id: str, project_id: Optional[str] = None):
|
|
210
|
+
"""Delete a job.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
job_id: ID of job to delete.
|
|
214
|
+
project_id: Job project ID.
|
|
215
|
+
"""
|
|
216
|
+
project_id = self._client.project_id(project_id)
|
|
217
|
+
url = f"/projects/{project_id}/jobs/{job_id}"
|
|
218
|
+
self._client.delete(url)
|
|
@@ -7,24 +7,21 @@ import numpy as np
|
|
|
7
7
|
def xform(
|
|
8
8
|
translation: Optional[ArrayLike] = None,
|
|
9
9
|
rotation: Optional[ArrayLike] = None,
|
|
10
|
-
scale: Optional[ArrayLike] = None,
|
|
11
10
|
) -> np.ndarray:
|
|
12
11
|
"""Compose transformation matrix.
|
|
13
12
|
|
|
14
13
|
Args:
|
|
15
14
|
translation: Translation in the x, y, and z directions.
|
|
16
15
|
rotation: Euler angles in degrees. Rotation is applied in the order Rz, Ry, Rx.
|
|
17
|
-
scale: Scale factor applied in the x, y, z directions before rotation.
|
|
18
16
|
|
|
19
17
|
Returns:
|
|
20
18
|
4 x 4 affine transformation matrix.
|
|
21
19
|
"""
|
|
22
20
|
translation = translation or np.array([0.0, 0.0, 0.0])
|
|
23
21
|
rotation = rotation or np.array([0.0, 0.0, 0.0])
|
|
24
|
-
scale = scale or np.array([1.0, 1.0, 1.0])
|
|
25
22
|
|
|
26
23
|
r = R.from_euler("xyz", rotation, degrees=True).as_matrix()
|
|
27
24
|
m = np.eye(4)
|
|
28
|
-
m[:3, :3] =
|
|
25
|
+
m[:3, :3] = r
|
|
29
26
|
m[3, :3] = translation
|
|
30
27
|
return m
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from attrs import field, frozen
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from metafold.api import asdatetime, asdict, optional_datetime
|
|
4
|
+
from metafold.client import Client
|
|
5
|
+
from metafold.exceptions import PollTimeout
|
|
6
|
+
from requests import Response
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@frozen(kw_only=True)
|
|
11
|
+
class Workflow:
|
|
12
|
+
"""Workflow resource.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
id: Workflow ID.
|
|
16
|
+
state: Workflow state. May be one of: pending, started, success, failure, or
|
|
17
|
+
canceled.
|
|
18
|
+
created: Workflow creation datetime.
|
|
19
|
+
started: Workflow started datetime.
|
|
20
|
+
finished: Workflow finished datetime.
|
|
21
|
+
definition: Workflow definition string.
|
|
22
|
+
"""
|
|
23
|
+
id: str
|
|
24
|
+
jobs: list[str] = field(factory=list)
|
|
25
|
+
state: str
|
|
26
|
+
created: datetime = field(converter=asdatetime)
|
|
27
|
+
started: Optional[datetime] = field(
|
|
28
|
+
converter=lambda v: optional_datetime(v), default=None)
|
|
29
|
+
finished: Optional[datetime] = field(
|
|
30
|
+
converter=lambda v: optional_datetime(v), default=None)
|
|
31
|
+
definition: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkflowsEndpoint:
|
|
35
|
+
"""Metafold workflows endpoint."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, client: Client) -> None:
|
|
38
|
+
self._client = client
|
|
39
|
+
|
|
40
|
+
def list(
|
|
41
|
+
self,
|
|
42
|
+
sort: Optional[str] = None,
|
|
43
|
+
q: Optional[str] = None,
|
|
44
|
+
project_id: Optional[str] = None,
|
|
45
|
+
) -> list[Workflow]:
|
|
46
|
+
"""List jobs.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
sort: Sort string. For details on syntax see the Metafold API docs.
|
|
50
|
+
Supported sorting fields are: "id", "created", "started", or "finished".
|
|
51
|
+
q: Query string. For details on syntax see the Metafold API docs.
|
|
52
|
+
Supported search fields are: "id" and "state".
|
|
53
|
+
project_id: Workflow project ID.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of job resources.
|
|
57
|
+
"""
|
|
58
|
+
project_id = self._client.project_id(project_id)
|
|
59
|
+
url = f"/projects/{project_id}/workflows"
|
|
60
|
+
payload = asdict(sort=sort, q=q)
|
|
61
|
+
r: Response = self._client.get(url, params=payload)
|
|
62
|
+
return [Workflow(**w) for w in r.json()]
|
|
63
|
+
|
|
64
|
+
def get(self, workflow_id: str, project_id: Optional[str] = None) -> Workflow:
|
|
65
|
+
"""Get a workflow.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
workflow_id: ID of workflow to get.
|
|
69
|
+
project_id: Workflow project ID.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Workflow resource.
|
|
73
|
+
"""
|
|
74
|
+
project_id = self._client.project_id(project_id)
|
|
75
|
+
url = f"/projects/{project_id}/workflows/{workflow_id}"
|
|
76
|
+
r: Response = self._client.get(url)
|
|
77
|
+
return Workflow(**r.json())
|
|
78
|
+
|
|
79
|
+
def run(
|
|
80
|
+
self, definition: str,
|
|
81
|
+
parameters: Optional[dict[str, str]] = None,
|
|
82
|
+
assets: Optional[dict[str, str]] = None,
|
|
83
|
+
timeout: Union[int, float] = 120,
|
|
84
|
+
project_id: Optional[str] = None,
|
|
85
|
+
) -> Workflow:
|
|
86
|
+
"""Dispatch a new workflow and wait for it to complete.
|
|
87
|
+
|
|
88
|
+
Workflow completion does not indicate success. Access the completed workflow's
|
|
89
|
+
state to check for success/failure.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
definition: Workflow definition YAML.
|
|
93
|
+
parameters: Parameter mapping for jobs in the definition.
|
|
94
|
+
assets: Asset mapping for jobs in the definition.
|
|
95
|
+
timeout: Time in seconds to wait for a result.
|
|
96
|
+
project_id: Workflow project ID.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Completed workflow resource.
|
|
100
|
+
"""
|
|
101
|
+
project_id = self._client.project_id(project_id)
|
|
102
|
+
payload = asdict(definition=definition, parameters=parameters, assets=assets)
|
|
103
|
+
r: Response = self._client.post(f"/projects/{project_id}/workflows", json=payload)
|
|
104
|
+
url = r.json()["link"]
|
|
105
|
+
try:
|
|
106
|
+
r = self._client.poll(url, timeout)
|
|
107
|
+
except PollTimeout as e:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"Workflow failed to complete within {timeout} seconds"
|
|
110
|
+
) from e
|
|
111
|
+
return Workflow(**r.json())
|
|
112
|
+
|
|
113
|
+
def cancel(self, workflow_id: str, project_id: Optional[str] = None) -> Workflow:
|
|
114
|
+
"""Cancel a running workflow.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
workflow_id: ID of workflow to cancel.
|
|
118
|
+
project_id: Workflow project ID.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Workflow resource.
|
|
122
|
+
"""
|
|
123
|
+
project_id = self._client.project_id(project_id)
|
|
124
|
+
url = f"/projects/{project_id}/workflows/{workflow_id}/cancel"
|
|
125
|
+
r: Response = self._client.post(url)
|
|
126
|
+
return Workflow(**r.json())
|
|
127
|
+
|
|
128
|
+
def delete(self, workflow_id: str, project_id: Optional[str] = None):
|
|
129
|
+
"""Delete a workflow.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
workflow_id: ID of workflow to delete.
|
|
133
|
+
project_id: Workflow project ID.
|
|
134
|
+
"""
|
|
135
|
+
project_id = self._client.project_id(project_id)
|
|
136
|
+
url = f"/projects/{project_id}/workflows/{workflow_id}"
|
|
137
|
+
self._client.delete(url)
|
|
@@ -13,6 +13,7 @@ metafold/jobs.py
|
|
|
13
13
|
metafold/nx.py
|
|
14
14
|
metafold/projects.py
|
|
15
15
|
metafold/utils.py
|
|
16
|
+
metafold/workflows.py
|
|
16
17
|
metafold.egg-info/PKG-INFO
|
|
17
18
|
metafold.egg-info/SOURCES.txt
|
|
18
19
|
metafold.egg-info/dependency_links.txt
|
|
@@ -22,4 +23,5 @@ tests/test_assets.py
|
|
|
22
23
|
tests/test_func.py
|
|
23
24
|
tests/test_jobs.py
|
|
24
25
|
tests/test_projects.py
|
|
25
|
-
tests/test_utils.py
|
|
26
|
+
tests/test_utils.py
|
|
27
|
+
tests/test_workflows.py
|
|
@@ -171,19 +171,6 @@ def test_create_asset(client):
|
|
|
171
171
|
)
|
|
172
172
|
|
|
173
173
|
|
|
174
|
-
def test_update_asset(client):
|
|
175
|
-
with open(test_file, "rb") as f:
|
|
176
|
-
a = client.assets.update("1", f)
|
|
177
|
-
assert a == Asset(
|
|
178
|
-
id="1",
|
|
179
|
-
filename="test.png",
|
|
180
|
-
size=67,
|
|
181
|
-
checksum="sha256:089ad5bf4831b6758e9907db43bc5ebba2e9248a9929dad6132c49932e538278",
|
|
182
|
-
created=default_dt,
|
|
183
|
-
modified=default_dt,
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
|
|
187
174
|
def test_delete_asset(client):
|
|
188
175
|
# FIXME: Assert something
|
|
189
176
|
client.assets.delete("1")
|
|
@@ -3,13 +3,18 @@ from datetime import datetime, timezone
|
|
|
3
3
|
from http import HTTPStatus
|
|
4
4
|
from http.server import BaseHTTPRequestHandler
|
|
5
5
|
from metafold.assets import Asset
|
|
6
|
-
from metafold.jobs import Job
|
|
6
|
+
from metafold.jobs import Job, IO
|
|
7
7
|
from urllib.parse import parse_qs, urlparse
|
|
8
8
|
import json
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
11
|
default_dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
12
12
|
|
|
13
|
+
default_params = {
|
|
14
|
+
"foo": "...",
|
|
15
|
+
"bar": "...",
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
asset_json = {
|
|
14
19
|
"id": "1",
|
|
15
20
|
"filename": "f763df409e79eb1c.bin",
|
|
@@ -34,36 +39,54 @@ job_list = [
|
|
|
34
39
|
"id": "3",
|
|
35
40
|
"name": "bar",
|
|
36
41
|
"type": "evaluate_graph",
|
|
37
|
-
"parameters": {
|
|
38
|
-
"graph": None,
|
|
39
|
-
},
|
|
40
|
-
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
41
42
|
"state": "success",
|
|
42
|
-
"
|
|
43
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
44
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
45
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
46
|
+
"error": None,
|
|
47
|
+
"inputs": {
|
|
48
|
+
"params": default_params,
|
|
49
|
+
},
|
|
50
|
+
"outputs": {
|
|
51
|
+
"params": None,
|
|
52
|
+
},
|
|
53
|
+
"parameters": default_params,
|
|
43
54
|
"meta": None,
|
|
44
55
|
},
|
|
45
56
|
{
|
|
46
57
|
"id": "2",
|
|
47
58
|
"name": "foo",
|
|
48
59
|
"type": "evaluate_graph",
|
|
49
|
-
"parameters": {
|
|
50
|
-
"graph": None,
|
|
51
|
-
},
|
|
52
|
-
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
53
60
|
"state": "success",
|
|
54
|
-
"
|
|
61
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
62
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
63
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
64
|
+
"error": None,
|
|
65
|
+
"inputs": {
|
|
66
|
+
"params": default_params,
|
|
67
|
+
},
|
|
68
|
+
"outputs": {
|
|
69
|
+
"params": None,
|
|
70
|
+
},
|
|
71
|
+
"parameters": default_params,
|
|
55
72
|
"meta": None,
|
|
56
73
|
},
|
|
57
74
|
{
|
|
58
75
|
"id": "1",
|
|
59
76
|
"name": "foo",
|
|
60
77
|
"type": "evaluate_graph",
|
|
61
|
-
"parameters": {
|
|
62
|
-
"graph": None,
|
|
63
|
-
},
|
|
64
|
-
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
65
78
|
"state": "success",
|
|
66
|
-
"
|
|
79
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
80
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
81
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
82
|
+
"error": None,
|
|
83
|
+
"inputs": {
|
|
84
|
+
"params": default_params,
|
|
85
|
+
},
|
|
86
|
+
"outputs": {
|
|
87
|
+
"params": None,
|
|
88
|
+
},
|
|
89
|
+
"parameters": default_params,
|
|
67
90
|
"meta": None,
|
|
68
91
|
},
|
|
69
92
|
]
|
|
@@ -72,13 +95,26 @@ new_job = {
|
|
|
72
95
|
"id": "1",
|
|
73
96
|
"name": "My Job",
|
|
74
97
|
"type": "test_job",
|
|
98
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
99
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
100
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
101
|
+
"error": None,
|
|
102
|
+
"inputs": {
|
|
103
|
+
"params": {
|
|
104
|
+
"foo": "1",
|
|
105
|
+
"bar": "a",
|
|
106
|
+
"baz": "[2, \"b\"]",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
"outputs": {
|
|
110
|
+
"params": None,
|
|
111
|
+
},
|
|
112
|
+
"assets": [],
|
|
75
113
|
"parameters": {
|
|
76
|
-
"foo": 1,
|
|
114
|
+
"foo": "1",
|
|
77
115
|
"bar": "a",
|
|
78
|
-
"baz": [2, "b"],
|
|
116
|
+
"baz": "[2, \"b\"]",
|
|
79
117
|
},
|
|
80
|
-
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
81
|
-
"assets": [],
|
|
82
118
|
"meta": None,
|
|
83
119
|
}
|
|
84
120
|
|
|
@@ -103,7 +139,8 @@ class MockRequestHandler(BaseHTTPRequestHandler):
|
|
|
103
139
|
self.send_response(HTTPStatus.OK)
|
|
104
140
|
self.send_header("Content-Type", "application/json")
|
|
105
141
|
self.end_headers()
|
|
106
|
-
payload = job_list[-1]
|
|
142
|
+
payload = deepcopy(job_list[-1])
|
|
143
|
+
payload["assets"] = [asset_json]
|
|
107
144
|
self.wfile.write(json.dumps(payload).encode())
|
|
108
145
|
elif u.path == "/projects/1/jobs/1/status":
|
|
109
146
|
global poll_count
|
|
@@ -145,7 +182,10 @@ class MockRequestHandler(BaseHTTPRequestHandler):
|
|
|
145
182
|
self.send_header("Content-Type", "application/json")
|
|
146
183
|
self.end_headers()
|
|
147
184
|
payload = deepcopy(job_list[-1])
|
|
148
|
-
payload
|
|
185
|
+
payload.update({
|
|
186
|
+
"name": "baz",
|
|
187
|
+
"assets": [asset_json],
|
|
188
|
+
})
|
|
149
189
|
self.wfile.write(json.dumps(payload).encode())
|
|
150
190
|
else:
|
|
151
191
|
self.send_error(HTTPStatus.NOT_FOUND)
|
|
@@ -177,40 +217,47 @@ def test_get_job(client):
|
|
|
177
217
|
id="1",
|
|
178
218
|
name="foo",
|
|
179
219
|
type="evaluate_graph",
|
|
180
|
-
parameters={
|
|
181
|
-
"graph": None,
|
|
182
|
-
},
|
|
183
|
-
created=default_dt,
|
|
184
220
|
state="success",
|
|
221
|
+
created=default_dt,
|
|
222
|
+
started=default_dt,
|
|
223
|
+
finished=default_dt,
|
|
224
|
+
error=None,
|
|
225
|
+
inputs=IO(params=default_params),
|
|
226
|
+
outputs=IO(),
|
|
185
227
|
assets=[asset_obj],
|
|
228
|
+
parameters=default_params,
|
|
186
229
|
meta=None,
|
|
187
230
|
)
|
|
188
231
|
|
|
189
232
|
|
|
190
233
|
def test_run_job(client):
|
|
191
234
|
params = {
|
|
192
|
-
"foo": 1,
|
|
235
|
+
"foo": "1",
|
|
193
236
|
"bar": "a",
|
|
194
|
-
"baz": [2, "b"],
|
|
237
|
+
"baz": "[2, \"b\"]",
|
|
195
238
|
}
|
|
196
239
|
j = client.jobs.run("test_job", params, name="My Job")
|
|
197
240
|
assert j == Job(
|
|
198
241
|
id="1",
|
|
199
242
|
name="My Job",
|
|
200
243
|
type="test_job",
|
|
201
|
-
parameters=params,
|
|
202
|
-
created=default_dt,
|
|
203
244
|
state="success",
|
|
245
|
+
created=default_dt,
|
|
246
|
+
started=default_dt,
|
|
247
|
+
finished=default_dt,
|
|
248
|
+
inputs=IO(params=params),
|
|
249
|
+
outputs=IO(),
|
|
204
250
|
assets=[asset_obj],
|
|
251
|
+
parameters=params,
|
|
205
252
|
meta=None,
|
|
206
253
|
)
|
|
207
254
|
|
|
208
255
|
|
|
209
256
|
def test_poll_job(client):
|
|
210
257
|
params = {
|
|
211
|
-
"foo": 1,
|
|
258
|
+
"foo": "1",
|
|
212
259
|
"bar": "a",
|
|
213
|
-
"baz": [2, "b"],
|
|
260
|
+
"baz": "[2, \"b\"]",
|
|
214
261
|
}
|
|
215
262
|
url = client.jobs.run_status("test_job", params, name="My Job")
|
|
216
263
|
assert url == "http://localhost:8000/projects/1/jobs/1/status"
|
|
@@ -220,10 +267,14 @@ def test_poll_job(client):
|
|
|
220
267
|
id="1",
|
|
221
268
|
name="My Job",
|
|
222
269
|
type="test_job",
|
|
223
|
-
parameters=params,
|
|
224
|
-
created=default_dt,
|
|
225
270
|
state="success",
|
|
271
|
+
created=default_dt,
|
|
272
|
+
started=default_dt,
|
|
273
|
+
finished=default_dt,
|
|
274
|
+
inputs=IO(params=params),
|
|
275
|
+
outputs=IO(),
|
|
226
276
|
assets=[asset_obj],
|
|
277
|
+
parameters=params,
|
|
227
278
|
meta=None,
|
|
228
279
|
)
|
|
229
280
|
|
|
@@ -12,42 +12,21 @@ def test_xform():
|
|
|
12
12
|
], atol=1.0e-7)
|
|
13
13
|
|
|
14
14
|
m = xform(
|
|
15
|
-
[
|
|
16
|
-
[180.0, -90.0, 0.0]) # R
|
|
15
|
+
rotation=[180.0, -90.0, 0.0]) # R
|
|
17
16
|
assert_allclose(m, [
|
|
18
17
|
[0.0, 0.0, 1.0, 0.0],
|
|
19
18
|
[0.0, -1.0, 0.0, 0.0],
|
|
20
19
|
[1.0, 0.0, 0.0, 0.0],
|
|
21
|
-
[1.0, 2.0, 3.0, 1.0],
|
|
22
|
-
], atol=1.0e-7)
|
|
23
|
-
|
|
24
|
-
m = xform(
|
|
25
|
-
[1.0, 2.0, 3.0], # T
|
|
26
|
-
scale=[2.0, 1.0, 1.0]) # S
|
|
27
|
-
assert_allclose(m, [
|
|
28
|
-
[2.0, 0.0, 0.0, 0.0],
|
|
29
|
-
[0.0, 1.0, 0.0, 0.0],
|
|
30
|
-
[0.0, 0.0, 1.0, 0.0],
|
|
31
|
-
[1.0, 2.0, 3.0, 1.0],
|
|
32
|
-
], atol=1.0e-7)
|
|
33
|
-
|
|
34
|
-
m = xform(
|
|
35
|
-
rotation=[180.0, -90.0, 0.0], # R
|
|
36
|
-
scale=[2.0, 1.0, 1.0]) # S
|
|
37
|
-
assert_allclose(m, [
|
|
38
|
-
[0.0, 0.0, 2.0, 0.0],
|
|
39
|
-
[0.0, -1.0, 0.0, 0.0],
|
|
40
|
-
[1.0, 0.0, 0.0, 0.0],
|
|
41
20
|
[0.0, 0.0, 0.0, 1.0],
|
|
42
21
|
], atol=1.0e-7)
|
|
43
22
|
|
|
44
23
|
m = xform(
|
|
45
24
|
[1.0, 2.0, 3.0], # T
|
|
46
|
-
[180.0, -90.0, 0.0]
|
|
47
|
-
[2.0, 1.0, 1.0]) # S
|
|
25
|
+
[180.0, -90.0, 0.0]) # R
|
|
48
26
|
assert_allclose(m, [
|
|
49
|
-
[0.0, 0.0,
|
|
27
|
+
[0.0, 0.0, 1.0, 0.0],
|
|
50
28
|
[0.0, -1.0, 0.0, 0.0],
|
|
51
29
|
[1.0, 0.0, 0.0, 0.0],
|
|
52
30
|
[1.0, 2.0, 3.0, 1.0],
|
|
53
31
|
], atol=1.0e-7)
|
|
32
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from http.server import BaseHTTPRequestHandler
|
|
5
|
+
from metafold.workflows import Workflow
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
import json
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
default_dt = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
11
|
+
|
|
12
|
+
# Default sort order is descending by id
|
|
13
|
+
workflow_list = [
|
|
14
|
+
{
|
|
15
|
+
"id": "3",
|
|
16
|
+
"jobs": ["1", "2"],
|
|
17
|
+
"state": "success",
|
|
18
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
19
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
20
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
21
|
+
"definition": "...",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "2",
|
|
25
|
+
"jobs": ["1", "2"],
|
|
26
|
+
"state": "started",
|
|
27
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
28
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
29
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
30
|
+
"definition": "...",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "1",
|
|
34
|
+
"jobs": ["1", "2"],
|
|
35
|
+
"state": "success",
|
|
36
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
37
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
38
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
39
|
+
"definition": "...",
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
new_workflow = {
|
|
44
|
+
"id": "1",
|
|
45
|
+
"jobs": ["1", "2"],
|
|
46
|
+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
47
|
+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
48
|
+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
49
|
+
"definition": "foo",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
poll_count: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MockRequestHandler(BaseHTTPRequestHandler):
|
|
56
|
+
def do_GET(self):
|
|
57
|
+
u = urlparse(self.path)
|
|
58
|
+
params = parse_qs(u.query)
|
|
59
|
+
if u.path == "/projects/1/workflows":
|
|
60
|
+
self.send_response(HTTPStatus.OK)
|
|
61
|
+
self.send_header("Content-Type", "application/json")
|
|
62
|
+
self.end_headers()
|
|
63
|
+
payload = workflow_list
|
|
64
|
+
if params.get("sort") == ["id:1"]:
|
|
65
|
+
payload = sorted(workflow_list, key=lambda p: p["id"])
|
|
66
|
+
elif params.get("q") == ["state:started"]:
|
|
67
|
+
payload = [p for p in workflow_list if p["state"] == "started"]
|
|
68
|
+
self.wfile.write(json.dumps(payload).encode())
|
|
69
|
+
elif u.path == "/projects/1/workflows/1":
|
|
70
|
+
self.send_response(HTTPStatus.OK)
|
|
71
|
+
self.send_header("Content-Type", "application/json")
|
|
72
|
+
self.end_headers()
|
|
73
|
+
payload = deepcopy(workflow_list[-1])
|
|
74
|
+
self.wfile.write(json.dumps(payload).encode())
|
|
75
|
+
elif u.path == "/projects/1/workflows/1/status":
|
|
76
|
+
global poll_count
|
|
77
|
+
poll_count += 1
|
|
78
|
+
if poll_count < 3:
|
|
79
|
+
self.send_response(HTTPStatus.ACCEPTED)
|
|
80
|
+
payload = deepcopy(new_workflow)
|
|
81
|
+
payload["state"] = "started"
|
|
82
|
+
else:
|
|
83
|
+
self.send_response(HTTPStatus.CREATED)
|
|
84
|
+
payload = deepcopy(new_workflow)
|
|
85
|
+
payload["state"] = "success"
|
|
86
|
+
self.send_header("Content-Type", "application/json")
|
|
87
|
+
self.end_headers()
|
|
88
|
+
self.wfile.write(json.dumps(payload).encode())
|
|
89
|
+
else:
|
|
90
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
91
|
+
|
|
92
|
+
def do_POST(self):
|
|
93
|
+
if self.path == "/projects/1/workflows":
|
|
94
|
+
self.send_response(HTTPStatus.ACCEPTED)
|
|
95
|
+
self.send_header("Content-Type", "application/json")
|
|
96
|
+
self.end_headers()
|
|
97
|
+
payload = deepcopy(new_workflow)
|
|
98
|
+
payload.update({
|
|
99
|
+
"state": "pending",
|
|
100
|
+
"link": "http://localhost:8000/projects/1/workflows/1/status",
|
|
101
|
+
})
|
|
102
|
+
self.wfile.write(json.dumps(payload).encode())
|
|
103
|
+
else:
|
|
104
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.fixture(scope="module")
|
|
108
|
+
def request_handler():
|
|
109
|
+
return MockRequestHandler
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_list_workflows(client):
|
|
113
|
+
workflows = client.workflows.list()
|
|
114
|
+
assert [w.id for w in workflows] == ["3", "2", "1"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_list_workflows_sorted(client):
|
|
118
|
+
workflows = client.workflows.list(sort="id:1")
|
|
119
|
+
assert [w.id for w in workflows] == ["1", "2", "3"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_list_workflows_filtered(client):
|
|
123
|
+
workflows = client.workflows.list(q="state:started")
|
|
124
|
+
assert all([w.state == "started" for w in workflows])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_get_workflow(client):
|
|
128
|
+
w = client.workflows.get("1")
|
|
129
|
+
assert w == Workflow(
|
|
130
|
+
id="1",
|
|
131
|
+
jobs=["1", "2"],
|
|
132
|
+
state="success",
|
|
133
|
+
created=default_dt,
|
|
134
|
+
started=default_dt,
|
|
135
|
+
finished=default_dt,
|
|
136
|
+
definition="...",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_run_workflow(client):
|
|
141
|
+
definition = "foo"
|
|
142
|
+
w = client.workflows.run(definition)
|
|
143
|
+
assert w == Workflow(
|
|
144
|
+
id="1",
|
|
145
|
+
jobs=["1", "2"],
|
|
146
|
+
state="success",
|
|
147
|
+
created=default_dt,
|
|
148
|
+
started=default_dt,
|
|
149
|
+
finished=default_dt,
|
|
150
|
+
definition=definition,
|
|
151
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|