Compare commits

12 Commits

Author SHA1 Message Date
5e6f2cb963 Merge pull request 'ci: add workflow to auto-update downstream submodule repos' (#17) from ci/submodule-notify into main
All checks were successful
Update Downstream Submodules / update-dependents (kindred/silo-calc, silo-client) (push) Successful in 9s
Update Downstream Submodules / update-dependents (kindred/silo-mod, silo-client) (push) Successful in 8s
Reviewed-on: #17
2026-02-17 14:47:45 +00:00
50a4f7725d ci: add workflow to auto-update downstream submodule repos
Opens PRs in kindred/silo-mod and kindred/silo-calc when main is
pushed, updating their silo-client submodule pointer to the new SHA.
Includes duplicate PR detection and no-op skip when already current.
2026-02-17 08:43:41 -06:00
e8e5b68617 Merge pull request 'feat: complete API coverage — add 37 missing endpoint methods' (#16) from feat/complete-api-coverage into main
Reviewed-on: #16
2026-02-17 13:15:17 +00:00
a646906e6f feat: add 37 missing API endpoint methods (issues #5-#11)
- Add _upload_csv helper for CSV multipart uploads
- Issue #5: search_items, get_item_by_uuid, delete_item, export/import CSV/ODS
- Issue #6: get_bom_flat, get_bom_cost, export/import BOM CSV, merge_bom
- Issue #7: get_project, create/update/delete_project, remove_item_project
- Issue #8: create_revision, presign_upload, list/add/delete attachments, set_thumbnail
- Issue #9: get_audit_completeness, get_audit_item_completeness
- Issue #10: get_dag_forward_cone, get_dag_dirty, mark_dag_dirty
- Issue #11: runner_heartbeat, runner_claim_job, runner_update_progress,
  runner_complete_job, runner_fail_job, runner_append_log, runner_push_dag
- Bump version 0.2.0 -> 0.3.0
2026-02-16 14:26:51 -06:00
a24e59082f Merge pull request 'feat(settings): add get_schema_name() to SiloSettings, remove hardcoded defaults' (#15) from feat/configurable-schema-name into main
Reviewed-on: #15
2026-02-16 19:20:19 +00:00
8c4fb4c433 feat(settings): add get_schema_name() to SiloSettings, remove hardcoded defaults
Add get_schema_name() to SiloSettings base class with default 'kindred-rd'.
Update get_schema(), get_schema_form(), and get_property_schema() to fall
back to settings instead of hardcoding the schema name. Add category
query parameter support to get_property_schema().

Closes #28
2026-02-16 13:15:41 -06:00
5276ff26fa Merge pull request 'refactor: remove hardcoded CATEGORY_NAMES, add missing schema API methods' (#14) from refactor/remove-hardcoded-categories-add-schema-api into main
Reviewed-on: #14
2026-02-16 17:24:48 +00:00
83faa993b2 refactor: remove hardcoded CATEGORY_NAMES, add missing schema API methods
Issues #3 and #4.

Remove:
- CATEGORY_NAMES dict (170 Kindred-specific category codes)
- sanitize_filename(), parse_part_number(), get_category_folder_name()
- import re (only used by removed sanitize_filename)

These are consumer-side concerns (filesystem path construction) that
don't belong in a generic HTTP client. Categories should be fetched
dynamically from the server via the schema API.

Add schema methods:
- list_schemas() — GET /api/schemas
- get_schema_form(name) — GET /api/schemas/{name}/form
- add_enum_value(schema, segment, code, label) — POST enum value
- update_enum_value(schema, segment, code, **fields) — PUT enum value
- delete_enum_value(schema, segment, code) — DELETE enum value

Bump version 0.1.0 -> 0.2.0.
2026-02-16 11:23:37 -06:00
0ef33ee464 Merge pull request 'feat: add job, job-definition, and runner API methods' (#1) from feat/worker-api-methods into main
Reviewed-on: #1
2026-02-15 19:04:56 +00:00
Zoe Forbes
9b71cf0375 feat: add job, job-definition, and runner API methods 2026-02-15 05:07:15 -06:00
Zoe Forbes
fb658c5a24 feat: add push_dag and get_dag methods to SiloClient
- push_dag(part_number, revision_number, nodes, edges): PUT /api/items/{pn}/dag
- get_dag(part_number, revision_number=None): GET /api/items/{pn}/dag

Closes kindred/create#215
2026-02-14 15:06:22 -06:00
Zoe Forbes
68a4139251 fix: use _request() in delete_bom_entry() for consistent error handling (#59)
delete_bom_entry() used raw urllib.request instead of self._request(),
bypassing 401 auth clearing and standard error normalization. Replace
with a single _request('DELETE', ..., raw=True) call, matching the
pattern used by all other BOM methods.
2026-02-08 18:29:06 -06:00
2 changed files with 593 additions and 262 deletions

View File

@@ -0,0 +1,92 @@
name: Update Downstream Submodules
on:
push:
branches: [main]
jobs:
update-dependents:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- downstream: kindred/silo-mod
submodule_path: silo-client
- downstream: kindred/silo-calc
submodule_path: silo-client
steps:
- name: Update submodule in ${{ matrix.downstream }}
env:
GITEA_TOKEN: ${{ secrets.BOT_TOKEN }}
GITEA_URL: https://git.kindred-systems.com
DOWNSTREAM: ${{ matrix.downstream }}
SUBMODULE_PATH: ${{ matrix.submodule_path }}
UPSTREAM_SHA: ${{ github.sha }}
UPSTREAM_REPO: ${{ github.repository }}
run: |
set -euo pipefail
SUBMODULE_NAME=$(basename "$SUBMODULE_PATH")
BRANCH="auto/update-${SUBMODULE_NAME}-${UPSTREAM_SHA:0:8}"
API="$GITEA_URL/api/v1"
AUTH="Authorization: token $GITEA_TOKEN"
# Get default branch
DEFAULT_BRANCH=$(curl -sf -H "$AUTH" "$API/repos/$DOWNSTREAM" | jq -r .default_branch)
# Check for existing open PRs for this submodule to avoid duplicates
EXISTING=$(curl -sf -H "$AUTH" \
"$API/repos/$DOWNSTREAM/pulls?state=open&limit=50" \
| jq -r "[.[] | select(.head.label | startswith(\"auto/update-${SUBMODULE_NAME}-\"))] | length")
if [ "$EXISTING" -gt 0 ]; then
echo "Open PR already exists for $SUBMODULE_NAME update in $DOWNSTREAM — skipping"
exit 0
fi
# Create branch from default
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
"$API/repos/$DOWNSTREAM/branches" \
-d "{\"new_branch_name\": \"$BRANCH\", \"old_branch_name\": \"$DEFAULT_BRANCH\"}"
# Configure git auth
git config --global url."https://bot:${GITEA_TOKEN}@git.kindred-systems.com/".insteadOf "https://git.kindred-systems.com/"
git config --global user.name "kindred-bot"
git config --global user.email "bot@kindred-systems.com"
# Clone downstream, update submodule, push
git clone --depth 1 -b "$BRANCH" \
"$GITEA_URL/$DOWNSTREAM.git" downstream
cd downstream
git submodule update --init "$SUBMODULE_PATH"
cd "$SUBMODULE_PATH"
git fetch origin main
git checkout "$UPSTREAM_SHA"
cd - > /dev/null
git add "$SUBMODULE_PATH"
# Only proceed if there are actual changes
if git diff --cached --quiet; then
echo "Submodule already at $UPSTREAM_SHA — nothing to do"
exit 0
fi
git commit -m "chore(deps): update ${SUBMODULE_NAME} to ${UPSTREAM_SHA:0:8}
Upstream: $GITEA_URL/$UPSTREAM_REPO/commit/$UPSTREAM_SHA"
git push origin "$BRANCH"
# Create PR
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
"$API/repos/$DOWNSTREAM/pulls" \
-d "{
\"title\": \"chore(deps): update ${SUBMODULE_NAME} to ${UPSTREAM_SHA:0:8}\",
\"body\": \"Automated submodule update.\\n\\nUpstream commit: $GITEA_URL/$UPSTREAM_REPO/commit/$UPSTREAM_SHA\\nUpstream repo: $UPSTREAM_REPO\\nNew SHA: \`$UPSTREAM_SHA\`\",
\"head\": \"$BRANCH\",
\"base\": \"$DEFAULT_BRANCH\"
}"
echo "PR created in $DOWNSTREAM"

View File

@@ -12,7 +12,6 @@ to find credentials and how to persist authentication state.
import http.cookiejar import http.cookiejar
import json import json
import os import os
import re
import socket import socket
import ssl import ssl
import urllib.error import urllib.error
@@ -22,7 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
from ._ssl import build_ssl_context from ._ssl import build_ssl_context
__version__ = "0.1.0" __version__ = "0.3.0"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -54,9 +53,7 @@ class SiloSettings:
"""Return path to a custom CA certificate file, or ``""``.""" """Return path to a custom CA certificate file, or ``""``."""
raise NotImplementedError raise NotImplementedError
def save_auth( def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
self, username: str, role: str = "", source: str = "", token: str = ""
):
"""Persist authentication info after a successful login.""" """Persist authentication info after a successful login."""
raise NotImplementedError raise NotImplementedError
@@ -64,219 +61,9 @@ class SiloSettings:
"""Remove all stored authentication credentials.""" """Remove all stored authentication credentials."""
raise NotImplementedError raise NotImplementedError
def get_schema_name(self) -> str:
# --------------------------------------------------------------------------- """Return the active part-numbering schema name (default ``"kindred-rd"``)."""
# Shared utilities return "kindred-rd"
# ---------------------------------------------------------------------------
# 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}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -471,9 +258,7 @@ class SiloClient:
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise RuntimeError(f"Connection error: {e.reason}") raise RuntimeError(f"Connection error: {e.reason}")
def _upload_ods( def _upload_ods(self, path: str, ods_bytes: bytes, filename: str = "upload.ods") -> Any:
self, path: str, ods_bytes: bytes, filename: str = "upload.ods"
) -> Any:
"""POST an ODS file as multipart/form-data.""" """POST an ODS file as multipart/form-data."""
boundary = "----SiloCalcUpload" + str(abs(hash(filename)))[-8:] boundary = "----SiloCalcUpload" + str(abs(hash(filename)))[-8:]
parts: list = [] parts: list = []
@@ -504,11 +289,40 @@ class SiloClient:
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise RuntimeError(f"Connection error: {e.reason}") 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 ----------------------------------------------------- # -- Authentication -----------------------------------------------------
def login( def login(self, username: str, password: str, token_name: str = "") -> Dict[str, Any]:
self, username: str, password: str, token_name: str = ""
) -> Dict[str, Any]:
"""Session login and create a persistent API token. """Session login and create a persistent API token.
Performs: ``POST /login`` (session) -> ``GET /api/auth/me`` (verify) Performs: ``POST /login`` (session) -> ``GET /api/auth/me`` (verify)
@@ -528,9 +342,7 @@ class SiloClient:
# Step 1: POST credentials to /login # Step 1: POST credentials to /login
login_url = f"{self._origin}/login" login_url = f"{self._origin}/login"
form_data = urllib.parse.urlencode( form_data = urllib.parse.urlencode({"username": username, "password": password}).encode()
{"username": username, "password": password}
).encode()
req = urllib.request.Request( req = urllib.request.Request(
login_url, login_url,
data=form_data, data=form_data,
@@ -609,9 +421,7 @@ class SiloClient:
"""List API tokens for the current user.""" """List API tokens for the current user."""
return self._request("GET", "/auth/tokens") return self._request("GET", "/auth/tokens")
def create_token( def create_token(self, name: str, expires_in_days: Optional[int] = None) -> Dict[str, Any]:
self, name: str, expires_in_days: Optional[int] = None
) -> Dict[str, Any]:
"""Create a new API token.""" """Create a new API token."""
data: Dict[str, Any] = {"name": name} data: Dict[str, Any] = {"name": name}
if expires_in_days is not None: if expires_in_days is not None:
@@ -639,9 +449,7 @@ class SiloClient:
url = f"{self._origin}/health" url = f"{self._origin}/health"
req = urllib.request.Request(url, method="GET") req = urllib.request.Request(url, method="GET")
try: try:
with urllib.request.urlopen( with urllib.request.urlopen(req, context=self._ssl_context(), timeout=5) as resp:
req, context=self._ssl_context(), timeout=5
) as resp:
return True, f"OK ({resp.status})" return True, f"OK ({resp.status})"
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return True, f"Server error ({e.code})" return True, f"Server error ({e.code})"
@@ -653,9 +461,7 @@ class SiloClient:
# -- Items -------------------------------------------------------------- # -- Items --------------------------------------------------------------
def get_item(self, part_number: str) -> Dict[str, Any]: def get_item(self, part_number: str) -> Dict[str, Any]:
return self._request( return self._request("GET", f"/items/{urllib.parse.quote(part_number, safe='')}")
"GET", f"/items/{urllib.parse.quote(part_number, safe='')}"
)
def list_items( def list_items(
self, self,
@@ -708,6 +514,60 @@ class SiloClient:
fields, 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 ---------------------------------------------------------- # -- Revisions ----------------------------------------------------------
def get_revisions(self, part_number: str) -> list: def get_revisions(self, part_number: str) -> list:
@@ -722,9 +582,7 @@ class SiloClient:
f"/items/{urllib.parse.quote(part_number, safe='')}/revisions/{revision}", f"/items/{urllib.parse.quote(part_number, safe='')}/revisions/{revision}",
) )
def compare_revisions( def compare_revisions(self, part_number: str, from_rev: int, to_rev: int) -> Dict[str, Any]:
self, part_number: str, from_rev: int, to_rev: int
) -> Dict[str, Any]:
pn = urllib.parse.quote(part_number, safe="") pn = urllib.parse.quote(part_number, safe="")
return self._request( return self._request(
"GET", "GET",
@@ -755,6 +613,21 @@ class SiloClient:
pn = urllib.parse.quote(part_number, safe="") pn = urllib.parse.quote(part_number, safe="")
return self._request("PATCH", f"/items/{pn}/revisions/{revision}", data) 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]]: def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]:
"""Check if item has files in storage.""" """Check if item has files in storage."""
try: try:
@@ -844,50 +717,131 @@ class SiloClient:
def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None: def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None:
ppn = urllib.parse.quote(parent_pn, safe="") ppn = urllib.parse.quote(parent_pn, safe="")
cpn = urllib.parse.quote(child_pn, safe="") cpn = urllib.parse.quote(child_pn, safe="")
url = f"{self.base_url}/items/{ppn}/bom/{cpn}" self._request("DELETE", f"/items/{ppn}/bom/{cpn}", raw=True)
headers = {"Content-Type": "application/json"}
headers.update(self._auth_headers()) def get_bom_flat(self, part_number: str) -> list:
req = urllib.request.Request(url, headers=headers, method="DELETE") """Get a flattened BOM for an item."""
try: pn = urllib.parse.quote(part_number, safe="")
urllib.request.urlopen(req, context=self._ssl_context()) return self._request("GET", f"/items/{pn}/bom/flat")
except urllib.error.HTTPError as e:
raise RuntimeError(f"API error {e.code}: {e.read().decode()}") def get_bom_cost(self, part_number: str) -> Dict[str, Any]:
except urllib.error.URLError as e: """Get cost rollup for an item's BOM."""
raise RuntimeError(f"Connection error: {e.reason}") 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 ------------------------------------------------------------ # -- Schemas ------------------------------------------------------------
def get_schema(self, name: str = "kindred-rd") -> Dict[str, Any]: def list_schemas(self) -> list:
"""List all available schemas."""
return self._request("GET", "/schemas")
def get_schema(self, name: str = "") -> Dict[str, Any]:
if not name:
name = self._settings.get_schema_name()
return self._request("GET", f"/schemas/{urllib.parse.quote(name, safe='')}") return self._request("GET", f"/schemas/{urllib.parse.quote(name, safe='')}")
def get_property_schema(self, name: str = "kindred-rd") -> Dict[str, Any]: def get_schema_form(self, name: str = "") -> Dict[str, Any]:
"""Get form descriptor for a schema (field groups, widgets, category picker)."""
if not name:
name = self._settings.get_schema_name()
return self._request( return self._request(
"GET", "GET",
f"/schemas/{urllib.parse.quote(name, safe='')}/properties", f"/schemas/{urllib.parse.quote(name, safe='')}/form",
) )
def get_property_schema(self, name: str = "", category: str = "") -> Dict[str, Any]:
if not name:
name = self._settings.get_schema_name()
path = f"/schemas/{urllib.parse.quote(name, safe='')}/properties"
if category:
path += f"?category={urllib.parse.quote(category)}"
return self._request("GET", path)
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 ----------------------------------------------------------- # -- Projects -----------------------------------------------------------
def get_projects(self) -> list: def get_projects(self) -> list:
return self._request("GET", "/projects") 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: def get_project_items(self, code: str) -> list:
return self._request( return self._request("GET", f"/projects/{urllib.parse.quote(code, safe='')}/items")
"GET", f"/projects/{urllib.parse.quote(code, safe='')}/items"
)
def get_item_projects(self, part_number: str) -> list: def get_item_projects(self, part_number: str) -> list:
pn = urllib.parse.quote(part_number, safe="") pn = urllib.parse.quote(part_number, safe="")
return self._request("GET", f"/items/{pn}/projects") return self._request("GET", f"/items/{pn}/projects")
def add_item_projects( def add_item_projects(self, part_number: str, project_codes: List[str]) -> Dict[str, Any]:
self, part_number: str, project_codes: List[str]
) -> Dict[str, Any]:
pn = urllib.parse.quote(part_number, safe="") 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( return self._request(
"POST", f"/items/{pn}/projects", {"projects": project_codes} "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 --------------------------------------------- # -- Part number generation ---------------------------------------------
def generate_part_number(self, schema: str, category: str) -> Dict[str, Any]: def generate_part_number(self, schema: str, category: str) -> Dict[str, Any]:
@@ -907,7 +861,292 @@ class SiloClient:
code = urllib.parse.quote(project_code, safe="") code = urllib.parse.quote(project_code, safe="")
return self._download(f"/projects/{code}/sheet.ods") return self._download(f"/projects/{code}/sheet.ods")
def upload_sheet_diff( def upload_sheet_diff(self, ods_bytes: bytes, filename: str = "sheet.ods") -> Dict[str, Any]:
self, ods_bytes: bytes, filename: str = "sheet.ods"
) -> Dict[str, Any]:
return self._upload_ods("/sheets/diff", ods_bytes, filename) 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(
self,
part_number: str,
revision_number: int,
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Push a feature DAG to the server.
Calls ``PUT /api/items/{partNumber}/dag`` with the payload
described in ``MULTI_USER_CLIENT.md`` Section 2.
"""
pn = urllib.parse.quote(part_number, safe="")
return self._request(
"PUT",
f"/items/{pn}/dag",
{
"revision_number": revision_number,
"nodes": nodes,
"edges": edges,
},
)
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
by *revision_number*.
"""
pn = urllib.parse.quote(part_number, safe="")
path = f"/items/{pn}/dag"
if revision_number is not None:
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(
self,
status: str = "",
item_id: str = "",
definition: str = "",
limit: int = 100,
) -> list:
"""List jobs, optionally filtered by status, item, or definition."""
params = [f"limit={limit}"]
if status:
params.append(f"status={urllib.parse.quote(status)}")
if item_id:
params.append(f"item_id={urllib.parse.quote(item_id)}")
if definition:
params.append(f"definition={urllib.parse.quote(definition)}")
return self._request("GET", "/jobs?" + "&".join(params))
def get_job(self, job_id: str) -> Dict[str, Any]:
"""Get details for a specific job."""
return self._request("GET", f"/jobs/{urllib.parse.quote(job_id, safe='')}")
def get_job_logs(self, job_id: str) -> list:
"""Get log entries for a job."""
return self._request("GET", f"/jobs/{urllib.parse.quote(job_id, safe='')}/logs")
def trigger_job(
self,
definition_name: str,
part_number: str = "",
item_id: str = "",
) -> Dict[str, Any]:
"""Manually trigger a job.
Either *part_number* or *item_id* should identify the target item.
"""
data: Dict[str, Any] = {"definition_name": definition_name}
if part_number:
data["part_number"] = part_number
if item_id:
data["item_id"] = item_id
return self._request("POST", "/jobs", data)
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")
# -- Job Definitions ----------------------------------------------------
def list_job_definitions(self) -> list:
"""List all loaded job definitions."""
return self._request("GET", "/job-definitions")
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='')}")
def reload_job_definitions(self) -> Dict[str, Any]:
"""Re-read YAML job definitions from disk (admin only)."""
return self._request("POST", "/job-definitions/reload")
# -- Runners (admin) ----------------------------------------------------
def list_runners(self) -> list:
"""List all registered runners."""
return self._request("GET", "/runners")
def register_runner(self, name: str, tags: List[str]) -> Dict[str, Any]:
"""Register a new runner. Returns the token (shown once)."""
return self._request("POST", "/runners", {"name": name, "tags": tags})
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,
},
)