diff --git a/silo_client/__init__.py b/silo_client/__init__.py index 039c497..0aea4e4 100644 --- a/silo_client/__init__.py +++ b/silo_client/__init__.py @@ -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, + }, + )