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.
Files changed (29) hide show
  1. {metafold-0.6.0 → metafold-0.8.dev0}/PKG-INFO +2 -2
  2. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/__init__.py +3 -0
  3. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/api.py +18 -14
  4. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/assets.py +0 -24
  5. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/client.py +29 -1
  6. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/func.py +75 -0
  7. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/jobs.py +70 -39
  8. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/utils.py +1 -4
  9. metafold-0.8.dev0/metafold/workflows.py +137 -0
  10. {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/PKG-INFO +2 -2
  11. {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/SOURCES.txt +3 -1
  12. {metafold-0.6.0 → metafold-0.8.dev0}/pyproject.toml +1 -1
  13. {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_assets.py +0 -13
  14. {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_jobs.py +85 -34
  15. {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_utils.py +4 -25
  16. metafold-0.8.dev0/tests/test_workflows.py +151 -0
  17. {metafold-0.6.0 → metafold-0.8.dev0}/LICENSE +0 -0
  18. {metafold-0.6.0 → metafold-0.8.dev0}/README.md +0 -0
  19. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/auth.py +0 -0
  20. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/exceptions.py +0 -0
  21. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/func_types.py +0 -0
  22. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/nx.py +0 -0
  23. {metafold-0.6.0 → metafold-0.8.dev0}/metafold/projects.py +0 -0
  24. {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/dependency_links.txt +0 -0
  25. {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/requires.txt +0 -0
  26. {metafold-0.6.0 → metafold-0.8.dev0}/metafold.egg-info/top_level.txt +0 -0
  27. {metafold-0.6.0 → metafold-0.8.dev0}/setup.cfg +0 -0
  28. {metafold-0.6.0 → metafold-0.8.dev0}/tests/test_func.py +0 -0
  29. {metafold-0.6.0 → metafold-0.8.dev0}/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.6.0
3
+ Version: 0.8.dev0
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
@@ -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 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,34 @@ 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
+ # 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", or "created".
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, 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
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] = 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.6.0
3
+ Version: 0.8.dev0
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.6.0"
7
+ version = "0.8.dev0"
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,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
- "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
+ "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
- "assets": [asset_json],
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
- "assets": [asset_json],
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["name"] = "baz"
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
- [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