refactor: remove hardcoded CATEGORY_NAMES, add missing schema API methods #14

Merged
forbes merged 1 commits from refactor/remove-hardcoded-categories-add-schema-api into main 2026-02-16 17:24:49 +00:00

View File

@@ -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)