refactor: remove hardcoded CATEGORY_NAMES, add missing schema API methods #14
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user