"""Cell specification client for managing cell type definitions.
This module provides the :class:`CellSpecificationClient` for creating,
reading, updating, and deleting cell specifications, which define the
properties of battery cell types (manufacturer, chemistry, ratings, etc.).
"""
from __future__ import annotations
from typing import Any
import warnings
from ionworks.errors import IonworksError
from ._project_id import inject_project_id, resolve_project_id
from .models import (
CellSpecification,
PaginatedList,
_build_endpoint,
_build_filter_params,
_parse_list_response,
)
#: Component slots a cell spec can fill, in the order the API reports them.
#: The backend owns the canonical list (``SPEC_SLOTS``); the SDK can't import
#: backend code, so this is the package-boundary copy.
_SLOTS = ("anode", "cathode", "electrolyte", "separator", "case")
#: The per-slot material query params, derived from :data:`_SLOTS` so the slot
#: set has one owner within this module.
_MATERIAL_SLOT_PARAMS = tuple(f"{slot}_material_id" for slot in _SLOTS)
[docs]
class CellSpecificationClient:
"""Client for managing cell specifications.
Provides methods to create, read, update, and delete cell specifications,
which define the properties of battery cell types (manufacturer, chemistry,
ratings, etc.).
"""
[docs]
def __init__(self, client: Any) -> None:
"""Initialize the CellSpecificationClient.
Parameters
----------
client : Any
The HTTP client instance for making API requests.
"""
self.client = client
[docs]
def get(self, cell_spec_id: str) -> CellSpecification:
"""Get a specific cell specification by ID.
Parameters
----------
cell_spec_id : str
The ID of the cell specification to retrieve.
Returns
-------
CellSpecification
The requested cell specification object.
"""
endpoint = f"/cell_specifications/{cell_spec_id}"
response_data = self.client.get(endpoint)
return CellSpecification(**response_data)
[docs]
def list(
self,
include_components: bool = False,
limit: int | None = None,
offset: int | None = None,
*,
name: str | None = None,
name_exact: str | None = None,
form_factor: str | None = None,
created_by_email: str | None = None,
created_after: str | None = None,
created_before: str | None = None,
updated_after: str | None = None,
updated_before: str | None = None,
order_by: str | None = None,
order: str | None = None,
project_id: str | None = None,
material_id: str | list[str] | None = None,
anode_material_id: str | None = None,
cathode_material_id: str | None = None,
electrolyte_material_id: str | None = None,
separator_material_id: str | None = None,
case_material_id: str | None = None,
exclude_cell_spec_id: str | None = None,
) -> PaginatedList[CellSpecification]:
"""List cell specifications with optional pagination and filtering.
Always returns a :class:`PaginatedList` which behaves like a regular
``list``. Use ``limit`` and ``offset`` to control the page.
Parameters
----------
include_components : bool, optional
If True, returns each specification with its nested component and
material data. Defaults to False (metadata only).
limit : int | None, optional
Maximum number of specs to return per page.
offset : int | None, optional
Number of specs to skip for pagination.
name : str | None, optional
Case-insensitive substring match on spec name.
name_exact : str | None, optional
Exact match on spec name. Takes precedence over ``name``.
form_factor : str | None, optional
Exact match on form factor.
created_by_email : str | None, optional
Case-insensitive substring match on the creator's email.
created_after : str | None, optional
ISO datetime; return specs created after this time.
created_before : str | None, optional
ISO datetime; return specs created before this time.
updated_after : str | None, optional
ISO datetime; return specs updated after this time.
updated_before : str | None, optional
ISO datetime; return specs updated before this time.
order_by : str | None, optional
Column to sort by (``"name"``, ``"created_at"``, ``"updated_at"``).
order : str | None, optional
Sort direction: ``"asc"`` or ``"desc"``.
project_id : str | None, optional
Restrict results to this project. Required for an identity lookup —
``name_exact`` or a material reverse-lookup (see ``material_id``) —
where it falls back to ``IONWORKS_PROJECT_ID`` and raises if neither
is set, because those resolve one specific spec and names and
materials are only unique within a project. For a plain list,
omitting it applies no project filter.
material_id : str | list[str] | None, optional
Return specs referencing this material through any component. A
single id matches specs using that material; a list matches specs
using any of the given materials.
anode_material_id : str | None, optional
Return specs whose anode uses this material.
cathode_material_id : str | None, optional
Return specs whose cathode uses this material.
electrolyte_material_id : str | None, optional
Return specs whose electrolyte uses this material.
separator_material_id : str | None, optional
Return specs whose separator uses this material.
case_material_id : str | None, optional
Return specs whose case uses this material.
exclude_cell_spec_id : str | None, optional
Exclude the spec with this id from the results.
Returns
-------
PaginatedList[CellSpecification]
A list of cell specification objects.
"""
filter_params = _build_filter_params(
name=name,
name_exact=name_exact,
created_by_email=created_by_email,
created_after=created_after,
created_before=created_before,
updated_after=updated_after,
updated_before=updated_before,
order_by=order_by,
order=order,
)
if form_factor is not None:
filter_params["form_factor"] = f"eq.{form_factor}"
if isinstance(material_id, list):
# An explicitly empty list means "match none of these materials". The
# query string can't carry an empty repeated param, so the request
# would drop the filter and return every spec — answer it here instead.
if not material_id:
return PaginatedList(items=[], total=0)
filter_params["material_ids"] = material_id
elif material_id is not None:
filter_params["material_id"] = material_id
# strict=True keeps the arg tuple aligned with _MATERIAL_SLOT_PARAMS: adding
# a slot without its argument here fails loudly rather than dropping it.
per_slot = dict(
zip(
_MATERIAL_SLOT_PARAMS,
(
anode_material_id,
cathode_material_id,
electrolyte_material_id,
separator_material_id,
case_material_id,
),
strict=True,
)
)
for name_, val in (
*per_slot.items(),
("exclude_cell_spec_id", exclude_cell_spec_id),
):
if val is not None:
filter_params[name_] = val
# Some lookups are only meaningful within a project, so they resolve
# project_id from the argument or IONWORKS_PROJECT_ID rather than
# falling back to an unscoped org-wide search:
#
# material reverse-lookup — materials and components are deduplicated
# per project, so a cross-project match is meaningless.
#
# name_exact — spec names are unique per project (20260813172714), so
# an org-wide exact-name lookup can return a sibling project's spec or
# report a name as ambiguous that is unique where the caller works.
#
# Enforced here rather than at each call site: this is the shared
# entry point every name resolver goes through, and a caller that
# forgets to scope would silently reintroduce the bug.
# Checks ``name_exact`` directly, not filter_params: the builder folds
# it into a ``name`` key ("eq.<value>"), which is indistinguishable from
# a substring ``name`` filter by key alone.
is_identity_lookup = name_exact is not None or any(
k in filter_params
for k in ("material_id", "material_ids", *_MATERIAL_SLOT_PARAMS)
)
if is_identity_lookup:
filter_params["project_id"] = resolve_project_id(self.client, project_id)
elif project_id is not None:
filter_params["project_id"] = project_id
endpoint = _build_endpoint(
"/cell_specifications",
{
"full": "true" if include_components else None,
"limit": limit,
"offset": offset,
**filter_params,
},
)
response_data = self.client.get(endpoint)
return _parse_list_response(response_data, CellSpecification)
[docs]
def create(self, data: dict[str, Any]) -> CellSpecification:
"""Create a new cell specification.
Parameters
----------
data : dict[str, Any]
Dictionary containing the cell specification data. If
``project_id`` is omitted, the client's default project_id is
used.
Returns
-------
CellSpecification
The newly created cell specification object.
"""
endpoint = "/cell_specifications"
data = inject_project_id(self.client, data)
response_data = self.client.post(endpoint, data)
return CellSpecification(**response_data)
[docs]
def create_or_get(self, data: dict[str, Any]) -> CellSpecification:
"""Create a new cell specification or get an existing one.
Creates a new cell specification if it doesn't exist, otherwise returns
the existing one.
Parameters
----------
data : dict[str, Any]
Dictionary containing the cell specification data.
Returns
-------
CellSpecification
The cell specification object (newly created or existing).
"""
try:
return self.create(data)
except IonworksError as e:
if e.error_code == "CONFLICT" or e.status_code == 409:
# Try to get existing spec by ID from error detail
if e.data is not None:
detail = e.data.get("detail", {})
existing_id = (
detail.get("existing_id") if isinstance(detail, dict) else None
)
if existing_id:
return self.get(existing_id)
# Deprecated: legacy error format fallback
legacy_id = e.data.get("existing_cell_specification_id")
if legacy_id:
warnings.warn(
"Received legacy error key "
"'existing_cell_specification_id'. "
"Update the backend to use the "
"standardized error format.",
DeprecationWarning,
stacklevel=2,
)
return self.get(legacy_id)
# Fall back to looking the spec up by name, matched exactly
# server-side. Scanning ``self.list()`` instead would only see
# the first page, so it could miss the conflicting spec.
# ``list`` scopes an exact-name lookup to the project itself,
# falling back to the client default just as ``create`` does.
spec_name = data.get("name")
if spec_name:
matches = self.list(
name_exact=spec_name,
project_id=data.get("project_id"),
)
if matches:
return matches[0]
raise ValueError(
f"Cell specification '{spec_name}' reported as "
"duplicate but could not be found"
) from e
raise
[docs]
def update(self, cell_spec_id: str, data: dict[str, Any]) -> CellSpecification:
"""Update an existing cell specification.
Parameters
----------
cell_spec_id : str
The ID of the cell specification to update.
data : dict[str, Any]
Dictionary containing the fields to update. Supports nested
component/material data for upsert.
Returns
-------
CellSpecification
The updated cell specification object.
"""
endpoint = f"/cell_specifications/{cell_spec_id}"
response_data = self.client.patch(endpoint, data)
return CellSpecification(**response_data)
[docs]
def delete(self, cell_spec_id: str) -> None:
"""Delete a cell specification by ID.
Parameters
----------
cell_spec_id : str
The ID of the cell specification to delete.
"""
endpoint = f"/cell_specifications/{cell_spec_id}"
self.client.delete(endpoint)