metafold 0.7.0__tar.gz → 0.8.dev1__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.
Files changed (29) hide show
  1. {metafold-0.7.0 → metafold-0.8.dev1}/PKG-INFO +2 -2
  2. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/__init__.py +3 -0
  3. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/api.py +18 -14
  4. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/assets.py +0 -24
  5. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/client.py +29 -1
  6. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/func.py +39 -0
  7. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/jobs.py +71 -39
  8. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/utils.py +1 -4
  9. metafold-0.8.dev1/metafold/workflows.py +137 -0
  10. {metafold-0.7.0 → metafold-0.8.dev1}/metafold.egg-info/PKG-INFO +2 -2
  11. {metafold-0.7.0 → metafold-0.8.dev1}/metafold.egg-info/SOURCES.txt +3 -1
  12. {metafold-0.7.0 → metafold-0.8.dev1}/pyproject.toml +1 -1
  13. {metafold-0.7.0 → metafold-0.8.dev1}/tests/test_assets.py +0 -13
  14. {metafold-0.7.0 → metafold-0.8.dev1}/tests/test_jobs.py +92 -34
  15. {metafold-0.7.0 → metafold-0.8.dev1}/tests/test_utils.py +4 -25
  16. metafold-0.8.dev1/tests/test_workflows.py +151 -0
  17. {metafold-0.7.0 → metafold-0.8.dev1}/LICENSE +0 -0
  18. {metafold-0.7.0 → metafold-0.8.dev1}/README.md +0 -0
  19. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/auth.py +0 -0
  20. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/exceptions.py +0 -0
  21. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/func_types.py +0 -0
  22. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/nx.py +0 -0
  23. {metafold-0.7.0 → metafold-0.8.dev1}/metafold/projects.py +0 -0
  24. {metafold-0.7.0 → metafold-0.8.dev1}/metafold.egg-info/dependency_links.txt +0 -0
  25. {metafold-0.7.0 → metafold-0.8.dev1}/metafold.egg-info/requires.txt +0 -0
  26. {metafold-0.7.0 → metafold-0.8.dev1}/metafold.egg-info/top_level.txt +0 -0
  27. {metafold-0.7.0 → metafold-0.8.dev1}/setup.cfg +0 -0
  28. {metafold-0.7.0 → metafold-0.8.dev1}/tests/test_func.py +0 -0
  29. {metafold-0.7.0 → metafold-0.8.dev1}/tests/test_projects.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: metafold
3
- Version: 0.7.0
3
+ Version: 0.8.dev1
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -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
@@ -608,6 +646,7 @@ class SampleLattice(TypedFunc[Literal[FuncType.FLOAT]]):
608
646
  class SampleSpinodoid_Parameters(TypedDict, total=False):
609
647
  angles: Vec3f
610
648
  density: float
649
+ pore_size: float
611
650
  wave_count: int
612
651
  xform: Mat4f
613
652
 
@@ -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 time
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,35 @@ class Job:
21
53
  id: Job ID.
22
54
  name: Job name.
23
55
  type: Job type.
24
- parameters: Job parameters.
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
- state: Job state. May be one of: pending, started, success, or failure.
28
- assets: List of generated asset resources.
29
- meta: Additional metadata generated by the job.
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
- parameters: dict[str, Any]
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: optional(asdatetime)(v),
38
- default=None,
39
- )
40
- state: str
41
- assets: list[Asset] = field(converter=_assets)
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
+ needs: list[str]
81
+ # NOTE(ryan): Deprecated
82
+ assets: Optional[list[Asset]] = field(
83
+ converter=lambda v: optional(_assets)(v), default=None)
84
+ parameters: dict[str, Any]
42
85
  meta: dict[str, Any]
43
86
 
44
87
 
@@ -58,7 +101,8 @@ class JobsEndpoint:
58
101
 
59
102
  Args:
60
103
  sort: Sort string. For details on syntax see the Metafold API docs.
61
- Supported sorting fields are: "id", "name", or "created".
104
+ Supported sorting fields are: "id", "name", "created", "started", or
105
+ "finished".
62
106
  q: Query string. For details on syntax see the Metafold API docs.
63
107
  Supported search fields are: "id", "name", "type", and "state".
64
108
  project_id: Job project ID.
@@ -139,31 +183,8 @@ class JobsEndpoint:
139
183
  r: Response = self._client.post(f"/projects/{project_id}/jobs", json=payload)
140
184
  return r.json()["link"]
141
185
 
142
- def poll(
143
- self, url: str,
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
186
+ def poll(self, *args, **kwargs):
187
+ return self._client.poll(*args, **kwargs)
167
188
 
168
189
  def update(
169
190
  self, job_id: str,
@@ -185,3 +206,14 @@ class JobsEndpoint:
185
206
  payload = asdict(name=name)
186
207
  r: Response = self._client.patch(url, data=payload)
187
208
  return Job(**r.json())
209
+
210
+ def delete(self, job_id: str, project_id: Optional[str] = None):
211
+ """Delete a job.
212
+
213
+ Args:
214
+ job_id: ID of job to delete.
215
+ project_id: Job project ID.
216
+ """
217
+ project_id = self._client.project_id(project_id)
218
+ url = f"/projects/{project_id}/jobs/{job_id}"
219
+ 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] = np.diag(scale) @ r
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)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: metafold
3
- Version: 0.7.0
3
+ Version: 0.8.dev1
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "metafold"
7
- version = "0.7.0"
7
+ version = "0.8.dev1"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -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,57 @@ 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
- "assets": [asset_json],
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
+ "needs": [],
54
+ "parameters": default_params,
43
55
  "meta": None,
44
56
  },
45
57
  {
46
58
  "id": "2",
47
59
  "name": "foo",
48
60
  "type": "evaluate_graph",
49
- "parameters": {
50
- "graph": None,
51
- },
52
- "created": "Mon, 01 Jan 2024 00:00:00 GMT",
53
61
  "state": "success",
54
- "assets": [asset_json],
62
+ "created": "Mon, 01 Jan 2024 00:00:00 GMT",
63
+ "started": "Mon, 01 Jan 2024 00:00:00 GMT",
64
+ "finished": "Mon, 01 Jan 2024 00:00:00 GMT",
65
+ "error": None,
66
+ "inputs": {
67
+ "params": default_params,
68
+ },
69
+ "outputs": {
70
+ "params": None,
71
+ },
72
+ "needs": [],
73
+ "parameters": default_params,
55
74
  "meta": None,
56
75
  },
57
76
  {
58
77
  "id": "1",
59
78
  "name": "foo",
60
79
  "type": "evaluate_graph",
61
- "parameters": {
62
- "graph": None,
63
- },
64
- "created": "Mon, 01 Jan 2024 00:00:00 GMT",
65
80
  "state": "success",
66
- "assets": [asset_json],
81
+ "created": "Mon, 01 Jan 2024 00:00:00 GMT",
82
+ "started": "Mon, 01 Jan 2024 00:00:00 GMT",
83
+ "finished": "Mon, 01 Jan 2024 00:00:00 GMT",
84
+ "error": None,
85
+ "inputs": {
86
+ "params": default_params,
87
+ },
88
+ "outputs": {
89
+ "params": None,
90
+ },
91
+ "needs": [],
92
+ "parameters": default_params,
67
93
  "meta": None,
68
94
  },
69
95
  ]
@@ -72,13 +98,27 @@ new_job = {
72
98
  "id": "1",
73
99
  "name": "My Job",
74
100
  "type": "test_job",
101
+ "created": "Mon, 01 Jan 2024 00:00:00 GMT",
102
+ "started": "Mon, 01 Jan 2024 00:00:00 GMT",
103
+ "finished": "Mon, 01 Jan 2024 00:00:00 GMT",
104
+ "error": None,
105
+ "inputs": {
106
+ "params": {
107
+ "foo": "1",
108
+ "bar": "a",
109
+ "baz": "[2, \"b\"]",
110
+ },
111
+ },
112
+ "outputs": {
113
+ "params": None,
114
+ },
115
+ "needs": [],
116
+ "assets": [],
75
117
  "parameters": {
76
- "foo": 1,
118
+ "foo": "1",
77
119
  "bar": "a",
78
- "baz": [2, "b"],
120
+ "baz": "[2, \"b\"]",
79
121
  },
80
- "created": "Mon, 01 Jan 2024 00:00:00 GMT",
81
- "assets": [],
82
122
  "meta": None,
83
123
  }
84
124
 
@@ -103,7 +143,8 @@ class MockRequestHandler(BaseHTTPRequestHandler):
103
143
  self.send_response(HTTPStatus.OK)
104
144
  self.send_header("Content-Type", "application/json")
105
145
  self.end_headers()
106
- payload = job_list[-1]
146
+ payload = deepcopy(job_list[-1])
147
+ payload["assets"] = [asset_json]
107
148
  self.wfile.write(json.dumps(payload).encode())
108
149
  elif u.path == "/projects/1/jobs/1/status":
109
150
  global poll_count
@@ -145,7 +186,10 @@ class MockRequestHandler(BaseHTTPRequestHandler):
145
186
  self.send_header("Content-Type", "application/json")
146
187
  self.end_headers()
147
188
  payload = deepcopy(job_list[-1])
148
- payload["name"] = "baz"
189
+ payload.update({
190
+ "name": "baz",
191
+ "assets": [asset_json],
192
+ })
149
193
  self.wfile.write(json.dumps(payload).encode())
150
194
  else:
151
195
  self.send_error(HTTPStatus.NOT_FOUND)
@@ -177,40 +221,49 @@ def test_get_job(client):
177
221
  id="1",
178
222
  name="foo",
179
223
  type="evaluate_graph",
180
- parameters={
181
- "graph": None,
182
- },
183
- created=default_dt,
184
224
  state="success",
225
+ created=default_dt,
226
+ started=default_dt,
227
+ finished=default_dt,
228
+ error=None,
229
+ inputs=IO(params=default_params),
230
+ outputs=IO(),
231
+ needs=[],
185
232
  assets=[asset_obj],
233
+ parameters=default_params,
186
234
  meta=None,
187
235
  )
188
236
 
189
237
 
190
238
  def test_run_job(client):
191
239
  params = {
192
- "foo": 1,
240
+ "foo": "1",
193
241
  "bar": "a",
194
- "baz": [2, "b"],
242
+ "baz": "[2, \"b\"]",
195
243
  }
196
244
  j = client.jobs.run("test_job", params, name="My Job")
197
245
  assert j == Job(
198
246
  id="1",
199
247
  name="My Job",
200
248
  type="test_job",
201
- parameters=params,
202
- created=default_dt,
203
249
  state="success",
250
+ created=default_dt,
251
+ started=default_dt,
252
+ finished=default_dt,
253
+ inputs=IO(params=params),
254
+ outputs=IO(),
255
+ needs=[],
204
256
  assets=[asset_obj],
257
+ parameters=params,
205
258
  meta=None,
206
259
  )
207
260
 
208
261
 
209
262
  def test_poll_job(client):
210
263
  params = {
211
- "foo": 1,
264
+ "foo": "1",
212
265
  "bar": "a",
213
- "baz": [2, "b"],
266
+ "baz": "[2, \"b\"]",
214
267
  }
215
268
  url = client.jobs.run_status("test_job", params, name="My Job")
216
269
  assert url == "http://localhost:8000/projects/1/jobs/1/status"
@@ -220,10 +273,15 @@ def test_poll_job(client):
220
273
  id="1",
221
274
  name="My Job",
222
275
  type="test_job",
223
- parameters=params,
224
- created=default_dt,
225
276
  state="success",
277
+ created=default_dt,
278
+ started=default_dt,
279
+ finished=default_dt,
280
+ inputs=IO(params=params),
281
+ outputs=IO(),
282
+ needs=[],
226
283
  assets=[asset_obj],
284
+ parameters=params,
227
285
  meta=None,
228
286
  )
229
287
 
@@ -12,42 +12,21 @@ def test_xform():
12
12
  ], atol=1.0e-7)
13
13
 
14
14
  m = xform(
15
- [1.0, 2.0, 3.0], # T
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], # R
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, 2.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