Compare commits
12 Commits
a6ac3d4d06
...
5e6f2cb963
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e6f2cb963 | |||
| 50a4f7725d | |||
| e8e5b68617 | |||
| a646906e6f | |||
| a24e59082f | |||
| 8c4fb4c433 | |||
| 5276ff26fa | |||
| 83faa993b2 | |||
| 0ef33ee464 | |||
|
|
9b71cf0375 | ||
|
|
fb658c5a24 | ||
|
|
68a4139251 |
92
.gitea/workflows/notify-dependents.yml
Normal file
92
.gitea/workflows/notify-dependents.yml
Normal 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"
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user