feat(schema): make schema name configurable, update silo-client submodule

Replace all hardcoded 'kindred-rd' schema references with the new
configurable get_schema_name() setting. Priority: FreeCAD preference
SchemaName > SILO_SCHEMA env var > default 'kindred-rd'.

- Add get_schema_name() to FreeCADSiloSettings and _get_schema_name() helper
- Add Schema Name field to Settings dialog
- Replace raw urllib calls in schema_form.py with SiloClient methods
  (get_property_schema, generate_part_number)
- Inline parse_part_number/sanitize_filename (removed from silo-client)
- Simplify category folder naming to use category code directly
- Update silo-client submodule to origin/main + configurable schema branch

Closes #28
This commit is contained in:
2026-02-16 13:17:50 -06:00
parent 3fe43710fa
commit 82d8741059
3 changed files with 141 additions and 226 deletions

View File

@@ -10,9 +10,6 @@ backward-compatible :class:`SchemaFormDialog` modal.
""" """
import json import json
import urllib.error
import urllib.parse
import urllib.request
import FreeCAD import FreeCAD
from PySide import QtCore, QtGui, QtWidgets from PySide import QtCore, QtGui, QtWidgets
@@ -267,17 +264,8 @@ class SchemaFormWidget(QtWidgets.QWidget):
def _fetch_properties(self, category: str) -> dict: def _fetch_properties(self, category: str) -> dict:
"""Fetch merged property definitions for a category.""" """Fetch merged property definitions for a category."""
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context
api_url = _get_api_url().rstrip("/")
url = f"{api_url}/schemas/kindred-rd/properties?category={urllib.parse.quote(category)}"
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
for k, v in _get_auth_headers().items():
req.add_header(k, v)
try: try:
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) data = self._client.get_property_schema(category=category)
data = json.loads(resp.read().decode("utf-8"))
return data.get("properties", data) return data.get("properties", data)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
@@ -287,19 +275,10 @@ class SchemaFormWidget(QtWidgets.QWidget):
def _generate_pn_preview(self, category: str) -> str: def _generate_pn_preview(self, category: str) -> str:
"""Call the server to preview the next part number.""" """Call the server to preview the next part number."""
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context from silo_commands import _get_schema_name
api_url = _get_api_url().rstrip("/")
url = f"{api_url}/generate-part-number"
payload = json.dumps({"schema": "kindred-rd", "category": category}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
for k, v in _get_auth_headers().items():
req.add_header(k, v)
try: try:
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) data = self._client.generate_part_number(_get_schema_name(), category)
data = json.loads(resp.read().decode("utf-8"))
return data.get("part_number", "") return data.get("part_number", "")
except Exception: except Exception:
return "" return ""
@@ -574,8 +553,10 @@ class SchemaFormWidget(QtWidgets.QWidget):
return return
try: try:
from silo_commands import _get_schema_name
result = self._client.create_item( result = self._client.create_item(
"kindred-rd", _get_schema_name(),
data["category"], data["category"],
data["description"], data["description"],
projects=data["projects"], projects=data["projects"],

View File

@@ -14,22 +14,34 @@ from typing import Any, Dict, List, Optional, Tuple
import FreeCAD import FreeCAD
import FreeCADGui import FreeCADGui
from PySide import QtCore from PySide import QtCore
from silo_client import ( from silo_client import SiloClient, SiloSettings
CATEGORY_NAMES,
SiloClient,
SiloSettings,
get_category_folder_name,
parse_part_number,
sanitize_filename,
)
# Preference group for Kindred Silo settings # Preference group for Kindred Silo settings
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo" _PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
# Configuration - preferences take priority over env vars # Configuration - preferences take priority over env vars
SILO_PROJECTS_DIR = os.environ.get( SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
)
# ---------------------------------------------------------------------------
# Local utility helpers (previously in silo_client, now server-driven)
# ---------------------------------------------------------------------------
def _parse_part_number(part_number: str) -> Tuple[str, str]:
"""Parse part number into ``(category, sequence)``. E.g. ``"F01-0001"`` -> ``("F01", "0001")``."""
parts = part_number.split("-")
if len(parts) >= 2:
return parts[0], parts[1]
return part_number, ""
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 _relative_time(dt): def _relative_time(dt):
@@ -86,9 +98,7 @@ class FreeCADSiloSettings(SiloSettings):
param = FreeCAD.ParamGet(_PREF_GROUP) param = FreeCAD.ParamGet(_PREF_GROUP)
return param.GetString("SslCertPath", "") return param.GetString("SslCertPath", "")
def save_auth( def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
self, username: str, role: str = "", source: str = "", token: str = ""
):
param = FreeCAD.ParamGet(_PREF_GROUP) param = FreeCAD.ParamGet(_PREF_GROUP)
param.SetString("AuthUsername", username) param.SetString("AuthUsername", username)
param.SetString("AuthRole", role) param.SetString("AuthRole", role)
@@ -96,6 +106,13 @@ class FreeCADSiloSettings(SiloSettings):
if token: if token:
param.SetString("ApiToken", token) param.SetString("ApiToken", token)
def get_schema_name(self) -> str:
param = FreeCAD.ParamGet(_PREF_GROUP)
name = param.GetString("SchemaName", "")
if not name:
name = os.environ.get("SILO_SCHEMA", "kindred-rd")
return name
def clear_auth(self): def clear_auth(self):
param = FreeCAD.ParamGet(_PREF_GROUP) param = FreeCAD.ParamGet(_PREF_GROUP)
param.SetString("ApiToken", "") param.SetString("ApiToken", "")
@@ -139,6 +156,10 @@ def _get_api_url() -> str:
return _fc_settings.get_api_url() return _fc_settings.get_api_url()
def _get_schema_name() -> str:
return _fc_settings.get_schema_name()
def _get_ssl_verify() -> bool: def _get_ssl_verify() -> bool:
return _fc_settings.get_ssl_verify() return _fc_settings.get_ssl_verify()
@@ -146,9 +167,7 @@ def _get_ssl_verify() -> bool:
def _get_ssl_context(): def _get_ssl_context():
from silo_client._ssl import build_ssl_context from silo_client._ssl import build_ssl_context
return build_ssl_context( return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path())
_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()
)
def _get_auth_headers() -> Dict[str, str]: def _get_auth_headers() -> Dict[str, str]:
@@ -205,9 +224,7 @@ def _fetch_server_mode() -> str:
# Icon helper # Icon helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ICON_DIR = os.path.join( _ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
)
def _icon(name): def _icon(name):
@@ -234,24 +251,22 @@ def get_projects_dir() -> Path:
def get_cad_file_path(part_number: str, description: str = "") -> Path: def get_cad_file_path(part_number: str, description: str = "") -> Path:
"""Generate canonical file path for a CAD file. """Generate canonical file path for a CAD file.
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.kc Path format: ~/projects/cad/{category_code}/{part_number}_{description}.kc
""" """
category, _ = parse_part_number(part_number) category, _ = _parse_part_number(part_number)
folder_name = get_category_folder_name(category)
if description: if description:
filename = f"{part_number}_{sanitize_filename(description)}.kc" filename = f"{part_number}_{_sanitize_filename(description)}.kc"
else: else:
filename = f"{part_number}.kc" filename = f"{part_number}.kc"
return get_projects_dir() / "cad" / folder_name / filename return get_projects_dir() / "cad" / category / filename
def find_file_by_part_number(part_number: str) -> Optional[Path]: def find_file_by_part_number(part_number: str) -> Optional[Path]:
"""Find existing CAD file for a part number. Prefers .kc over .FCStd.""" """Find existing CAD file for a part number. Prefers .kc over .FCStd."""
category, _ = parse_part_number(part_number) category, _ = _parse_part_number(part_number)
folder_name = get_category_folder_name(category) cad_dir = get_projects_dir() / "cad" / category
cad_dir = get_projects_dir() / "cad" / folder_name
for search_dir in _search_dirs(cad_dir): for search_dir in _search_dirs(cad_dir):
for ext in ("*.kc", "*.FCStd"): for ext in ("*.kc", "*.FCStd"):
@@ -516,7 +531,7 @@ class SiloSync:
) )
# Add a Body for parts (not assemblies) # Add a Body for parts (not assemblies)
body_label = sanitize_filename(description) if description else "Body" body_label = _sanitize_filename(description) if description else "Body"
body = doc.addObject("PartDesign::Body", "_" + body_label) body = doc.addObject("PartDesign::Body", "_" + body_label)
body.Label = body_label body.Label = body_label
part_obj.addObject(body) part_obj.addObject(body)
@@ -608,9 +623,7 @@ def handle_kindred_url(url: str):
parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p] parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p]
if len(parts) >= 2 and parts[0] == "item": if len(parts) >= 2 and parts[0] == "item":
part_number = parts[1] part_number = parts[1]
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n")
f"Silo: Opening item {part_number} from kindred:// URL\n"
)
_sync.open_item(part_number) _sync.open_item(part_number)
@@ -717,9 +730,7 @@ class Silo_New:
}, },
) )
obj.Label = part_number obj.Label = part_number
_sync.save_to_canonical_path( _sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True)
FreeCAD.ActiveDocument, force_rename=True
)
else: else:
_sync.create_document_for_item(result, save=True) _sync.create_document_for_item(result, save=True)
@@ -752,9 +763,7 @@ def _push_dag_after_upload(doc, part_number, revision_number):
result = _client.push_dag(part_number, revision_number, nodes, edges) result = _client.push_dag(part_number, revision_number, nodes, edges)
node_count = result.get("node_count", len(nodes)) node_count = result.get("node_count", len(nodes))
edge_count = result.get("edge_count", len(edges)) edge_count = result.get("edge_count", len(edges))
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"DAG synced: {node_count} nodes, {edge_count} edges\n")
f"DAG synced: {node_count} nodes, {edge_count} edges\n"
)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n") FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
@@ -822,9 +831,7 @@ class Silo_Save:
# Try to upload to MinIO # Try to upload to MinIO
try: try:
result = _client._upload_file( result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
part_number, str(file_path), properties, "Auto-save"
)
new_rev = result["revision_number"] new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n") FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
@@ -859,9 +866,7 @@ class Silo_Commit:
obj = get_tracked_object(doc) obj = get_tracked_object(doc)
if not obj: if not obj:
FreeCAD.Console.PrintError( FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
"No tracked object. Use 'New' to register first.\n"
)
return return
part_number = obj.SiloPartNumber part_number = obj.SiloPartNumber
@@ -878,9 +883,7 @@ class Silo_Commit:
if not file_path: if not file_path:
return return
result = _client._upload_file( result = _client._upload_file(part_number, str(file_path), properties, comment)
part_number, str(file_path), properties, comment
)
new_rev = result["revision_number"] new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n") FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
@@ -931,9 +934,7 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
server_updated = item.get("updated_at", "") server_updated = item.get("updated_at", "")
if server_updated: if server_updated:
# Parse ISO format timestamp # Parse ISO format timestamp
server_dt = datetime.datetime.fromisoformat( server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
server_updated.replace("Z", "+00:00")
)
if server_dt > local_mtime: if server_dt > local_mtime:
conflicts.append("Server version is newer than local file.") conflicts.append("Server version is newer than local file.")
except Exception: except Exception:
@@ -963,9 +964,7 @@ class SiloPullDialog:
# Revision table # Revision table
self._table = QtGui.QTableWidget() self._table = QtGui.QTableWidget()
self._table.setColumnCount(5) self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels( self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
["Rev", "Date", "Comment", "Status", "File"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -1058,9 +1057,7 @@ def _pull_dependencies(part_number, progress_callback=None):
# Skip if already exists locally # Skip if already exists locally
existing = find_file_by_part_number(child_pn) existing = find_file_by_part_number(child_pn)
if existing and existing.exists(): if existing and existing.exists():
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f" {child_pn}: already exists at {existing}\n")
f" {child_pn}: already exists at {existing}\n"
)
# Still recurse — this child may itself be an assembly with missing deps # Still recurse — this child may itself be an assembly with missing deps
_pull_dependencies(child_pn, progress_callback) _pull_dependencies(child_pn, progress_callback)
continue continue
@@ -1140,18 +1137,14 @@ class Silo_Pull:
if not has_any_file: if not has_any_file:
if existing_local: if existing_local:
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
f"Opening existing local file: {existing_local}\n"
)
FreeCAD.openDocument(str(existing_local)) FreeCAD.openDocument(str(existing_local))
else: else:
try: try:
item = _client.get_item(part_number) item = _client.get_item(part_number)
new_doc = _sync.create_document_for_item(item, save=True) new_doc = _sync.create_document_for_item(item, save=True)
if new_doc: if new_doc:
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
f"Created local file for {part_number}\n"
)
else: else:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(
None, None,
@@ -1238,9 +1231,7 @@ class Silo_Pull:
progress.setValue(100) progress.setValue(100)
progress.close() progress.close()
if dep_pulled: if dep_pulled:
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Pulled {len(dep_pulled)} dependency file(s)\n")
f"Pulled {len(dep_pulled)} dependency file(s)\n"
)
# Close existing document if open, then reopen # Close existing document if open, then reopen
if doc and doc.FileName == str(dest_path): if doc and doc.FileName == str(dest_path):
@@ -1295,9 +1286,7 @@ class Silo_Push:
server_dt = datetime.fromisoformat( server_dt = datetime.fromisoformat(
server_time_str.replace("Z", "+00:00") server_time_str.replace("Z", "+00:00")
) )
local_dt = datetime.fromtimestamp( local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
local_mtime, tz=timezone.utc
)
if local_dt > server_dt: if local_dt > server_dt:
unuploaded.append(lf) unuploaded.append(lf)
else: else:
@@ -1310,9 +1299,7 @@ class Silo_Push:
pass # Not in DB, skip pass # Not in DB, skip
if not unuploaded: if not unuploaded:
QtGui.QMessageBox.information( QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
None, "Push", "All local files are already uploaded."
)
return return
msg = f"Found {len(unuploaded)} files to upload:\n\n" msg = f"Found {len(unuploaded)} files to upload:\n\n"
@@ -1330,9 +1317,7 @@ class Silo_Push:
uploaded = 0 uploaded = 0
for item in unuploaded: for item in unuploaded:
result = _sync.upload_file( result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
item["part_number"], item["path"], "Synced from local"
)
if result: if result:
uploaded += 1 uploaded += 1
@@ -1381,7 +1366,9 @@ class Silo_Info:
msg = f"<h3>{part_number}</h3>" msg = f"<h3>{part_number}</h3>"
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>" msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>" msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>" msg += (
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
)
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>" msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>" msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
@@ -1447,9 +1434,7 @@ class Silo_TagProjects:
try: try:
# Get current projects for item # Get current projects for item
current_projects = _client.get_item_projects(part_number) current_projects = _client.get_item_projects(part_number)
current_codes = { current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
p.get("code", "") for p in current_projects if p.get("code")
}
# Get all available projects # Get all available projects
all_projects = _client.get_projects() all_projects = _client.get_projects()
@@ -1560,9 +1545,7 @@ class Silo_Rollback:
dialog.setMinimumHeight(300) dialog.setMinimumHeight(300)
layout = QtGui.QVBoxLayout(dialog) layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel( label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
f"Select a revision to rollback to (current: Rev {current_rev}):"
)
layout.addWidget(label) layout.addWidget(label)
# Revision table # Revision table
@@ -1577,12 +1560,8 @@ class Silo_Rollback:
for i, rev in enumerate(prev_revisions): for i, rev in enumerate(prev_revisions):
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"]))) table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft"))) table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
table.setItem( table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]) table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
)
table.setItem(
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
)
table.resizeColumnsToContents() table.resizeColumnsToContents()
layout.addWidget(table) layout.addWidget(table)
@@ -1608,9 +1587,7 @@ class Silo_Rollback:
def on_rollback(): def on_rollback():
selected = table.selectedItems() selected = table.selectedItems()
if not selected: if not selected:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
dialog, "Rollback", "Please select a revision"
)
return return
selected_rev[0] = int(table.item(selected[0].row(), 0).text()) selected_rev[0] = int(table.item(selected[0].row(), 0).text())
dialog.accept() dialog.accept()
@@ -1708,9 +1685,7 @@ class Silo_SetStatus:
# Update status # Update status
_client.update_revision(part_number, rev_num, status=status) _client.update_revision(part_number, rev_num, status=status)
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
f"Updated Rev {rev_num} status to '{status}'\n"
)
QtGui.QMessageBox.information( QtGui.QMessageBox.information(
None, "Status Updated", f"Revision {rev_num} status set to '{status}'" None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
) )
@@ -1769,14 +1744,37 @@ class Silo_Settings:
layout.addSpacing(10) layout.addSpacing(10)
# Schema name
schema_label = QtGui.QLabel("Schema Name:")
layout.addWidget(schema_label)
schema_input = QtGui.QLineEdit()
schema_input.setPlaceholderText("kindred-rd")
current_schema = param.GetString("SchemaName", "")
if current_schema:
schema_input.setText(current_schema)
else:
env_schema = os.environ.get("SILO_SCHEMA", "")
if env_schema:
schema_input.setText(env_schema)
layout.addWidget(schema_input)
schema_hint = QtGui.QLabel(
"The part-numbering schema to use. Leave empty for "
"SILO_SCHEMA env var or default (kindred-rd)."
)
schema_hint.setWordWrap(True)
schema_hint.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(schema_hint)
layout.addSpacing(10)
# SSL # SSL
ssl_checkbox = QtGui.QCheckBox("Verify SSL certificates") ssl_checkbox = QtGui.QCheckBox("Verify SSL certificates")
ssl_checkbox.setChecked(param.GetBool("SslVerify", True)) ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
layout.addWidget(ssl_checkbox) layout.addWidget(ssl_checkbox)
ssl_hint = QtGui.QLabel( ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
"Disable only for internal servers with self-signed certificates."
)
ssl_hint.setWordWrap(True) ssl_hint.setWordWrap(True)
ssl_hint.setStyleSheet("color: #888; font-size: 11px;") ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(ssl_hint) layout.addWidget(ssl_hint)
@@ -1911,6 +1909,7 @@ class Silo_Settings:
auth_display = "not configured" auth_display = "not configured"
status_label = QtGui.QLabel( status_label = QtGui.QLabel(
f"<b>Active URL:</b> {_get_api_url()}<br>" f"<b>Active URL:</b> {_get_api_url()}<br>"
f"<b>Schema:</b> {_get_schema_name()}<br>"
f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}<br>" f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}<br>"
f"<b>CA certificate:</b> {cert_display}<br>" f"<b>CA certificate:</b> {cert_display}<br>"
f"<b>Authentication:</b> {auth_display}" f"<b>Authentication:</b> {auth_display}"
@@ -1932,6 +1931,7 @@ class Silo_Settings:
def on_save(): def on_save():
url = url_input.text().strip() url = url_input.text().strip()
param.SetString("ApiUrl", url) param.SetString("ApiUrl", url)
param.SetString("SchemaName", schema_input.text().strip())
param.SetBool("SslVerify", ssl_checkbox.isChecked()) param.SetBool("SslVerify", ssl_checkbox.isChecked())
cert_path = cert_input.text().strip() cert_path = cert_input.text().strip()
param.SetString("SslCertPath", cert_path) param.SetString("SslCertPath", cert_path)
@@ -2053,9 +2053,7 @@ class Silo_BOM:
wu_table = QtGui.QTableWidget() wu_table = QtGui.QTableWidget()
wu_table.setColumnCount(5) wu_table.setColumnCount(5)
wu_table.setHorizontalHeaderLabels( wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
)
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -2084,16 +2082,12 @@ class Silo_BOM:
bom_table.setItem( bom_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", "")) row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
) )
bom_table.setItem( bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity") qty = entry.get("quantity")
bom_table.setItem( bom_table.setItem(
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
) )
bom_table.setItem( bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or [] ref_des = entry.get("reference_designators") or []
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des))) bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
bom_table.setItem( bom_table.setItem(
@@ -2115,16 +2109,12 @@ class Silo_BOM:
wu_table.setItem( wu_table.setItem(
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", "")) row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
) )
wu_table.setItem( wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity") qty = entry.get("quantity")
wu_table.setItem( wu_table.setItem(
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
) )
wu_table.setItem( wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or [] ref_des = entry.get("reference_designators") or []
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des))) wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
wu_table.resizeColumnsToContents() wu_table.resizeColumnsToContents()
@@ -2177,9 +2167,7 @@ class Silo_BOM:
try: try:
qty = float(qty_text) qty = float(qty_text)
except ValueError: except ValueError:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
dialog, "BOM", "Quantity must be a number."
)
return return
unit = unit_input.text().strip() or None unit = unit_input.text().strip() or None
@@ -2258,9 +2246,7 @@ class Silo_BOM:
try: try:
new_qty = float(qty_text) new_qty = float(qty_text)
except ValueError: except ValueError:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
dialog, "BOM", "Quantity must be a number."
)
return return
new_unit = unit_input.text().strip() or None new_unit = unit_input.text().strip() or None
@@ -2284,9 +2270,7 @@ class Silo_BOM:
) )
load_bom() load_bom()
except Exception as exc: except Exception as exc:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
dialog, "BOM", f"Failed to update entry:\n{exc}"
)
def on_remove(): def on_remove():
selected = bom_table.selectedItems() selected = bom_table.selectedItems()
@@ -2312,9 +2296,7 @@ class Silo_BOM:
_client.delete_bom_entry(part_number, child_pn) _client.delete_bom_entry(part_number, child_pn)
load_bom() load_bom()
except Exception as exc: except Exception as exc:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
dialog, "BOM", f"Failed to remove entry:\n{exc}"
)
add_btn.clicked.connect(on_add) add_btn.clicked.connect(on_add)
edit_btn.clicked.connect(on_edit) edit_btn.clicked.connect(on_edit)
@@ -2353,9 +2335,7 @@ class SiloEventListener(QtCore.QThread):
item_updated = QtCore.Signal(str) # part_number item_updated = QtCore.Signal(str) # part_number
revision_created = QtCore.Signal(str, int) # part_number, revision revision_created = QtCore.Signal(str, int) # part_number, revision
connection_status = QtCore.Signal( connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message)
str, int, str
) # (status, retry_count, error_message)
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded" server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
# DAG events # DAG events
@@ -2441,9 +2421,7 @@ class SiloEventListener(QtCore.QThread):
req = urllib.request.Request(url, headers=headers, method="GET") req = urllib.request.Request(url, headers=headers, method="GET")
try: try:
self._response = urllib.request.urlopen( self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
req, context=_get_ssl_context(), timeout=90
)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code in (404, 501): if e.code in (404, 501):
raise _SSEUnsupported() raise _SSEUnsupported()
@@ -2669,9 +2647,7 @@ class SiloAuthDockWidget:
layout.addLayout(btn_row) layout.addLayout(btn_row)
# Keep the auth panel compact so the Activity panel below gets more space # Keep the auth panel compact so the Activity panel below gets more space
self.widget.setSizePolicy( self.widget.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Maximum)
QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Maximum
)
# -- Status refresh ----------------------------------------------------- # -- Status refresh -----------------------------------------------------
@@ -2785,9 +2761,7 @@ class SiloAuthDockWidget:
FreeCAD.Console.PrintMessage("Silo: SSE connected\n") FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
self._seed_activity_feed() self._seed_activity_feed()
elif status == "disconnected": elif status == "disconnected":
self._sse_label.setText( self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...")
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
)
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;") self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
self._sse_label.setToolTip(error or "Connection lost") self._sse_label.setToolTip(error or "Connection lost")
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
@@ -2797,9 +2771,7 @@ class SiloAuthDockWidget:
self._sse_label.setText("Disconnected") self._sse_label.setText("Disconnected")
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;") self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
self._sse_label.setToolTip(error or "Max retries reached") self._sse_label.setToolTip(error or "Max retries reached")
FreeCAD.Console.PrintError( FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n")
f"Silo: SSE gave up after {retry} retries: {error}\n"
)
elif status == "unsupported": elif status == "unsupported":
self._sse_label.setText("Not available") self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;") self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
@@ -2843,17 +2815,11 @@ class SiloAuthDockWidget:
self._append_activity_event(f"{part_number} updated", part_number) self._append_activity_event(f"{part_number} updated", part_number)
def _on_remote_revision(self, part_number, revision): def _on_remote_revision(self, part_number, revision):
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
f"Silo: New revision {revision} for {part_number}\n"
)
mw = FreeCADGui.getMainWindow() mw = FreeCADGui.getMainWindow()
if mw is not None: if mw is not None:
mw.statusBar().showMessage( mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
f"Silo: {part_number} rev {revision} available", 5000 self._append_activity_event(f"{part_number} Rev {revision} created", part_number)
)
self._append_activity_event(
f"{part_number} Rev {revision} created", part_number
)
def _append_activity_event(self, text, pn=""): def _append_activity_event(self, text, pn=""):
"""Prepend an event to the activity feed and rebuild the display.""" """Prepend an event to the activity feed and rebuild the display."""
@@ -2879,9 +2845,9 @@ class SiloAuthDockWidget:
ts = datetime.now() ts = datetime.now()
if updated: if updated:
try: try:
ts = datetime.fromisoformat( ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).replace(
updated.replace("Z", "+00:00") tzinfo=None
).replace(tzinfo=None) )
except (ValueError, AttributeError): except (ValueError, AttributeError):
pass pass
self._activity_events.insert(0, (ts, text, pn)) self._activity_events.insert(0, (ts, text, pn))
@@ -2929,12 +2895,10 @@ class SiloAuthDockWidget:
def _on_dag_updated(self, part_number, node_count, edge_count): def _on_dag_updated(self, part_number, node_count, edge_count):
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(
f"Silo: DAG updated for {part_number}" f"Silo: DAG updated for {part_number} ({node_count} nodes, {edge_count} edges)\n"
f" ({node_count} nodes, {edge_count} edges)\n"
) )
self._append_activity_event( self._append_activity_event(
f"\u25b6 {part_number} \u2013 DAG synced" f"\u25b6 {part_number} \u2013 DAG synced ({node_count} nodes, {edge_count} edges)",
f" ({node_count} nodes, {edge_count} edges)",
part_number, part_number,
) )
@@ -2945,15 +2909,12 @@ class SiloAuthDockWidget:
else: else:
status = f"\u2717 FAIL ({failed_count} failed)" status = f"\u2717 FAIL ({failed_count} failed)"
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
f"Silo: Validation failed for {part_number}" f"Silo: Validation failed for {part_number} ({failed_count} features failed)\n"
f" ({failed_count} features failed)\n"
) )
self._append_activity_event(f"{status} \u2013 {part_number}", part_number) self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
def _on_job_created(self, job_id, definition_name, part_number): def _on_job_created(self, job_id, definition_name, part_number):
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Silo: Job {definition_name} created for {part_number}\n")
f"Silo: Job {definition_name} created for {part_number}\n"
)
self._append_activity_event( self._append_activity_event(
f"\u23f3 {part_number} \u2013 {definition_name} queued", f"\u23f3 {part_number} \u2013 {definition_name} queued",
part_number, part_number,
@@ -2968,14 +2929,10 @@ class SiloAuthDockWidget:
self._append_activity_event(f"\u2717 Job {job_id[:8]} failed: {error}") self._append_activity_event(f"\u2717 Job {job_id[:8]} failed: {error}")
def _on_job_claimed(self, job_id, runner_id): def _on_job_claimed(self, job_id, runner_id):
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} claimed by runner {runner_id}\n")
f"Silo: Job {job_id[:8]} claimed by runner {runner_id}\n"
)
def _on_job_progress(self, job_id, progress, message): def _on_job_progress(self, job_id, progress, message):
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} progress {progress}%: {message}\n")
f"Silo: Job {job_id[:8]} progress {progress}%: {message}\n"
)
def _on_job_cancelled(self, job_id): def _on_job_cancelled(self, job_id):
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} cancelled\n") FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} cancelled\n")
@@ -3306,15 +3263,9 @@ class JobMonitorDialog:
status = job.get("status", "") status = job.get("status", "")
icon = _STATUS_ICONS.get(status, "?") icon = _STATUS_ICONS.get(status, "?")
self._table.setItem(row, 0, QtGui.QTableWidgetItem(f"{icon} {status}")) self._table.setItem(row, 0, QtGui.QTableWidgetItem(f"{icon} {status}"))
self._table.setItem( self._table.setItem(row, 1, QtGui.QTableWidgetItem(job.get("definition_name", "")))
row, 1, QtGui.QTableWidgetItem(job.get("definition_name", "")) self._table.setItem(row, 2, QtGui.QTableWidgetItem(job.get("part_number", "")))
) self._table.setItem(row, 3, QtGui.QTableWidgetItem(job.get("runner_name", "")))
self._table.setItem(
row, 2, QtGui.QTableWidgetItem(job.get("part_number", ""))
)
self._table.setItem(
row, 3, QtGui.QTableWidgetItem(job.get("runner_name", ""))
)
progress = job.get("progress", 0) progress = job.get("progress", 0)
progress_msg = job.get("progress_message", "") progress_msg = job.get("progress_message", "")
@@ -3377,8 +3328,7 @@ class JobMonitorDialog:
reply = QtGui.QMessageBox.question( reply = QtGui.QMessageBox.question(
self.dialog, self.dialog,
"Cancel Job", "Cancel Job",
f"Cancel job {job.get('definition_name', '')} for " f"Cancel job {job.get('definition_name', '')} for {job.get('part_number', '')}?",
f"{job.get('part_number', '')}?",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
) )
if reply != QtGui.QMessageBox.Yes: if reply != QtGui.QMessageBox.Yes:
@@ -3387,9 +3337,7 @@ class JobMonitorDialog:
_client.cancel_job(job_id) _client.cancel_job(job_id)
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} cancelled\n") FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} cancelled\n")
except Exception as e: except Exception as e:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(self.dialog, "Cancel Failed", f"Failed to cancel job:\n{e}")
self.dialog, "Cancel Failed", f"Failed to cancel job:\n{e}"
)
self._refresh() self._refresh()
def _trigger_job(self): def _trigger_job(self):
@@ -3398,9 +3346,7 @@ class JobMonitorDialog:
try: try:
definitions = _client.list_job_definitions() definitions = _client.list_job_definitions()
except Exception as e: except Exception as e:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(self.dialog, "Error", f"Failed to load job definitions:\n{e}")
self.dialog, "Error", f"Failed to load job definitions:\n{e}"
)
return return
if not definitions: if not definitions:
@@ -3428,13 +3374,9 @@ class JobMonitorDialog:
try: try:
result = _client.trigger_job(name, part_number=pn) result = _client.trigger_job(name, part_number=pn)
FreeCAD.Console.PrintMessage( FreeCAD.Console.PrintMessage(f"Silo: Job triggered: {result.get('id', '')}\n")
f"Silo: Job triggered: {result.get('id', '')}\n"
)
except Exception as e: except Exception as e:
QtGui.QMessageBox.warning( QtGui.QMessageBox.warning(self.dialog, "Trigger Failed", f"Failed to trigger job:\n{e}")
self.dialog, "Trigger Failed", f"Failed to trigger job:\n{e}"
)
self._refresh() self._refresh()
def on_job_event(self): def on_job_event(self):
@@ -3496,9 +3438,7 @@ class RunnerAdminDialog:
# Runner table # Runner table
self._table = QtGui.QTableWidget() self._table = QtGui.QTableWidget()
self._table.setColumnCount(5) self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels( self._table.setHorizontalHeaderLabels(["Name", "Tags", "Status", "Last Heartbeat", "Jobs"])
["Name", "Tags", "Status", "Last Heartbeat", "Jobs"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -3554,9 +3494,7 @@ class RunnerAdminDialog:
def _register_runner(self): def _register_runner(self):
from PySide import QtGui from PySide import QtGui
name, ok = QtGui.QInputDialog.getText( name, ok = QtGui.QInputDialog.getText(self.dialog, "Register Runner", "Runner name:")
self.dialog, "Register Runner", "Runner name:"
)
if not ok or not name: if not ok or not name:
return return
@@ -3882,9 +3820,7 @@ class Silo_StartPanel:
dock = QtGui.QDockWidget("Silo", mw) dock = QtGui.QDockWidget("Silo", mw)
dock.setObjectName("SiloStartPanel") dock.setObjectName("SiloStartPanel")
dock.setWidget(content.widget) dock.setWidget(content.widget)
dock.setAllowedAreas( dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea
)
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock) mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
def IsActive(self): def IsActive(self):
@@ -3918,9 +3854,7 @@ class _DiagWorker(QtCore.QThread):
self.result.emit("DNS", False, "no hostname in URL") self.result.emit("DNS", False, "no hostname in URL")
return return
try: try:
addrs = socket.getaddrinfo( addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
first_ip = addrs[0][4][0] if addrs else "?" first_ip = addrs[0][4][0] if addrs else "?"
self.result.emit("DNS", True, f"{hostname} -> {first_ip}") self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
except socket.gaierror as e: except socket.gaierror as e: