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/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