diff --git a/silo_client/__init__.py b/silo_client/__init__.py index 1828153..039c497 100644 --- a/silo_client/__init__.py +++ b/silo_client/__init__.py @@ -12,7 +12,6 @@ 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 @@ -22,7 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple from ._ssl import build_ssl_context -__version__ = "0.1.0" +__version__ = "0.2.0" # --------------------------------------------------------------------------- @@ -54,9 +53,7 @@ class SiloSettings: """Return path to a custom CA certificate file, or ``""``.""" raise NotImplementedError - def save_auth( - self, username: str, role: str = "", source: str = "", token: str = "" - ): + def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""): """Persist authentication info after a successful login.""" raise NotImplementedError @@ -65,220 +62,6 @@ class SiloSettings: 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 # --------------------------------------------------------------------------- @@ -471,9 +254,7 @@ class SiloClient: 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: + 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 = [] @@ -506,9 +287,7 @@ class SiloClient: # -- Authentication ----------------------------------------------------- - def login( - self, username: str, password: str, token_name: str = "" - ) -> Dict[str, Any]: + 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) @@ -528,9 +307,7 @@ class SiloClient: # Step 1: POST credentials to /login login_url = f"{self._origin}/login" - form_data = urllib.parse.urlencode( - {"username": username, "password": password} - ).encode() + form_data = urllib.parse.urlencode({"username": username, "password": password}).encode() req = urllib.request.Request( login_url, data=form_data, @@ -609,9 +386,7 @@ class SiloClient: """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]: + 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: @@ -639,9 +414,7 @@ class SiloClient: 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: + 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})" @@ -653,9 +426,7 @@ class SiloClient: # -- Items -------------------------------------------------------------- def get_item(self, part_number: str) -> Dict[str, Any]: - return self._request( - "GET", f"/items/{urllib.parse.quote(part_number, safe='')}" - ) + return self._request("GET", f"/items/{urllib.parse.quote(part_number, safe='')}") def list_items( self, @@ -722,9 +493,7 @@ class SiloClient: 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]: + 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", @@ -848,36 +617,66 @@ class SiloClient: # -- Schemas ------------------------------------------------------------ + def list_schemas(self) -> list: + """List all available schemas.""" + return self._request("GET", "/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_schema_form(self, name: str) -> Dict[str, Any]: + """Get form descriptor for a schema (field groups, widgets, category picker).""" + return self._request( + "GET", + f"/schemas/{urllib.parse.quote(name, safe='')}/form", + ) + def get_property_schema(self, name: str = "kindred-rd") -> Dict[str, Any]: return self._request( "GET", f"/schemas/{urllib.parse.quote(name, safe='')}/properties", ) + def add_enum_value( + self, schema: str, segment: str, code: str, label: str = "" + ) -> Dict[str, Any]: + """Add a value to an enum segment in a schema.""" + s = urllib.parse.quote(schema, safe="") + seg = urllib.parse.quote(segment, safe="") + data: Dict[str, Any] = {"code": code} + if label: + data["label"] = label + return self._request("POST", f"/schemas/{s}/segments/{seg}/values", data) + + def update_enum_value(self, schema: str, segment: str, code: str, **fields) -> Dict[str, Any]: + """Update an enum value in a schema segment.""" + s = urllib.parse.quote(schema, safe="") + seg = urllib.parse.quote(segment, safe="") + c = urllib.parse.quote(code, safe="") + return self._request("PUT", f"/schemas/{s}/segments/{seg}/values/{c}", fields) + + def delete_enum_value(self, schema: str, segment: str, code: str) -> None: + """Delete an enum value from a schema segment.""" + s = urllib.parse.quote(schema, safe="") + seg = urllib.parse.quote(segment, safe="") + c = urllib.parse.quote(code, safe="") + self._request("DELETE", f"/schemas/{s}/segments/{seg}/values/{c}", raw=True) + # -- 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" - ) + 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]: + 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} - ) + return self._request("POST", f"/items/{pn}/projects", {"projects": project_codes}) # -- Part number generation --------------------------------------------- @@ -898,9 +697,7 @@ class SiloClient: 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]: + def upload_sheet_diff(self, ods_bytes: bytes, filename: str = "sheet.ods") -> Dict[str, Any]: return self._upload_ods("/sheets/diff", ods_bytes, filename) # -- DAG (feature tree) ------------------------------------------------- @@ -928,9 +725,7 @@ class SiloClient: }, ) - def get_dag( - self, part_number: str, revision_number: Optional[int] = None - ) -> Dict[str, Any]: + def get_dag(self, part_number: str, revision_number: Optional[int] = None) -> Dict[str, Any]: """Fetch the stored DAG for an item. Calls ``GET /api/items/{partNumber}/dag``, optionally filtered @@ -988,9 +783,7 @@ class SiloClient: def cancel_job(self, job_id: str) -> Dict[str, Any]: """Cancel a pending or running job.""" - return self._request( - "POST", f"/jobs/{urllib.parse.quote(job_id, safe='')}/cancel" - ) + return self._request("POST", f"/jobs/{urllib.parse.quote(job_id, safe='')}/cancel") # -- Job Definitions ---------------------------------------------------- @@ -1000,9 +793,7 @@ class SiloClient: def get_job_definition(self, name: str) -> Dict[str, Any]: """Get a specific job definition by name.""" - return self._request( - "GET", f"/job-definitions/{urllib.parse.quote(name, safe='')}" - ) + return self._request("GET", f"/job-definitions/{urllib.parse.quote(name, safe='')}") def reload_job_definitions(self) -> Dict[str, Any]: """Re-read YAML job definitions from disk (admin only).""" @@ -1020,6 +811,4 @@ 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 - ) + self._request("DELETE", f"/runners/{urllib.parse.quote(runner_id, safe='')}", raw=True)