From a6ac3d4d0664d160c49e145dead5e790e8e723f3 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Fri, 6 Feb 2026 11:14:21 -0600 Subject: [PATCH] initial: shared Python API client for Silo PLM Extracted from silo monorepo. Provides SiloClient (HTTP client) and SiloSettings (abstract config adapter) so both the FreeCAD workbench and LibreOffice Calc extension share the same API layer. Includes CATEGORY_NAMES, sanitize_filename, parse_part_number, get_category_folder_name, and SSL context builder. --- LICENSE | 21 + README.md | 36 + silo_client/__init__.py | 913 ++++++++++++++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 46793 bytes silo_client/__pycache__/_ssl.cpython-313.pyc | Bin 0 -> 1609 bytes silo_client/_ssl.py | 44 + 6 files changed, 1014 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 silo_client/__init__.py create mode 100644 silo_client/__pycache__/__init__.cpython-313.pyc create mode 100644 silo_client/__pycache__/_ssl.cpython-313.pyc create mode 100644 silo_client/_ssl.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e2c094f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kindred Systems LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..466b59c --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# silo-client + +Shared Python HTTP client for the Silo REST API. + +## Usage + +Consumers subclass `SiloSettings` to bridge the client to their configuration storage, then instantiate `SiloClient`: + +```python +from silo_client import SiloClient, SiloSettings + +class MySettings(SiloSettings): + def get_api_url(self): + return "http://localhost:8080/api" + def get_api_token(self): + return os.environ.get("SILO_API_TOKEN", "") + def get_ssl_verify(self): + return True + def get_ssl_cert_path(self): + return "" + def save_auth(self, username, role="", source="", token=""): + ... # persist credentials + def clear_auth(self): + ... # remove credentials + +client = SiloClient(MySettings()) +items = client.list_items(search="M3 screw") +``` + +## Dependencies + +None beyond the Python standard library (`urllib`, `ssl`, `json`). + +## License + +MIT diff --git a/silo_client/__init__.py b/silo_client/__init__.py new file mode 100644 index 0000000..3447536 --- /dev/null +++ b/silo_client/__init__.py @@ -0,0 +1,913 @@ +"""Silo API client -- shared HTTP client for Silo REST API. + +This package provides ``SiloClient``, a pure-Python HTTP client for the +Silo parts-database server. It uses only ``urllib`` (no external +dependencies) so it can be embedded in FreeCAD workbenches, LibreOffice +extensions, and CLI tools without any packaging overhead. + +Consumers supply a ``SiloSettings`` adapter that tells the client where +to find credentials and how to persist authentication state. +""" + +import http.cookiejar +import json +import os +import re +import socket +import ssl +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +from ._ssl import build_ssl_context + +__version__ = "0.1.0" + + +# --------------------------------------------------------------------------- +# Settings protocol +# --------------------------------------------------------------------------- + + +class SiloSettings: + """Abstract settings interface. + + Consumers must subclass this and implement all methods to bridge the + client to their application's configuration storage (FreeCAD + preferences, JSON file, environment variables, etc.). + """ + + def get_api_url(self) -> str: + """Return the Silo API base URL (e.g. ``http://localhost:8080/api``).""" + raise NotImplementedError + + def get_api_token(self) -> str: + """Return the stored API bearer token, or ``""``.""" + raise NotImplementedError + + def get_ssl_verify(self) -> bool: + """Return whether to verify server TLS certificates.""" + raise NotImplementedError + + def get_ssl_cert_path(self) -> str: + """Return path to a custom CA certificate file, or ``""``.""" + raise NotImplementedError + + def save_auth( + self, username: str, role: str = "", source: str = "", token: str = "" + ): + """Persist authentication info after a successful login.""" + raise NotImplementedError + + def clear_auth(self): + """Remove all stored authentication credentials.""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# Shared utilities +# --------------------------------------------------------------------------- + +# Category name mapping for folder structure. +# Format: CCC -> "descriptive_name" +CATEGORY_NAMES = { + # Fasteners + "F01": "screws_bolts", + "F02": "threaded_rods", + "F03": "eyebolts", + "F04": "u_bolts", + "F05": "nuts", + "F06": "washers", + "F07": "shims", + "F08": "inserts", + "F09": "spacers", + "F10": "pins", + "F11": "anchors", + "F12": "nails", + "F13": "rivets", + "F14": "staples", + "F15": "key_stock", + "F16": "retaining_rings", + "F17": "cable_ties", + "F18": "hook_loop", + # Fluid Fittings + "C01": "full_couplings", + "C02": "half_couplings", + "C03": "reducers", + "C04": "elbows", + "C05": "tees", + "C06": "crosses", + "C07": "unions", + "C08": "adapters", + "C09": "plugs_caps", + "C10": "nipples", + "C11": "flanges", + "C12": "valves", + "C13": "quick_disconnects", + "C14": "hose_barbs", + "C15": "compression_fittings", + "C16": "tubing", + "C17": "hoses", + # Motion Components + "R01": "ball_bearings", + "R02": "roller_bearings", + "R03": "sleeve_bearings", + "R04": "thrust_bearings", + "R05": "linear_bearings", + "R06": "spur_gears", + "R07": "helical_gears", + "R08": "bevel_gears", + "R09": "worm_gears", + "R10": "rack_pinion", + "R11": "sprockets", + "R12": "timing_pulleys", + "R13": "v_belt_pulleys", + "R14": "idler_pulleys", + "R15": "wheels", + "R16": "casters", + "R17": "shaft_couplings", + "R18": "clutches", + "R19": "brakes", + "R20": "lead_screws", + "R21": "ball_screws", + "R22": "linear_rails", + "R23": "linear_actuators", + "R24": "brushed_dc_motor", + "R25": "brushless_dc_motor", + "R26": "stepper_motor", + "R27": "servo_motor", + "R28": "ac_induction_motor", + "R29": "gear_motor", + "R30": "motor_driver", + "R31": "motor_controller", + "R32": "encoder", + "R33": "pneumatic_cylinder", + "R34": "pneumatic_actuator", + "R35": "pneumatic_valve", + "R36": "pneumatic_regulator", + "R37": "pneumatic_frl_unit", + "R38": "air_compressor", + "R39": "vacuum_pump", + "R40": "hydraulic_cylinder", + "R41": "hydraulic_pump", + "R42": "hydraulic_motor", + "R43": "hydraulic_valve", + "R44": "hydraulic_accumulator", + # Structural Materials + "S01": "square_tube", + "S02": "round_tube", + "S03": "rectangular_tube", + "S04": "i_beam", + "S05": "t_slot_extrusion", + "S06": "angle", + "S07": "channel", + "S08": "flat_bar", + "S09": "round_bar", + "S10": "square_bar", + "S11": "hex_bar", + "S12": "sheet_metal", + "S13": "plate", + "S14": "expanded_metal", + "S15": "perforated_sheet", + "S16": "wire_mesh", + "S17": "grating", + # Electrical Components + "E01": "wire", + "E02": "cable", + "E03": "connectors", + "E04": "terminals", + "E05": "circuit_breakers", + "E06": "fuses", + "E07": "relays", + "E08": "contactors", + "E09": "switches", + "E10": "buttons", + "E11": "indicators", + "E12": "resistors", + "E13": "capacitors", + "E14": "inductors", + "E15": "transformers", + "E16": "diodes", + "E17": "transistors", + "E18": "ics", + "E19": "microcontrollers", + "E20": "sensors", + "E21": "displays", + "E22": "power_supplies", + "E23": "batteries", + "E24": "pcb", + "E25": "enclosures", + "E26": "heat_sinks", + "E27": "fans", + # Mechanical Components + "M01": "compression_springs", + "M02": "extension_springs", + "M03": "torsion_springs", + "M04": "gas_springs", + "M05": "dampers", + "M06": "shock_absorbers", + "M07": "vibration_mounts", + "M08": "hinges", + "M09": "latches", + "M10": "handles", + "M11": "knobs", + "M12": "levers", + "M13": "linkages", + "M14": "cams", + "M15": "bellows", + "M16": "seals", + "M17": "o_rings", + "M18": "gaskets", + # Tooling and Fixtures + "T01": "jigs", + "T02": "fixtures", + "T03": "molds", + "T04": "dies", + "T05": "gauges", + "T06": "templates", + "T07": "work_holding", + "T08": "test_fixtures", + # Assemblies + "A01": "mechanical_assembly", + "A02": "electrical_assembly", + "A03": "electromechanical_assembly", + "A04": "subassembly", + "A05": "cable_assembly", + "A06": "pneumatic_assembly", + "A07": "hydraulic_assembly", + # Purchased/Off-the-Shelf + "P01": "purchased_mechanical", + "P02": "purchased_electrical", + "P03": "purchased_assembly", + "P04": "raw_material", + "P05": "consumables", + # Custom Fabricated Parts + "X01": "machined_part", + "X02": "sheet_metal_part", + "X03": "3d_printed_part", + "X04": "cast_part", + "X05": "molded_part", + "X06": "welded_fabrication", + "X07": "laser_cut_part", + "X08": "waterjet_cut_part", +} + + +def sanitize_filename(name: str) -> str: + """Sanitize a string for use in filenames.""" + sanitized = re.sub(r'[<>:"/\\|?*]', "_", name) + sanitized = re.sub(r"[\s_]+", "_", sanitized) + sanitized = sanitized.strip("_ ") + return sanitized[:50] + + +def parse_part_number(part_number: str) -> Tuple[str, str]: + """Parse part number into (category, sequence). + + New format: CCC-NNNN (e.g., F01-0001) + """ + parts = part_number.split("-") + if len(parts) >= 2: + return parts[0], parts[1] + return part_number, "" + + +def get_category_folder_name(category_code: str) -> str: + """Get the folder name for a category (e.g., ``'F01_screws_bolts'``).""" + name = CATEGORY_NAMES.get(category_code.upper(), "misc") + return f"{category_code}_{name}" + + +# --------------------------------------------------------------------------- +# SiloClient +# --------------------------------------------------------------------------- + + +class SiloClient: + """HTTP client for the Silo REST API. + + Args: + settings: A :class:`SiloSettings` adapter for configuration storage. + base_url: Optional explicit base URL (overrides ``settings``). + """ + + def __init__(self, settings: SiloSettings, base_url: str = None): + self._settings = settings + self._explicit_url = base_url + + # -- URL helpers -------------------------------------------------------- + + @property + def base_url(self) -> str: + if self._explicit_url: + return self._explicit_url.rstrip("/") + url = self._settings.get_api_url().rstrip("/") + if not url: + url = os.environ.get("SILO_API_URL", "http://localhost:8080/api") + # Auto-append /api for bare origins + parsed = urllib.parse.urlparse(url) + if not parsed.path or parsed.path == "/": + url = url + "/api" + return url + + @property + def _origin(self) -> str: + """Server origin (without ``/api``) for auth endpoints.""" + base = self.base_url + return base.rsplit("/api", 1)[0] if base.endswith("/api") else base + + def _ssl_context(self) -> ssl.SSLContext: + return build_ssl_context( + self._settings.get_ssl_verify(), + self._settings.get_ssl_cert_path(), + ) + + # -- Auth headers ------------------------------------------------------- + + def _auth_headers(self) -> Dict[str, str]: + token = self._settings.get_api_token() + if not token: + token = os.environ.get("SILO_API_TOKEN", "") + if token: + return {"Authorization": f"Bearer {token}"} + return {} + + # -- Core HTTP ---------------------------------------------------------- + + def _request( + self, + method: str, + path: str, + data: Optional[Dict] = None, + raw: bool = False, + ) -> Any: + """Make an authenticated JSON request. + + If *raw* is True the response bytes are returned instead. + """ + url = f"{self.base_url}{path}" + headers = {"Content-Type": "application/json"} + headers.update(self._auth_headers()) + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, context=self._ssl_context()) as resp: + payload = resp.read() + if raw: + return payload + return json.loads(payload.decode()) + except urllib.error.HTTPError as e: + if e.code == 401: + self._settings.clear_auth() + error_body = e.read().decode() + raise RuntimeError(f"API error {e.code}: {error_body}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + def _download(self, path: str) -> bytes: + """Download raw bytes from an API path.""" + url = f"{self.base_url}{path}" + req = urllib.request.Request(url, headers=self._auth_headers(), method="GET") + try: + with urllib.request.urlopen(req, context=self._ssl_context()) as resp: + return resp.read() + except urllib.error.HTTPError as e: + raise RuntimeError(f"Download error {e.code}: {e.read().decode()}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + def _download_file( + self, + part_number: str, + revision: int, + dest_path: str, + progress_callback=None, + ) -> bool: + """Download a file from MinIO storage. + + Args: + progress_callback: Optional ``callable(bytes_downloaded, total_bytes)``. + *total_bytes* is ``-1`` when the server omits Content-Length. + """ + url = f"{self.base_url}/items/{part_number}/file/{revision}" + req = urllib.request.Request(url, headers=self._auth_headers(), method="GET") + try: + with urllib.request.urlopen(req, context=self._ssl_context()) as resp: + total = int(resp.headers.get("Content-Length", -1)) + downloaded = 0 + with open(dest_path, "wb") as f: + while True: + chunk = resp.read(8192) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if progress_callback is not None: + progress_callback(downloaded, total) + return True + except urllib.error.HTTPError as e: + if e.code == 404: + return False + raise RuntimeError(f"Download error {e.code}: {e.read().decode()}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + def _upload_file( + self, part_number: str, file_path: str, properties: Dict, comment: str = "" + ) -> Dict[str, Any]: + """Upload a file and create a new revision.""" + import mimetypes + + url = f"{self.base_url}/items/{part_number}/file" + + with open(file_path, "rb") as f: + file_data = f.read() + + boundary = "----SiloUploadBoundary" + str(hash(file_path))[-8:] + body_parts: list = [] + + filename = os.path.basename(file_path) + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + body_parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="file"; ' + f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n' + ) + body_parts.append(file_data) + body_parts.append(b"\r\n") + + if comment: + body_parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="comment"\r\n\r\n' + f"{comment}\r\n" + ) + + if properties: + props_json = json.dumps(properties, allow_nan=False, default=str) + body_parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="properties"\r\n\r\n' + f"{props_json}\r\n" + ) + + body_parts.append(f"--{boundary}--\r\n") + + body = b"" + for part in body_parts: + body += part.encode("utf-8") if isinstance(part, str) else part + + 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}") + + def _upload_ods( + self, path: str, ods_bytes: bytes, filename: str = "upload.ods" + ) -> Any: + """POST an ODS file as multipart/form-data.""" + boundary = "----SiloCalcUpload" + 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: application/vnd.oasis.opendocument.spreadsheet\r\n\r\n" + ) + parts.append(ods_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]: + """Session login and create a persistent API token. + + Performs: ``POST /login`` (session) -> ``GET /api/auth/me`` (verify) + -> ``POST /api/auth/tokens`` (create token). The token is persisted + via ``settings.save_auth()``. + + Args: + token_name: Human-readable label for the token. Defaults to + the local hostname. + """ + ctx = self._ssl_context() + cookie_jar = http.cookiejar.CookieJar() + opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cookie_jar), + urllib.request.HTTPSHandler(context=ctx), + ) + + # Step 1: POST credentials to /login + login_url = f"{self._origin}/login" + form_data = urllib.parse.urlencode( + {"username": username, "password": password} + ).encode() + req = urllib.request.Request( + login_url, + data=form_data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + opener.open(req) + except urllib.error.HTTPError as e: + if e.code not in (302, 303): + raise RuntimeError(f"Login failed (HTTP {e.code})") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + # Step 2: Verify session via /api/auth/me + me_req = urllib.request.Request(f"{self._origin}/api/auth/me", method="GET") + try: + with opener.open(me_req) as resp: + user_info = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 401: + raise RuntimeError("Login failed: invalid username or password") + raise RuntimeError(f"Login verification failed (HTTP {e.code})") + + # Step 3: Create a persistent API token + if not token_name: + token_name = socket.gethostname() + token_body = json.dumps({"name": token_name, "expires_in_days": 90}).encode() + token_req = urllib.request.Request( + f"{self._origin}/api/auth/tokens", + data=token_body, + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with opener.open(token_req) as resp: + token_result = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + raise RuntimeError(f"Failed to create API token (HTTP {e.code})") + + raw_token = token_result.get("token", "") + if not raw_token: + raise RuntimeError("Server did not return an API token") + + self._settings.save_auth( + username=user_info.get("username", username), + role=user_info.get("role", ""), + source=user_info.get("auth_source", ""), + token=raw_token, + ) + + return { + "username": user_info.get("username", username), + "role": user_info.get("role", ""), + "auth_source": user_info.get("auth_source", ""), + "token_name": token_result.get("name", ""), + "token_prefix": token_result.get("token_prefix", ""), + } + + def logout(self): + """Clear stored API token and authentication info.""" + self._settings.clear_auth() + + def is_authenticated(self) -> bool: + """Return True if a valid API token is configured.""" + return bool(self._settings.get_api_token() or os.environ.get("SILO_API_TOKEN")) + + def get_current_user(self) -> Optional[Dict[str, Any]]: + """Fetch the current user info from the server.""" + try: + return self._request("GET", "/auth/me") + except RuntimeError: + return None + + def list_tokens(self) -> List[Dict[str, Any]]: + """List API tokens for the current user.""" + return self._request("GET", "/auth/tokens") + + def create_token( + self, name: str, expires_in_days: Optional[int] = None + ) -> Dict[str, Any]: + """Create a new API token.""" + data: Dict[str, Any] = {"name": name} + if expires_in_days is not None: + data["expires_in_days"] = expires_in_days + return self._request("POST", "/auth/tokens", data) + + def revoke_token(self, token_id: str) -> None: + """Revoke an API token by its ID.""" + url = f"{self.base_url}/auth/tokens/{token_id}" + headers = {"Content-Type": "application/json"} + headers.update(self._auth_headers()) + req = urllib.request.Request(url, headers=headers, method="DELETE") + try: + urllib.request.urlopen(req, context=self._ssl_context()) + except urllib.error.HTTPError as e: + raise RuntimeError(f"API error {e.code}: {e.read().decode()}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + def check_connection(self) -> Tuple[bool, str]: + """Check connectivity to the Silo server. + + Returns ``(reachable, message)``. + """ + url = f"{self._origin}/health" + req = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen( + req, context=self._ssl_context(), timeout=5 + ) as resp: + return True, f"OK ({resp.status})" + except urllib.error.HTTPError as e: + return True, f"Server error ({e.code})" + except urllib.error.URLError as e: + return False, str(e.reason) + except Exception as e: + return False, str(e) + + # -- Items -------------------------------------------------------------- + + def get_item(self, part_number: str) -> Dict[str, Any]: + return self._request( + "GET", f"/items/{urllib.parse.quote(part_number, safe='')}" + ) + + def list_items( + self, + search: str = "", + item_type: str = "", + project: str = "", + limit: int = 100, + ) -> list: + params = [f"limit={limit}"] + if search: + params.append(f"search={urllib.parse.quote(search)}") + if item_type: + params.append(f"type={item_type}") + if project: + params.append(f"project={urllib.parse.quote(project)}") + return self._request("GET", "/items?" + "&".join(params)) + + def create_item( + self, + schema: str, + category: str, + description: str = "", + projects: Optional[List[str]] = None, + sourcing_type: str = "", + sourcing_link: str = "", + standard_cost: Optional[float] = None, + long_description: str = "", + ) -> Dict[str, Any]: + data: Dict[str, Any] = { + "schema": schema, + "category": category, + "description": description, + } + if projects: + data["projects"] = projects + if sourcing_type: + data["sourcing_type"] = sourcing_type + if sourcing_link: + data["sourcing_link"] = sourcing_link + if standard_cost is not None: + data["standard_cost"] = standard_cost + if long_description: + data["long_description"] = long_description + return self._request("POST", "/items", data) + + def update_item(self, part_number: str, **fields) -> Dict[str, Any]: + return self._request( + "PUT", + f"/items/{urllib.parse.quote(part_number, safe='')}", + fields, + ) + + # -- Revisions ---------------------------------------------------------- + + def get_revisions(self, part_number: str) -> list: + return self._request( + "GET", + f"/items/{urllib.parse.quote(part_number, safe='')}/revisions", + ) + + def get_revision(self, part_number: str, revision: int) -> Dict[str, Any]: + return self._request( + "GET", + f"/items/{urllib.parse.quote(part_number, safe='')}/revisions/{revision}", + ) + + def compare_revisions( + self, part_number: str, from_rev: int, to_rev: int + ) -> Dict[str, Any]: + pn = urllib.parse.quote(part_number, safe="") + return self._request( + "GET", + f"/items/{pn}/revisions/compare?from={from_rev}&to={to_rev}", + ) + + def rollback_revision( + self, part_number: str, revision: int, comment: str = "" + ) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if comment: + data["comment"] = comment + pn = urllib.parse.quote(part_number, safe="") + return self._request("POST", f"/items/{pn}/revisions/{revision}/rollback", data) + + def update_revision( + self, + part_number: str, + revision: int, + status: str = None, + labels: list = None, + ) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if status: + data["status"] = status + if labels is not None: + data["labels"] = labels + pn = urllib.parse.quote(part_number, safe="") + return self._request("PATCH", f"/items/{pn}/revisions/{revision}", data) + + def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]: + """Check if item has files in storage.""" + try: + revisions = self.get_revisions(part_number) + for rev in revisions: + if rev.get("file_key"): + return True, rev["revision_number"] + return False, None + except Exception: + return False, None + + def latest_file_revision(self, part_number: str) -> Optional[Dict]: + """Return the most recent revision with a file, or ``None``.""" + try: + revisions = self.get_revisions(part_number) + for rev in revisions: + if rev.get("file_key"): + return rev + return None + except Exception: + return None + + # -- BOM / Relationships ------------------------------------------------ + + def get_bom(self, part_number: str) -> list: + pn = urllib.parse.quote(part_number, safe="") + return self._request("GET", f"/items/{pn}/bom") + + def get_bom_expanded(self, part_number: str, depth: int = 10) -> list: + pn = urllib.parse.quote(part_number, safe="") + return self._request("GET", f"/items/{pn}/bom/expanded?depth={depth}") + + def get_bom_where_used(self, part_number: str) -> list: + pn = urllib.parse.quote(part_number, safe="") + return self._request("GET", f"/items/{pn}/bom/where-used") + + def add_bom_entry( + self, + parent_pn: str, + child_pn: str, + quantity: Optional[float] = None, + unit: str = None, + rel_type: str = "component", + ref_des: list = None, + metadata: Optional[Dict] = None, + ) -> Dict[str, Any]: + data: Dict[str, Any] = { + "child_part_number": child_pn, + "rel_type": rel_type, + } + if quantity is not None: + data["quantity"] = quantity + if unit: + data["unit"] = unit + if ref_des: + data["reference_designators"] = ref_des + if metadata: + data["metadata"] = metadata + pn = urllib.parse.quote(parent_pn, safe="") + return self._request("POST", f"/items/{pn}/bom", data) + + def update_bom_entry( + self, + parent_pn: str, + child_pn: str, + quantity: Optional[float] = None, + unit: str = None, + rel_type: str = None, + ref_des: list = None, + metadata: Optional[Dict] = None, + ) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if quantity is not None: + data["quantity"] = quantity + if unit is not None: + data["unit"] = unit + if rel_type is not None: + data["rel_type"] = rel_type + if ref_des is not None: + data["reference_designators"] = ref_des + if metadata: + data["metadata"] = metadata + ppn = urllib.parse.quote(parent_pn, safe="") + cpn = urllib.parse.quote(child_pn, safe="") + return self._request("PUT", f"/items/{ppn}/bom/{cpn}", data) + + def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None: + ppn = urllib.parse.quote(parent_pn, safe="") + cpn = urllib.parse.quote(child_pn, safe="") + url = f"{self.base_url}/items/{ppn}/bom/{cpn}" + headers = {"Content-Type": "application/json"} + headers.update(self._auth_headers()) + req = urllib.request.Request(url, headers=headers, method="DELETE") + try: + urllib.request.urlopen(req, context=self._ssl_context()) + except urllib.error.HTTPError as e: + raise RuntimeError(f"API error {e.code}: {e.read().decode()}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + # -- Schemas ------------------------------------------------------------ + + def get_schema(self, name: str = "kindred-rd") -> Dict[str, Any]: + return self._request("GET", f"/schemas/{urllib.parse.quote(name, safe='')}") + + def get_property_schema(self, name: str = "kindred-rd") -> Dict[str, Any]: + return self._request( + "GET", + f"/schemas/{urllib.parse.quote(name, safe='')}/properties", + ) + + # -- Projects ----------------------------------------------------------- + + def get_projects(self) -> list: + return self._request("GET", "/projects") + + def get_project_items(self, code: str) -> list: + return self._request( + "GET", f"/projects/{urllib.parse.quote(code, safe='')}/items" + ) + + def get_item_projects(self, part_number: str) -> list: + pn = urllib.parse.quote(part_number, safe="") + return self._request("GET", f"/items/{pn}/projects") + + def add_item_projects( + self, part_number: str, project_codes: List[str] + ) -> Dict[str, Any]: + pn = urllib.parse.quote(part_number, safe="") + return self._request( + "POST", f"/items/{pn}/projects", {"projects": project_codes} + ) + + # -- Part number generation --------------------------------------------- + + def generate_part_number(self, schema: str, category: str) -> Dict[str, Any]: + return self._request( + "POST", + "/generate-part-number", + {"schema": schema, "category": category}, + ) + + # -- ODS endpoints ------------------------------------------------------ + + def download_bom_ods(self, part_number: str) -> bytes: + pn = urllib.parse.quote(part_number, safe="") + return self._download(f"/items/{pn}/bom/export.ods") + + def download_project_sheet(self, project_code: str) -> bytes: + code = urllib.parse.quote(project_code, safe="") + return self._download(f"/projects/{code}/sheet.ods") + + def upload_sheet_diff( + self, ods_bytes: bytes, filename: str = "sheet.ods" + ) -> Dict[str, Any]: + return self._upload_ods("/sheets/diff", ods_bytes, filename) diff --git a/silo_client/__pycache__/__init__.cpython-313.pyc b/silo_client/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb8e6e8e3d2b59cb66871d86886a7de80d7c2bc9 GIT binary patch literal 46793 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 literal 0 HcmV?d00001 diff --git a/silo_client/_ssl.py b/silo_client/_ssl.py new file mode 100644 index 0000000..18ca417 --- /dev/null +++ b/silo_client/_ssl.py @@ -0,0 +1,44 @@ +"""SSL context builder for Silo API clients.""" + +import os +import ssl + + +def build_ssl_context(verify: bool = True, cert_path: str = "") -> ssl.SSLContext: + """Build an SSL context honouring the caller's verify/cert preferences. + + Args: + verify: Whether to verify server certificates. + cert_path: Optional path to a custom CA certificate file. + + Returns: + A configured ``ssl.SSLContext``. + """ + ctx = ssl.create_default_context() + + if not verify: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + if cert_path and os.path.isfile(cert_path): + try: + ctx.load_verify_locations(cert_path) + except Exception: + pass + + # The bundled Python may not find the system CA store automatically + # (its compiled-in path points to the build environment). Load the + # system CA bundle explicitly so internal CAs (e.g. FreeIPA) are trusted. + for ca_path in ( + "/etc/ssl/certs/ca-certificates.crt", # Debian / Ubuntu + "/etc/pki/tls/certs/ca-bundle.crt", # RHEL / CentOS + ): + if os.path.isfile(ca_path): + try: + ctx.load_verify_locations(ca_path) + except Exception: + pass + break + + return ctx