Compare commits
11 Commits
8c4fb4c433
...
5dfb567bac
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dfb567bac | |||
|
|
9d07de1bca | ||
| 285bd1fa11 | |||
|
|
f602eee7cc | ||
| c5c8288eeb | |||
|
|
256fced4f0 | ||
| 5e6f2cb963 | |||
| 50a4f7725d | |||
| e8e5b68617 | |||
| a646906e6f | |||
| a24e59082f |
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"
|
||||
@@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ._ssl import build_ssl_context
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -53,7 +53,9 @@ 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
|
||||
|
||||
@@ -171,7 +173,7 @@ class SiloClient:
|
||||
dest_path: str,
|
||||
progress_callback=None,
|
||||
) -> bool:
|
||||
"""Download a file from MinIO storage.
|
||||
"""Download a revision file from the server.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional ``callable(bytes_downloaded, total_bytes)``.
|
||||
@@ -258,7 +260,9 @@ 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 = []
|
||||
@@ -289,9 +293,44 @@ class SiloClient:
|
||||
except urllib.error.URLError as e:
|
||||
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 -----------------------------------------------------
|
||||
|
||||
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)
|
||||
@@ -311,7 +350,9 @@ 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,
|
||||
@@ -390,7 +431,9 @@ 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:
|
||||
@@ -418,7 +461,9 @@ 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})"
|
||||
@@ -430,7 +475,9 @@ 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,
|
||||
@@ -483,6 +530,64 @@ class SiloClient:
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
def get_revisions(self, part_number: str) -> list:
|
||||
@@ -497,7 +602,9 @@ 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",
|
||||
@@ -528,8 +635,23 @@ class SiloClient:
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
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]]:
|
||||
"""Check if item has files in storage."""
|
||||
"""Check if any revision of this item has an associated file."""
|
||||
try:
|
||||
revisions = self.get_revisions(part_number)
|
||||
for rev in revisions:
|
||||
@@ -550,6 +672,39 @@ class SiloClient:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# -- .kc Metadata -------------------------------------------------------
|
||||
|
||||
def get_metadata(self, part_number: str) -> Dict[str, Any]:
|
||||
"""Get indexed .kc metadata for an item."""
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
return self._request("GET", f"/items/{pn}/metadata")
|
||||
|
||||
def update_metadata(
|
||||
self, part_number: str, fields: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Update .kc metadata fields."""
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
return self._request("PUT", f"/items/{pn}/metadata", fields)
|
||||
|
||||
def patch_lifecycle(self, part_number: str, state: str) -> Dict[str, Any]:
|
||||
"""Transition lifecycle state."""
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
return self._request(
|
||||
"PATCH", f"/items/{pn}/metadata/lifecycle", {"state": state}
|
||||
)
|
||||
|
||||
def patch_tags(self, part_number: str, tags: list) -> Dict[str, Any]:
|
||||
"""Add/remove tags on .kc metadata."""
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
return self._request("PATCH", f"/items/{pn}/metadata/tags", {"tags": tags})
|
||||
|
||||
# -- .kc Dependencies ---------------------------------------------------
|
||||
|
||||
def resolve_dependencies(self, part_number: str) -> list:
|
||||
"""Resolve dependency UUIDs to part numbers and file availability."""
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
return self._request("GET", f"/items/{pn}/dependencies/resolve")
|
||||
|
||||
# -- BOM / Relationships ------------------------------------------------
|
||||
|
||||
def get_bom(self, part_number: str) -> list:
|
||||
@@ -619,6 +774,53 @@ class SiloClient:
|
||||
cpn = urllib.parse.quote(child_pn, safe="")
|
||||
self._request("DELETE", f"/items/{ppn}/bom/{cpn}", raw=True)
|
||||
|
||||
def get_bom_flat(self, part_number: str) -> list:
|
||||
"""Get a flattened BOM for an item."""
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
return self._request("GET", f"/items/{pn}/bom/flat")
|
||||
|
||||
def get_bom_cost(self, part_number: str) -> Dict[str, Any]:
|
||||
"""Get cost rollup for an item's BOM."""
|
||||
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)
|
||||
|
||||
def merge_bom_json(self, part_number: str, entries: list) -> Dict[str, Any]:
|
||||
"""Submit a JSON BOM merge from assembly extraction.
|
||||
|
||||
Calls ``POST /api/items/{partNumber}/bom/merge`` with a JSON body
|
||||
containing assembly-derived BOM entries. The server applies adds
|
||||
and quantity changes, flags removed items as unreferenced, and
|
||||
returns the diff.
|
||||
|
||||
Not used by Phase 1 (which calls add/update individually).
|
||||
Ready for Phase 2 when the server endpoint ships.
|
||||
"""
|
||||
pn = urllib.parse.quote(part_number, safe="")
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/items/{pn}/bom/merge",
|
||||
{"source": "assembly", "entries": entries},
|
||||
)
|
||||
|
||||
# -- Schemas ------------------------------------------------------------
|
||||
|
||||
def list_schemas(self) -> list:
|
||||
@@ -658,7 +860,9 @@ class SiloClient:
|
||||
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]:
|
||||
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="")
|
||||
@@ -677,16 +881,55 @@ class SiloClient:
|
||||
def get_projects(self) -> list:
|
||||
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:
|
||||
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}
|
||||
)
|
||||
|
||||
def update_project(self, code: str, **fields) -> Dict[str, Any]:
|
||||
"""Update a project's fields."""
|
||||
return self._request(
|
||||
"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 ---------------------------------------------
|
||||
|
||||
@@ -707,9 +950,104 @@ 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)
|
||||
|
||||
# -- 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(
|
||||
@@ -735,7 +1073,9 @@ 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
|
||||
@@ -747,6 +1087,23 @@ class SiloClient:
|
||||
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(
|
||||
@@ -793,7 +1150,9 @@ 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 ----------------------------------------------------
|
||||
|
||||
@@ -803,7 +1162,9 @@ 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)."""
|
||||
@@ -821,4 +1182,77 @@ 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
|
||||
)
|
||||
|
||||
# -- 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