feat: complete API coverage — add 37 missing endpoint methods #16

Merged
forbes merged 1 commits from feat/complete-api-coverage into main 2026-02-17 13:15:18 +00:00

View File

@@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
from ._ssl import build_ssl_context
__version__ = "0.2.0"
__version__ = "0.3.0"
# ---------------------------------------------------------------------------
@@ -285,6 +285,37 @@ class SiloClient:
except urllib.error.URLError as e:
raise RuntimeError(f"Connection error: {e.reason}")
def _upload_csv(self, path: str, csv_bytes: bytes, filename: str = "import.csv") -> Any:
"""POST a CSV file as multipart/form-data."""
boundary = "----SiloCsvUpload" + str(abs(hash(filename)))[-8:]
parts: list = []
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: text/csv\r\n\r\n"
)
parts.append(csv_bytes)
parts.append(f"\r\n--{boundary}--\r\n")
body = b""
for p in parts:
body += p.encode("utf-8") if isinstance(p, str) else p
url = f"{self.base_url}{path}"
headers = {
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
}
headers.update(self._auth_headers())
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, context=self._ssl_context()) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raise RuntimeError(f"Upload error {e.code}: {e.read().decode()}")
except urllib.error.URLError as e:
raise RuntimeError(f"Connection error: {e.reason}")
# -- Authentication -----------------------------------------------------
def login(self, username: str, password: str, token_name: str = "") -> Dict[str, Any]:
@@ -479,6 +510,60 @@ class SiloClient:
fields,
)
def search_items(self, query: str, limit: int = 100, offset: int = 0) -> list:
"""Full-text search across items."""
q = urllib.parse.quote(query)
return self._request("GET", f"/items/search?q={q}&limit={limit}&offset={offset}")
def get_item_by_uuid(self, uuid: str) -> Dict[str, Any]:
"""Get an item by its internal UUID."""
return self._request("GET", f"/items/by-uuid/{urllib.parse.quote(uuid, safe='')}")
def delete_item(self, part_number: str) -> None:
"""Delete an item by part number."""
pn = urllib.parse.quote(part_number, safe="")
self._request("DELETE", f"/items/{pn}", raw=True)
def export_items_csv(self, project: str = "", category: str = "") -> bytes:
"""Export items as CSV."""
params: list = []
if project:
params.append(f"project={urllib.parse.quote(project)}")
if category:
params.append(f"category={urllib.parse.quote(category)}")
path = "/items/export.csv"
if params:
path += "?" + "&".join(params)
return self._download(path)
def download_items_csv_template(self) -> bytes:
"""Download the CSV import template for items."""
return self._download("/items/template.csv")
def export_items_ods(self, project: str = "", category: str = "") -> bytes:
"""Export items as ODS spreadsheet."""
params: list = []
if project:
params.append(f"project={urllib.parse.quote(project)}")
if category:
params.append(f"category={urllib.parse.quote(category)}")
path = "/items/export.ods"
if params:
path += "?" + "&".join(params)
return self._download(path)
def download_items_ods_template(self) -> bytes:
"""Download the ODS import template for items."""
return self._download("/items/template.ods")
def import_items_csv(self, csv_bytes: bytes, filename: str = "import.csv") -> Any:
"""Import items from a CSV file."""
return self._upload_csv("/items/import", csv_bytes, filename)
def import_items_ods(self, ods_bytes: bytes, filename: str = "import.ods") -> Any:
"""Import items from an ODS spreadsheet."""
return self._upload_ods("/items/import.ods", ods_bytes, filename)
# -- Revisions ----------------------------------------------------------
def get_revisions(self, part_number: str) -> list:
@@ -524,6 +609,21 @@ class SiloClient:
pn = urllib.parse.quote(part_number, safe="")
return self._request("PATCH", f"/items/{pn}/revisions/{revision}", data)
def create_revision(
self,
part_number: str,
properties: Optional[Dict] = None,
comment: str = "",
) -> Dict[str, Any]:
"""Create a new revision for an item."""
data: Dict[str, Any] = {}
if properties:
data["properties"] = properties
if comment:
data["comment"] = comment
pn = urllib.parse.quote(part_number, safe="")
return self._request("POST", f"/items/{pn}/revisions", data)
def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]:
"""Check if item has files in storage."""
try:
@@ -615,6 +715,31 @@ class SiloClient:
cpn = urllib.parse.quote(child_pn, safe="")
self._request("DELETE", f"/items/{ppn}/bom/{cpn}", raw=True)
def get_bom_flat(self, part_number: str) -> list:
"""Get a flattened BOM for an item."""
pn = urllib.parse.quote(part_number, safe="")
return self._request("GET", f"/items/{pn}/bom/flat")
def get_bom_cost(self, part_number: str) -> Dict[str, Any]:
"""Get cost rollup for an item's BOM."""
pn = urllib.parse.quote(part_number, safe="")
return self._request("GET", f"/items/{pn}/bom/cost")
def export_bom_csv(self, part_number: str) -> bytes:
"""Export a BOM as CSV."""
pn = urllib.parse.quote(part_number, safe="")
return self._download(f"/items/{pn}/bom/export.csv")
def import_bom_csv(self, part_number: str, csv_bytes: bytes, filename: str = "bom.csv") -> Any:
"""Import a BOM from a CSV file."""
pn = urllib.parse.quote(part_number, safe="")
return self._upload_csv(f"/items/{pn}/bom/import", csv_bytes, filename)
def merge_bom(self, part_number: str, ods_bytes: bytes, filename: str = "bom.ods") -> Any:
"""Merge a BOM from an ODS spreadsheet."""
pn = urllib.parse.quote(part_number, safe="")
return self._upload_ods(f"/items/{pn}/bom/merge", ods_bytes, filename)
# -- Schemas ------------------------------------------------------------
def list_schemas(self) -> list:
@@ -667,6 +792,17 @@ class SiloClient:
def get_projects(self) -> list:
return self._request("GET", "/projects")
def get_project(self, code: str) -> Dict[str, Any]:
"""Get a single project by code."""
return self._request("GET", f"/projects/{urllib.parse.quote(code, safe='')}")
def create_project(self, code: str, name: str, description: str = "") -> Dict[str, Any]:
"""Create a new project."""
data: Dict[str, Any] = {"code": code, "name": name}
if description:
data["description"] = description
return self._request("POST", "/projects", data)
def get_project_items(self, code: str) -> list:
return self._request("GET", f"/projects/{urllib.parse.quote(code, safe='')}/items")
@@ -678,6 +814,24 @@ class SiloClient:
pn = urllib.parse.quote(part_number, safe="")
return self._request("POST", f"/items/{pn}/projects", {"projects": project_codes})
def update_project(self, code: str, **fields) -> Dict[str, Any]:
"""Update a project's fields."""
return self._request(
"PUT",
f"/projects/{urllib.parse.quote(code, safe='')}",
fields,
)
def delete_project(self, code: str) -> None:
"""Delete a project."""
self._request("DELETE", f"/projects/{urllib.parse.quote(code, safe='')}", raw=True)
def remove_item_project(self, part_number: str, project_code: str) -> None:
"""Remove a project tag from an item."""
pn = urllib.parse.quote(part_number, safe="")
code = urllib.parse.quote(project_code, safe="")
self._request("DELETE", f"/items/{pn}/projects/{code}", raw=True)
# -- Part number generation ---------------------------------------------
def generate_part_number(self, schema: str, category: str) -> Dict[str, Any]:
@@ -700,6 +854,94 @@ class SiloClient:
def upload_sheet_diff(self, ods_bytes: bytes, filename: str = "sheet.ods") -> Dict[str, Any]:
return self._upload_ods("/sheets/diff", ods_bytes, filename)
# -- Files / Attachments ------------------------------------------------
def presign_upload(self, filename: str, content_type: str = "") -> Dict[str, Any]:
"""Get a pre-signed upload URL for a file."""
data: Dict[str, Any] = {"filename": filename}
if content_type:
data["content_type"] = content_type
return self._request("POST", "/uploads/presign", data)
def list_attachments(self, part_number: str) -> list:
"""List file attachments on an item."""
pn = urllib.parse.quote(part_number, safe="")
return self._request("GET", f"/items/{pn}/files")
def add_attachment(
self,
part_number: str,
upload_key: str,
filename: str,
content_type: str = "",
description: str = "",
) -> Dict[str, Any]:
"""Attach an uploaded file to an item."""
pn = urllib.parse.quote(part_number, safe="")
data: Dict[str, Any] = {"upload_key": upload_key, "filename": filename}
if content_type:
data["content_type"] = content_type
if description:
data["description"] = description
return self._request("POST", f"/items/{pn}/files", data)
def delete_attachment(self, part_number: str, file_id: str) -> None:
"""Delete a file attachment from an item."""
pn = urllib.parse.quote(part_number, safe="")
fid = urllib.parse.quote(file_id, safe="")
self._request("DELETE", f"/items/{pn}/files/{fid}", raw=True)
def set_thumbnail(
self,
part_number: str,
image_bytes: bytes,
content_type: str = "image/png",
) -> None:
"""Set the thumbnail image for an item (binary PUT)."""
pn = urllib.parse.quote(part_number, safe="")
url = f"{self.base_url}/items/{pn}/thumbnail"
headers = {"Content-Type": content_type, "Content-Length": str(len(image_bytes))}
headers.update(self._auth_headers())
req = urllib.request.Request(url, data=image_bytes, headers=headers, method="PUT")
try:
with urllib.request.urlopen(req, context=self._ssl_context()) as resp:
resp.read()
except urllib.error.HTTPError as e:
raise RuntimeError(f"Upload error {e.code}: {e.read().decode()}")
except urllib.error.URLError as e:
raise RuntimeError(f"Connection error: {e.reason}")
# -- Audit --------------------------------------------------------------
def get_audit_completeness(
self,
project: str = "",
category: str = "",
min_score: Optional[float] = None,
max_score: Optional[float] = None,
sort: str = "",
limit: int = 100,
offset: int = 0,
) -> list:
"""Get component audit completeness scores."""
params = [f"limit={limit}", f"offset={offset}"]
if project:
params.append(f"project={urllib.parse.quote(project)}")
if category:
params.append(f"category={urllib.parse.quote(category)}")
if min_score is not None:
params.append(f"min_score={min_score}")
if max_score is not None:
params.append(f"max_score={max_score}")
if sort:
params.append(f"sort={urllib.parse.quote(sort)}")
return self._request("GET", "/audit/completeness?" + "&".join(params))
def get_audit_item_completeness(self, part_number: str) -> Dict[str, Any]:
"""Get audit completeness details for a single item."""
pn = urllib.parse.quote(part_number, safe="")
return self._request("GET", f"/audit/completeness/{pn}")
# -- DAG (feature tree) -------------------------------------------------
def push_dag(
@@ -737,6 +979,23 @@ class SiloClient:
path += f"?revision={revision_number}"
return self._request("GET", path)
def get_dag_forward_cone(self, part_number: str, node_key: str) -> Dict[str, Any]:
"""Get the forward cone (downstream dependents) of a DAG node."""
pn = urllib.parse.quote(part_number, safe="")
nk = urllib.parse.quote(node_key, safe="")
return self._request("GET", f"/items/{pn}/dag/forward-cone/{nk}")
def get_dag_dirty(self, part_number: str) -> Dict[str, Any]:
"""Get the dirty subgraph of an item's DAG."""
pn = urllib.parse.quote(part_number, safe="")
return self._request("GET", f"/items/{pn}/dag/dirty")
def mark_dag_dirty(self, part_number: str, node_key: str) -> Dict[str, Any]:
"""Mark a DAG node as dirty (needs recomputation)."""
pn = urllib.parse.quote(part_number, safe="")
nk = urllib.parse.quote(node_key, safe="")
return self._request("POST", f"/items/{pn}/dag/mark-dirty/{nk}")
# -- Jobs ---------------------------------------------------------------
def list_jobs(
@@ -812,3 +1071,72 @@ class SiloClient:
def delete_runner(self, runner_id: str) -> None:
"""Delete a registered runner."""
self._request("DELETE", f"/runners/{urllib.parse.quote(runner_id, safe='')}", raw=True)
# -- Runner-facing ------------------------------------------------------
def runner_heartbeat(self) -> Dict[str, Any]:
"""Send a heartbeat from the runner to the server."""
return self._request("POST", "/runner/heartbeat")
def runner_claim_job(self) -> Optional[Dict[str, Any]]:
"""Attempt to claim the next available job for this runner."""
return self._request("POST", "/runner/claim")
def runner_update_progress(
self, job_id: str, progress: float, message: str = ""
) -> Dict[str, Any]:
"""Report progress on a running job."""
jid = urllib.parse.quote(job_id, safe="")
data: Dict[str, Any] = {"progress": progress}
if message:
data["message"] = message
return self._request("PUT", f"/runner/jobs/{jid}/progress", data)
def runner_complete_job(self, job_id: str, result: Optional[Dict] = None) -> Dict[str, Any]:
"""Mark a job as successfully completed."""
jid = urllib.parse.quote(job_id, safe="")
data: Dict[str, Any] = {}
if result is not None:
data["result"] = result
return self._request("POST", f"/runner/jobs/{jid}/complete", data)
def runner_fail_job(self, job_id: str, error_message: str = "") -> Dict[str, Any]:
"""Mark a job as failed."""
jid = urllib.parse.quote(job_id, safe="")
data: Dict[str, Any] = {}
if error_message:
data["error"] = error_message
return self._request("POST", f"/runner/jobs/{jid}/fail", data)
def runner_append_log(
self,
job_id: str,
level: str,
message: str,
metadata: Optional[Dict] = None,
) -> Dict[str, Any]:
"""Append a log entry to a running job."""
jid = urllib.parse.quote(job_id, safe="")
data: Dict[str, Any] = {"level": level, "message": message}
if metadata:
data["metadata"] = metadata
return self._request("POST", f"/runner/jobs/{jid}/log", data)
def runner_push_dag(
self,
job_id: str,
revision_number: int,
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Push a feature DAG from a runner job."""
jid = urllib.parse.quote(job_id, safe="")
return self._request(
"PUT",
f"/runner/jobs/{jid}/dag",
{
"revision_number": revision_number,
"nodes": nodes,
"edges": edges,
},
)