ionworks-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ionworks/__init__.py +3 -0
- ionworks/cell_instance.py +201 -0
- ionworks/cell_measurement.py +339 -0
- ionworks/cell_specification.py +108 -0
- ionworks/client.py +126 -0
- ionworks/errors.py +35 -0
- ionworks/job.py +111 -0
- ionworks/models.py +185 -0
- ionworks/pipeline.py +256 -0
- ionworks/simulation.py +371 -0
- ionworks/validators.py +171 -0
- ionworks_api-0.1.0.dist-info/METADATA +318 -0
- ionworks_api-0.1.0.dist-info/RECORD +14 -0
- ionworks_api-0.1.0.dist-info/WHEEL +4 -0
ionworks/client.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .cell_instance import CellInstanceClient
|
|
8
|
+
from .cell_measurement import CellMeasurementClient
|
|
9
|
+
from .cell_specification import CellSpecificationClient
|
|
10
|
+
from .errors import IonworksError
|
|
11
|
+
from .job import JobClient
|
|
12
|
+
from .pipeline import PipelineClient
|
|
13
|
+
from .simulation import SimulationClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Ionworks:
|
|
17
|
+
def __init__(self, api_key: str | None = None, api_url: str | None = None):
|
|
18
|
+
"""Initialize Ionworks client.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
api_key : str, optional
|
|
23
|
+
API key. If not provided, will look for IONWORKS_API_KEY env var
|
|
24
|
+
api_url : str, optional
|
|
25
|
+
API URL. If not provided, will look for IONWORKS_API_URL env var
|
|
26
|
+
"""
|
|
27
|
+
load_dotenv()
|
|
28
|
+
|
|
29
|
+
self.api_key = api_key or os.getenv("IONWORKS_API_KEY")
|
|
30
|
+
if not self.api_key:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"API key must be provided either as argument or in IONWORKS_API_KEY "
|
|
33
|
+
"environment variable"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self.api_url = (
|
|
37
|
+
api_url or os.getenv("IONWORKS_API_URL") or "https://api.ionworks.com"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.session = requests.Session()
|
|
41
|
+
self.session.headers.update(
|
|
42
|
+
{
|
|
43
|
+
"X-API-Key": self.api_key,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
"Accept": "application/json",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Initialize components
|
|
50
|
+
self.job = JobClient(self)
|
|
51
|
+
self.pipeline = PipelineClient(self)
|
|
52
|
+
self.cell_spec = CellSpecificationClient(self)
|
|
53
|
+
self.cell_instance = CellInstanceClient(self)
|
|
54
|
+
self.cell_measurement = CellMeasurementClient(self)
|
|
55
|
+
self.simulation = SimulationClient(self)
|
|
56
|
+
# Backwards-compatible alias
|
|
57
|
+
self.measurement = self.cell_measurement
|
|
58
|
+
|
|
59
|
+
def request(
|
|
60
|
+
self, method: str, endpoint: str, json_payload: dict[str, Any] | None = None
|
|
61
|
+
) -> Any:
|
|
62
|
+
"""Make a request to the Ionworks API with standardized error handling."""
|
|
63
|
+
url = f"{self.api_url}{endpoint}"
|
|
64
|
+
try:
|
|
65
|
+
response = self.session.request(method, url, json=json_payload)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
|
|
68
|
+
# For DELETE operations, don't try to parse JSON if response is empty
|
|
69
|
+
if method.upper() == "DELETE":
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
# Return JSON response if content type is JSON and response has content
|
|
73
|
+
if (
|
|
74
|
+
response.headers.get("Content-Type") == "application/json"
|
|
75
|
+
and response.text
|
|
76
|
+
):
|
|
77
|
+
return response.json()
|
|
78
|
+
return response
|
|
79
|
+
except requests.exceptions.HTTPError as e:
|
|
80
|
+
try:
|
|
81
|
+
error_detail = e.response.json().get("detail", str(e))
|
|
82
|
+
except requests.exceptions.JSONDecodeError:
|
|
83
|
+
error_detail = str(e)
|
|
84
|
+
# Raise the custom error with just the detail message
|
|
85
|
+
raise IonworksError(
|
|
86
|
+
error_detail, status_code=e.response.status_code
|
|
87
|
+
) from None
|
|
88
|
+
except requests.exceptions.RequestException as e:
|
|
89
|
+
# Extract more detailed error information
|
|
90
|
+
error_msg = f"Error during request to {url}"
|
|
91
|
+
if hasattr(e, "response") and e.response is not None:
|
|
92
|
+
error_msg += f" (status code: {e.response.status_code})"
|
|
93
|
+
try:
|
|
94
|
+
error_detail = e.response.json().get("detail", e.response.text)
|
|
95
|
+
error_msg += f": {error_detail}"
|
|
96
|
+
except requests.exceptions.JSONDecodeError:
|
|
97
|
+
error_msg += f": {e.response.text}"
|
|
98
|
+
else:
|
|
99
|
+
error_msg += f": {str(e)}"
|
|
100
|
+
# Raise the custom error for general request issues
|
|
101
|
+
raise IonworksError(error_msg) from None
|
|
102
|
+
|
|
103
|
+
def get(self, endpoint: str) -> Any:
|
|
104
|
+
"""Make a GET request using the request helper."""
|
|
105
|
+
return self.request("GET", endpoint)
|
|
106
|
+
|
|
107
|
+
def post(self, endpoint: str, json_payload: dict[str, Any]) -> Any:
|
|
108
|
+
"""Make a POST request using the request helper."""
|
|
109
|
+
return self.request("POST", endpoint, json_payload=json_payload)
|
|
110
|
+
|
|
111
|
+
def put(self, endpoint: str, json_payload: dict[str, Any]) -> Any:
|
|
112
|
+
"""Make a PUT request using the request helper."""
|
|
113
|
+
return self.request("PUT", endpoint, json_payload=json_payload)
|
|
114
|
+
|
|
115
|
+
def delete(self, endpoint: str) -> None:
|
|
116
|
+
"""Make a DELETE request using the request helper."""
|
|
117
|
+
self.request("DELETE", endpoint)
|
|
118
|
+
|
|
119
|
+
def health_check(self) -> dict[str, Any]:
|
|
120
|
+
"""Check the health of the Ionworks API.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
dict[str, Any]: Health check response
|
|
124
|
+
"""
|
|
125
|
+
response_data = self.get("/healthz")
|
|
126
|
+
return cast(dict[str, Any], response_data)
|
ionworks/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IonworksError(Exception):
|
|
5
|
+
"""Custom exception for Ionworks API errors.
|
|
6
|
+
|
|
7
|
+
Attributes
|
|
8
|
+
----------
|
|
9
|
+
message : str
|
|
10
|
+
A string description of the error
|
|
11
|
+
data : dict[str, Any] | None
|
|
12
|
+
Structured error data if available (e.g., from API error response)
|
|
13
|
+
status_code : int | None
|
|
14
|
+
HTTP status code if applicable
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str | dict[str, Any],
|
|
20
|
+
status_code: int | None = None,
|
|
21
|
+
):
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
|
|
24
|
+
# Parse message into string and optional data dict
|
|
25
|
+
if isinstance(message, dict):
|
|
26
|
+
self.message = message.get("message", str(message))
|
|
27
|
+
self.data: dict[str, Any] | None = message
|
|
28
|
+
else:
|
|
29
|
+
self.message = message
|
|
30
|
+
self.data = None
|
|
31
|
+
|
|
32
|
+
super().__init__(self.message)
|
|
33
|
+
|
|
34
|
+
def __str__(self):
|
|
35
|
+
return f"error code: {self.status_code}, message: {self.message}"
|
ionworks/job.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ValidationError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Model for the data required to create ANY job
|
|
7
|
+
class JobCreationPayload(BaseModel):
|
|
8
|
+
job_type: str
|
|
9
|
+
params: dict[str, Any]
|
|
10
|
+
priority: int = 5 # Default priority
|
|
11
|
+
callback_url: Optional[str] = None # Optional callback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Model for the detailed job response
|
|
15
|
+
class JobResponse(BaseModel):
|
|
16
|
+
job_id: str
|
|
17
|
+
status: str
|
|
18
|
+
job_type: str
|
|
19
|
+
params: dict[str, Any]
|
|
20
|
+
priority: int
|
|
21
|
+
created_at: str
|
|
22
|
+
updated_at: str
|
|
23
|
+
metadata: Optional[dict[str, Any]]
|
|
24
|
+
error: Optional[str]
|
|
25
|
+
result: Optional[dict[str, Any]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JobClient:
|
|
29
|
+
def __init__(self, client: Any):
|
|
30
|
+
self.client = client
|
|
31
|
+
|
|
32
|
+
def create(self, payload: JobCreationPayload) -> JobResponse:
|
|
33
|
+
"""
|
|
34
|
+
Submit a job using the provided payload.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
payload : JobCreationPayload
|
|
39
|
+
The configuration for the job to be created.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
JobResponse
|
|
44
|
+
Response containing the job_id and initial status.
|
|
45
|
+
|
|
46
|
+
Raises
|
|
47
|
+
------
|
|
48
|
+
requests.exceptions.RequestException
|
|
49
|
+
If the API request fails.
|
|
50
|
+
ValueError
|
|
51
|
+
If the response parsing fails.
|
|
52
|
+
"""
|
|
53
|
+
endpoint = "/jobs/"
|
|
54
|
+
try:
|
|
55
|
+
response_data = self.client.post(
|
|
56
|
+
endpoint, json_payload=payload.model_dump(exclude_none=True)
|
|
57
|
+
)
|
|
58
|
+
# Pydantic validation is applied here
|
|
59
|
+
return JobResponse(**response_data)
|
|
60
|
+
except ValidationError as e:
|
|
61
|
+
# Catch Pydantic validation errors specifically
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Invalid response format received from {endpoint}: {e}"
|
|
64
|
+
) from e
|
|
65
|
+
# RequestExceptions (including HTTPError) are handled by client._post
|
|
66
|
+
|
|
67
|
+
def get(self, job_id: str) -> JobResponse:
|
|
68
|
+
"""
|
|
69
|
+
Get the status and details of a specific job.
|
|
70
|
+
"""
|
|
71
|
+
endpoint = f"/jobs/{job_id}"
|
|
72
|
+
try:
|
|
73
|
+
response_data = self.client.get(endpoint)
|
|
74
|
+
return JobResponse(**response_data)
|
|
75
|
+
except ValidationError as e:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Invalid response format received from {endpoint}: {e}"
|
|
78
|
+
) from e
|
|
79
|
+
|
|
80
|
+
def list(self) -> list[JobResponse]:
|
|
81
|
+
"""
|
|
82
|
+
List all jobs.
|
|
83
|
+
"""
|
|
84
|
+
endpoint = "/jobs/"
|
|
85
|
+
try:
|
|
86
|
+
response_data = self.client.get(endpoint)
|
|
87
|
+
# Ensure response_data is a list before list comprehension
|
|
88
|
+
if not isinstance(response_data, list):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Unexpected response format from {endpoint}: expected a list, "
|
|
91
|
+
"got {type(response_data).__name__}"
|
|
92
|
+
)
|
|
93
|
+
# Apply validation within list comprehension
|
|
94
|
+
return [JobResponse(**job) for job in response_data]
|
|
95
|
+
except ValidationError as e:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Invalid job data format received from {endpoint}: {e}"
|
|
98
|
+
) from e
|
|
99
|
+
|
|
100
|
+
def cancel(self, job_id: str) -> JobResponse:
|
|
101
|
+
"""
|
|
102
|
+
Cancel a job.
|
|
103
|
+
"""
|
|
104
|
+
endpoint = f"/jobs/{job_id}/cancel"
|
|
105
|
+
try:
|
|
106
|
+
response_data = self.client.post(endpoint, json_payload={})
|
|
107
|
+
return JobResponse(**response_data)
|
|
108
|
+
except ValidationError as e:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Invalid response format received from {endpoint}: {e}"
|
|
111
|
+
) from e
|
ionworks/models.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic models for the Ionworks API client.
|
|
3
|
+
|
|
4
|
+
These models use extra="allow" to accept any fields from the API response,
|
|
5
|
+
letting the API handle validation. Required fields are kept minimal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pandas as pd # type: ignore
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
12
|
+
|
|
13
|
+
from .validators import dict_to_df_validator
|
|
14
|
+
|
|
15
|
+
# --- Cell Specification Models --- #
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CellSpecification(BaseModel):
|
|
19
|
+
"""
|
|
20
|
+
Cell specification model - accepts any fields from the API.
|
|
21
|
+
|
|
22
|
+
The API returns nested component/material data and ratings objects.
|
|
23
|
+
This model is permissive to allow the API to define the schema.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="allow")
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
slug: str
|
|
30
|
+
name: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- Cell Instance Models --- #
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CellInstance(BaseModel):
|
|
37
|
+
"""
|
|
38
|
+
Cell instance model - accepts any fields from the API.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="allow")
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
slug: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Cell Measurement Models --- #
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CellMeasurement(BaseModel):
|
|
51
|
+
"""
|
|
52
|
+
Cell measurement model - accepts any fields from the API.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="allow")
|
|
56
|
+
|
|
57
|
+
id: str
|
|
58
|
+
slug: str
|
|
59
|
+
name: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- Bundle Models --- #
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CellMeasurementBundleResponse(BaseModel):
|
|
66
|
+
"""Response from creating a measurement bundle."""
|
|
67
|
+
|
|
68
|
+
measurement: CellMeasurement
|
|
69
|
+
steps_created: int
|
|
70
|
+
file_path: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InitiateUploadResponse(BaseModel):
|
|
74
|
+
"""Response from the initiate-upload endpoint for signed URL uploads."""
|
|
75
|
+
|
|
76
|
+
measurement_id: str
|
|
77
|
+
signed_url: str
|
|
78
|
+
token: str
|
|
79
|
+
path: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ConfirmUploadResponse(BaseModel):
|
|
83
|
+
"""Response from the confirm-upload endpoint after successful upload."""
|
|
84
|
+
|
|
85
|
+
instance: CellInstance
|
|
86
|
+
measurement: CellMeasurement
|
|
87
|
+
steps_created: int
|
|
88
|
+
file_path: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- Detail Models --- #
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CellMeasurementDetailBase(BaseModel):
|
|
95
|
+
"""Base model for measurement detail with steps and time series."""
|
|
96
|
+
|
|
97
|
+
measurement: CellMeasurement
|
|
98
|
+
steps: pd.DataFrame
|
|
99
|
+
time_series: pd.DataFrame
|
|
100
|
+
|
|
101
|
+
@field_validator("steps", "time_series", mode="before")
|
|
102
|
+
@classmethod
|
|
103
|
+
def convert_dict_to_df(cls, v: Any) -> Any:
|
|
104
|
+
return dict_to_df_validator(v)
|
|
105
|
+
|
|
106
|
+
class Config:
|
|
107
|
+
arbitrary_types_allowed = True # Allow pandas DataFrame
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CellInstanceDetail(BaseModel):
|
|
111
|
+
"""Detail model for a cell instance with all measurements."""
|
|
112
|
+
|
|
113
|
+
specification: CellSpecification
|
|
114
|
+
instance: CellInstance
|
|
115
|
+
measurements: list[CellMeasurementDetailBase]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CellMeasurementDetail(CellMeasurementDetailBase):
|
|
119
|
+
"""Detail model for a single measurement with context."""
|
|
120
|
+
|
|
121
|
+
specification: CellSpecification
|
|
122
|
+
instance: CellInstance
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- Group Response Models --- #
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CellInstanceMeasurementsWithSteps(BaseModel):
|
|
129
|
+
"""
|
|
130
|
+
Response model for all measurements + steps associated with a specific cell
|
|
131
|
+
instance.
|
|
132
|
+
Returns normalized data: separate lists for instance, measurements, and steps.
|
|
133
|
+
Includes parent information for consistency with other group endpoints.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
specification: CellSpecification
|
|
137
|
+
instance: CellInstance
|
|
138
|
+
measurements: list[CellMeasurement]
|
|
139
|
+
steps: list[pd.DataFrame]
|
|
140
|
+
|
|
141
|
+
@field_validator("steps", mode="before")
|
|
142
|
+
@classmethod
|
|
143
|
+
def convert_dict_to_df(cls, v: Any) -> Any:
|
|
144
|
+
if isinstance(v, list):
|
|
145
|
+
return [dict_to_df_validator(item) for item in v]
|
|
146
|
+
return dict_to_df_validator(v)
|
|
147
|
+
|
|
148
|
+
class Config:
|
|
149
|
+
arbitrary_types_allowed = True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CellSpecificationInstances(BaseModel):
|
|
153
|
+
"""
|
|
154
|
+
Response model for all instances associated with a cell specification.
|
|
155
|
+
Returns normalized data: separate lists for specification, instances,
|
|
156
|
+
and measurements.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
specification: CellSpecification
|
|
160
|
+
instances: list[CellInstance]
|
|
161
|
+
measurements: list[CellMeasurement]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class CellSpecificationInstancesWithSteps(BaseModel):
|
|
165
|
+
"""
|
|
166
|
+
Response model for all instances + measurements + steps associated with a cell
|
|
167
|
+
specification.
|
|
168
|
+
Returns normalized data: separate lists for specification, instances, measurements,
|
|
169
|
+
and steps.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
specification: CellSpecification
|
|
173
|
+
instances: list[CellInstance]
|
|
174
|
+
measurements: list[CellMeasurement]
|
|
175
|
+
steps: list[pd.DataFrame]
|
|
176
|
+
|
|
177
|
+
@field_validator("steps", mode="before")
|
|
178
|
+
@classmethod
|
|
179
|
+
def convert_dict_to_df(cls, v: Any) -> Any:
|
|
180
|
+
if isinstance(v, list):
|
|
181
|
+
return [dict_to_df_validator(item) for item in v]
|
|
182
|
+
return dict_to_df_validator(v)
|
|
183
|
+
|
|
184
|
+
class Config:
|
|
185
|
+
arbitrary_types_allowed = True
|