diff --git a/src/scitex/_dev/__init__.py b/src/scitex/_dev/__init__.py
index e1c76c7e4..2beca9d9f 100755
--- a/src/scitex/_dev/__init__.py
+++ b/src/scitex/_dev/__init__.py
@@ -1,165 +1,13 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-03-13
-# File: scitex/_dev/__init__.py
+"""SciTeX _dev — thin compatibility shim for scitex-dev."""
-"""SciTeX Developer Utilities — thin re-export from scitex_dev.
+import sys as _sys
-All functionality has been migrated to the standalone ``scitex-dev`` package.
-This module re-exports everything for backward compatibility.
-"""
+try:
+ import scitex_dev as _real
+except ImportError as _e:
+ raise ImportError(
+ "scitex._dev requires the 'scitex-dev' package. "
+ "Install with: pip install scitex-dev"
+ ) from _e
-from scitex_dev import (
- ECOSYSTEM,
- RESULT_SCHEMA,
- DevConfig,
- ErrorCode,
- GitHubRemote,
- HostConfig,
- PackageConfig,
- PyPIAccount,
- RenameConfig,
- RenameResult,
- Result,
- SideEffect,
- TestConfig,
- add_dry_run_argument,
- add_json_argument,
- async_wrap_as_mcp,
- bulk_rename,
- check_all_hosts,
- check_all_remotes,
- check_versions,
- classify_exception,
- compare_with_local,
- config_to_dict,
- create_default_config,
- dry_run_option,
- execute_rename,
- fetch_hpc_result,
- fix_mismatches,
- get_all_packages,
- get_config_path,
- get_enabled_hosts,
- get_enabled_remotes,
- get_github_latest_tag,
- get_github_release,
- get_github_tags,
- get_local_path,
- get_mismatches,
- get_remote_version,
- get_remote_versions,
- handle_result,
- json_option,
- list_versions,
- load_config,
- poll_hpc_job,
- preview_rename,
- pull_local,
- remote_commit,
- remote_diff,
- result_to_mcp,
- run_as_cli,
- run_as_mcp,
- run_hpc_sbatch,
- run_hpc_srun,
- run_local,
- supports_return_as,
- sync_all,
- sync_host,
- sync_local,
- sync_tags,
- sync_to_hpc,
- test_host_connection,
- watch_hpc_job,
- wrap_as_cli,
- wrap_as_mcp,
-)
-
-__all__ = [
- # Versions
- "list_versions",
- "check_versions",
- "get_mismatches",
- # Fix
- "fix_mismatches",
- # Ecosystem
- "ECOSYSTEM",
- "get_all_packages",
- "get_local_path",
- # Config
- "load_config",
- "get_config_path",
- "create_default_config",
- "get_enabled_hosts",
- "get_enabled_remotes",
- "config_to_dict",
- "DevConfig",
- "HostConfig",
- "GitHubRemote",
- "PackageConfig",
- "PyPIAccount",
- # SSH
- "check_all_hosts",
- "get_remote_version",
- "get_remote_versions",
- "test_host_connection",
- # GitHub
- "check_all_remotes",
- "compare_with_local",
- "get_github_tags",
- "get_github_latest_tag",
- "get_github_release",
- # Rename
- "bulk_rename",
- "preview_rename",
- "execute_rename",
- "RenameConfig",
- "RenameResult",
- # Sync (local → remote)
- "sync_all",
- "sync_host",
- "sync_local",
- "sync_tags",
- # Sync (remote → local)
- "remote_diff",
- "remote_commit",
- "pull_local",
- # Test
- "run_local",
- "run_hpc_srun",
- "run_hpc_sbatch",
- "poll_hpc_job",
- "fetch_hpc_result",
- "watch_hpc_job",
- "sync_to_hpc",
- "TestConfig",
- # LLM-friendly types (Phase 1-3)
- "Result",
- "ErrorCode",
- "classify_exception",
- "supports_return_as",
- "SideEffect",
- "handle_result",
- "run_as_cli",
- "wrap_as_cli",
- "run_as_mcp",
- "wrap_as_mcp",
- "async_wrap_as_mcp",
- "result_to_mcp",
-]
-
-
-def run_dashboard(
- host: str = "127.0.0.1",
- port: int = 5000,
- debug: bool = False,
- open_browser: bool = True,
- force: bool = False,
-) -> None:
- """Run the Flask version dashboard."""
- from scitex_dev.dashboard import run_dashboard as _run
-
- _run(host=host, port=port, debug=debug, open_browser=open_browser, force=force)
-
-
-# EOF
+_sys.modules[__name__] = _real
diff --git a/src/scitex/_dev/_config.py b/src/scitex/_dev/_config.py
deleted file mode 100755
index 93964edb4..000000000
--- a/src/scitex/_dev/_config.py
+++ /dev/null
@@ -1,449 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_config.py
-
-"""Configuration management for scitex developer utilities."""
-
-from __future__ import annotations
-
-import os
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any
-
-
-@dataclass
-class HostConfig:
- """SSH host configuration."""
-
- name: str
- hostname: str
- user: str
- role: str = "dev" # dev, staging, prod, hpc
- enabled: bool = True
- ssh_key: str | None = None
- port: int = 22
- # Sync fields
- python_bin: str = "python3"
- pip_bin: str = "pip"
- remote_base: str = "~/proj"
- packages: list[str] = field(default_factory=list)
-
-
-@dataclass
-class GitHubRemote:
- """GitHub remote configuration."""
-
- name: str
- org: str
- enabled: bool = True
-
-
-@dataclass
-class PyPIAccount:
- """PyPI account configuration."""
-
- name: str
- enabled: bool = True
-
-
-@dataclass
-class PackageConfig:
- """Package configuration."""
-
- name: str
- local_path: str
- pypi_name: str
- github_repo: str | None = None
- import_name: str | None = None
-
-
-@dataclass
-class DevConfig:
- """Full developer configuration."""
-
- packages: list[PackageConfig] = field(default_factory=list)
- hosts: list[HostConfig] = field(default_factory=list)
- github_remotes: list[GitHubRemote] = field(default_factory=list)
- pypi_accounts: list[PyPIAccount] = field(default_factory=list)
- branches: list[str] = field(default_factory=lambda: ["main", "develop"])
-
-
-def _get_default_config_path() -> Path:
- """Get default config file path."""
- return Path.home() / ".scitex" / "dev_config.yaml"
-
-
-def _load_yaml(path: Path) -> dict[str, Any]:
- """Load YAML file."""
- if not path.exists():
- return {}
-
- try:
- import yaml
-
- with open(path) as f:
- return yaml.safe_load(f) or {}
- except ImportError:
- # Fallback: basic YAML parsing for simple configs
- content = path.read_text()
- # Very basic parsing - handles simple key: value pairs
- result: dict[str, Any] = {}
- current_key = None
- current_list: list[Any] = []
-
- for line in content.split("\n"):
- line = line.rstrip()
- if not line or line.startswith("#"):
- continue
- if line.startswith(" - "):
- # List item
- current_list.append(line[4:].strip())
- elif line.startswith(" "):
- # Nested dict item - skip for basic parsing
- continue
- elif ":" in line:
- if current_key and current_list:
- result[current_key] = current_list
- current_list = []
- key, val = line.split(":", 1)
- current_key = key.strip()
- val = val.strip()
- if val:
- result[current_key] = val
- if current_key and current_list:
- result[current_key] = current_list
- return result
- except Exception:
- return {}
-
-
-def _parse_host_config(data: dict[str, Any]) -> HostConfig:
- """Parse host config from dict."""
- packages = data.get("packages", [])
- if isinstance(packages, str):
- packages = [p.strip() for p in packages.split(",") if p.strip()]
- return HostConfig(
- name=data.get("name", "unknown"),
- hostname=data.get("hostname", "localhost"),
- user=data.get("user", os.getenv("USER", "user")),
- role=data.get("role", "dev"),
- enabled=data.get("enabled", True),
- ssh_key=data.get("ssh_key"),
- port=data.get("port", 22),
- python_bin=data.get("python_bin", "python3"),
- pip_bin=data.get("pip_bin", "pip"),
- remote_base=data.get("remote_base", "~/proj"),
- packages=packages if isinstance(packages, list) else [],
- )
-
-
-def _parse_github_remote(data: dict[str, Any]) -> GitHubRemote:
- """Parse GitHub remote from dict."""
- return GitHubRemote(
- name=data.get("name", "unknown"),
- org=data.get("org", ""),
- enabled=data.get("enabled", True),
- )
-
-
-def _parse_pypi_account(data: dict[str, Any]) -> PyPIAccount:
- """Parse PyPI account from dict."""
- return PyPIAccount(
- name=data.get("name", ""),
- enabled=data.get("enabled", True),
- )
-
-
-def _parse_package_config(data: dict[str, Any]) -> PackageConfig:
- """Parse package config from dict."""
- return PackageConfig(
- name=data.get("name", "unknown"),
- local_path=data.get("local_path", ""),
- pypi_name=data.get("pypi_name", data.get("name", "")),
- github_repo=data.get("github_repo"),
- import_name=data.get("import_name"),
- )
-
-
-def load_config(config_path: str | Path | None = None) -> DevConfig:
- """Load config from YAML with environment variable overrides.
-
- Parameters
- ----------
- config_path : str | Path | None
- Path to config file. If None, uses SCITEX_DEV_CONFIG env var
- or ~/.scitex/dev_config.yaml
-
- Returns
- -------
- DevConfig
- Loaded configuration.
- """
- # Determine config path
- if config_path is None:
- config_path = os.getenv("SCITEX_DEV_CONFIG")
- if config_path is None:
- config_path = _get_default_config_path()
- else:
- config_path = Path(config_path).expanduser()
-
- # Load YAML
- data = _load_yaml(config_path)
-
- # Parse packages
- packages = []
- if "packages" in data and isinstance(data["packages"], list):
- for pkg_data in data["packages"]:
- if isinstance(pkg_data, dict):
- packages.append(_parse_package_config(pkg_data))
-
- # If no packages in config, use ecosystem defaults
- if not packages:
- from ._ecosystem import ECOSYSTEM
-
- for name, info in ECOSYSTEM.items():
- packages.append(
- PackageConfig(
- name=name,
- local_path=info.get("local_path", ""),
- pypi_name=info.get("pypi_name", name),
- github_repo=info.get("github_repo"),
- import_name=info.get("import_name"),
- )
- )
-
- # Parse hosts
- hosts = []
- if "hosts" in data and isinstance(data["hosts"], list):
- for host_data in data["hosts"]:
- if isinstance(host_data, dict):
- hosts.append(_parse_host_config(host_data))
-
- # Override from env
- env_hosts = os.getenv("SCITEX_DEV_HOSTS", "").strip()
- if env_hosts:
- enabled_names = set(env_hosts.split(","))
- for host in hosts:
- host.enabled = host.name in enabled_names
-
- # Parse GitHub remotes
- github_remotes = []
- if "github_remotes" in data and isinstance(data["github_remotes"], list):
- for remote_data in data["github_remotes"]:
- if isinstance(remote_data, dict):
- github_remotes.append(_parse_github_remote(remote_data))
-
- # Default GitHub remote from ecosystem
- if not github_remotes:
- github_remotes.append(GitHubRemote(name="ywatanabe1989", org="ywatanabe1989"))
-
- # Override from env
- env_remotes = os.getenv("SCITEX_DEV_GITHUB_REMOTES", "").strip()
- if env_remotes:
- enabled_names = set(env_remotes.split(","))
- for remote in github_remotes:
- remote.enabled = remote.name in enabled_names
-
- # Parse PyPI accounts
- pypi_accounts = []
- if "pypi_accounts" in data and isinstance(data["pypi_accounts"], list):
- for acct_data in data["pypi_accounts"]:
- if isinstance(acct_data, dict):
- pypi_accounts.append(_parse_pypi_account(acct_data))
-
- if not pypi_accounts:
- pypi_accounts.append(PyPIAccount(name="ywatanabe1989"))
-
- # Parse branches
- branches = data.get("branches", ["main", "develop"])
- if not isinstance(branches, list):
- branches = ["main", "develop"]
-
- return DevConfig(
- packages=packages,
- hosts=hosts,
- github_remotes=github_remotes,
- pypi_accounts=pypi_accounts,
- branches=branches,
- )
-
-
-def get_enabled_hosts(config: DevConfig | None = None) -> list[HostConfig]:
- """Get list of enabled hosts.
-
- Parameters
- ----------
- config : DevConfig | None
- Configuration to use. If None, loads default config.
-
- Returns
- -------
- list[HostConfig]
- List of enabled hosts.
- """
- if config is None:
- config = load_config()
- return [h for h in config.hosts if h.enabled]
-
-
-def get_enabled_remotes(config: DevConfig | None = None) -> list[GitHubRemote]:
- """Get list of enabled GitHub remotes.
-
- Parameters
- ----------
- config : DevConfig | None
- Configuration to use. If None, loads default config.
-
- Returns
- -------
- list[GitHubRemote]
- List of enabled remotes.
- """
- if config is None:
- config = load_config()
- return [r for r in config.github_remotes if r.enabled]
-
-
-def config_to_dict(config: DevConfig, config_path: Path | None = None) -> dict:
- """Serialize a DevConfig to a plain dict for JSON responses.
-
- Parameters
- ----------
- config : DevConfig
- Configuration to serialize.
- config_path : Path | None
- If provided, included as ``"config_path"`` in the result.
-
- Returns
- -------
- dict
- Serialized configuration.
- """
- result: dict = {
- "packages": [
- {
- "name": p.name,
- "local_path": p.local_path,
- "pypi_name": p.pypi_name,
- "github_repo": p.github_repo,
- }
- for p in config.packages
- ],
- "hosts": [
- {
- "name": h.name,
- "hostname": h.hostname,
- "user": h.user,
- "role": h.role,
- "enabled": h.enabled,
- }
- for h in config.hosts
- ],
- "github_remotes": [
- {"name": r.name, "org": r.org, "enabled": r.enabled}
- for r in config.github_remotes
- ],
- "branches": config.branches,
- }
- if config_path is not None:
- result["config_path"] = str(config_path)
- return result
-
-
-def get_config_path() -> Path:
- """Get the config file path (may not exist)."""
- path = os.getenv("SCITEX_DEV_CONFIG")
- if path:
- return Path(path).expanduser()
- return _get_default_config_path()
-
-
-def create_default_config() -> Path:
- """Create default config file if it doesn't exist.
-
- Returns
- -------
- Path
- Path to the config file.
- """
- config_path = _get_default_config_path()
- config_path.parent.mkdir(parents=True, exist_ok=True)
-
- if config_path.exists():
- return config_path
-
- default_config = """\
-# SciTeX Developer Configuration
-# Timestamp: 2026-02-02
-
-# Ecosystem packages to track
-packages:
- - name: scitex
- local_path: ~/proj/scitex-python
- pypi_name: scitex
- github_repo: ywatanabe1989/scitex-python
- import_name: scitex
- - name: figrecipe
- local_path: ~/proj/figrecipe
- pypi_name: figrecipe
- github_repo: ywatanabe1989/figrecipe
- import_name: figrecipe
- - name: scitex-cloud
- local_path: ~/proj/scitex-cloud
- pypi_name: scitex-cloud
- github_repo: ywatanabe1989/scitex-cloud
- import_name: scitex_cloud
- - name: scitex-writer
- local_path: ~/proj/scitex-writer
- pypi_name: scitex-writer
- github_repo: ywatanabe1989/scitex-writer
- import_name: scitex_writer
- - name: crossref-local
- local_path: ~/proj/crossref-local
- pypi_name: crossref-local
- github_repo: ywatanabe1989/crossref-local
- import_name: crossref_local
-
-# Hosts to check via SSH
-hosts:
- - name: ywata-note-win
- hostname: localhost
- user: ywatanabe
- role: dev
- enabled: true
- - name: nas
- hostname: nas.local
- user: ywatanabe
- role: staging
- enabled: true
- - name: scitex-cloud
- hostname: scitex.ai
- user: deploy
- role: prod
- enabled: false
-
-# GitHub remotes to check
-github_remotes:
- - name: ywatanabe1989
- org: ywatanabe1989
- enabled: true
- - name: scitex-ai
- org: scitex-ai
- enabled: false
-
-# PyPI accounts
-pypi_accounts:
- - name: ywatanabe1989
- enabled: true
-
-# Branches to track
-branches:
- - main
- - develop
-"""
- config_path.write_text(default_config)
- return config_path
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/__init__.py b/src/scitex/_dev/_dashboard/__init__.py
deleted file mode 100755
index 0e93fa607..000000000
--- a/src/scitex/_dev/_dashboard/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_dashboard/__init__.py
-
-"""Flask dashboard for scitex version management."""
-
-from ._app import create_app, run_dashboard
-
-__all__ = ["create_app", "run_dashboard"]
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_app.py b/src/scitex/_dev/_dashboard/_app.py
deleted file mode 100755
index ccf779833..000000000
--- a/src/scitex/_dev/_dashboard/_app.py
+++ /dev/null
@@ -1,226 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: "2026-02-26 11:12:34 (ywatanabe)"
-# File: /home/ywatanabe/proj/scitex-python/src/scitex/_dev/_dashboard/_app.py
-
-# Timestamp: 2026-02-02
-
-"""Flask application factory for the dashboard."""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- from flask import Flask
-
-
-def create_app() -> Flask:
- """Create and configure the Flask application.
-
- Returns
- -------
- Flask
- Configured Flask application.
- """
- try:
- from flask import Flask
- except ImportError as e:
- raise ImportError(
- "Flask is required for the dashboard. Install with: pip install flask"
- ) from e
-
- from pathlib import Path
-
- static_folder = Path(__file__).parent / "static"
- app = Flask(__name__, static_folder=str(static_folder), static_url_path="/static")
-
- # Disable JSON key sorting to preserve insertion order (Flask 2.2+)
- app.json.sort_keys = False
-
- # Register routes
- from ._routes import register_routes
-
- register_routes(app)
-
- return app
-
-
-def _kill_process_on_port(port: int) -> None:
- """Kill any process using the specified port.
-
- Parameters
- ----------
- port : int
- Port number to free up.
- """
- import subprocess
- import sys
-
- try:
- if sys.platform == "win32":
- # Windows: use netstat and taskkill
- result = subprocess.run(
- ["netstat", "-ano"],
- capture_output=True,
- text=True,
- check=False,
- )
- for line in result.stdout.splitlines():
- if f":{port}" in line and "LISTENING" in line:
- pid = line.strip().split()[-1]
- subprocess.run(
- ["taskkill", "/F", "/PID", pid],
- capture_output=True,
- check=False,
- )
- print(f"Killed process {pid} on port {port}")
- else:
- # Unix: use lsof
- result = subprocess.run(
- ["lsof", "-ti", f":{port}"],
- capture_output=True,
- text=True,
- check=False,
- )
- if result.stdout.strip():
- pids = result.stdout.strip().split("\n")
- for pid in pids:
- subprocess.run(
- ["kill", "-9", pid], capture_output=True, check=False
- )
- print(f"Killed process {pid} on port {port}")
- except Exception as e:
- print(f"Warning: Could not kill process on port {port}: {e}")
-
-
-def run_dashboard(
- host: str = "127.0.0.1",
- port: int = 5000,
- debug: bool = False,
- open_browser: bool = True,
- force: bool = False,
-) -> None:
- """Run the Flask dashboard server.
-
- Parameters
- ----------
- host : str
- Host to bind to. Default "127.0.0.1".
- port : int
- Port to listen on. Default 5000.
- debug : bool
- Enable Flask debug mode.
- open_browser : bool
- Open browser automatically.
- force : bool
- Kill existing process using the port if any.
- """
- if force:
- _kill_process_on_port(port)
-
- app = create_app()
-
- url = f"http://{host}:{port}"
- print(f"Starting SciTeX Version Dashboard at {url}")
- print("Press Ctrl+C to stop.")
-
- if open_browser:
- import threading
- import webbrowser
-
- def open_url():
- import time
-
- time.sleep(1) # Wait for server to start
- webbrowser.open(url)
-
- threading.Thread(target=open_url, daemon=True).start()
-
- try:
- app.run(host=host, port=port, debug=debug, threaded=True)
- except KeyboardInterrupt:
- print("\nDashboard stopped.")
-
-
-def run_background(
- host: str = "127.0.0.1",
- port: int = 5000,
- force: bool = False,
-) -> None:
- """Launch the dashboard as a detached background subprocess.
-
- Parameters
- ----------
- host : str
- Host to bind to. Default "127.0.0.1".
- port : int
- Port to listen on. Default 5000.
- force : bool
- Kill existing process using the port if any.
- """
- import subprocess
- import sys
- from pathlib import Path
-
- cache_dir = Path.home() / ".cache" / "scitex"
- cache_dir.mkdir(parents=True, exist_ok=True)
-
- log_path = cache_dir / "dashboard.log"
- pid_path = cache_dir / "dashboard.pid"
-
- inline_script = (
- f"from scitex._dev._dashboard._app import run_dashboard; "
- f"run_dashboard(host={host!r}, port={port!r}, debug=False, open_browser=False, force={force!r})"
- )
-
- log_file = open(log_path, "a")
- proc = subprocess.Popen(
- [sys.executable, "-c", inline_script],
- stdout=log_file,
- stderr=log_file,
- start_new_session=True,
- )
-
- pid_path.write_text(str(proc.pid))
-
- # print("Dashboard started in background.")
- # print(f" PID: {proc.pid}")
- # print(f" URL: http://{host}:{port}")
- # print(f" Log: {log_path}")
- # print(f" PID file: {pid_path}")
-
-
-def stop_dashboard() -> bool:
- """Stop a running background dashboard process.
-
- Returns
- -------
- bool
- True if the process was successfully stopped, False otherwise.
- """
- import os
- import signal
- from pathlib import Path
-
- pid_path = Path.home() / ".cache" / "scitex" / "dashboard.pid"
-
- if not pid_path.exists():
- print("No dashboard PID file found. Is the dashboard running in background?")
- return False
-
- try:
- pid = int(pid_path.read_text().strip())
- os.kill(pid, signal.SIGTERM)
- pid_path.unlink()
- print(f"Dashboard (PID {pid}) stopped.")
- return True
- except ProcessLookupError:
- print("Process not found. Removing stale PID file.")
- pid_path.unlink(missing_ok=True)
- return False
- except Exception as e:
- print(f"Error stopping dashboard: {e}")
- return False
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_routes.py b/src/scitex/_dev/_dashboard/_routes.py
deleted file mode 100755
index a394c6bae..000000000
--- a/src/scitex/_dev/_dashboard/_routes.py
+++ /dev/null
@@ -1,202 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_dashboard/_routes.py
-
-"""Flask routes for the dashboard."""
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING, Any
-
-if TYPE_CHECKING:
- from flask import Flask
-
-
-def register_routes(app: Flask) -> None:
- """Register dashboard routes with Flask app."""
- from flask import jsonify, request
-
- from ._templates import get_dashboard_html, get_error_html
-
- @app.route("/")
- def index():
- """Serve the main dashboard page."""
- try:
- return get_dashboard_html()
- except Exception as e:
- return get_error_html(str(e)), 500
-
- @app.route("/json")
- @app.route("/api/versions")
- def api_versions():
- """Get version data as JSON (also available at /json)."""
- try:
- data = _get_all_version_data()
- return jsonify(data)
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
- @app.route("/api/ecosystem")
- def api_ecosystem():
- """Get ecosystem registry (repos, paths, clone URLs) for AI agents."""
- from .._ecosystem import ECOSYSTEM
-
- repos = []
- for name, info in ECOSYSTEM.items():
- repos.append(
- {
- "name": name,
- "github_repo": info["github_repo"],
- "clone_url": f"git@github.com:{info['github_repo']}.git",
- "local_path": info["local_path"],
- "pypi_name": info.get("pypi_name", name),
- "import_name": info.get("import_name", ""),
- }
- )
- return jsonify({"ecosystem": repos})
-
- @app.route("/api/packages")
- def api_packages():
- """Get local package versions only (fast)."""
- try:
- from .._versions import list_versions
-
- return jsonify(list_versions())
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
- @app.route("/api/fix_mismatches", methods=["POST"])
- def api_fix_mismatches():
- """Detect and fix version mismatches. POST with confirm=true to execute."""
- try:
- from .._fix import fix_mismatches
-
- data = request.get_json(silent=True) or {}
- result = fix_mismatches(
- hosts=data.get("hosts"),
- packages=data.get("packages"),
- local=data.get("local", True),
- remote=data.get("remote", True),
- confirm=data.get("confirm", False),
- )
- return jsonify(result)
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
- @app.route("/api/config")
- def api_config():
- """Get current configuration."""
- try:
- from .._config import config_to_dict, get_config_path, load_config
-
- config = load_config()
- return jsonify(config_to_dict(config, config_path=get_config_path()))
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
- @app.route("/api/refresh", methods=["POST"])
- def api_refresh():
- """Trigger a data refresh."""
- try:
- data = _get_all_version_data(force_refresh=True)
- return jsonify({"status": "ok", "data": data})
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
- @app.route("/api/hosts")
- def api_hosts():
- """Get host version data."""
- try:
- packages = request.args.getlist("package") or None
- hosts = request.args.getlist("host") or None
- from .._config import get_enabled_hosts, load_config
- from .._ssh import check_all_hosts
-
- config = load_config()
- data = check_all_hosts(packages=packages, hosts=hosts, config=config)
-
- # Add host metadata (hostname/IP) for display
- enabled_hosts = get_enabled_hosts(config)
- data["_meta"] = {
- h.name: {"hostname": h.hostname, "role": h.role} for h in enabled_hosts
- }
- return jsonify(data)
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
- @app.route("/api/remotes")
- def api_remotes():
- """Get GitHub remote version data."""
- try:
- packages = request.args.getlist("package") or None
- remotes = request.args.getlist("remote") or None
- from .._github import check_all_remotes
-
- data = check_all_remotes(packages=packages, remotes=remotes)
- return jsonify(data)
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
- @app.route("/api/rtd")
- def api_rtd():
- """Get Read the Docs build status."""
- try:
- packages = request.args.getlist("package") or None
- versions = request.args.getlist("version") or None
- from .._rtd import check_all_rtd
-
- data = check_all_rtd(packages=packages, versions=versions)
- return jsonify(data)
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
-
-def _get_all_version_data(force_refresh: bool = False) -> dict[str, Any]:
- """Get all version data from all sources.
-
- Parameters
- ----------
- force_refresh : bool
- If True, bypass any caching.
-
- Returns
- -------
- dict
- Combined version data.
- """
- from .._config import get_enabled_hosts, get_enabled_remotes, load_config
- from .._github import check_all_remotes
- from .._ssh import check_all_hosts
- from .._versions import list_versions
-
- config = load_config()
-
- # Get local versions
- packages_data = list_versions()
-
- # Get host versions (if any hosts configured)
- hosts_data = {}
- enabled_hosts = get_enabled_hosts(config)
- if enabled_hosts:
- try:
- hosts_data = check_all_hosts(config=config)
- except Exception:
- pass
-
- # Get remote versions (if any remotes configured)
- remotes_data = {}
- enabled_remotes = get_enabled_remotes(config)
- if enabled_remotes:
- try:
- remotes_data = check_all_remotes(config=config)
- except Exception:
- pass
-
- return {
- "packages": packages_data,
- "hosts": hosts_data,
- "remotes": remotes_data,
- }
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_scripts.py b/src/scitex/_dev/_dashboard/_scripts.py
deleted file mode 100755
index 8b5b24ad8..000000000
--- a/src/scitex/_dev/_dashboard/_scripts.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-05
-# File: scitex/_dev/_dashboard/_scripts.py
-
-"""JavaScript for the dashboard.
-
-This module re-exports get_javascript() from the modular _scripts/ package.
-The JavaScript has been split into:
-- _scripts/_core.py: Fetch, cache, refresh functions
-- _scripts/_filters.py: Filter rendering
-- _scripts/_cards.py: Package card rendering with source badges
-- _scripts/_render.py: Main data rendering
-- _scripts/_utils.py: Export, copy, toggle utilities
-"""
-
-from ._scripts import get_javascript
-
-__all__ = ["get_javascript"]
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_scripts/__init__.py b/src/scitex/_dev/_dashboard/_scripts/__init__.py
deleted file mode 100755
index a2283d4dd..000000000
--- a/src/scitex/_dev/_dashboard/_scripts/__init__.py
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-05
-# File: scitex/_dev/_dashboard/_scripts/__init__.py
-
-"""Dashboard JavaScript modules aggregator."""
-
-from ._cards import get_cards_js
-from ._core import get_core_js
-from ._filters import get_filters_js
-from ._render import get_render_js
-from ._utils import get_utils_js
-
-
-def get_javascript() -> str:
- """Return complete dashboard JavaScript by aggregating all modules."""
- return (
- get_core_js()
- + get_filters_js()
- + get_cards_js()
- + get_render_js()
- + get_utils_js()
- + "\nfetchVersions();\ntoggleAutoRefresh(30);\n"
- )
-
-
-__all__ = ["get_javascript"]
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_scripts/_cards.py b/src/scitex/_dev/_dashboard/_scripts/_cards.py
deleted file mode 100755
index 1f471ba25..000000000
--- a/src/scitex/_dev/_dashboard/_scripts/_cards.py
+++ /dev/null
@@ -1,320 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-05
-# File: scitex/_dev/_dashboard/_scripts/_cards.py
-
-"""Package card rendering functions for dashboard JavaScript."""
-
-
-def get_cards_js() -> str:
- """Return JavaScript for package card rendering."""
- return """
-function renderWorktreeStatus(gitInfo) {
- if (!gitInfo || gitInfo.dirty === undefined) return '';
- const parts = [];
- if (gitInfo.dirty) {
- parts.push('dirty');
- } else {
- parts.push('clean');
- }
- if (gitInfo.ahead > 0) parts.push('ahead:' + gitInfo.ahead + '');
- if (gitInfo.behind > 0) parts.push('behind:' + gitInfo.behind + '');
- return '
worktree' + parts.join(', ') + '
';
-}
-
-function renderHostWorktreeStatus(h, loading) {
- if (loading) return '';
- if (h.git_dirty === undefined) return '';
- const parts = [];
- if (h.git_dirty) {
- parts.push('dirty');
- } else {
- parts.push('clean');
- }
- if (h.git_ahead > 0) parts.push('ahead:' + h.git_ahead + '');
- if (h.git_behind > 0) parts.push('behind:' + h.git_behind + '');
- return 'worktree' + parts.join(', ') + '
';
-}
-
-function getEffectiveStatus(name, info) {
- const rtdData = cachedData.rtd || {};
- const hostData = cachedData.hosts || {};
- let status = info.status;
-
- if (status === 'ok') {
- // Check RTD
- const rtdLatest = rtdData['latest'] && rtdData['latest'][name];
- const rtdStable = rtdData['stable'] && rtdData['stable'][name];
- if ((rtdLatest && rtdLatest.status === 'failing') ||
- (rtdStable && rtdStable.status === 'failing') ||
- (rtdLatest && rtdLatest.status === 'not_found')) {
- status = 'mismatch';
- }
-
- // Check host versions (NAS, etc.) against LOCAL
- const localTag = info.git && info.git.latest_tag ? info.git.latest_tag.replace(/^v/, '') : null;
- const localToml = info.local && info.local.pyproject_toml;
- Object.entries(hostData).forEach(([hostName, hostInfo]) => {
- if (hostName.startsWith('_')) return;
- const pkgInfo = hostInfo[name];
- if (pkgInfo) {
- const hostTag = pkgInfo.git_tag ? pkgInfo.git_tag.replace(/^v/, '') : null;
- const hostToml = pkgInfo.toml;
- const hostInstalled = pkgInfo.installed;
- // Mismatch if host tag differs from local tag
- if (localTag && hostTag && localTag !== hostTag) {
- status = 'mismatch';
- }
- // Mismatch if host toml differs from local toml
- if (localToml && hostToml && localToml !== hostToml) {
- status = 'mismatch';
- }
- // Mismatch if host installed differs from host toml
- if (hostToml && hostInstalled && hostToml !== hostInstalled) {
- status = 'mismatch';
- }
- }
- });
- }
- return status;
-}
-
-function getSourceStatuses(name, info, hostVersions, remoteVersions, rtdStatus) {
- const statuses = {};
- const local = info.local || {};
- const git = info.git || {};
- const remote = info.remote || {};
- const hostData = cachedData.hosts || {};
- const hostsLoading = !hostData || Object.keys(hostData).filter(k => !k.startsWith('_')).length === 0;
- const remotesLoading = !cachedData.remotes || Object.keys(cachedData.remotes).filter(k => !k.startsWith('_')).length === 0;
- const rtdLoading = !cachedData.rtd || Object.keys(cachedData.rtd).length === 0;
-
- const localToml = local.pyproject_toml;
- const localInstalled = local.installed;
- const localTag = git.latest_tag ? git.latest_tag.replace(/^v/, '') : null;
-
- // LOCAL status
- if (localToml && localInstalled) {
- statuses.local = (localToml === localInstalled) ? 'ok' : 'warn';
- } else if (localToml || localInstalled) {
- statuses.local = 'warn';
- } else {
- statuses.local = 'na';
- }
- // Downgrade local status if worktree is dirty or behind
- if (statuses.local === 'ok') {
- const gitInfo = info.git || {};
- if (gitInfo.dirty) statuses.local = 'warn';
- if (gitInfo.behind > 0) statuses.local = 'error';
- }
-
- // HOST statuses (NAS, etc.)
- hostVersions.forEach(h => {
- if (hostsLoading) {
- statuses[h.name] = 'loading';
- } else if (h.error || h.status === 'error' || h.status === 'not_installed') {
- statuses[h.name] = 'error';
- } else {
- const hostTag = h.git_tag ? h.git_tag.replace(/^v/, '') : null;
- // Compare host with local
- if (localTag && hostTag && localTag !== hostTag) {
- statuses[h.name] = 'error';
- } else if (localToml && h.toml && localToml !== h.toml) {
- statuses[h.name] = 'error';
- } else if (h.installed && h.toml && h.installed !== h.toml) {
- statuses[h.name] = 'warn';
- } else if (h.installed || h.toml) {
- statuses[h.name] = 'ok';
- } else {
- statuses[h.name] = 'na';
- }
- // Downgrade host status if worktree dirty or behind
- if (statuses[h.name] === 'ok') {
- if (h.git_dirty) statuses[h.name] = 'warn';
- if (h.git_behind > 0) statuses[h.name] = 'error';
- }
- }
- });
-
- // PYPI status
- if (remote.pypi) {
- statuses.pypi = (localToml && remote.pypi === localToml) ? 'ok' : (localToml ? 'warn' : 'ok');
- } else {
- statuses.pypi = 'na';
- }
-
- // GITHUB status
- if (remotesLoading) {
- statuses.github = 'loading';
- } else if (remoteVersions.length > 0) {
- const ghTag = remoteVersions[0].latest_tag;
- if (ghTag) {
- const ghVer = ghTag.replace(/^v/, '');
- statuses.github = (localToml && ghVer === localToml) ? 'ok' : (localToml ? 'warn' : 'ok');
- } else {
- statuses.github = remoteVersions[0].error ? 'error' : 'na';
- }
- } else {
- statuses.github = 'na';
- }
-
- // RTD status
- if (rtdLoading) {
- statuses.rtd = 'loading';
- } else if (rtdStatus && Object.keys(rtdStatus).length > 0) {
- const rtdLatest = rtdStatus['latest'];
- const rtdStable = rtdStatus['stable'];
- if ((rtdLatest && rtdLatest.status === 'failing') || (rtdStable && rtdStable.status === 'failing')) {
- statuses.rtd = 'error';
- } else if (rtdLatest && rtdLatest.status === 'not_found') {
- statuses.rtd = 'na';
- } else if ((rtdLatest && rtdLatest.status === 'passing') || (rtdStable && rtdStable.status === 'passing')) {
- statuses.rtd = 'ok';
- } else {
- statuses.rtd = 'warn';
- }
- } else {
- statuses.rtd = 'na';
- }
-
- return statuses;
-}
-
-function renderSourceBadges(sourceStatuses) {
- // Build order dynamically: local, then all hosts, then pypi/github/rtd
- const hostKeys = Object.keys(sourceStatuses).filter(k => !['local', 'pypi', 'github', 'rtd'].includes(k));
- const order = ['local', ...hostKeys, 'pypi', 'github', 'rtd'];
- const labels = { local: 'Local', pypi: 'PyPI', github: 'GitHub', rtd: 'RTD' };
-
- let html = '';
- order.forEach(key => {
- if (sourceStatuses[key] !== undefined) {
- const st = sourceStatuses[key];
- const cls = st === 'ok' ? 'src-ok' : st === 'warn' ? 'src-warn' : st === 'error' ? 'src-error' : st === 'loading' ? 'src-loading' : 'src-na';
- const label = labels[key] || key.toUpperCase();
- html += `${label}`;
- }
- });
- html += '';
- return html;
-}
-
-function renderPackageCard(name, info, local, git, remote, hostVersions, remoteVersions, rtdStatus) {
- const pypiUrl = `https://pypi.org/project/${name}/`;
- const githubUrl = `https://github.com/ywatanabe1989/${name}`;
- const rtdUrl = `https://${name === 'scitex' ? 'scitex-python' : name}.readthedocs.io/`;
-
- let allIssues = [...(info.issues || [])];
- let effectiveStatus = getEffectiveStatus(name, info);
-
- // Add host-related issues
- const localTag = git.latest_tag ? git.latest_tag.replace(/^v/, '') : null;
- const localToml = local.pyproject_toml;
- hostVersions.forEach(h => {
- const hostTag = h.git_tag ? h.git_tag.replace(/^v/, '') : null;
- if (localTag && hostTag && localTag !== hostTag) {
- allIssues.push(`${h.name.toUpperCase()} tag (${hostTag}) != LOCAL tag (${localTag})`);
- }
- if (localToml && h.toml && localToml !== h.toml) {
- allIssues.push(`${h.name.toUpperCase()} toml (${h.toml}) != LOCAL toml (${localToml})`);
- }
- });
-
- // RTD issues
- if (rtdStatus && Object.keys(rtdStatus).length > 0) {
- const rtdLatest = rtdStatus['latest'];
- const rtdStable = rtdStatus['stable'];
- if (rtdLatest && rtdLatest.status === 'failing') allIssues.push('RTD latest build failing');
- if (rtdStable && rtdStable.status === 'failing') allIssues.push('RTD stable build failing');
- if (rtdLatest && rtdLatest.status === 'not_found') allIssues.push('RTD project not found');
- }
-
- const tooltipText = allIssues.length > 0 ? allIssues.join('
') : '';
- const tooltipAttr = tooltipText ? `title="${tooltipText}"` : '';
-
- const hostMeta = (cachedData.hosts && cachedData.hosts._meta) || {};
- const sourceStatuses = getSourceStatuses(name, info, hostVersions, remoteVersions, rtdStatus);
- const sourceBadgesHtml = renderSourceBadges(sourceStatuses);
-
- let html = `
-
-
-
-
-
-
LOCAL (127.0.0.1)
-
toml${local.pyproject_toml || '-'}
-
installed${local.installed || '-'}
-
tag${git.latest_tag || '-'}
-
branch${git.branch || '-'}${git.short_hash ? ' (' + git.short_hash + ')' : ''}
- ${renderWorktreeStatus(git)}
-
`;
-
- // Host versions (NAS, etc.)
- const expectedHosts = [...document.querySelectorAll('#hostFilters input:checked')].map(el => el.value);
- const hostsLoading = !cachedData.hosts || Object.keys(cachedData.hosts).filter(k => !k.startsWith('_')).length === 0;
-
- expectedHosts.forEach(hostName => {
- const h = hostVersions.find(hv => hv.name === hostName) || {};
- const meta = hostMeta[hostName] || {};
- const ipDisplay = meta.hostname ? `
(${meta.hostname})` : '';
- const loadingClass = hostsLoading ? ' loading-cell' : '';
-
- html += `
${hostName.toUpperCase()} ${ipDisplay}
`;
- html += `
toml${hostsLoading ? '...' : (h.toml || '-')}
`;
- html += `
installed${hostsLoading ? '...' : (h.installed || h.error || '-')}
`;
- html += `
tag${hostsLoading ? '...' : (h.git_tag || '-')}
`;
- html += `
branch${hostsLoading ? '...' : ((h.git_branch || '-') + (h.git_hash ? ' (' + h.git_hash + ')' : ''))}
`;
- html += renderHostWorktreeStatus(h, hostsLoading);
- html += `
`;
- });
-
- html += `
-
-
-
published${remote.pypi || '-'}
-
`;
-
- if (remoteVersions.length > 0) {
- html += `
`;
- remoteVersions.forEach(r => {
- html += `
${r.name}${r.latest_tag || r.error || '-'}
`;
- });
- html += `
`;
- }
-
- if (rtdStatus && Object.keys(rtdStatus).length > 0) {
- html += `
`;
- Object.entries(rtdStatus).forEach(([version, data]) => {
- const statusClass = data.status === 'passing' ? 'rtd-passing' : (data.status === 'failing' ? 'rtd-failing' : 'rtd-unknown');
- const statusIcon = data.status === 'passing' ? '✓' : (data.status === 'failing' ? '✗' : '?');
- const link = data.url ? `
${statusIcon}` : statusIcon;
- html += `
${version}${link} ${data.status || '-'}
`;
- });
- html += `
`;
- }
-
- html += `
`;
-
- if (allIssues.length > 0) {
- html += `
Issues
`;
- allIssues.forEach(i => { html += `- ${i}
`; });
- html += `
`;
- }
-
- html += `
`;
- return html;
-}
-"""
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_scripts/_core.py b/src/scitex/_dev/_dashboard/_scripts/_core.py
deleted file mode 100755
index b75294e0c..000000000
--- a/src/scitex/_dev/_dashboard/_scripts/_core.py
+++ /dev/null
@@ -1,162 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-05
-# File: scitex/_dev/_dashboard/_scripts/_core.py
-
-"""Core fetch and cache functions for dashboard JavaScript."""
-
-
-def get_core_js() -> str:
- """Return core JavaScript for data fetching and caching."""
- return """
-let cachedData = { packages: {}, hosts: {}, remotes: {}, rtd: {} };
-
-async function fetchVersions() {
- showLoading(true);
- cachedData = { packages: {}, hosts: {}, remotes: {}, rtd: {} };
- renderFilters();
- renderData();
-
- // Load packages first (fast)
- fetchPackages();
- // Load hosts, remotes, and RTD in parallel (slower)
- fetchHosts();
- fetchRemotes();
- fetchRtd();
-}
-
-async function fetchPackages() {
- setSectionLoading('package', true);
- try {
- const response = await fetch('/api/packages');
- cachedData.packages = await response.json();
- renderFilters();
- renderData();
- updateTimestamp();
- setSectionUpdated('package');
- } catch (err) {
- console.error('Failed to fetch packages:', err);
- } finally {
- showLoading(false);
- setSectionLoading('package', false);
- }
-}
-
-async function fetchHosts() {
- setSectionLoading('host', true);
- try {
- const response = await fetch('/api/hosts');
- cachedData.hosts = await response.json();
- renderFilters();
- renderData();
- setSectionUpdated('host');
- } catch (err) {
- console.error('Failed to fetch hosts:', err);
- cachedData.hosts = { error: err.message };
- } finally {
- setSectionLoading('host', false);
- }
-}
-
-async function fetchRemotes() {
- setSectionLoading('remote', true);
- try {
- const response = await fetch('/api/remotes');
- cachedData.remotes = await response.json();
- renderFilters();
- renderData();
- setSectionUpdated('remote');
- } catch (err) {
- console.error('Failed to fetch remotes:', err);
- cachedData.remotes = { error: err.message };
- } finally {
- setSectionLoading('remote', false);
- }
-}
-
-async function fetchRtd() {
- setSectionLoading('rtd', true);
- try {
- const response = await fetch('/api/rtd');
- cachedData.rtd = await response.json();
- renderFilters();
- renderData();
- setSectionUpdated('rtd');
- } catch (err) {
- console.error('Failed to fetch RTD status:', err);
- cachedData.rtd = { error: err.message };
- } finally {
- setSectionLoading('rtd', false);
- }
-}
-
-function setSectionLoading(section, loading) {
- const el = document.getElementById(section + 'Filters');
- if (el) {
- if (loading) {
- el.classList.add('loading-section');
- } else {
- el.classList.remove('loading-section');
- }
- }
-}
-
-function setSectionUpdated(section) {
- const el = document.getElementById(section + 'Filters');
- if (el) {
- el.classList.add('just-updated');
- setTimeout(() => el.classList.remove('just-updated'), 1000);
- }
-}
-
-function updateTimestamp() {
- document.getElementById('lastUpdated').textContent =
- 'Last updated: ' + new Date().toLocaleTimeString();
-}
-
-function showLoading(show) {
- document.getElementById('loading').classList.toggle('active', show);
- document.getElementById('overlay').classList.toggle('active', show);
-}
-
-// Auto-refresh settings
-let autoRefreshInterval = null;
-let autoRefreshSeconds = 0;
-
-function toggleAutoRefresh(seconds) {
- if (autoRefreshInterval) {
- clearInterval(autoRefreshInterval);
- autoRefreshInterval = null;
- autoRefreshSeconds = 0;
- document.getElementById('autoRefreshBtn').textContent = 'Auto: Off';
- return;
- }
- autoRefreshSeconds = seconds;
- document.getElementById('autoRefreshBtn').textContent = `Auto: ${seconds}s`;
- autoRefreshInterval = setInterval(() => {
- fetchPackages();
- fetchHosts();
- fetchRemotes();
- }, seconds * 1000);
-}
-
-function cycleAutoRefresh() {
- const options = [0, 30, 60, 120];
- const current = options.indexOf(autoRefreshSeconds);
- const next = options[(current + 1) % options.length];
- if (autoRefreshInterval) {
- clearInterval(autoRefreshInterval);
- autoRefreshInterval = null;
- }
- if (next > 0) {
- toggleAutoRefresh(next);
- } else {
- autoRefreshSeconds = 0;
- document.getElementById('autoRefreshBtn').textContent = 'Auto: Off';
- }
-}
-
-async function refreshData() { await fetchVersions(); }
-"""
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_scripts/_filters.py b/src/scitex/_dev/_dashboard/_scripts/_filters.py
deleted file mode 100755
index 6f435c7e8..000000000
--- a/src/scitex/_dev/_dashboard/_scripts/_filters.py
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-05
-# File: scitex/_dev/_dashboard/_scripts/_filters.py
-
-"""Filter rendering functions for dashboard JavaScript."""
-
-
-def get_filters_js() -> str:
- """Return JavaScript for filter rendering."""
- return """
-function renderFilters() {
- if (!cachedData) return;
-
- const packageFilters = document.getElementById('packageFilters');
- const packages = Object.keys(cachedData.packages || {});
- packageFilters.innerHTML = packages.map(pkg =>
- ``
- ).join('');
-
- const hostFilters = document.getElementById('hostFilters');
- const hosts = Object.keys(cachedData.hosts || {}).filter(h => !h.startsWith('_'));
- if (hosts.length > 0) {
- hostFilters.innerHTML = hosts.map(host =>
- ``
- ).join('');
- } else {
- hostFilters.innerHTML = 'Loading...';
- }
-
- const remoteFilters = document.getElementById('remoteFilters');
- const remotes = Object.keys(cachedData.remotes || {}).filter(r => !r.startsWith('_'));
- if (remotes.length > 0) {
- remoteFilters.innerHTML = remotes.map(remote =>
- ``
- ).join('');
- } else {
- remoteFilters.innerHTML = 'Loading...';
- }
-
- const rtdFilters = document.getElementById('rtdFilters');
- const rtdVersions = Object.keys(cachedData.rtd || {});
- if (rtdVersions.length > 0) {
- rtdFilters.innerHTML = rtdVersions.map(v =>
- ``
- ).join('');
- } else {
- rtdFilters.innerHTML = 'Loading...';
- }
-
- document.querySelectorAll('#statusFilters input').forEach(input => {
- input.onchange = renderData;
- });
-}
-
-function getSelectedFilters() {
- const getChecked = (containerId) =>
- [...document.querySelectorAll(`#${containerId} input:checked`)].map(el => el.value);
- return {
- packages: getChecked('packageFilters'),
- statuses: getChecked('statusFilters'),
- hosts: getChecked('hostFilters'),
- remotes: getChecked('remoteFilters')
- };
-}
-
-function toggleFilters() {
- const filters = document.querySelector('.filters');
- filters.classList.toggle('collapsed');
-}
-"""
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_scripts/_render.py b/src/scitex/_dev/_dashboard/_scripts/_render.py
deleted file mode 100755
index ff236d0ef..000000000
--- a/src/scitex/_dev/_dashboard/_scripts/_render.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-05
-# File: scitex/_dev/_dashboard/_scripts/_render.py
-
-"""Data rendering functions for dashboard JavaScript."""
-
-
-def get_render_js() -> str:
- """Return JavaScript for data rendering."""
- return """
-function renderData() {
- if (!cachedData) return;
-
- // Save expanded states before re-render
- const expandedCards = new Set();
- document.querySelectorAll('.package-card:not(.collapsed)').forEach(card => {
- const nameEl = card.querySelector('.package-name');
- if (nameEl) expandedCards.add(nameEl.textContent);
- });
-
- const filters = getSelectedFilters();
- const packages = cachedData.packages || {};
-
- const filteredPackages = Object.entries(packages)
- .filter(([name, info]) => {
- if (!filters.packages.includes(name)) return false;
- const effectiveStatus = getEffectiveStatus(name, info);
- if (!filters.statuses.includes(effectiveStatus)) return false;
- return true;
- });
-
- const summary = {
- total: filteredPackages.length,
- ok: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'ok').length,
- unreleased: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'unreleased').length,
- mismatch: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'mismatch').length,
- outdated: filteredPackages.filter(([n, i]) => getEffectiveStatus(n, i) === 'outdated').length
- };
-
- document.getElementById('summary').innerHTML = `
-
-
- ${summary.unreleased}
Unreleased
- ${summary.mismatch}
Mismatch
- `;
-
- document.getElementById('packages').innerHTML = filteredPackages.map(([name, info]) => {
- const local = info.local || {};
- const git = info.git || {};
- const remote = info.remote || {};
- const hostData = cachedData.hosts || {};
- const remoteData = cachedData.remotes || {};
- const rtdData = cachedData.rtd || {};
-
- const hostVersions = Object.entries(hostData)
- .filter(([h]) => !h.startsWith('_') && filters.hosts.includes(h))
- .map(([hostName, hostInfo]) => ({ name: hostName, ...(hostInfo[name] || {}) }));
-
- const remoteVersions = Object.entries(remoteData)
- .filter(([r]) => !r.startsWith('_') && filters.remotes.includes(r))
- .map(([remoteName, remoteInfo]) => ({ name: remoteName, ...(remoteInfo[name] || {}) }));
-
- const rtdStatus = {};
- Object.entries(rtdData).forEach(([version, pkgData]) => {
- if (pkgData[name]) {
- rtdStatus[version] = pkgData[name];
- }
- });
-
- return renderPackageCard(name, info, local, git, remote, hostVersions, remoteVersions, rtdStatus);
- }).join('');
-
- // Restore expanded states after re-render
- document.querySelectorAll('.package-card').forEach(card => {
- const nameEl = card.querySelector('.package-name');
- if (nameEl && expandedCards.has(nameEl.textContent)) {
- card.classList.remove('collapsed');
- }
- });
-}
-"""
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_scripts/_utils.py b/src/scitex/_dev/_dashboard/_scripts/_utils.py
deleted file mode 100755
index e941cac58..000000000
--- a/src/scitex/_dev/_dashboard/_scripts/_utils.py
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-05
-# File: scitex/_dev/_dashboard/_scripts/_utils.py
-
-"""Utility functions for dashboard JavaScript."""
-
-
-def get_utils_js() -> str:
- """Return JavaScript utility functions."""
- return """
-function toggleCard(header) {
- const card = header.parentElement;
- card.classList.toggle('collapsed');
-}
-
-function toggleAllCards(expand) {
- document.querySelectorAll('.package-card').forEach(card => {
- if (expand) {
- card.classList.remove('collapsed');
- } else {
- card.classList.add('collapsed');
- }
- });
-}
-
-function exportJSON() {
- if (!cachedData) return;
- const blob = new Blob([JSON.stringify(cachedData, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = 'scitex-versions.json';
- a.click();
- URL.revokeObjectURL(url);
-}
-
-function copyResults() {
- if (!cachedData) return;
- const packages = cachedData.packages || {};
- const hosts = cachedData.hosts || {};
- const hostMeta = hosts._meta || {};
-
- const lines = ['# SciTeX Version Report', ''];
-
- Object.entries(packages).forEach(([name, info]) => {
- const local = info.local || {};
- const git = info.git || {};
- const remote = info.remote || {};
- const status = getEffectiveStatus(name, info);
-
- lines.push(`## ${name} [${status}]`);
- lines.push(`LOCAL: toml=${local.pyproject_toml || '-'}, installed=${local.installed || '-'}, tag=${git.latest_tag || '-'}, branch=${git.branch || '-'}`);
-
- // Add host info
- Object.entries(hosts).forEach(([hostName, hostInfo]) => {
- if (hostName.startsWith('_')) return;
- const pkgInfo = hostInfo[name] || {};
- const meta = hostMeta[hostName] || {};
- lines.push(`${hostName.toUpperCase()} (${meta.hostname || '?'}): toml=${pkgInfo.toml || '-'}, installed=${pkgInfo.installed || '-'}, tag=${pkgInfo.git_tag || '-'}, branch=${pkgInfo.git_branch || '-'}`);
- });
-
- lines.push(`PYPI: ${remote.pypi || '-'}`);
- lines.push('');
- });
-
- navigator.clipboard.writeText(lines.join('\\n')).then(() => {
- const btn = document.querySelector('[onclick="copyResults()"]');
- const orig = btn.textContent;
- btn.textContent = 'Copied!';
- setTimeout(() => { btn.textContent = orig; }, 2000);
- });
-}
-"""
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_styles.py b/src/scitex/_dev/_dashboard/_styles.py
deleted file mode 100755
index ef0148b9a..000000000
--- a/src/scitex/_dev/_dashboard/_styles.py
+++ /dev/null
@@ -1,336 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_dashboard/_styles.py
-
-"""CSS styles for the dashboard."""
-
-
-def get_css() -> str:
- """Return dashboard CSS."""
- return """
-:root {
- --bg-primary: #1a1a2e;
- --bg-secondary: #16213e;
- --bg-card: #0f3460;
- --text-primary: #eee;
- --text-secondary: #aaa;
- --accent: #e94560;
- --success: #4ade80;
- --warning: #fbbf24;
- --error: #f87171;
- --info: #60a5fa;
-}
-* { margin: 0; padding: 0; box-sizing: border-box; }
-body {
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- background: var(--bg-primary);
- color: var(--text-primary);
- min-height: 100vh;
- padding: 20px;
-}
-.container { max-width: 1400px; margin: 0 auto; }
-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 30px;
- padding: 20px;
- background: var(--bg-secondary);
- border-radius: 10px;
-}
-h1 { font-size: 1.8rem; color: var(--accent); }
-.actions { display: flex; gap: 10px; }
-button {
- padding: 10px 20px;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- font-size: 0.9rem;
- transition: all 0.3s ease;
-}
-.btn-primary { background: var(--accent); color: white; }
-.btn-primary:hover { background: #c9184a; }
-.btn-secondary { background: var(--bg-card); color: var(--text-primary); }
-.btn-secondary:hover { background: #1a4d80; }
-.filters {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 20px;
- margin-bottom: 30px;
- padding: 20px;
- background: var(--bg-secondary);
- border-radius: 10px;
-}
-.filter-group { display: flex; flex-direction: column; gap: 10px; }
-.filter-group h3 {
- font-size: 0.9rem;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 1px;
-}
-.filter-options { display: flex; flex-wrap: wrap; gap: 8px; }
-.filter-options label {
- display: flex;
- align-items: center;
- gap: 5px;
- padding: 5px 10px;
- background: var(--bg-card);
- border-radius: 5px;
- cursor: pointer;
- font-size: 0.85rem;
- transition: all 0.2s ease;
-}
-.filter-options label:hover { background: #1a4d80; }
-.filter-options input[type="checkbox"],
-.filter-options input[type="radio"] { accent-color: var(--accent); }
-.summary {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- gap: 15px;
- margin-bottom: 30px;
-}
-.summary-card {
- padding: 20px;
- background: var(--bg-secondary);
- border-radius: 10px;
- text-align: center;
-}
-.summary-card .number { font-size: 2rem; font-weight: bold; }
-.summary-card .label { font-size: 0.85rem; color: var(--text-secondary); margin-top: 5px; }
-.summary-card.ok .number { color: var(--success); }
-.summary-card.unreleased .number { color: var(--warning); }
-.summary-card.mismatch .number { color: var(--error); }
-.summary-card.total .number { color: var(--info); }
-.packages { display: grid; gap: 20px; }
-.package-card { background: var(--bg-secondary); border-radius: 10px; overflow: hidden; }
-.package-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 15px 20px;
- background: var(--bg-card);
-}
-.package-name { font-size: 1.1rem; font-weight: bold; }
-.status-badge {
- padding: 5px 12px;
- border-radius: 15px;
- font-size: 0.75rem;
- font-weight: bold;
- text-transform: uppercase;
-}
-.status-ok { background: rgba(74, 222, 128, 0.2); color: var(--success); }
-.status-unreleased { background: rgba(251, 191, 36, 0.2); color: var(--warning); }
-.status-mismatch { background: rgba(248, 113, 113, 0.2); color: var(--error); }
-.status-outdated { background: rgba(167, 139, 250, 0.2); color: #a78bfa; }
-.status-unavailable { background: rgba(156, 163, 175, 0.2); color: #9ca3af; }
-.status-error { background: rgba(248, 113, 113, 0.2); color: var(--error); }
-.package-body { padding: 20px; }
-.version-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 15px;
-}
-.version-section { padding: 10px; background: var(--bg-primary); border-radius: 5px; }
-.version-section h4 {
- font-size: 0.8rem;
- color: var(--text-secondary);
- margin-bottom: 8px;
- text-transform: uppercase;
-}
-.host-ip {
- font-size: 0.7rem;
- color: var(--text-secondary);
- opacity: 0.7;
- font-weight: normal;
- text-transform: none;
-}
-.version-item { display: flex; justify-content: space-between; padding: 5px 0; font-size: 0.9rem; }
-.version-item .key { color: var(--text-secondary); }
-.version-item .value { font-family: monospace; }
-.issues {
- margin-top: 15px;
- padding: 10px;
- background: rgba(248, 113, 113, 0.1);
- border-radius: 5px;
- border-left: 3px solid var(--error);
-}
-.issues h4 { font-size: 0.8rem; color: var(--error); margin-bottom: 5px; }
-.issues ul { list-style: none; font-size: 0.85rem; color: var(--text-secondary); }
-.issues li::before { content: "!"; margin-right: 8px; color: var(--error); }
-.loading {
- display: none;
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: var(--bg-secondary);
- padding: 30px;
- border-radius: 10px;
- text-align: center;
- z-index: 1000;
-}
-.loading.active { display: block; }
-.spinner {
- border: 4px solid var(--bg-card);
- border-top: 4px solid var(--accent);
- border-radius: 50%;
- width: 40px;
- height: 40px;
- animation: spin 1s linear infinite;
- margin: 0 auto 15px;
-}
-@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
-.overlay {
- display: none;
- position: fixed;
- top: 0; left: 0; right: 0; bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- z-index: 999;
-}
-.overlay.active { display: block; }
-.last-updated { font-size: 0.8rem; color: var(--text-secondary); }
-.loading-section { opacity: 0.5; position: relative; }
-.loading-section::after {
- content: "⟳";
- position: absolute;
- right: 5px;
- top: -20px;
- font-size: 1rem;
- animation: spin 1s linear infinite;
- color: var(--accent);
-}
-.just-updated {
- animation: flash-green 0.5s ease;
-}
-@keyframes flash-green {
- 0% { background: rgba(74, 222, 128, 0.3); }
- 100% { background: transparent; }
-}
-.rtd-passing { color: var(--success); }
-.rtd-failing { color: var(--error); }
-.rtd-unknown { color: var(--warning); }
-.rtd-passing a, .rtd-failing a, .rtd-unknown a {
- color: inherit;
- text-decoration: none;
-}
-.rtd-passing a:hover, .rtd-failing a:hover, .rtd-unknown a:hover {
- text-decoration: underline;
-}
-/* Collapsible cards */
-.package-card .package-header {
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 10px;
-}
-.package-card .fold-icon {
- transition: transform 0.2s ease;
- font-size: 0.8rem;
- color: var(--text-secondary);
-}
-.package-card:not(.collapsed) .fold-icon {
- transform: rotate(90deg);
-}
-.package-card.collapsed .package-body {
- display: none;
-}
-.package-card .package-name {
- color: var(--accent);
- text-decoration: none;
- font-weight: bold;
-}
-.package-card .package-name:hover {
- text-decoration: underline;
-}
-.package-card .quick-links {
- margin-left: auto;
- display: flex;
- gap: 8px;
- font-size: 1rem;
-}
-.package-card .quick-links a {
- text-decoration: none;
- opacity: 0.7;
- transition: opacity 0.2s;
-}
-.package-card .quick-links a:hover {
- opacity: 1;
-}
-/* Collapsible filters */
-.filters.collapsed .filter-group {
- display: none;
-}
-.filters-toggle {
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px;
- margin: -20px -20px 15px -20px;
- background: var(--bg-card);
- border-radius: 10px 10px 0 0;
- color: var(--text-secondary);
- font-size: 0.9rem;
-}
-.filters-toggle .fold-icon {
- transition: transform 0.2s ease;
-}
-.filters:not(.collapsed) .filters-toggle .fold-icon {
- transform: rotate(90deg);
-}
-.expand-controls {
- display: flex;
- gap: 10px;
- margin-bottom: 15px;
-}
-.expand-controls button {
- padding: 5px 12px;
- font-size: 0.8rem;
-}
-/* Section header links */
-.version-section h4 a {
- color: var(--text-secondary);
- text-decoration: none;
-}
-.version-section h4 a:hover {
- color: var(--accent);
- text-decoration: underline;
-}
-/* Source mini-badges */
-.source-badges {
- display: flex;
- gap: 4px;
- margin-left: 8px;
-}
-.source-badge {
- display: inline-flex;
- align-items: center;
- gap: 2px;
- padding: 2px 8px;
- border-radius: 8px;
- font-size: 0.6rem;
- font-weight: bold;
- opacity: 0.9;
-}
-.source-badge.src-ok { background: rgba(74, 222, 128, 0.25); color: var(--success); }
-.source-badge.src-warn { background: rgba(251, 191, 36, 0.25); color: var(--warning); }
-.source-badge.src-error { background: rgba(248, 113, 113, 0.25); color: var(--error); }
-.source-badge.src-loading { background: rgba(156, 163, 175, 0.2); color: #9ca3af; }
-.source-badge.src-na { background: rgba(156, 163, 175, 0.15); color: #6b7280; }
-/* Loading indicator in cells */
-.loading-cell {
- opacity: 0.6;
-}
-.loading-cell .value {
- color: var(--text-secondary);
- font-style: italic;
-}
-/* Worktree status indicators */
-.wt-clean { color: var(--success); font-weight: bold; }
-.wt-dirty { color: var(--error); font-weight: bold; }
-.wt-ahead { color: var(--warning); }
-.wt-behind { color: var(--error); }
-"""
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/_templates.py b/src/scitex/_dev/_dashboard/_templates.py
deleted file mode 100755
index 35b6a7f31..000000000
--- a/src/scitex/_dev/_dashboard/_templates.py
+++ /dev/null
@@ -1,131 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_dashboard/_templates.py
-
-"""HTML template generation for the dashboard."""
-
-from ._scripts import get_javascript
-from ._styles import get_css
-
-
-def get_dashboard_html() -> str:
- """Generate the main dashboard HTML."""
- css = get_css()
- js = get_javascript()
-
- return f"""
-
-
-
-
- SciTeX Version Dashboard
-
-
-
-
-
-
-
-
-
- ▶
- Filters & Config
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Loading version data...
-
-
-
-
-
-"""
-
-
-def get_error_html(error: str) -> str:
- """Generate error page HTML."""
- return f"""
-
-
- Error - SciTeX Dashboard
-
-
-
-
-
-
-"""
-
-
-# EOF
diff --git a/src/scitex/_dev/_dashboard/static/version-dashboard-favicon.svg b/src/scitex/_dev/_dashboard/static/version-dashboard-favicon.svg
deleted file mode 100644
index 5faa40833..000000000
--- a/src/scitex/_dev/_dashboard/static/version-dashboard-favicon.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
diff --git a/src/scitex/_dev/_ecosystem.py b/src/scitex/_dev/_ecosystem.py
deleted file mode 100755
index 04eae4f2b..000000000
--- a/src/scitex/_dev/_ecosystem.py
+++ /dev/null
@@ -1,157 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-03
-# File: scitex/_dev/_ecosystem.py
-
-"""SciTeX ecosystem package registry."""
-
-from pathlib import Path
-from typing import Dict, List, Optional, TypedDict
-
-
-class PackageInfo(TypedDict, total=False):
- """Package information structure."""
-
- local_path: str
- pypi_name: str
- github_repo: str
- import_name: str
-
-
-# Ordered dict - order matters for display
-ECOSYSTEM: Dict[str, PackageInfo] = {
- "scitex": {
- "local_path": "~/proj/scitex-python",
- "pypi_name": "scitex",
- "github_repo": "ywatanabe1989/scitex-python",
- "import_name": "scitex",
- },
- "scitex-io": {
- "local_path": "~/proj/scitex-io",
- "pypi_name": "scitex-io",
- "github_repo": "ywatanabe1989/scitex-io",
- "import_name": "scitex_io",
- },
- "scitex-stats": {
- "local_path": "~/proj/scitex-stats",
- "pypi_name": "scitex-stats",
- "github_repo": "ywatanabe1989/scitex-stats",
- "import_name": "scitex_stats",
- },
- "scitex-clew": {
- "local_path": "~/proj/scitex-clew",
- "pypi_name": "scitex-clew",
- "github_repo": "ywatanabe1989/scitex-clew",
- "import_name": "scitex_clew",
- },
- "scitex-notification": {
- "local_path": "~/proj/scitex-notification",
- "pypi_name": "scitex-notification",
- "github_repo": "ywatanabe1989/scitex-notification",
- "import_name": "scitex_notification",
- },
- "scitex-cloud": {
- "local_path": "~/proj/scitex-cloud",
- "pypi_name": "scitex-cloud",
- "github_repo": "ywatanabe1989/scitex-cloud",
- "import_name": "scitex_cloud",
- },
- "figrecipe": {
- "local_path": "~/proj/figrecipe",
- "pypi_name": "figrecipe",
- "github_repo": "ywatanabe1989/figrecipe",
- "import_name": "figrecipe",
- },
- "scitex-plt": {
- "local_path": "~/proj/scitex-plt",
- "pypi_name": "scitex-plt",
- "github_repo": "ywatanabe1989/scitex-plt",
- "import_name": "scitex_plt",
- },
- "openalex-local": {
- "local_path": "~/proj/openalex-local",
- "pypi_name": "openalex-local",
- "github_repo": "ywatanabe1989/openalex-local",
- "import_name": "openalex_local",
- },
- "crossref-local": {
- "local_path": "~/proj/crossref-local",
- "pypi_name": "crossref-local",
- "github_repo": "ywatanabe1989/crossref-local",
- "import_name": "crossref_local",
- },
- "scitex-writer": {
- "local_path": "~/proj/scitex-writer",
- "pypi_name": "scitex-writer",
- "github_repo": "ywatanabe1989/scitex-writer",
- "import_name": "scitex_writer",
- },
- "scitex-linter": {
- "local_path": "~/proj/scitex-linter",
- "pypi_name": "scitex-linter",
- "github_repo": "ywatanabe1989/scitex-linter",
- "import_name": "scitex_linter",
- },
- "scitex-dataset": {
- "local_path": "~/proj/scitex-dataset",
- "pypi_name": "scitex-dataset",
- "github_repo": "ywatanabe1989/scitex-dataset",
- "import_name": "scitex_dataset",
- },
- "socialia": {
- "local_path": "~/proj/socialia",
- "pypi_name": "socialia",
- "github_repo": "ywatanabe1989/socialia",
- "import_name": "socialia",
- },
- "automated-research-demo": {
- "local_path": "~/proj/automated-research-demo",
- "pypi_name": "automated-research-demo",
- "github_repo": "ywatanabe1989/automated-research-demo",
- "import_name": "automated_research_demo",
- },
- "scitex-research-template": {
- "local_path": "~/proj/scitex-research-template",
- "pypi_name": "scitex-research-template",
- "github_repo": "ywatanabe1989/scitex-research-template",
- "import_name": "scitex_research_template",
- },
- "pip-project-template": {
- "local_path": "~/proj/pip-project-template",
- "pypi_name": "pip-project-template",
- "github_repo": "ywatanabe1989/pip-project-template",
- "import_name": "pip_project_template",
- },
- "scitex-container": {
- "local_path": "~/proj/scitex-container",
- "pypi_name": "scitex-container",
- "github_repo": "ywatanabe1989/scitex-container",
- "import_name": "scitex_container",
- },
- "scitex-tunnel": {
- "local_path": "~/proj/scitex-tunnel",
- "pypi_name": "scitex-tunnel",
- "github_repo": "ywatanabe1989/scitex-tunnel",
- "import_name": "scitex_tunnel",
- },
- "singularity-template": {
- "local_path": "~/proj/singularity-template",
- "pypi_name": "singularity-template",
- "github_repo": "ywatanabe1989/singularity-template",
- "import_name": "singularity_template",
- },
-}
-
-
-def get_local_path(package: str) -> Optional[Path]:
- """Get expanded local path for a package."""
- if package not in ECOSYSTEM:
- return None
- return Path(ECOSYSTEM[package]["local_path"]).expanduser()
-
-
-def get_all_packages() -> List[str]:
- """Get list of all ecosystem package names."""
- return list(ECOSYSTEM.keys())
-
-
-# EOF
diff --git a/src/scitex/_dev/_fix.py b/src/scitex/_dev/_fix.py
deleted file mode 100755
index 75a9e851e..000000000
--- a/src/scitex/_dev/_fix.py
+++ /dev/null
@@ -1,122 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-03-11
-# File: scitex/_dev/_fix.py
-
-"""Detect and fix version mismatches across the ecosystem.
-
-Combines detection (list_versions) with sync (sync_local + sync_all)
-into a single command.
-
-Safety model: defaults to dry_run (confirm=False).
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-from ._config import DevConfig, load_config
-from ._sync import sync_all, sync_local
-from ._versions import get_mismatches
-
-
-def fix_mismatches(
- hosts: list[str] | None = None,
- packages: list[str] | None = None,
- local: bool = True,
- remote: bool = True,
- confirm: bool = False,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Detect version mismatches and fix them.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- hosts : list[str] | None
- Host names to fix. None = all enabled hosts.
- packages : list[str] | None
- Package names. None = all with mismatches.
- local : bool
- Fix local mismatches (pip install -e .).
- remote : bool
- Fix remote mismatches (git pull + pip install on hosts).
- confirm : bool
- If False (default), preview only.
- If True, execute fixes.
- config : DevConfig | None
- Configuration.
-
- Returns
- -------
- dict
- {detected, local_fixes, remote_fixes, summary}
- """
- if config is None:
- config = load_config()
-
- mismatches = get_mismatches(packages)
- mismatch_names = list(mismatches.keys()) if not packages else packages
-
- result: dict[str, Any] = {
- "detected": {
- pkg: {"status": info.get("status"), "issues": info.get("issues", [])}
- for pkg, info in mismatches.items()
- },
- "local_fixes": {},
- "remote_fixes": {},
- "summary": {"detected": len(mismatches), "local_fixed": 0, "remote_fixed": 0},
- }
-
- if not mismatch_names:
- return result
-
- # Fix local: pip install -e . where installed != toml
- if local:
- local_to_fix = _find_local_mismatches(mismatches)
- if local_to_fix:
- result["local_fixes"] = sync_local(
- packages=local_to_fix, confirm=confirm, config=config
- )
- if confirm:
- result["summary"]["local_fixed"] = sum(
- 1 for r in result["local_fixes"].values() if r.get("status") == "ok"
- )
-
- # Fix remote: git pull + pip install on hosts
- if remote:
- result["remote_fixes"] = sync_all(
- hosts=hosts,
- packages=mismatch_names,
- stash=True,
- install=True,
- confirm=confirm,
- config=config,
- )
- if confirm:
- for host_results in result["remote_fixes"].values():
- if isinstance(host_results, dict):
- result["summary"]["remote_fixed"] += sum(
- 1
- for r in host_results.values()
- if isinstance(r, dict) and r.get("status") == "ok"
- )
-
- return result
-
-
-def _find_local_mismatches(mismatches: dict[str, Any]) -> list[str]:
- """Extract package names where local installed != toml version."""
- to_fix = []
- for pkg, info in mismatches.items():
- lv = info.get("local", {})
- toml = lv.get("pyproject_toml")
- installed = lv.get("installed")
- if toml and installed and toml != installed:
- to_fix.append(pkg)
- elif toml and not installed:
- to_fix.append(pkg)
- return to_fix
-
-
-# EOF
diff --git a/src/scitex/_dev/_github.py b/src/scitex/_dev/_github.py
deleted file mode 100755
index 362c77971..000000000
--- a/src/scitex/_dev/_github.py
+++ /dev/null
@@ -1,360 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_github.py
-
-"""GitHub API integration for version checking."""
-
-from __future__ import annotations
-
-import os
-import urllib.request
-from typing import Any
-
-from ._config import DevConfig, GitHubRemote, get_enabled_remotes, load_config
-
-
-def _get_github_token() -> str | None:
- """Get GitHub token from environment."""
- return os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
-
-
-def _github_api_request(url: str) -> dict[str, Any] | list[Any] | None:
- """Make a GitHub API request.
-
- Parameters
- ----------
- url : str
- API URL to fetch.
-
- Returns
- -------
- dict | list | None
- JSON response or None on error.
- """
- import json
-
- headers = {
- "Accept": "application/vnd.github.v3+json",
- "User-Agent": "scitex-dev",
- }
-
- token = _get_github_token()
- if token:
- headers["Authorization"] = f"token {token}"
-
- req = urllib.request.Request(url, headers=headers)
-
- try:
- with urllib.request.urlopen(req, timeout=10) as response:
- return json.loads(response.read().decode())
- except urllib.error.HTTPError as e:
- if e.code == 404:
- return None
- raise
- except Exception:
- return None
-
-
-def get_github_tags(org: str, repo: str) -> list[str]:
- """Get tags from a GitHub repository.
-
- Parameters
- ----------
- org : str
- GitHub organization or user.
- repo : str
- Repository name.
-
- Returns
- -------
- list[str]
- List of tag names (most recent first).
- """
- url = f"https://api.github.com/repos/{org}/{repo}/tags?per_page=10"
- data = _github_api_request(url)
-
- if not data or not isinstance(data, list):
- return []
-
- return [tag.get("name", "") for tag in data if tag.get("name")]
-
-
-def get_github_latest_tag(org: str, repo: str) -> str | None:
- """Get the latest version tag from a GitHub repository.
-
- Parameters
- ----------
- org : str
- GitHub organization or user.
- repo : str
- Repository name.
-
- Returns
- -------
- str | None
- Latest version tag or None.
- """
- tags = get_github_tags(org, repo)
-
- # Filter to version tags (v*)
- version_tags = [t for t in tags if t.startswith("v")]
- if version_tags:
- return version_tags[0]
-
- # Fallback: any tags
- return tags[0] if tags else None
-
-
-def get_github_release(org: str, repo: str) -> dict[str, Any] | None:
- """Get the latest release from a GitHub repository.
-
- Parameters
- ----------
- org : str
- GitHub organization or user.
- repo : str
- Repository name.
-
- Returns
- -------
- dict | None
- Release info with keys: tag_name, name, published_at, prerelease.
- """
- url = f"https://api.github.com/repos/{org}/{repo}/releases/latest"
- data = _github_api_request(url)
-
- if not data or not isinstance(data, dict):
- return None
-
- return {
- "tag_name": data.get("tag_name"),
- "name": data.get("name"),
- "published_at": data.get("published_at"),
- "prerelease": data.get("prerelease", False),
- }
-
-
-def get_github_repo_info(org: str, repo: str) -> dict[str, Any] | None:
- """Get basic info about a GitHub repository.
-
- Parameters
- ----------
- org : str
- GitHub organization or user.
- repo : str
- Repository name.
-
- Returns
- -------
- dict | None
- Repository info with keys: default_branch, description, stars, forks.
- """
- url = f"https://api.github.com/repos/{org}/{repo}"
- data = _github_api_request(url)
-
- if not data or not isinstance(data, dict):
- return None
-
- return {
- "default_branch": data.get("default_branch"),
- "description": data.get("description"),
- "stars": data.get("stargazers_count", 0),
- "forks": data.get("forks_count", 0),
- "private": data.get("private", False),
- }
-
-
-def check_github_remote(
- remote: GitHubRemote,
- packages: list[str],
- config: DevConfig | None = None,
-) -> dict[str, dict[str, Any]]:
- """Check versions for packages on a GitHub remote.
-
- Parameters
- ----------
- remote : GitHubRemote
- GitHub remote configuration.
- packages : list[str]
- List of package names to check.
- config : DevConfig | None
- Configuration for package -> repo mapping.
-
- Returns
- -------
- dict
- Package name -> version info mapping.
- """
- if config is None:
- config = load_config()
-
- from ._ecosystem import ECOSYSTEM
-
- results = {}
-
- for pkg in packages:
- # Get repo name from ecosystem or config
- repo = None
- if pkg in ECOSYSTEM:
- github_repo = ECOSYSTEM[pkg].get("github_repo", "")
- if github_repo:
- # Extract repo name (last part after /)
- repo = github_repo.split("/")[-1]
-
- # Check config packages
- if not repo:
- for pkg_conf in config.packages:
- if pkg_conf.name == pkg and pkg_conf.github_repo:
- repo = pkg_conf.github_repo.split("/")[-1]
- break
-
- if not repo:
- # Try package name as repo name
- repo = pkg
-
- # Get tag and release info
- try:
- latest_tag = get_github_latest_tag(remote.org, repo)
- release = get_github_release(remote.org, repo)
-
- results[pkg] = {
- "org": remote.org,
- "repo": repo,
- "latest_tag": latest_tag,
- "release": release.get("tag_name") if release else None,
- "release_date": release.get("published_at") if release else None,
- "status": "ok" if latest_tag else "no_tags",
- }
- except Exception as e:
- results[pkg] = {
- "org": remote.org,
- "repo": repo,
- "latest_tag": None,
- "release": None,
- "status": "error",
- "error": str(e),
- }
-
- return results
-
-
-def check_all_remotes(
- packages: list[str] | None = None,
- remotes: list[str] | None = None,
- config: DevConfig | None = None,
-) -> dict[str, dict[str, dict[str, Any]]]:
- """Check versions on all enabled GitHub remotes.
-
- Parameters
- ----------
- packages : list[str] | None
- List of package names. If None, uses ecosystem packages.
- remotes : list[str] | None
- List of remote names to check. If None, checks all enabled remotes.
- config : DevConfig | None
- Configuration to use. If None, loads default config.
-
- Returns
- -------
- dict
- Mapping: remote_name -> package_name -> version_info
- """
- if config is None:
- config = load_config()
-
- if packages is None:
- from ._ecosystem import get_all_packages
-
- packages = get_all_packages()
-
- enabled_remotes = get_enabled_remotes(config)
- if remotes:
- enabled_remotes = [r for r in enabled_remotes if r.name in remotes]
-
- results = {}
-
- for remote in enabled_remotes:
- results[remote.name] = check_github_remote(remote, packages, config)
- results[remote.name]["_remote"] = {
- "org": remote.org,
- }
-
- return results
-
-
-def compare_with_local(
- packages: list[str] | None = None,
- config: DevConfig | None = None,
-) -> dict[str, dict[str, Any]]:
- """Compare local versions with GitHub remotes.
-
- Parameters
- ----------
- packages : list[str] | None
- List of package names. If None, uses ecosystem packages.
- config : DevConfig | None
- Configuration to use.
-
- Returns
- -------
- dict
- Comparison results with local vs remote versions.
- """
- if config is None:
- config = load_config()
-
- if packages is None:
- from ._ecosystem import get_all_packages
-
- packages = get_all_packages()
-
- from ._versions import list_versions
-
- local_versions = list_versions(packages)
- remote_versions = check_all_remotes(packages, config=config)
-
- results = {}
-
- for pkg in packages:
- local_info = local_versions.get(pkg, {})
- local_tag = local_info.get("git", {}).get("latest_tag")
- local_toml = local_info.get("local", {}).get("pyproject_toml")
-
- pkg_result = {
- "local": {
- "tag": local_tag,
- "toml": local_toml,
- },
- "remotes": {},
- "sync_status": "ok",
- "issues": [],
- }
-
- for remote_name, remote_data in remote_versions.items():
- if remote_name.startswith("_"):
- continue
-
- pkg_remote = remote_data.get(pkg, {})
- remote_tag = pkg_remote.get("latest_tag")
-
- pkg_result["remotes"][remote_name] = {
- "tag": remote_tag,
- "release": pkg_remote.get("release"),
- }
-
- # Check sync status
- if local_tag and remote_tag and local_tag != remote_tag:
- pkg_result["issues"].append(
- f"local tag ({local_tag}) != {remote_name} ({remote_tag})"
- )
- pkg_result["sync_status"] = "out_of_sync"
-
- if pkg_result["issues"]:
- pkg_result["sync_status"] = "out_of_sync"
-
- results[pkg] = pkg_result
-
- return results
-
-
-# EOF
diff --git a/src/scitex/_dev/_mcp/__init__.py b/src/scitex/_dev/_mcp/__init__.py
deleted file mode 100755
index c8934813d..000000000
--- a/src/scitex/_dev/_mcp/__init__.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_mcp/__init__.py
-
-"""MCP handlers for developer utilities."""
-
-from .handlers import (
- fix_mismatches_handler,
- get_config_handler,
- list_versions_handler,
- pull_local_handler,
- remote_commit_handler,
- remote_diff_handler,
- rename_handler,
- sync_handler,
- sync_local_handler,
- test_hpc_poll_handler,
- test_hpc_result_handler,
- test_hpc_run_handler,
- test_run_handler,
-)
-
-__all__ = [
- "fix_mismatches_handler",
- "get_config_handler",
- "list_versions_handler",
- "pull_local_handler",
- "remote_commit_handler",
- "remote_diff_handler",
- "rename_handler",
- "sync_handler",
- "sync_local_handler",
- "test_hpc_poll_handler",
- "test_hpc_result_handler",
- "test_hpc_run_handler",
- "test_run_handler",
-]
-
-# EOF
diff --git a/src/scitex/_dev/_mcp/handlers.py b/src/scitex/_dev/_mcp/handlers.py
deleted file mode 100755
index 14f33044f..000000000
--- a/src/scitex/_dev/_mcp/handlers.py
+++ /dev/null
@@ -1,468 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_mcp/handlers.py
-
-"""MCP handler implementations for developer utilities."""
-
-from __future__ import annotations
-
-from typing import Any
-
-
-async def list_versions_handler(
- packages: list[str] | None = None,
-) -> dict[str, Any]:
- """List versions across the scitex ecosystem.
-
- Parameters
- ----------
- packages : list[str] | None
- List of package names to check. If None, checks all ecosystem packages.
-
- Returns
- -------
- dict
- Version information for each package.
- """
- from scitex._dev import list_versions
-
- return list_versions(packages)
-
-
-async def get_config_handler() -> dict[str, Any]:
- """Get current developer configuration.
-
- Returns
- -------
- dict
- Configuration including packages, hosts, remotes, branches.
- """
- from scitex._dev._config import config_to_dict, get_config_path, load_config
-
- config = load_config()
- return config_to_dict(config, config_path=get_config_path())
-
-
-async def test_run_handler(
- module: str = "",
- fast: bool = False,
- coverage: bool = False,
- exitfirst: bool = True,
- pattern: str = "",
- parallel: str = "auto",
-) -> dict[str, Any]:
- """Run tests locally.
-
- Parameters
- ----------
- module : str
- Module to test (e.g., "stats", "io"). Empty for all.
- fast : bool
- Skip slow tests.
- coverage : bool
- Enable coverage.
- exitfirst : bool
- Stop on first failure.
- pattern : str
- Test name filter pattern.
- parallel : str
- Parallel workers ("auto", "0", or number).
-
- Returns
- -------
- dict
- {"exit_code": int}
- """
- from scitex._dev._test import TestConfig, run_local
-
- config = TestConfig(
- module=module,
- fast=fast,
- coverage=coverage,
- exitfirst=exitfirst,
- pattern=pattern,
- parallel=parallel,
- )
- exit_code = run_local(config)
- return {"exit_code": exit_code}
-
-
-async def test_hpc_run_handler(
- module: str = "",
- fast: bool = False,
- hpc_cpus: int = 8,
- hpc_partition: str = "sapphire",
- hpc_time: str = "00:10:00",
- hpc_mem: str = "16G",
- async_mode: bool = False,
-) -> dict[str, Any]:
- """Run tests on HPC via Slurm.
-
- Parameters
- ----------
- module : str
- Module to test.
- fast : bool
- Skip slow tests.
- hpc_cpus : int
- CPUs per task.
- hpc_partition : str
- Slurm partition.
- hpc_time : str
- Time limit.
- hpc_mem : str
- Memory limit.
- async_mode : bool
- If True, submit via sbatch (returns job ID).
- If False, run blocking via srun.
-
- Returns
- -------
- dict
- {"exit_code": int} for srun, {"job_id": str} for sbatch.
- """
- from scitex._dev._test import (
- TestConfig,
- _check_ssh,
- _get_hpc_config,
- run_hpc_sbatch,
- run_hpc_srun,
- sync_to_hpc,
- )
-
- config = TestConfig(
- module=module,
- fast=fast,
- hpc_cpus=hpc_cpus,
- hpc_partition=hpc_partition,
- hpc_time=hpc_time,
- hpc_mem=hpc_mem,
- )
- hpc_cfg = _get_hpc_config(config)
- host = hpc_cfg["host"]
-
- if not _check_ssh(host):
- return {"error": f"Cannot connect to {host}"}
-
- if not sync_to_hpc(config):
- return {"error": "rsync failed"}
-
- if async_mode:
- job_id = run_hpc_sbatch(config)
- if job_id:
- return {"job_id": job_id, "host": host}
- return {"error": "Failed to submit job"}
- else:
- exit_code = run_hpc_srun(config)
- return {"exit_code": exit_code, "host": host}
-
-
-async def test_hpc_poll_handler(
- job_id: str | None = None,
-) -> dict[str, Any]:
- """Poll HPC job status.
-
- Parameters
- ----------
- job_id : str, optional
- Job ID. If None, uses last submitted job.
-
- Returns
- -------
- dict
- {"state": str, "output": str or None, "job_id": str}
- """
- from scitex._dev._test import poll_hpc_job
-
- return poll_hpc_job(job_id=job_id)
-
-
-async def test_hpc_result_handler(
- job_id: str | None = None,
-) -> dict[str, Any]:
- """Fetch full HPC test output.
-
- Parameters
- ----------
- job_id : str, optional
- Job ID. If None, uses last submitted job.
-
- Returns
- -------
- dict
- {"output": str or None, "job_id": str}
- """
- from scitex._dev._test import fetch_hpc_result
-
- output = fetch_hpc_result(job_id=job_id)
- return {"output": output, "job_id": job_id or "last"}
-
-
-async def sync_handler(
- hosts: list[str] | None = None,
- packages: list[str] | None = None,
- install: bool = True,
- confirm: bool = False,
-) -> dict[str, Any]:
- """Sync ecosystem packages to remote hosts.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- hosts : list[str] | None
- Host names to sync. None = all enabled hosts.
- packages : list[str] | None
- Package names. None = host-specific defaults.
- install : bool
- Pip install after pull (default True).
- confirm : bool
- If False (default), preview only (dry run).
- If True, execute the sync operation.
-
- Returns
- -------
- dict
- {host_name: {package: result}}.
- """
- from scitex._dev._sync import sync_all
-
- return sync_all(hosts=hosts, packages=packages, install=install, confirm=confirm)
-
-
-async def sync_local_handler(
- packages: list[str] | None = None,
- confirm: bool = False,
-) -> dict[str, Any]:
- """Install all local editable packages.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- packages : list[str] | None
- Package names. None = all configured packages.
- confirm : bool
- If False (default), preview only.
- If True, execute pip install -e.
-
- Returns
- -------
- dict
- {package: {status, output}}.
- """
- from scitex._dev._sync import sync_local
-
- return sync_local(packages=packages, confirm=confirm)
-
-
-async def remote_diff_handler(
- host: str | None = None,
- packages: list[str] | None = None,
-) -> dict[str, Any]:
- """Show git diff on remote host(s). Read-only operation.
-
- Parameters
- ----------
- host : str | None
- Host name. None = first enabled host.
- packages : list[str] | None
- Package names. None = host-configured defaults.
-
- Returns
- -------
- dict
- {host_name: {package: {status, files, diff_stat, diff}}}.
- """
- from scitex._dev._sync_remote import remote_diff
-
- return remote_diff(host=host, packages=packages)
-
-
-async def remote_commit_handler(
- host: str,
- packages: list[str] | None = None,
- message: str | None = None,
- push: bool = True,
- confirm: bool = False,
-) -> dict[str, Any]:
- """Commit dirty changes on a remote host and optionally push to origin.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- host : str
- Host name (required).
- packages : list[str] | None
- Package names. None = host-configured defaults.
- message : str | None
- Commit message. Auto-generated if not provided.
- push : bool
- Push to origin after commit (default True).
- confirm : bool
- If False (default), preview only.
-
- Returns
- -------
- dict
- {package: {status, commands|output}}.
- """
- from scitex._dev._sync_remote import remote_commit
-
- return remote_commit(
- host=host, packages=packages, message=message, push=push, confirm=confirm
- )
-
-
-async def pull_local_handler(
- packages: list[str] | None = None,
- confirm: bool = False,
- stash: bool = True,
-) -> dict[str, Any]:
- """Pull latest from origin to local repos.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- packages : list[str] | None
- Package names. None = all configured packages.
- confirm : bool
- If False (default), preview only.
- stash : bool
- If True (default), stash local changes before pull and pop after.
-
- Returns
- -------
- dict
- {package: {status, output|commands, stashed}}.
- """
- from scitex._dev._sync_remote import pull_local
-
- return pull_local(packages=packages, confirm=confirm, stash=stash)
-
-
-async def rename_handler(
- pattern: str,
- replacement: str,
- directory: str = ".",
- confirm: bool = False,
- django_safe: bool = True,
- extra_excludes: list[str] | None = None,
- force: bool = False,
- skip_ids: list[str] | None = None,
- use_sudo: bool = False,
- sudo_password: str | None = None,
-) -> dict[str, Any]:
- """Bulk rename files, contents, directories, and symlinks.
-
- Two-step safety: call first without confirm to preview,
- then with confirm=True to execute.
-
- Parameters
- ----------
- pattern : str
- Pattern to search for.
- replacement : str
- String to replace matches with.
- directory : str
- Target directory.
- confirm : bool
- If False (default), dry-run preview. If True, execute.
- django_safe : bool
- Enable Django-safe mode.
- extra_excludes : list of str, optional
- Additional exclude patterns.
- force : bool
- Skip uncommitted changes check (default False).
- skip_ids : list of str, optional
- IDs of changes to skip (from preview output).
- use_sudo : bool
- Use sudo for file operations (default False).
- sudo_password : str, optional
- Password for non-interactive sudo -S. Required when use_sudo=True
- on systems without NOPASSWD configured.
-
- Returns
- -------
- dict
- Rename results with summary.
- """
- from dataclasses import asdict
-
- from scitex._dev._rename import RenameConfig, bulk_rename
- from scitex._dev._rename._safety import has_uncommitted_changes
-
- if confirm and not force and has_uncommitted_changes(directory):
- return {"error": "Uncommitted changes detected. Commit or stash first."}
-
- if use_sudo and sudo_password:
- from scitex._dev._rename._io import set_sudo_password
-
- set_sudo_password(sudo_password)
-
- config = RenameConfig(
- pattern=pattern,
- replacement=replacement,
- directory=directory,
- dry_run=not confirm,
- django_safe=django_safe,
- extra_excludes=extra_excludes or [],
- skip_ids=skip_ids or [],
- use_sudo=use_sudo,
- )
- result = bulk_rename(config)
-
- if use_sudo and sudo_password:
- from scitex._dev._rename._io import set_sudo_password
-
- set_sudo_password(None)
-
- return asdict(result)
-
-
-async def fix_mismatches_handler(
- hosts: list[str] | None = None,
- packages: list[str] | None = None,
- local: bool = True,
- remote: bool = True,
- confirm: bool = False,
-) -> dict[str, Any]:
- """Detect and fix version mismatches across the ecosystem.
-
- Combines mismatch detection with sync: pip install locally,
- git pull + pip install on remote hosts.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- hosts : list[str] | None
- Host names. None = all enabled hosts.
- packages : list[str] | None
- Package names. None = all with mismatches.
- local : bool
- Fix local mismatches (default True).
- remote : bool
- Fix remote mismatches (default True).
- confirm : bool
- If False (default), preview only.
-
- Returns
- -------
- dict
- {detected, local_fixes, remote_fixes, summary}
- """
- from scitex._dev._fix import fix_mismatches
-
- return fix_mismatches(
- hosts=hosts,
- packages=packages,
- local=local,
- remote=remote,
- confirm=confirm,
- )
-
-
-# EOF
diff --git a/src/scitex/_dev/_rename/__init__.py b/src/scitex/_dev/_rename/__init__.py
deleted file mode 100755
index ec7e5a432..000000000
--- a/src/scitex/_dev/_rename/__init__.py
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-14
-# File: scitex/_dev/_rename/__init__.py
-
-"""Bulk rename utility for files, contents, directories, and symlinks.
-
-Ported from rename.sh - Django-safe bulk renaming with two-level filtering.
-
-Execution order (critical for path integrity):
- 0. File contents - Safe: doesn't change paths
- 1. Symlink targets - Update to future paths (before renaming files/dirs)
- 2. Symlink names - Rename symlink names (leaf nodes)
- 3. File names - Rename files (leaf nodes)
- 4. Directory names - Rename directories (deepest first, children -> parents)
-"""
-
-from ._config import RenameConfig, RenameResult
-from ._core import bulk_rename, execute_rename, preview_rename
-
-__all__ = [
- "RenameConfig",
- "RenameResult",
- "bulk_rename",
- "execute_rename",
- "preview_rename",
-]
-
-# EOF
diff --git a/src/scitex/_dev/_rename/_config.py b/src/scitex/_dev/_rename/_config.py
deleted file mode 100755
index ddca8d7ba..000000000
--- a/src/scitex/_dev/_rename/_config.py
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-14
-# File: scitex/_dev/_rename/_config.py
-
-"""Configuration dataclasses for bulk rename operations."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from typing import Any
-
-
-@dataclass
-class RenameConfig:
- """Configuration for bulk rename operations."""
-
- pattern: str
- replacement: str
- directory: str = "."
- dry_run: bool = True
- django_safe: bool = True
- create_backup: bool = False
- # PATH-level filtering
- path_includes: str = (
- "py,txt,sh,md,yaml,toml,cfg,ini,json,html,css,ts,js,tsx,jsx,scss,less,svg,xml"
- )
- path_excludes: str = "__pycache__,staticfiles,node_modules,.git,venv,.venv"
- path_must_excludes: str = ".old,old,legacy,archive,backup,.backup,migrations"
- # SRC-level filtering
- src_excludes: str = "db_table=,related_name=,table=,name=,old_name=,new_name="
- src_must_excludes: str = ""
- extra_excludes: list[str] = field(default_factory=list)
- skip_ids: list[str] = field(default_factory=list)
- use_sudo: bool = False
-
-
-@dataclass
-class RenameResult:
- """Result of a bulk rename operation."""
-
- dry_run: bool
- pattern: str
- replacement: str
- directory: str
- contents: list[dict[str, Any]]
- symlink_targets: list[dict[str, Any]]
- symlink_names: list[dict[str, Any]]
- file_names: list[dict[str, Any]]
- dir_names: list[dict[str, Any]]
- summary: dict[str, Any]
- collisions: list[dict[str, Any]] = field(default_factory=list)
- error: str | None = None
-
-
-# EOF
diff --git a/src/scitex/_dev/_rename/_core.py b/src/scitex/_dev/_rename/_core.py
deleted file mode 100755
index f68760cad..000000000
--- a/src/scitex/_dev/_rename/_core.py
+++ /dev/null
@@ -1,265 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-14
-# File: scitex/_dev/_rename/_core.py
-
-"""Core orchestration for bulk rename operations."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any
-
-from ._config import RenameConfig, RenameResult
-from ._safety import check_directory_safety, create_backup, has_uncommitted_changes
-from ._steps import (
- rename_directory_names,
- rename_file_contents,
- rename_file_names,
- rename_symlink_names,
- update_symlink_targets,
-)
-
-
-def _make_error_result(
- pattern: str, replacement: str, directory: str, error: str
-) -> RenameResult:
- """Create an error RenameResult."""
- return RenameResult(
- dry_run=False,
- pattern=pattern,
- replacement=replacement,
- directory=directory,
- contents=[],
- symlink_targets=[],
- symlink_names=[],
- file_names=[],
- dir_names=[],
- summary={},
- error=error,
- )
-
-
-def preview_rename(
- pattern: str,
- replacement: str,
- directory: str = ".",
- django_safe: bool = True,
- extra_excludes: list[str] | None = None,
- skip_ids: list[str] | None = None,
- **kwargs: Any,
-) -> RenameResult:
- """Preview rename changes without executing (dry run).
-
- Parameters
- ----------
- pattern : str
- Pattern to search for.
- replacement : str
- String to replace matches with.
- directory : str
- Target directory.
- django_safe : bool
- Enable Django-safe mode.
- extra_excludes : list of str, optional
- Additional exclude patterns.
-
- Returns
- -------
- RenameResult
- Preview of all changes that would be made.
- """
- config = RenameConfig(
- pattern=pattern,
- replacement=replacement,
- directory=directory,
- dry_run=True,
- django_safe=django_safe,
- extra_excludes=extra_excludes or [],
- skip_ids=skip_ids or [],
- **kwargs,
- )
- return bulk_rename(config)
-
-
-def execute_rename(
- pattern: str,
- replacement: str,
- directory: str = ".",
- django_safe: bool = True,
- create_backup: bool = False,
- extra_excludes: list[str] | None = None,
- force: bool = False,
- skip_ids: list[str] | None = None,
- **kwargs: Any,
-) -> RenameResult:
- """Execute rename with safety checks.
-
- Checks for uncommitted git changes before proceeding.
-
- Parameters
- ----------
- pattern : str
- Pattern to search for.
- replacement : str
- String to replace matches with.
- directory : str
- Target directory.
- django_safe : bool
- Enable Django-safe mode.
- create_backup : bool
- Create backup before changes.
- extra_excludes : list of str, optional
- Additional exclude patterns.
- force : bool
- Skip uncommitted changes check (default False).
-
- Returns
- -------
- RenameResult
- Results of the rename operation.
- """
- if not force and has_uncommitted_changes(directory):
- return _make_error_result(
- pattern,
- replacement,
- directory,
- "Uncommitted changes detected. Commit or stash first.",
- )
-
- config = RenameConfig(
- pattern=pattern,
- replacement=replacement,
- directory=directory,
- dry_run=False,
- django_safe=django_safe,
- create_backup=create_backup,
- extra_excludes=extra_excludes or [],
- skip_ids=skip_ids or [],
- **kwargs,
- )
- return bulk_rename(config)
-
-
-def bulk_rename(config: RenameConfig) -> RenameResult:
- """Execute bulk rename operation.
-
- Parameters
- ----------
- config : RenameConfig
- Configuration for the rename operation.
-
- Returns
- -------
- RenameResult
- Results including all changes made or previewed.
- """
- directory = str(Path(config.directory).resolve())
-
- # Safety: block dangerous directories and require git for live runs
- if not config.dry_run:
- safety_error = check_directory_safety(directory)
- if safety_error:
- return _make_error_result(
- config.pattern,
- config.replacement,
- directory,
- safety_error,
- )
-
- if config.create_backup and not config.dry_run:
- create_backup(directory, config.pattern, config.replacement)
-
- # Dry-run pass to detect collisions before any changes
- if not config.dry_run:
- dry_config = RenameConfig(
- pattern=config.pattern,
- replacement=config.replacement,
- directory=config.directory,
- dry_run=True,
- django_safe=config.django_safe,
- path_includes=config.path_includes,
- path_excludes=config.path_excludes,
- path_must_excludes=config.path_must_excludes,
- src_excludes=config.src_excludes,
- src_must_excludes=config.src_must_excludes,
- extra_excludes=config.extra_excludes,
- skip_ids=config.skip_ids,
- )
- preview = bulk_rename(dry_config)
- # Block file/symlink collisions; directory collisions are handled via merge
- non_dir_collisions = [
- c for c in preview.collisions if c.get("type") != "directory"
- ]
- if non_dir_collisions:
- return _make_error_result(
- config.pattern,
- config.replacement,
- directory,
- f"Collisions detected: {len(non_dir_collisions)} target(s) already exist. "
- "Run dry-run to inspect.",
- )
-
- # Execute in order (critical for path integrity)
- contents = rename_file_contents(config, directory)
- symlink_targets = update_symlink_targets(config, directory)
- symlink_names = rename_symlink_names(config, directory)
- file_names = rename_file_names(config, directory)
- dir_names = rename_directory_names(config, directory)
-
- # Collect collisions from path-renaming steps
- collisions = []
- for item in symlink_names:
- if item.get("target_exists"):
- collisions.append({"type": "symlink", "path": item["new_name"]})
- for item in file_names:
- if item.get("target_exists"):
- collisions.append({"type": "file", "path": item["new_path"]})
- for item in dir_names:
- if item.get("target_exists"):
- collisions.append({"type": "directory", "path": item["new_path"]})
-
- # Detect Django app directory renames and emit warnings
- warnings: list[str] = []
- for item in dir_names:
- old_p = Path(item["old_path"])
- new_p = Path(item["new_path"])
- if (old_p / "apps.py").exists() or (new_p / "apps.py").exists():
- warnings.append(
- f"DJANGO APP RENAME DETECTED: {old_p.name} → {new_p.name}. "
- "You MUST manually: (1) add explicit db_table to all models, "
- "(2) update migration file dependencies/references, "
- "(3) run SQL: UPDATE django_migrations SET app='new' WHERE app='old', "
- "(4) create a migration to update django_content_type rows. "
- "Model class renames need separate RenameModel migrations."
- )
-
- summary: dict[str, Any] = {
- "content_files": len(contents),
- "content_matches": sum(c.get("matches", 0) for c in contents),
- "content_protected": sum(c.get("protected", 0) for c in contents),
- "protected_files": sum(1 for c in contents if c.get("protected", 0) > 0),
- "symlink_targets_updated": len(symlink_targets),
- "symlinks_renamed": len(symlink_names),
- "files_renamed": len(file_names),
- "dirs_renamed": len(dir_names),
- "collisions": len(collisions),
- }
- if warnings:
- summary["warnings"] = warnings
-
- return RenameResult(
- dry_run=config.dry_run,
- pattern=config.pattern,
- replacement=config.replacement,
- directory=directory,
- contents=contents,
- symlink_targets=symlink_targets,
- symlink_names=symlink_names,
- file_names=file_names,
- dir_names=dir_names,
- summary=summary,
- collisions=collisions,
- )
-
-
-# EOF
diff --git a/src/scitex/_dev/_rename/_filters.py b/src/scitex/_dev/_rename/_filters.py
deleted file mode 100755
index d543d8a09..000000000
--- a/src/scitex/_dev/_rename/_filters.py
+++ /dev/null
@@ -1,132 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-14
-# File: scitex/_dev/_rename/_filters.py
-
-"""Filtering logic for bulk rename operations (PATH and SRC level)."""
-
-from __future__ import annotations
-
-import re
-from pathlib import Path
-
-from ._config import RenameConfig
-
-
-def parse_csv_config(value: str) -> list[str]:
- """Parse comma-separated config string into list."""
- return [v.strip() for v in value.split(",") if v.strip()]
-
-
-def should_exclude_path(path: Path, config: RenameConfig) -> bool:
- """Check if a path should be excluded based on config."""
- path_str = str(path)
- parts = path.parts
-
- # Must-excludes (strongest) - exact directory name match
- for exc in parse_csv_config(config.path_must_excludes):
- if exc in parts:
- return True
-
- # Standard excludes - exact directory name match
- for exc in parse_csv_config(config.path_excludes):
- if exc in parts:
- return True
-
- # Extra excludes from user
- for exc in config.extra_excludes:
- if exc in path_str:
- return True
-
- return False
-
-
-def matches_include_extensions(path: Path, config: RenameConfig) -> bool:
- """Check if file extension matches include list."""
- includes = parse_csv_config(config.path_includes)
- if not includes:
- return True
-
- suffix = path.suffix.lstrip(".")
- name = path.name
-
- for inc in includes:
- if suffix == inc:
- return True
- if inc.startswith(".") and name.startswith(inc):
- return True
- if "*" in inc:
- import fnmatch
-
- if fnmatch.fnmatch(name, inc):
- return True
-
- return False
-
-
-def find_matching_files(
- directory: str, config: RenameConfig, need_content_match: bool = False
-) -> list[Path]:
- """Find files matching the filtering criteria."""
- root = Path(directory)
- matching = []
-
- for path in root.rglob("*"):
- if not path.is_file() or path.is_symlink():
- continue
- if should_exclude_path(path, config):
- continue
- if not matches_include_extensions(path, config):
- continue
- if need_content_match:
- try:
- content = path.read_text(errors="replace")
- if config.pattern not in content:
- continue
- except (OSError, UnicodeDecodeError):
- continue
- matching.append(path)
-
- return matching
-
-
-def is_django_protected_line(line: str, pattern: str) -> bool:
- """Check if a line should be protected in Django-safe mode."""
- if re.search(r"db_table\s*=\s*['\"]", line):
- return True
- if re.search(r"(old_name|new_name)\s*=\s*['\"]", line):
- return True
- if re.search(r"related_name\s*=\s*['\"]", line):
- return True
- if re.search(r"objects\s*=\s*.*Manager", line):
- return True
- settings_patterns = (
- "INSTALLED_APPS",
- "DATABASES",
- "CACHES",
- "SECRET_KEY",
- "DEBUG",
- "ALLOWED_HOSTS",
- "MIDDLEWARE",
- "TEMPLATES",
- )
- stripped = line.lstrip()
- for sp in settings_patterns:
- if stripped.startswith(sp):
- return True
- if re.search(r"(django|Django).*\d+\.\d+", line):
- return True
- return False
-
-
-def is_src_excluded(line: str, config: RenameConfig) -> bool:
- """Check if a line matches SRC-level exclusion patterns."""
- for exc in parse_csv_config(config.src_must_excludes):
- if exc in line:
- return True
- for exc in parse_csv_config(config.src_excludes):
- if exc in line:
- return True
- return False
-
-
-# EOF
diff --git a/src/scitex/_dev/_rename/_io.py b/src/scitex/_dev/_rename/_io.py
deleted file mode 100755
index 797cf8b94..000000000
--- a/src/scitex/_dev/_rename/_io.py
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-03-09
-# File: scitex/_dev/_rename/_io.py
-
-"""I/O helpers for bulk rename — with optional sudo escalation."""
-
-from __future__ import annotations
-
-import subprocess
-from pathlib import Path
-
-# Module-level sudo password cache (not serialized to output)
-_sudo_password: str | None = None
-
-
-def set_sudo_password(password: str | None) -> None:
- """Set the sudo password for non-interactive sudo -S calls."""
- global _sudo_password
- _sudo_password = password
-
-
-def _sudo_run(cmd: list[str], input_data: bytes | None = None) -> None:
- """Run a command with sudo -S, piping password via stdin."""
- sudo_cmd = ["sudo", "-S"] + cmd
- stdin_data = input_data
- if _sudo_password:
- pw_bytes = (_sudo_password + "\n").encode()
- stdin_data = pw_bytes + (input_data or b"")
- subprocess.run(
- sudo_cmd,
- input=stdin_data,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- check=True,
- )
-
-
-def write_text(path: Path, content: str, use_sudo: bool = False) -> None:
- """Write text to file, optionally via sudo."""
- if not use_sudo:
- path.write_text(content)
- return
- _sudo_run(["tee", str(path)], input_data=content.encode())
-
-
-def rename_path(src: Path, dst: Path, use_sudo: bool = False) -> None:
- """Rename (move) a path, optionally via sudo."""
- if not use_sudo:
- src.rename(dst)
- return
- _sudo_run(["mv", str(src), str(dst)])
-
-
-def unlink_path(path: Path, use_sudo: bool = False) -> None:
- """Remove a file or symlink, optionally via sudo."""
- if not use_sudo:
- path.unlink()
- return
- _sudo_run(["rm", str(path)])
-
-
-def mkdir(path: Path, parents: bool = False, use_sudo: bool = False) -> None:
- """Create directory, optionally via sudo."""
- if not use_sudo:
- path.mkdir(parents=parents, exist_ok=True)
- return
- cmd = ["mkdir"]
- if parents:
- cmd.append("-p")
- cmd.append(str(path))
- _sudo_run(cmd)
-
-
-def rmdir(path: Path, use_sudo: bool = False) -> None:
- """Remove empty directory, optionally via sudo."""
- if not use_sudo:
- path.rmdir()
- return
- _sudo_run(["rmdir", str(path)])
-
-
-def symlink_to(link: Path, target: str, use_sudo: bool = False) -> None:
- """Create a symlink, optionally via sudo."""
- if not use_sudo:
- link.symlink_to(target)
- return
- _sudo_run(["ln", "-s", target, str(link)])
-
-
-# EOF
diff --git a/src/scitex/_dev/_rename/_safety.py b/src/scitex/_dev/_rename/_safety.py
deleted file mode 100755
index 380647bc1..000000000
--- a/src/scitex/_dev/_rename/_safety.py
+++ /dev/null
@@ -1,112 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-14
-# File: scitex/_dev/_rename/_safety.py
-
-"""Safety checks for bulk rename operations."""
-
-from __future__ import annotations
-
-import shutil
-import subprocess
-from datetime import datetime
-from pathlib import Path
-
-
-def has_uncommitted_changes(directory: str) -> bool:
- """Check if git working tree has uncommitted changes."""
- try:
- result = subprocess.run(
- ["git", "status", "--porcelain"],
- cwd=directory,
- capture_output=True,
- text=True,
- timeout=10,
- )
- return bool(result.stdout.strip())
- except (subprocess.SubprocessError, FileNotFoundError):
- return False
-
-
-def is_git_repo(directory: str) -> bool:
- """Check if directory is inside a git repository."""
- try:
- result = subprocess.run(
- ["git", "rev-parse", "--is-inside-work-tree"],
- cwd=directory,
- capture_output=True,
- text=True,
- timeout=5,
- )
- return result.returncode == 0
- except (subprocess.SubprocessError, FileNotFoundError):
- return False
-
-
-def check_directory_safety(directory: str) -> str | None:
- """Validate directory is safe for bulk rename.
-
- Returns None if safe, or an error message string.
- """
- resolved = Path(directory).resolve()
-
- # Block system-critical paths
- dangerous = {
- "/",
- "/home",
- "/usr",
- "/etc",
- "/var",
- "/bin",
- "/sbin",
- "/opt",
- "/tmp",
- }
- if str(resolved) in dangerous:
- return f"Refusing to rename in system directory: {resolved}"
-
- # Block shallow paths (less than 3 components like /home/user)
- if len(resolved.parts) < 3:
- return f"Refusing to rename in shallow directory: {resolved}"
-
- # Must be inside a git repo (so we can revert with git checkout)
- if not is_git_repo(str(resolved)):
- return (
- f"Directory is not inside a git repository: {resolved}. "
- "Rename requires git for safety (allows git checkout to revert)."
- )
-
- return None
-
-
-def create_backup(directory: str, pattern: str, replacement: str) -> Path:
- """Create a backup of the directory before renaming."""
- backup_base = Path(directory) / ".rename_backups"
- backup_base.mkdir(exist_ok=True)
-
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- backup_dir = backup_base / f"backup_{timestamp}"
- backup_dir.mkdir()
-
- # Save operation metadata
- meta = backup_dir / "operation.txt"
- meta.write_text(
- f"pattern={pattern}\nreplacement={replacement}\n"
- f"directory={directory}\ntimestamp={timestamp}\n"
- )
-
- # Copy directory contents
- original_dir = backup_dir / "original"
- original_dir.mkdir()
- for item in Path(directory).iterdir():
- if item.name == ".rename_backups":
- continue
- dest = original_dir / item.name
- if item.is_dir():
- shutil.copytree(str(item), str(dest), symlinks=True)
- else:
- shutil.copy2(str(item), str(dest))
-
- return backup_dir
-
-
-# EOF
diff --git a/src/scitex/_dev/_rename/_steps.py b/src/scitex/_dev/_rename/_steps.py
deleted file mode 100755
index 60adb7a45..000000000
--- a/src/scitex/_dev/_rename/_steps.py
+++ /dev/null
@@ -1,325 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-14
-# File: scitex/_dev/_rename/_steps.py
-
-"""Five-step execution order for bulk rename operations."""
-
-from __future__ import annotations
-
-import os
-from pathlib import Path
-from typing import Any
-
-from ._config import RenameConfig
-from ._filters import (
- find_matching_files,
- is_django_protected_line,
- is_src_excluded,
- should_exclude_path,
-)
-from ._io import (
- mkdir as _mkdir,
-)
-from ._io import (
- rename_path as _rename_path,
-)
-from ._io import (
- rmdir as _rmdir,
-)
-from ._io import (
- symlink_to as _symlink_to,
-)
-from ._io import (
- unlink_path as _unlink_path,
-)
-from ._io import (
- write_text as _write_text,
-)
-
-
-def _should_skip(item_id: str, skip_ids: list[str]) -> bool:
- """Check whether an item should be skipped based on skip_ids."""
- return item_id in skip_ids
-
-
-def rename_file_contents(config: RenameConfig, directory: str) -> list[dict[str, Any]]:
- """Step 0: Replace pattern in file contents."""
- files = find_matching_files(directory, config, need_content_match=True)
- results = []
-
- for i, file_path in enumerate(files):
- file_id = f"c-{i:03d}"
-
- try:
- content = file_path.read_text(errors="replace")
- except (OSError, UnicodeDecodeError):
- continue
-
- # Skip entire file if file-level ID is in skip_ids
- skip_entire_file = _should_skip(file_id, config.skip_ids)
-
- lines = content.split("\n")
- matches = 0
- protected = 0
- new_lines = []
- line_details: list[dict[str, Any]] = []
-
- for line_num, line in enumerate(lines, 1):
- if config.pattern in line:
- line_id = f"{file_id}-L{line_num}"
- skip_this_line = skip_entire_file or _should_skip(
- line_id, config.skip_ids
- )
-
- should_protect = False
- if config.django_safe and is_django_protected_line(
- line, config.pattern
- ):
- should_protect = True
- if is_src_excluded(line, config):
- should_protect = True
-
- if should_protect:
- protected += 1
- new_lines.append(line)
- if config.dry_run and len(line_details) < 20:
- line_details.append(
- {
- "id": line_id,
- "line_num": line_num,
- "action": "protect",
- "before": line,
- "after": line,
- }
- )
- elif skip_this_line:
- # Skipped by skip_ids -- keep original line
- new_lines.append(line)
- if config.dry_run and len(line_details) < 20:
- line_details.append(
- {
- "id": line_id,
- "line_num": line_num,
- "action": "skip",
- "before": line,
- "after": line,
- }
- )
- else:
- matches += line.count(config.pattern)
- replaced = line.replace(config.pattern, config.replacement)
- new_lines.append(replaced)
- if config.dry_run and len(line_details) < 20:
- line_details.append(
- {
- "id": line_id,
- "line_num": line_num,
- "action": "replace",
- "before": line,
- "after": replaced,
- }
- )
- else:
- new_lines.append(line)
-
- if matches > 0:
- if not config.dry_run and not skip_entire_file:
- _write_text(file_path, "\n".join(new_lines), config.use_sudo)
-
- entry: dict[str, Any] = {
- "id": file_id,
- "file": str(file_path),
- "matches": matches,
- "protected": protected,
- }
- if config.dry_run:
- entry["lines"] = line_details
-
- results.append(entry)
-
- return results
-
-
-def update_symlink_targets(
- config: RenameConfig, directory: str
-) -> list[dict[str, Any]]:
- """Step 1: Update symlink targets to point to future paths."""
- root = Path(directory)
- results = []
- idx = 0
-
- for path in sorted(root.rglob("*")):
- if not path.is_symlink():
- continue
- if should_exclude_path(path, config):
- continue
-
- target = os.readlink(str(path))
- if config.pattern in target:
- item_id = f"st-{idx:03d}"
- new_target = target.replace(config.pattern, config.replacement)
-
- if not config.dry_run and not _should_skip(item_id, config.skip_ids):
- _unlink_path(path, config.use_sudo)
- _symlink_to(path, new_target, config.use_sudo)
-
- results.append(
- {
- "id": item_id,
- "link": str(path),
- "old_target": target,
- "new_target": new_target,
- }
- )
- idx += 1
-
- return results
-
-
-def rename_symlink_names(config: RenameConfig, directory: str) -> list[dict[str, Any]]:
- """Step 2: Rename symlink basenames."""
- root = Path(directory)
- results = []
- idx = 0
-
- for path in sorted(root.rglob("*")):
- if not path.is_symlink():
- continue
- if should_exclude_path(path, config):
- continue
-
- name = path.name
- if config.pattern in name:
- item_id = f"sn-{idx:03d}"
- new_name = name.replace(config.pattern, config.replacement)
- new_path = path.parent / new_name
- target_exists = new_path.exists() and new_path != path
-
- if not config.dry_run and not _should_skip(item_id, config.skip_ids):
- _rename_path(path, new_path, config.use_sudo)
-
- results.append(
- {
- "id": item_id,
- "old_name": str(path),
- "new_name": str(new_path),
- "target_exists": target_exists,
- }
- )
- idx += 1
-
- return results
-
-
-def rename_file_names(config: RenameConfig, directory: str) -> list[dict[str, Any]]:
- """Step 3: Rename file basenames."""
- files = find_matching_files(directory, config)
- results = []
- idx = 0
-
- for file_path in files:
- name = file_path.name
- if config.pattern in name:
- item_id = f"f-{idx:03d}"
- new_name = name.replace(config.pattern, config.replacement)
- new_path = file_path.parent / new_name
- target_exists = new_path.exists() and new_path != file_path
-
- if not config.dry_run and not _should_skip(item_id, config.skip_ids):
- _rename_path(file_path, new_path, config.use_sudo)
-
- results.append(
- {
- "id": item_id,
- "old_path": str(file_path),
- "new_path": str(new_path),
- "target_exists": target_exists,
- }
- )
- idx += 1
-
- return results
-
-
-def _merge_directory(src: Path, dst: Path, use_sudo: bool = False) -> int:
- """Move all children from src into dst, then remove empty src.
-
- Returns number of items moved.
- """
- moved = 0
- for child in list(src.iterdir()):
- target = dst / child.name
- if child.is_dir() and target.is_dir():
- moved += _merge_directory(child, target, use_sudo)
- else:
- if target.exists():
- _unlink_path(target, use_sudo)
- _rename_path(child, target, use_sudo)
- moved += 1
- # Remove src if now empty
- if src.exists() and not any(src.iterdir()):
- _rmdir(src, use_sudo)
- return moved
-
-
-def rename_directory_names(
- config: RenameConfig, directory: str
-) -> list[dict[str, Any]]:
- """Step 4: Rename directories (deepest first).
-
- Matches pattern against both:
- - Leaf directory name (e.g., 'js')
- - Relative path from root (e.g., 'static/scholar_app/js')
- This enables patterns like 'scholar_app/js' to match path segments.
-
- When target directory exists, merges contents into it.
- """
- root = Path(directory)
- results = []
-
- dirs = []
- for path in root.rglob("*"):
- if path.is_dir() and not path.is_symlink():
- if should_exclude_path(path, config):
- continue
- rel_path = str(path.relative_to(root))
- if config.pattern in path.name or config.pattern in rel_path:
- dirs.append(path)
-
- dirs.sort(key=lambda p: len(p.parts), reverse=True)
-
- for idx, dir_path in enumerate(dirs):
- item_id = f"d-{idx:03d}"
-
- if not dir_path.exists():
- continue # already moved by parent merge
- if config.pattern in dir_path.name:
- new_name = dir_path.name.replace(config.pattern, config.replacement)
- new_path = dir_path.parent / new_name
- else:
- rel = str(dir_path.relative_to(root))
- new_rel = rel.replace(config.pattern, config.replacement)
- new_path = root / new_rel
- target_exists = new_path.exists() and new_path != dir_path
-
- if not config.dry_run and not _should_skip(item_id, config.skip_ids):
- _mkdir(new_path.parent, parents=True, use_sudo=config.use_sudo)
- if target_exists:
- _merge_directory(dir_path, new_path, config.use_sudo)
- else:
- _rename_path(dir_path, new_path, config.use_sudo)
-
- results.append(
- {
- "id": item_id,
- "old_path": str(dir_path),
- "new_path": str(new_path),
- "target_exists": target_exists,
- "merged": target_exists,
- }
- )
-
- return results
-
-
-# EOF
diff --git a/src/scitex/_dev/_rtd.py b/src/scitex/_dev/_rtd.py
deleted file mode 100755
index d432b292c..000000000
--- a/src/scitex/_dev/_rtd.py
+++ /dev/null
@@ -1,122 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-03
-# File: scitex/_dev/_rtd.py
-
-"""Read the Docs build status checking for scitex ecosystem."""
-
-from __future__ import annotations
-
-import urllib.request
-from typing import Any
-
-from ._ecosystem import ECOSYSTEM
-
-# RTD project slugs (if different from package name)
-RTD_SLUGS: dict[str, str] = {
- "scitex": "scitex-python",
-}
-
-
-def get_rtd_slug(package: str) -> str:
- """Get RTD project slug for a package."""
- return RTD_SLUGS.get(package, package)
-
-
-def check_rtd_status(package: str, version: str = "latest") -> dict[str, Any]:
- """Check Read the Docs build status for a package.
-
- Parameters
- ----------
- package : str
- Package name.
- version : str
- RTD version to check (latest, stable, etc.).
-
- Returns
- -------
- dict
- Status info with keys: status, version, url, error (if any).
- """
- slug = get_rtd_slug(package)
- badge_url = f"https://readthedocs.org/projects/{slug}/badge/?version={version}"
- docs_url = f"https://{slug}.readthedocs.io/en/{version}/"
-
- try:
- # Fetch the badge SVG content to determine status
- req = urllib.request.Request(badge_url)
- req.add_header("User-Agent", "scitex-dev-tools/1.0")
-
- with urllib.request.urlopen(req, timeout=10) as response:
- content = response.read().decode("utf-8")
-
- # Parse SVG content for status
- if "passing" in content.lower():
- status = "passing"
- elif "failing" in content.lower():
- status = "failing"
- elif "unknown" in content.lower():
- status = "unknown"
- else:
- status = "unknown"
-
- return {
- "status": status,
- "version": version,
- "url": docs_url,
- }
-
- except urllib.error.HTTPError as e:
- if e.code == 404:
- return {
- "status": "not_found",
- "version": version,
- "error": f"Project '{slug}' not found on RTD",
- }
- return {
- "status": "error",
- "version": version,
- "error": f"HTTP {e.code}: {e.reason}",
- }
- except Exception as e:
- return {
- "status": "error",
- "version": version,
- "error": str(e),
- }
-
-
-def check_all_rtd(
- packages: list[str] | None = None,
- versions: list[str] | None = None,
-) -> dict[str, dict[str, dict[str, Any]]]:
- """Check RTD status for all ecosystem packages.
-
- Parameters
- ----------
- packages : list[str] | None
- List of package names. If None, uses ecosystem packages.
- versions : list[str] | None
- List of versions to check. Default: ["latest", "stable"].
-
- Returns
- -------
- dict
- Mapping: version -> package_name -> status_info
- """
- if packages is None:
- packages = list(ECOSYSTEM.keys())
-
- if versions is None:
- versions = ["latest", "stable"]
-
- results: dict[str, dict[str, dict[str, Any]]] = {}
-
- for version in versions:
- results[version] = {}
- for package in packages:
- results[version][package] = check_rtd_status(package, version)
-
- return results
-
-
-# EOF
diff --git a/src/scitex/_dev/_ssh.py b/src/scitex/_dev/_ssh.py
deleted file mode 100755
index 2ccf1ceea..000000000
--- a/src/scitex/_dev/_ssh.py
+++ /dev/null
@@ -1,438 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_ssh.py
-
-"""SSH-based remote version checking for scitex ecosystem."""
-
-from __future__ import annotations
-
-import subprocess
-from typing import Any
-
-from ._config import DevConfig, HostConfig, get_enabled_hosts, load_config
-
-
-def get_remote_version(host: HostConfig, package: str) -> dict[str, Any]:
- """Get version of a package on a remote host via SSH.
-
- Parameters
- ----------
- host : HostConfig
- Host configuration.
- package : str
- Package name (PyPI name).
-
- Returns
- -------
- dict
- Version info with keys: installed, status, error (if any).
- """
- # Build SSH command
- ssh_args = ["ssh"]
-
- if host.ssh_key:
- ssh_args.extend(["-i", host.ssh_key])
-
- if host.port != 22:
- ssh_args.extend(["-p", str(host.port)])
-
- # Add connection options for non-interactive use
- ssh_args.extend(
- [
- "-o",
- "BatchMode=yes",
- "-o",
- "StrictHostKeyChecking=accept-new",
- "-o",
- "ConnectTimeout=5",
- ]
- )
-
- ssh_target = f"{host.user}@{host.hostname}"
- ssh_args.append(ssh_target)
-
- # Python command to get version
- python_cmd = f"""python3 -c "
-try:
- from importlib.metadata import version
- print(version('{package}'))
-except Exception as e:
- print('ERROR:' + str(e))
-"
-"""
- ssh_args.append(python_cmd)
-
- try:
- result = subprocess.run(
- ssh_args,
- capture_output=True,
- text=True,
- timeout=15,
- )
-
- # SSH stdout may contain shell warnings before the actual output.
- # Take the last non-empty line as the python output.
- output = result.stdout.strip()
- lines = [l.strip() for l in output.splitlines() if l.strip()]
- output = lines[-1] if lines else ""
-
- if result.returncode != 0:
- error = result.stderr.strip() or "SSH connection failed"
- return {
- "installed": None,
- "status": "error",
- "error": error,
- }
-
- if output.startswith("ERROR:"):
- return {
- "installed": None,
- "status": "not_installed",
- "error": output[6:],
- }
-
- return {
- "installed": output,
- "status": "ok",
- }
-
- except subprocess.TimeoutExpired:
- return {
- "installed": None,
- "status": "timeout",
- "error": "SSH connection timed out",
- }
- except Exception as e:
- return {
- "installed": None,
- "status": "error",
- "error": str(e),
- }
-
-
-def get_remote_versions(
- host: HostConfig,
- packages: list[str],
-) -> dict[str, dict[str, Any]]:
- """Get versions of multiple packages on a remote host.
-
- Parameters
- ----------
- host : HostConfig
- Host configuration.
- packages : list[str]
- List of package names.
-
- Returns
- -------
- dict
- Package name -> version info mapping.
- """
- # Build SSH command that checks all packages at once
- ssh_args = ["ssh"]
-
- if host.ssh_key:
- ssh_args.extend(["-i", host.ssh_key])
-
- if host.port != 22:
- ssh_args.extend(["-p", str(host.port)])
-
- ssh_args.extend(
- [
- "-o",
- "BatchMode=yes",
- "-o",
- "StrictHostKeyChecking=accept-new",
- "-o",
- "ConnectTimeout=5",
- ]
- )
-
- ssh_target = f"{host.user}@{host.hostname}"
- ssh_args.append(ssh_target)
-
- # Build Python command to check all packages (installed + toml + git)
- # Use base64 encoding to avoid shell escaping issues
- import base64
-
- packages_list = repr(packages)
- python_script = f"""
-import json
-import subprocess
-from importlib.metadata import version
-from pathlib import Path
-import re
-
-def get_pkg_dir(pkg):
- pkg_dir_names = [pkg, pkg.replace("-", "_"), pkg.replace("_", "-")]
- if pkg == "scitex":
- pkg_dir_names.extend(["scitex-python", "scitex-code"])
- for dir_name in pkg_dir_names:
- pkg_dir = Path.home() / "proj" / dir_name
- if pkg_dir.exists():
- return pkg_dir
- return None
-
-def get_toml_version(pkg_dir):
- if not pkg_dir:
- return None
- toml_path = pkg_dir / "pyproject.toml"
- if toml_path.exists():
- try:
- content = toml_path.read_text()
- match = re.search(r'^version\\s*=\\s*["\\'](.*?)["\\']\\s*$', content, re.MULTILINE)
- if match:
- return match.group(1)
- except Exception:
- pass
- return None
-
-def get_git_info(pkg_dir):
- if not pkg_dir:
- return None, None, {{}}
- try:
- tag = subprocess.run(
- ["git", "describe", "--tags", "--abbrev=0"],
- capture_output=True, text=True, cwd=str(pkg_dir), timeout=5
- ).stdout.strip() or None
- except Exception:
- tag = None
- try:
- branch = subprocess.run(
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
- capture_output=True, text=True, cwd=str(pkg_dir), timeout=5
- ).stdout.strip() or None
- except Exception:
- branch = None
- # Worktree status
- wt = {{"dirty": False, "ahead": 0, "behind": 0, "short_hash": None}}
- try:
- r = subprocess.run(
- ["git", "status", "--porcelain"],
- capture_output=True, text=True, cwd=str(pkg_dir), timeout=5
- )
- wt["dirty"] = bool(r.stdout.strip()) if r.returncode == 0 else False
- except Exception:
- pass
- try:
- r = subprocess.run(
- ["git", "rev-parse", "--short", "HEAD"],
- capture_output=True, text=True, cwd=str(pkg_dir), timeout=5
- )
- wt["short_hash"] = r.stdout.strip() if r.returncode == 0 else None
- except Exception:
- pass
- try:
- r = subprocess.run(
- ["git", "rev-list", "--left-right", "--count", "HEAD...@{{upstream}}"],
- capture_output=True, text=True, cwd=str(pkg_dir), timeout=5
- )
- if r.returncode == 0:
- parts = r.stdout.strip().split()
- if len(parts) == 2:
- wt["ahead"] = int(parts[0])
- wt["behind"] = int(parts[1])
- except Exception:
- pass
- return tag, branch, wt
-
-results = {{}}
-for pkg in {packages_list}:
- result = {{"installed": None, "toml": None, "git_tag": None, "git_branch": None, "git_dirty": False, "git_ahead": 0, "git_behind": 0, "git_hash": None, "status": "not_installed"}}
- try:
- result["installed"] = version(pkg)
- result["status"] = "ok"
- except Exception as e:
- result["error"] = str(e)
- pkg_dir = get_pkg_dir(pkg)
- result["toml"] = get_toml_version(pkg_dir)
- result["git_tag"], result["git_branch"], wt = get_git_info(pkg_dir)
- result["git_dirty"] = wt.get("dirty", False)
- result["git_ahead"] = wt.get("ahead", 0)
- result["git_behind"] = wt.get("behind", 0)
- result["git_hash"] = wt.get("short_hash")
- results[pkg] = result
-print(json.dumps(results))
-"""
- encoded = base64.b64encode(python_script.encode()).decode()
- python_cmd = (
- f"python3 -c \"import base64;exec(base64.b64decode('{encoded}').decode())\""
- )
- ssh_args.append(python_cmd)
-
- try:
- result = subprocess.run(
- ssh_args,
- capture_output=True,
- text=True,
- timeout=30,
- )
-
- if result.returncode != 0:
- error = result.stderr.strip() or "SSH connection failed"
- return {
- pkg: {"installed": None, "status": "error", "error": error}
- for pkg in packages
- }
-
- import json
- from typing import cast
-
- try:
- # SSH stdout may contain shell warnings before the JSON.
- # Extract the last line that looks like JSON (starts with '{').
- stdout = result.stdout.strip()
- for line in reversed(stdout.splitlines()):
- line = line.strip()
- if line.startswith("{"):
- stdout = line
- break
- return cast(dict[str, dict[str, Any]], json.loads(stdout))
- except json.JSONDecodeError:
- return {
- pkg: {
- "installed": None,
- "status": "error",
- "error": f"Invalid response: {result.stdout[:100]}",
- }
- for pkg in packages
- }
-
- except subprocess.TimeoutExpired:
- return {
- pkg: {"installed": None, "status": "timeout", "error": "SSH timed out"}
- for pkg in packages
- }
- except Exception as e:
- return {
- pkg: {"installed": None, "status": "error", "error": str(e)}
- for pkg in packages
- }
-
-
-def check_all_hosts(
- packages: list[str] | None = None,
- hosts: list[str] | None = None,
- config: DevConfig | None = None,
-) -> dict[str, dict[str, dict[str, Any]]]:
- """Check versions on all enabled hosts.
-
- Parameters
- ----------
- packages : list[str] | None
- List of package names. If None, uses ecosystem packages.
- hosts : list[str] | None
- List of host names to check. If None, checks all enabled hosts.
- config : DevConfig | None
- Configuration to use. If None, loads default config.
-
- Returns
- -------
- dict
- Mapping: host_name -> package_name -> version_info
- """
- if config is None:
- config = load_config()
-
- if packages is None:
- from ._ecosystem import get_all_packages
-
- packages = get_all_packages()
-
- # Get pypi names for packages
- from ._ecosystem import ECOSYSTEM
-
- pypi_names = []
- name_map = {} # pypi_name -> package_name
- for pkg in packages:
- if pkg in ECOSYSTEM:
- pypi_name = ECOSYSTEM[pkg].get("pypi_name", pkg)
- else:
- pypi_name = pkg
- pypi_names.append(pypi_name)
- name_map[pypi_name] = pkg
-
- # Get enabled hosts
- enabled_hosts = get_enabled_hosts(config)
- if hosts:
- enabled_hosts = [h for h in enabled_hosts if h.name in hosts]
-
- results: dict[str, dict[str, dict[str, Any]]] = {}
-
- for host in enabled_hosts:
- host_versions = get_remote_versions(host, pypi_names)
- # Map back to package names
- results[host.name] = {
- name_map.get(pypi, pypi): info for pypi, info in host_versions.items()
- }
- # Add host metadata
- results[host.name]["_host"] = {
- "hostname": host.hostname,
- "role": host.role,
- "user": host.user,
- }
-
- return results
-
-
-def test_host_connection(host: HostConfig) -> dict[str, Any]:
- """Test SSH connection to a host.
-
- Parameters
- ----------
- host : HostConfig
- Host to test.
-
- Returns
- -------
- dict
- Connection status with keys: connected, error, python_version.
- """
- ssh_args = ["ssh"]
-
- if host.ssh_key:
- ssh_args.extend(["-i", host.ssh_key])
-
- if host.port != 22:
- ssh_args.extend(["-p", str(host.port)])
-
- ssh_args.extend(
- [
- "-o",
- "BatchMode=yes",
- "-o",
- "StrictHostKeyChecking=accept-new",
- "-o",
- "ConnectTimeout=5",
- ]
- )
-
- ssh_target = f"{host.user}@{host.hostname}"
- ssh_args.append(ssh_target)
- ssh_args.append("python3 --version")
-
- try:
- result = subprocess.run(
- ssh_args,
- capture_output=True,
- text=True,
- timeout=10,
- )
-
- if result.returncode == 0:
- return {
- "connected": True,
- "python_version": result.stdout.strip(),
- }
- return {
- "connected": False,
- "error": result.stderr.strip() or "Connection failed",
- }
-
- except subprocess.TimeoutExpired:
- return {"connected": False, "error": "Connection timed out"}
- except Exception as e:
- return {"connected": False, "error": str(e)}
-
-
-# EOF
diff --git a/src/scitex/_dev/_sync.py b/src/scitex/_dev/_sync.py
deleted file mode 100755
index 86cd8158f..000000000
--- a/src/scitex/_dev/_sync.py
+++ /dev/null
@@ -1,420 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-24
-# File: scitex/_dev/_sync.py
-
-"""Ecosystem package sync across local and remote hosts.
-
-Safety model (like bulk_rename):
- - All operations default to dry_run=True (preview only).
- - Pass confirm=True to actually execute.
- - CLI requires --confirm flag.
- - MCP tool requires confirm=True parameter.
-"""
-
-from __future__ import annotations
-
-import subprocess
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from pathlib import Path
-from typing import Any
-
-from ._config import DevConfig, HostConfig, get_enabled_hosts, load_config
-
-# ---------------------------------------------------------------------------
-# SSH helpers
-# ---------------------------------------------------------------------------
-
-
-def _build_ssh_args(host: HostConfig) -> list[str]:
- """Build SSH command prefix for a host."""
- args = ["ssh"]
- if host.ssh_key:
- args.extend(["-i", host.ssh_key])
- if host.port != 22:
- args.extend(["-p", str(host.port)])
- args.extend(
- [
- "-o",
- "BatchMode=yes",
- "-o",
- "StrictHostKeyChecking=accept-new",
- "-o",
- "ConnectTimeout=10",
- ]
- )
- args.append(f"{host.user}@{host.hostname}")
- return args
-
-
-def _get_host_packages(host: HostConfig, config: DevConfig) -> list[tuple[str, str]]:
- """Get (package_name, remote_dir_name) pairs for a host.
-
- Uses host.packages if set, otherwise all ecosystem packages.
- Returns tuples of (pypi_name, directory_name) where directory_name
- is the local_path basename (e.g., 'scitex-python' for scitex).
- """
- pkg_map = {p.name: p for p in config.packages}
-
- names = host.packages if host.packages else [p.name for p in config.packages]
- result = []
- for name in names:
- pkg = pkg_map.get(name)
- if pkg and pkg.local_path:
- dir_name = Path(pkg.local_path).expanduser().name
- result.append((name, dir_name))
- return result
-
-
-def _build_sync_commands(
- host: HostConfig, dir_name: str, stash: bool, install: bool
-) -> list[str]:
- """Build the shell commands that would be run for a package."""
- base = f"{host.remote_base}/{dir_name}"
- cmds = [f"cd {base}"]
- if install:
- # Ensure .venv symlink exists so pip resolves to the right env
- cmds.append("test -e .venv || ln -s ~/.venv .venv 2>/dev/null || true")
- if stash:
- cmds.append("git stash")
- cmds.append("git pull")
- if install:
- cmds.append(f"{host.pip_bin} install -e . -q")
- if stash:
- cmds.append("git stash pop 2>/dev/null || true")
- return cmds
-
-
-def _sync_one_package(
- host: HostConfig, dir_name: str, stash: bool, install: bool
-) -> dict[str, Any]:
- """Sync a single package on a remote host."""
- cmds = _build_sync_commands(host, dir_name, stash, install)
- remote_cmd = " && ".join(cmds)
-
- ssh_args = _build_ssh_args(host)
- ssh_args.append(remote_cmd)
-
- try:
- result = subprocess.run(ssh_args, capture_output=True, text=True, timeout=120)
- stdout = result.stdout.strip()
- stderr = result.stderr.strip()
-
- if result.returncode == 0:
- return {"status": "ok", "output": stdout}
- return {
- "status": "error",
- "output": stdout,
- "error": stderr or f"exit code {result.returncode}",
- }
- except subprocess.TimeoutExpired:
- return {"status": "timeout", "error": "SSH command timed out (120s)"}
- except Exception as e:
- return {"status": "error", "error": str(e)}
-
-
-# ---------------------------------------------------------------------------
-# Public API — all default to dry_run=True (safe preview)
-# ---------------------------------------------------------------------------
-
-
-def sync_host(
- host: HostConfig,
- packages: list[str] | None = None,
- stash: bool = True,
- install: bool = True,
- confirm: bool = False,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Sync packages to a remote host via SSH.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Steps per package: git stash, git pull, pip install -e ., git stash pop.
-
- Parameters
- ----------
- host : HostConfig
- Target host configuration.
- packages : list[str] | None
- Package names to sync. None = use host's configured packages.
- stash : bool
- Git stash before pull (default True).
- install : bool
- Pip install after pull (default True).
- confirm : bool
- If False (default), preview only (dry run).
- If True, execute the sync operation.
- config : DevConfig | None
- Configuration. Loaded from default if None.
-
- Returns
- -------
- dict
- Per-package results: {package: {status, commands|output, error}}.
- """
- if config is None:
- config = load_config()
-
- host_pkgs = _get_host_packages(host, config)
- if packages:
- host_pkgs = [(n, d) for n, d in host_pkgs if n in packages]
-
- if not confirm:
- return {
- name: {
- "status": "dry_run",
- "commands": _build_sync_commands(host, dir_name, stash, install),
- }
- for name, dir_name in host_pkgs
- }
-
- # Parallel package sync within a single host
- results: dict[str, Any] = {}
- with ThreadPoolExecutor(max_workers=4) as executor:
- futures = {
- executor.submit(_sync_one_package, host, dir_name, stash, install): name
- for name, dir_name in host_pkgs
- }
- for future in as_completed(futures):
- name = futures[future]
- try:
- results[name] = future.result()
- except Exception as e:
- results[name] = {"status": "error", "error": str(e)}
- return results
-
-
-def sync_all(
- hosts: list[str] | None = None,
- packages: list[str] | None = None,
- stash: bool = True,
- install: bool = True,
- confirm: bool = False,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Sync packages across all enabled hosts.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
- Parallel: hosts are synced concurrently by default.
-
- Parameters
- ----------
- hosts : list[str] | None
- Host names to sync. None = all enabled hosts.
- packages : list[str] | None
- Package names. None = host-specific defaults.
- stash : bool
- Git stash before pull.
- install : bool
- Pip install after pull.
- confirm : bool
- If False (default), preview only (dry run).
- If True, execute the sync operation.
- config : DevConfig | None
- Configuration.
-
- Returns
- -------
- dict
- {host_name: {package: result}}.
- """
- if config is None:
- config = load_config()
-
- enabled = get_enabled_hosts(config)
- if hosts:
- enabled = [h for h in enabled if h.name in hosts]
-
- if not confirm:
- # Dry-run: no SSH needed, compute locally
- return {
- host.name: sync_host(
- host,
- packages=packages,
- stash=stash,
- install=install,
- confirm=False,
- config=config,
- )
- for host in enabled
- }
-
- # Execute: parallel across hosts
- results: dict[str, Any] = {}
- with ThreadPoolExecutor(max_workers=len(enabled) or 1) as executor:
- futures = {
- executor.submit(
- sync_host,
- host,
- packages=packages,
- stash=stash,
- install=install,
- confirm=True,
- config=config,
- ): host.name
- for host in enabled
- }
- for future in as_completed(futures):
- host_name = futures[future]
- try:
- results[host_name] = future.result()
- except Exception as e:
- results[host_name] = {"error": str(e)}
- return results
-
-
-def sync_local(
- packages: list[str] | None = None,
- confirm: bool = False,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Install all local editable packages.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- packages : list[str] | None
- Package names. None = all configured packages.
- confirm : bool
- If False (default), preview only.
- If True, execute pip install -e.
- config : DevConfig | None
- Configuration.
-
- Returns
- -------
- dict
- {package: {status, output|commands}}.
- """
- if config is None:
- config = load_config()
-
- targets = config.packages
- if packages:
- targets = [p for p in targets if p.name in packages]
-
- results: dict[str, Any] = {}
- for pkg in targets:
- if not pkg.local_path:
- continue
-
- path = Path(pkg.local_path).expanduser()
- if not path.exists():
- results[pkg.name] = {"status": "skipped", "error": f"{path} not found"}
- continue
-
- if not confirm:
- results[pkg.name] = {
- "status": "dry_run",
- "commands": ["pip", "install", "-e", str(path), "-q"],
- }
- continue
-
- try:
- result = subprocess.run(
- ["pip", "install", "-e", str(path), "-q"],
- capture_output=True,
- text=True,
- timeout=120,
- )
- if result.returncode == 0:
- results[pkg.name] = {"status": "ok", "output": result.stdout.strip()}
- else:
- results[pkg.name] = {
- "status": "error",
- "error": result.stderr.strip(),
- }
- except Exception as e:
- results[pkg.name] = {"status": "error", "error": str(e)}
- return results
-
-
-def sync_tags(
- packages: list[str] | None = None,
- confirm: bool = False,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Push local tags for all packages to origin.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- packages : list[str] | None
- Package names. None = all configured packages.
- confirm : bool
- If False (default), preview only.
- If True, execute git push --tags.
- config : DevConfig | None
- Configuration.
-
- Returns
- -------
- dict
- {package: {status, tag, output|commands}}.
- """
- if config is None:
- config = load_config()
-
- targets = config.packages
- if packages:
- targets = [p for p in targets if p.name in packages]
-
- results: dict[str, Any] = {}
- for pkg in targets:
- if not pkg.local_path:
- continue
-
- path = Path(pkg.local_path).expanduser()
- if not path.exists():
- results[pkg.name] = {"status": "skipped", "error": f"{path} not found"}
- continue
-
- # Get latest tag (always safe to check)
- try:
- tag_result = subprocess.run(
- ["git", "describe", "--tags", "--abbrev=0"],
- capture_output=True,
- text=True,
- cwd=str(path),
- timeout=10,
- )
- tag = tag_result.stdout.strip() if tag_result.returncode == 0 else None
- except Exception:
- tag = None
-
- if not confirm:
- results[pkg.name] = {
- "status": "dry_run",
- "tag": tag,
- "commands": ["git", "push", "origin", "--tags"],
- }
- continue
-
- try:
- push_result = subprocess.run(
- ["git", "push", "origin", "--tags"],
- capture_output=True,
- text=True,
- cwd=str(path),
- timeout=30,
- )
- if push_result.returncode == 0:
- results[pkg.name] = {
- "status": "ok",
- "tag": tag,
- "output": push_result.stderr.strip(), # git push outputs to stderr
- }
- else:
- results[pkg.name] = {
- "status": "error",
- "tag": tag,
- "error": push_result.stderr.strip(),
- }
- except Exception as e:
- results[pkg.name] = {"status": "error", "error": str(e)}
- return results
-
-
-# EOF
diff --git a/src/scitex/_dev/_sync_remote.py b/src/scitex/_dev/_sync_remote.py
deleted file mode 100755
index 283aaec7b..000000000
--- a/src/scitex/_dev/_sync_remote.py
+++ /dev/null
@@ -1,348 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-26
-# File: scitex/_dev/_sync_remote.py
-
-"""Reverse sync operations: remote → local.
-
-Complements _sync.py (local → remote) with:
- - remote_diff(): Show uncommitted changes on remote hosts
- - remote_commit(): Commit + push dirty changes on remote hosts
- - pull_local(): Pull origin → local repos
-
-Safety model (same as _sync.py):
- - All mutating operations default to confirm=False (preview only).
- - Pass confirm=True to actually execute.
-"""
-
-from __future__ import annotations
-
-import subprocess
-from pathlib import Path
-from typing import Any
-
-from ._config import DevConfig, HostConfig, get_enabled_hosts, load_config
-from ._sync import _build_ssh_args, _get_host_packages
-
-# ---------------------------------------------------------------------------
-# SSH helper (filters X11 noise)
-# ---------------------------------------------------------------------------
-
-
-def _run_ssh(host: HostConfig, remote_cmd: str, timeout: int = 120) -> dict[str, Any]:
- """Run a single SSH command and return structured result."""
- ssh_args = _build_ssh_args(host)
- ssh_args.append(remote_cmd)
- try:
- result = subprocess.run(
- ssh_args, capture_output=True, text=True, timeout=timeout
- )
- stdout = result.stdout.strip()
- stderr = result.stderr.strip()
- stderr_lines = [
- line for line in stderr.splitlines() if "X11 forwarding" not in line
- ]
- stderr = "\n".join(stderr_lines).strip()
- if result.returncode == 0:
- return {"status": "ok", "output": stdout, "stderr": stderr}
- return {
- "status": "error",
- "output": stdout,
- "error": stderr or f"exit code {result.returncode}",
- }
- except subprocess.TimeoutExpired:
- return {"status": "timeout", "error": f"SSH command timed out ({timeout}s)"}
- except Exception as e:
- return {"status": "error", "error": str(e)}
-
-
-# ---------------------------------------------------------------------------
-# Public API
-# ---------------------------------------------------------------------------
-
-
-def remote_diff(
- host: str | None = None,
- packages: list[str] | None = None,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Show git diff on remote host(s). Read-only operation.
-
- Parameters
- ----------
- host : str | None
- Host name. None = first enabled host.
- packages : list[str] | None
- Package names. None = host-configured defaults.
- config : DevConfig | None
- Configuration.
-
- Returns
- -------
- dict
- {host_name: {package: {status, files, diff_stat, diff}}}.
- """
- if config is None:
- config = load_config()
-
- enabled = get_enabled_hosts(config)
- if host:
- enabled = [h for h in enabled if h.name == host]
- if not enabled:
- return {"error": f"Host '{host}' not found or not enabled"}
-
- results: dict[str, Any] = {}
- for h in enabled:
- host_pkgs = _get_host_packages(h, config)
- if packages:
- host_pkgs = [(n, d) for n, d in host_pkgs if n in packages]
-
- pkg_results: dict[str, Any] = {}
- for name, dir_name in host_pkgs:
- base = f"{h.remote_base}/{dir_name}"
- cmd = (
- f"cd {base} && "
- f"echo '---STATUS---' && git status --short && "
- f"echo '---STAT---' && git diff --stat && "
- f"echo '---DIFF---' && git diff"
- )
- r = _run_ssh(h, cmd)
- if r["status"] == "ok":
- output = r["output"]
- parts = output.split("---DIFF---")
- header = parts[0] if parts else ""
- diff = parts[1].strip() if len(parts) > 1 else ""
-
- stat_parts = header.split("---STAT---")
- status_text = (
- stat_parts[0].replace("---STATUS---", "").strip()
- if stat_parts
- else ""
- )
- stat_text = stat_parts[1].strip() if len(stat_parts) > 1 else ""
-
- pkg_results[name] = {
- "status": "dirty" if status_text else "clean",
- "files": status_text,
- "diff_stat": stat_text,
- "diff": diff,
- }
- else:
- pkg_results[name] = r
- results[h.name] = pkg_results
- return results
-
-
-def remote_commit(
- host: str,
- packages: list[str] | None = None,
- message: str | None = None,
- push: bool = True,
- confirm: bool = False,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Commit dirty changes on a remote host and optionally push to origin.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- host : str
- Host name (required).
- packages : list[str] | None
- Package names. None = host-configured defaults.
- message : str | None
- Commit message. Auto-generated if not provided.
- push : bool
- Push to origin after commit (default True).
- confirm : bool
- If False (default), preview only (dry run).
- config : DevConfig | None
- Configuration.
-
- Returns
- -------
- dict
- {package: {status, commands|output}}.
- """
- if config is None:
- config = load_config()
-
- enabled = get_enabled_hosts(config)
- target = next((h for h in enabled if h.name == host), None)
- if target is None:
- return {"error": f"Host '{host}' not found or not enabled"}
-
- host_pkgs = _get_host_packages(target, config)
- if packages:
- host_pkgs = [(n, d) for n, d in host_pkgs if n in packages]
-
- results: dict[str, Any] = {}
- for name, dir_name in host_pkgs:
- base = f"{target.remote_base}/{dir_name}"
-
- if not confirm:
- cmd = f"cd {base} && git status --short"
- r = _run_ssh(target, cmd)
- dirty_files = r.get("output", "").strip() if r["status"] == "ok" else ""
- if not dirty_files:
- results[name] = {"status": "clean", "message": "nothing to commit"}
- continue
-
- msg = message or f"chore({name}): sync from {host}"
- commit_cmds = [f"cd {base}", "git add -A", f'git commit -m "{msg}"']
- if push:
- commit_cmds.append("git push origin $(git rev-parse --abbrev-ref HEAD)")
- results[name] = {
- "status": "dry_run",
- "dirty_files": dirty_files,
- "commands": commit_cmds,
- }
- continue
-
- # Execute: check if dirty first
- status_r = _run_ssh(target, f"cd {base} && git status --short")
- dirty = status_r.get("output", "").strip() if status_r["status"] == "ok" else ""
- if not dirty:
- results[name] = {"status": "clean", "message": "nothing to commit"}
- continue
-
- msg = message or f"chore({name}): sync from {host}"
- cmds = [f"cd {base}", "git add -A", f'git commit -m "{msg}"']
- if push:
- cmds.append("git push origin $(git rev-parse --abbrev-ref HEAD)")
- results[name] = _run_ssh(target, " && ".join(cmds))
-
- return results
-
-
-def pull_local(
- packages: list[str] | None = None,
- confirm: bool = False,
- stash: bool = True,
- config: DevConfig | None = None,
-) -> dict[str, Any]:
- """Pull latest from origin to local repos.
-
- Safety: defaults to preview only. Pass confirm=True to execute.
-
- Parameters
- ----------
- packages : list[str] | None
- Package names. None = all configured packages.
- confirm : bool
- If False (default), preview only.
- stash : bool
- If True (default), stash local changes before pull and pop after.
- If False and repo is dirty, pull proceeds as-is (may fail).
- config : DevConfig | None
- Configuration.
-
- Returns
- -------
- dict
- {package: {status, output|commands, stashed}}.
- """
- if config is None:
- config = load_config()
-
- targets = config.packages
- if packages:
- targets = [p for p in targets if p.name in packages]
-
- results: dict[str, Any] = {}
- for pkg in targets:
- if not pkg.local_path:
- continue
-
- path = Path(pkg.local_path).expanduser()
- if not path.exists():
- results[pkg.name] = {"status": "skipped", "error": f"{path} not found"}
- continue
-
- # Check if repo is dirty
- status_result = subprocess.run(
- ["git", "-C", str(path), "status", "--porcelain"],
- capture_output=True,
- text=True,
- timeout=10,
- )
- is_dirty = bool(status_result.stdout.strip())
-
- if not confirm:
- entry: dict[str, Any] = {
- "status": "dry_run",
- "commands": ["git", "-C", str(path), "pull", "origin"],
- }
- if is_dirty:
- entry["dirty"] = True
- if stash:
- entry["note"] = "repo is dirty; would stash before pull"
- results[pkg.name] = entry
- continue
-
- try:
- did_stash = False
- if is_dirty and stash:
- stash_result = subprocess.run(
- ["git", "-C", str(path), "stash"],
- capture_output=True,
- text=True,
- timeout=30,
- )
- if stash_result.returncode != 0:
- results[pkg.name] = {
- "status": "error",
- "error": stash_result.stderr.strip() or "git stash failed",
- "stashed": False,
- }
- continue
- did_stash = True
-
- result = subprocess.run(
- ["git", "-C", str(path), "pull", "origin"],
- capture_output=True,
- text=True,
- timeout=60,
- )
- stdout = result.stdout.strip()
- stderr = result.stderr.strip()
- stderr_lines = [
- line for line in stderr.splitlines() if "X11 forwarding" not in line
- ]
- stderr = "\n".join(stderr_lines).strip()
-
- if did_stash:
- pop_result = subprocess.run(
- ["git", "-C", str(path), "stash", "pop"],
- capture_output=True,
- text=True,
- timeout=30,
- )
- if pop_result.returncode != 0:
- results[pkg.name] = {
- "status": "stash_conflict",
- "output": stdout or stderr,
- "stashed": True,
- "error": pop_result.stderr.strip()
- or "git stash pop failed; resolve conflicts manually",
- }
- continue
-
- if result.returncode == 0:
- results[pkg.name] = {
- "status": "ok",
- "output": stdout or stderr,
- "stashed": did_stash,
- }
- else:
- results[pkg.name] = {
- "status": "error",
- "error": stderr or f"exit code {result.returncode}",
- "stashed": did_stash,
- }
- except Exception as e:
- results[pkg.name] = {"status": "error", "error": str(e)}
- return results
-
-
-# EOF
diff --git a/src/scitex/_dev/_test.py b/src/scitex/_dev/_test.py
deleted file mode 100755
index c9da0524e..000000000
--- a/src/scitex/_dev/_test.py
+++ /dev/null
@@ -1,499 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-14
-# File: scitex/_dev/_test.py
-
-"""
-Core API for running tests locally and on HPC via Slurm.
-
-Functions
----------
-run_local : Run pytest locally via subprocess
-sync_to_hpc : rsync project to HPC host
-run_hpc_srun : Blocking srun on HPC
-run_hpc_sbatch : Async sbatch, returns job ID
-poll_hpc_job : Check sacct status
-fetch_hpc_result : Fetch full output via scp
-"""
-
-from __future__ import annotations
-
-import os
-import re
-import subprocess
-import sys
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any
-
-from scitex.config import PriorityConfig # noqa: E402
-
-_RSYNC_EXCLUDES = [
- ".git",
- "__pycache__",
- "*.pyc",
- ".eggs",
- "*.egg-info",
- "dist",
- "build",
- "docs/sphinx/_build",
- ".tox",
- ".mypy_cache",
- ".pytest_cache",
- "*_out",
- "GITIGNORED",
- ".pytest-hpc-output",
-]
-
-# HPC defaults (match figrecipe production settings)
-_HPC_DEFAULTS = {
- "host": "spartan",
- "cpus": 16,
- "partition": "sapphire",
- "time": "00:20:00",
- "mem": "128G",
- "remote_base": "~/proj",
-}
-
-
-@dataclass
-class TestConfig:
- """Configuration for test execution."""
-
- module: str = ""
- parallel: str = "auto"
- fast: bool = False
- coverage: bool = False
- exitfirst: bool = False
- pattern: str = ""
- changed: bool = False
- last_failed: bool = False
- # HPC (None = resolve via SCITEX_DEV_TEST_* env → default)
- hpc_host: str | None = None
- hpc_cpus: int | None = None
- hpc_partition: str | None = None
- hpc_time: str | None = None
- hpc_mem: str | None = None
- remote_base: str | None = None
-
-
-def _get_project_info() -> tuple:
- """Auto-detect git root and project name.
-
- Returns
- -------
- tuple
- (git_root_path, project_name)
- """
- try:
- git_root = subprocess.check_output(
- ["git", "rev-parse", "--show-toplevel"],
- stderr=subprocess.DEVNULL,
- text=True,
- ).strip()
- except (subprocess.CalledProcessError, FileNotFoundError):
- git_root = os.getcwd()
- project = os.path.basename(git_root)
- return git_root, project
-
-
-def _get_hpc_config(config: TestConfig) -> dict[str, Any]:
- """Resolve HPC config via PriorityConfig cascade.
-
- Priority: CLI flag (direct) → SCITEX_DEV_TEST_* env → default.
- """
- pc = PriorityConfig(env_prefix="SCITEX_DEV_TEST_")
- return {
- "host": pc.resolve("host", config.hpc_host, default=_HPC_DEFAULTS["host"]),
- "cpus": pc.resolve(
- "cpus", config.hpc_cpus, default=_HPC_DEFAULTS["cpus"], type=int
- ),
- "partition": pc.resolve(
- "partition", config.hpc_partition, default=_HPC_DEFAULTS["partition"]
- ),
- "time": pc.resolve("time", config.hpc_time, default=_HPC_DEFAULTS["time"]),
- "mem": pc.resolve("mem", config.hpc_mem, default=_HPC_DEFAULTS["mem"]),
- "remote_base": pc.resolve(
- "remote_base", config.remote_base, default=_HPC_DEFAULTS["remote_base"]
- ),
- }
-
-
-def _job_id_path(git_root: str) -> Path:
- """Path to persist last HPC job ID."""
- return Path(git_root) / ".last-hpc-job"
-
-
-def _build_pytest_args(config: TestConfig, git_root: str) -> list[str]:
- """Build pytest command-line arguments."""
- args = [sys.executable, "-m", "pytest"]
-
- # Test path
- if config.module:
- test_dir = os.path.join(git_root, "tests", "scitex", config.module)
- if not os.path.isdir(test_dir):
- test_dir = os.path.join(git_root, "tests", config.module)
- if not os.path.isdir(test_dir):
- test_dir = os.path.join(git_root, "tests")
- args.append(test_dir)
- else:
- args.append(os.path.join(git_root, "tests"))
-
- # Parallel
- if config.parallel != "0":
- args.extend(["-n", config.parallel])
- args.extend(["--dist", "loadfile"])
-
- # Options
- if config.fast:
- args.extend(["-m", "not slow"])
- if config.exitfirst:
- args.append("-x")
- if config.pattern:
- args.extend(["-k", config.pattern])
- if config.last_failed:
- args.append("--lf")
- if config.changed:
- args.append("--testmon")
- if config.coverage:
- args.extend(["--cov", "--cov-report=term-missing"])
-
- args.append("--tb=short")
- return args
-
-
-def run_local(config: TestConfig) -> int:
- """Run pytest locally via subprocess.
-
- Parameters
- ----------
- config : TestConfig
- Test configuration.
-
- Returns
- -------
- int
- Exit code from pytest.
- """
- git_root, _ = _get_project_info()
- args = _build_pytest_args(config, git_root)
- result = subprocess.run(args, cwd=git_root)
- return result.returncode
-
-
-def _emit_test_event(
- exit_code: int,
- project: str,
- module: str = "",
- source: str = "local",
- log_tail: str = "",
-) -> None:
- """Emit test result via scitex.events.
-
- Delegates to the general-purpose event bus which handles
- state files (~/.scitex/events/) and optional webhook delivery.
- """
- from scitex.events import emit
-
- emit(
- "test_complete",
- project=project,
- status="success" if exit_code == 0 else "failure",
- payload={
- "exit_code": exit_code,
- "module": module,
- "log_tail": log_tail[-2000:] if log_tail else "",
- },
- source=source,
- )
-
-
-def _check_ssh(host: str) -> bool:
- """Check SSH connectivity."""
- result = subprocess.run(
- ["ssh", "-o", "ConnectTimeout=5", host, "true"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- return result.returncode == 0
-
-
-def sync_to_hpc(config: TestConfig) -> bool:
- """Rsync project to HPC host.
-
- Parameters
- ----------
- config : TestConfig
- Test configuration with HPC settings.
-
- Returns
- -------
- bool
- True if sync succeeded.
- """
- git_root, project = _get_project_info()
- hpc = _get_hpc_config(config)
-
- cmd = ["rsync", "-az", "--delete"]
- for exc in _RSYNC_EXCLUDES:
- cmd.extend(["--exclude", exc])
- cmd.append(f"{git_root}/")
- cmd.append(f"{hpc['host']}:{hpc['remote_base']}/{project}/")
-
- result = subprocess.run(cmd)
- return result.returncode == 0
-
-
-def run_hpc_srun(config: TestConfig) -> int:
- """Blocking srun on HPC.
-
- Parameters
- ----------
- config : TestConfig
- Test configuration with HPC settings.
-
- Returns
- -------
- int
- Exit code from remote pytest.
- """
- _, project = _get_project_info()
- hpc = _get_hpc_config(config)
- cpus = str(hpc["cpus"])
-
- test_path = f"tests/{config.module}" if config.module else "tests/"
- pytest_opts = f"-n {cpus} --dist loadfile -x --tb=short"
- if config.fast:
- pytest_opts += " -m 'not slow'"
- if config.coverage:
- pytest_opts += " --cov --cov-report=term-missing"
-
- remote_cmd = (
- f"cd {hpc['remote_base']}/{project} "
- f"&& pip install -e .[dev] -q --no-deps "
- f"&& python -m pytest {test_path} {pytest_opts}"
- )
-
- ssh_cmd = [
- "ssh",
- hpc["host"],
- f"bash -lc 'srun "
- f"--partition={hpc['partition']} "
- f"--cpus-per-task={cpus} "
- f"--time={hpc['time']} "
- f"--mem={hpc['mem']} "
- f"--job-name=pytest-{project} "
- f'bash -lc "{remote_cmd}"\'',
- ]
-
- result = subprocess.run(ssh_cmd)
- return result.returncode
-
-
-def run_hpc_sbatch(config: TestConfig) -> str | None:
- """Async sbatch, returns job ID.
-
- Parameters
- ----------
- config : TestConfig
- Test configuration with HPC settings.
-
- Returns
- -------
- str or None
- Job ID string, or None on failure.
- """
- git_root, project = _get_project_info()
- hpc = _get_hpc_config(config)
- cpus = str(hpc["cpus"])
- remote_out = f"{hpc['remote_base']}/{project}/.pytest-hpc-output"
-
- test_path = f"tests/{config.module}" if config.module else "tests/"
- pytest_opts = f"-n {cpus} --dist loadfile -x --tb=short"
- if config.fast:
- pytest_opts += " -m 'not slow'"
- if config.coverage:
- pytest_opts += " --cov --cov-report=term-missing"
-
- remote_cmd = (
- f"cd {hpc['remote_base']}/{project} "
- f"&& pip install -e .[dev] -q --no-deps "
- f"&& python -m pytest {test_path} {pytest_opts}"
- )
-
- ssh_cmd = [
- "ssh",
- hpc["host"],
- f"bash -lc '"
- f"mkdir -p {remote_out} && "
- f"sbatch --parsable "
- f"--partition={hpc['partition']} "
- f"--cpus-per-task={cpus} "
- f"--time={hpc['time']} "
- f"--mem={hpc['mem']} "
- f"--job-name=pytest-{project} "
- f"--output={remote_out}/%j.out "
- f"--error={remote_out}/%j.err "
- f'--wrap="bash -lc \\"{remote_cmd}\\""\'',
- ]
-
- result = subprocess.run(ssh_cmd, capture_output=True, text=True)
- if result.returncode != 0:
- return None
-
- # Extract numeric job ID from output
- match = re.search(r"(\d+)", result.stdout)
- if not match:
- return None
-
- job_id = match.group(1)
- _job_id_path(git_root).write_text(job_id + "\n")
- return job_id
-
-
-def poll_hpc_job(
- job_id: str | None = None,
- hpc_host: str | None = None,
-) -> dict[str, Any]:
- """Check sacct status.
-
- Parameters
- ----------
- job_id : str, optional
- Job ID to poll. If None, reads from .last-hpc-job.
- hpc_host : str, optional
- HPC host name. Defaults to HPC_HOST env var or "spartan".
-
- Returns
- -------
- dict
- {"state": str, "output": str or None, "job_id": str}
- """
- git_root, project = _get_project_info()
- pc = PriorityConfig(env_prefix="SCITEX_DEV_TEST_")
- host = pc.resolve("host", hpc_host, default=_HPC_DEFAULTS["host"])
- remote_base = pc.resolve("remote_base", None, default=_HPC_DEFAULTS["remote_base"])
- remote_out = f"{remote_base}/{project}/.pytest-hpc-output"
-
- if not job_id:
- jpath = _job_id_path(git_root)
- if jpath.exists():
- job_id = jpath.read_text().strip()
- if not job_id:
- return {"state": "error", "output": None, "job_id": ""}
-
- # Query sacct
- ssh_cmd = [
- "ssh",
- host,
- f"bash -lc 'sacct -j {job_id} --format=State --noheader -P | head -1'",
- ]
- result = subprocess.run(ssh_cmd, capture_output=True, text=True)
- raw = result.stdout.strip()
- match = re.search(
- r"(COMPLETED|FAILED|RUNNING|PENDING|TIMEOUT|CANCELLED|OUT_OF_ME)", raw
- )
- state = match.group(1) if match else "UNKNOWN"
-
- output = None
- if state in ("COMPLETED", "FAILED", "TIMEOUT", "CANCELLED"):
- tmp = f"/tmp/pytest-hpc-{job_id}.out"
- subprocess.run(
- ["scp", "-q", f"{host}:{remote_out}/{job_id}.out", tmp],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- if os.path.exists(tmp):
- with open(tmp) as f:
- output = f.read()
-
- return {"state": state, "output": output, "job_id": job_id}
-
-
-def watch_hpc_job(
- job_id: str | None = None,
- hpc_host: str | None = None,
- interval: int = 15,
-) -> dict[str, Any]:
- """Poll HPC job until completion.
-
- Parameters
- ----------
- job_id : str, optional
- Job ID to watch. If None, reads from .last-hpc-job.
- hpc_host : str, optional
- HPC host name.
- interval : int
- Polling interval in seconds.
-
- Returns
- -------
- dict
- {"state": str, "output": str or None, "job_id": str}
- """
- import time
-
- while True:
- info = poll_hpc_job(job_id=job_id, hpc_host=hpc_host)
- state = info.get("state", "UNKNOWN")
-
- if state in ("COMPLETED", "FAILED", "TIMEOUT", "CANCELLED", "error"):
- return info
- if state == "UNKNOWN":
- return info
-
- # Still running/pending, wait and retry
- time.sleep(interval)
-
-
-def fetch_hpc_result(
- job_id: str | None = None,
- hpc_host: str | None = None,
-) -> str | None:
- """Fetch full output via scp.
-
- Parameters
- ----------
- job_id : str, optional
- Job ID. If None, reads from .last-hpc-job.
- hpc_host : str, optional
- HPC host name.
-
- Returns
- -------
- str or None
- Full test output, or None if not found.
- """
- git_root, project = _get_project_info()
- pc = PriorityConfig(env_prefix="SCITEX_DEV_TEST_")
- host = pc.resolve("host", hpc_host, default=_HPC_DEFAULTS["host"])
- remote_base = pc.resolve("remote_base", None, default=_HPC_DEFAULTS["remote_base"])
- remote_out = f"{remote_base}/{project}/.pytest-hpc-output"
-
- if not job_id:
- jpath = _job_id_path(git_root)
- if jpath.exists():
- job_id = jpath.read_text().strip()
- if not job_id:
- return None
-
- tmp_out = f"/tmp/pytest-hpc-{job_id}.out"
- tmp_err = f"/tmp/pytest-hpc-{job_id}.err"
-
- subprocess.run(
- ["scp", "-q", f"{host}:{remote_out}/{job_id}.out", tmp_out],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- subprocess.run(
- ["scp", "-q", f"{host}:{remote_out}/{job_id}.err", tmp_err],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
-
- if os.path.exists(tmp_out):
- with open(tmp_out) as f:
- return f.read()
- return None
-
-
-# EOF
diff --git a/src/scitex/_dev/_versions.py b/src/scitex/_dev/_versions.py
deleted file mode 100755
index 42f4b2aba..000000000
--- a/src/scitex/_dev/_versions.py
+++ /dev/null
@@ -1,387 +0,0 @@
-#!/usr/bin/env python3
-# Timestamp: 2026-02-02
-# File: scitex/_dev/_versions.py
-
-"""Core version checking logic for the scitex ecosystem."""
-
-from __future__ import annotations
-
-import re
-import subprocess
-from pathlib import Path
-from typing import Any
-
-from ._ecosystem import ECOSYSTEM, get_all_packages, get_local_path
-
-
-def get_version_from_toml(path: Path) -> str | None:
- """Read version from pyproject.toml."""
- toml_path = path / "pyproject.toml"
- if not toml_path.exists():
- return None
-
- try:
- # Python 3.11+
- import tomllib
-
- with open(toml_path, "rb") as f:
- data = tomllib.load(f)
- except ImportError:
- try:
- import tomli
-
- with open(toml_path, "rb") as f:
- data = tomli.load(f)
- except ImportError:
- # Fallback: regex parse
- content = toml_path.read_text()
- match = re.search(
- r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE
- )
- return match.group(1) if match else None
-
- return data.get("project", {}).get("version")
-
-
-def get_version_installed(package: str) -> str | None:
- """Get version from importlib.metadata."""
- try:
- from importlib.metadata import version
-
- return version(package)
- except Exception:
- return None
-
-
-def get_git_latest_tag(path: Path) -> str | None:
- """Get latest git tag (version tags only)."""
- if not path.exists():
- return None
-
- try:
- result = subprocess.run(
- ["git", "describe", "--tags", "--abbrev=0", "--match", "v*"],
- cwd=path,
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode == 0:
- return result.stdout.strip()
- except Exception:
- pass
-
- # Fallback: list all tags
- try:
- result = subprocess.run(
- ["git", "tag", "-l", "v*", "--sort=-v:refname"],
- cwd=path,
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode == 0 and result.stdout.strip():
- return result.stdout.strip().split("\n")[0]
- except Exception:
- pass
-
- return None
-
-
-def get_git_branch(path: Path) -> str | None:
- """Get current git branch."""
- if not path.exists():
- return None
-
- try:
- result = subprocess.run(
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
- cwd=path,
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode == 0:
- return result.stdout.strip()
- except Exception:
- pass
-
- return None
-
-
-def get_git_status(path: Path) -> dict[str, Any] | None:
- """Get git worktree status (dirty/clean, ahead/behind remote).
-
- Returns
- -------
- dict or None
- Keys: dirty (bool), ahead (int), behind (int), short_hash (str).
- Returns None if path doesn't exist or is not a git repo.
- """
- if not path.exists():
- return None
-
- info: dict[str, Any] = {"dirty": False, "ahead": 0, "behind": 0, "short_hash": None}
-
- # Check if dirty (uncommitted changes)
- try:
- result = subprocess.run(
- ["git", "status", "--porcelain"],
- cwd=path,
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode == 0:
- info["dirty"] = bool(result.stdout.strip())
- except Exception:
- pass
-
- # Get short commit hash
- try:
- result = subprocess.run(
- ["git", "rev-parse", "--short", "HEAD"],
- cwd=path,
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode == 0:
- info["short_hash"] = result.stdout.strip()
- except Exception:
- pass
-
- # Ahead/behind upstream
- try:
- result = subprocess.run(
- ["git", "rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
- cwd=path,
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode == 0:
- parts = result.stdout.strip().split()
- if len(parts) == 2:
- info["ahead"] = int(parts[0])
- info["behind"] = int(parts[1])
- except Exception:
- pass
-
- return info
-
-
-def get_pypi_version(package: str) -> str | None:
- """Fetch latest version from PyPI API."""
- try:
- import urllib.request
-
- url = f"https://pypi.org/pypi/{package}/json"
- with urllib.request.urlopen(url, timeout=5) as response:
- import json
-
- data = json.loads(response.read().decode())
- return data.get("info", {}).get("version")
- except Exception:
- return None
-
-
-def _normalize_version(v: str | None) -> str | None:
- """Normalize version string (strip v prefix)."""
- if v is None:
- return None
- return v.lstrip("v")
-
-
-def _pep440_equal(v1: str | None, v2: str | None) -> bool:
- """Compare two version strings using PEP 440 normalization.
-
- Treats e.g. '0.10.3-alpha' and '0.10.3a0' as equal.
- """
- if v1 is None or v2 is None:
- return v1 == v2
- from packaging.version import InvalidVersion, Version
-
- try:
- return Version(_normalize_version(v1)) == Version(_normalize_version(v2))
- except InvalidVersion:
- return _normalize_version(v1) == _normalize_version(v2)
-
-
-def _compare_versions(v1: str | None, v2: str | None) -> int:
- """Compare two version strings. Returns -1, 0, or 1."""
- if v1 is None or v2 is None:
- return 0
-
- from packaging.version import Version
-
- try:
- ver1 = Version(_normalize_version(v1))
- ver2 = Version(_normalize_version(v2))
- if ver1 < ver2:
- return -1
- if ver1 > ver2:
- return 1
- return 0
- except Exception:
- # Fallback: string comparison
- return 0
-
-
-def _determine_status(info: dict[str, Any]) -> tuple[str, list[str]]:
- """Determine version status and issues."""
- issues = []
-
- toml_ver = info.get("local", {}).get("pyproject_toml")
- installed_ver = info.get("local", {}).get("installed")
- tag_ver = _normalize_version(info.get("git", {}).get("latest_tag"))
- pypi_ver = info.get("remote", {}).get("pypi")
-
- # Check local consistency (PEP 440: '0.10.3-alpha' == '0.10.3a0')
- if toml_ver and installed_ver and not _pep440_equal(toml_ver, installed_ver):
- issues.append(f"pyproject.toml ({toml_ver}) != installed ({installed_ver})")
-
- # Check if toml matches tag
- if toml_ver and tag_ver and not _pep440_equal(toml_ver, tag_ver):
- issues.append(f"pyproject.toml ({toml_ver}) != git tag ({tag_ver})")
-
- # Check pypi status
- if toml_ver and pypi_ver:
- cmp = _compare_versions(toml_ver, pypi_ver)
- if cmp > 0:
- issues.append(f"local ({toml_ver}) > pypi ({pypi_ver}) - ready to release")
- return "unreleased", issues
- if cmp < 0:
- issues.append(f"local ({toml_ver}) < pypi ({pypi_ver}) - outdated")
- return "outdated", issues
-
- # Check git worktree status
- git_info = info.get("git", {})
- if git_info.get("dirty"):
- issues.append("uncommitted changes")
- ahead = git_info.get("ahead", 0)
- behind = git_info.get("behind", 0)
- if ahead:
- issues.append(f"{ahead} commit(s) ahead of remote")
- if behind:
- issues.append(f"{behind} commit(s) behind remote")
-
- if issues:
- return "mismatch", issues
-
- if not toml_ver:
- return "unavailable", ["package not found locally"]
-
- return "ok", []
-
-
-def list_versions(packages: list[str] | None = None) -> dict[str, Any]:
- """List versions for all ecosystem packages.
-
- Parameters
- ----------
- packages : list[str] | None
- List of package names to check. If None, checks all ecosystem packages.
-
- Returns
- -------
- dict
- Version information for each package.
- """
- if packages is None:
- packages = get_all_packages()
-
- result = {}
- for pkg in packages:
- if pkg not in ECOSYSTEM:
- result[pkg] = {"status": "unknown", "issues": [f"'{pkg}' not in ecosystem"]}
- continue
-
- info: dict[str, Any] = {"local": {}, "git": {}, "remote": {}}
- local_path = get_local_path(pkg)
- pypi_name = ECOSYSTEM[pkg].get("pypi_name", pkg)
-
- # Local sources
- if local_path and local_path.exists():
- info["local"]["pyproject_toml"] = get_version_from_toml(local_path)
- info["local"]["installed"] = get_version_installed(pypi_name)
-
- # Git sources
- if local_path and local_path.exists():
- info["git"]["latest_tag"] = get_git_latest_tag(local_path)
- info["git"]["branch"] = get_git_branch(local_path)
- git_status = get_git_status(local_path)
- if git_status:
- info["git"]["dirty"] = git_status["dirty"]
- info["git"]["ahead"] = git_status["ahead"]
- info["git"]["behind"] = git_status["behind"]
- info["git"]["short_hash"] = git_status["short_hash"]
-
- # Remote sources
- info["remote"]["pypi"] = get_pypi_version(pypi_name)
-
- # Determine status
- status, issues = _determine_status(info)
- info["status"] = status
- info["issues"] = issues
-
- result[pkg] = info
-
- return result
-
-
-def check_versions(packages: list[str] | None = None) -> dict[str, Any]:
- """Check version consistency and return detailed status.
-
- Parameters
- ----------
- packages : list[str] | None
- List of package names to check. If None, checks all ecosystem packages.
-
- Returns
- -------
- dict
- Detailed version check results with overall summary.
- """
- versions = list_versions(packages)
-
- summary = {
- "total": len(versions),
- "ok": 0,
- "mismatch": 0,
- "unreleased": 0,
- "outdated": 0,
- "unavailable": 0,
- "unknown": 0,
- }
-
- for _pkg, info in versions.items():
- status = info.get("status", "unknown")
- if status in summary:
- summary[status] += 1
-
- return {"packages": versions, "summary": summary}
-
-
-def get_mismatches(packages: list[str] | None = None) -> dict[str, Any]:
- """Return packages with non-ok status and their issues.
-
- Parameters
- ----------
- packages : list[str] | None
- Package names to check. None = all ecosystem packages.
-
- Returns
- -------
- dict
- {package_name: {status, issues, local, git, remote}} for non-ok packages.
- """
- versions = list_versions(packages)
- return {
- pkg: info
- for pkg, info in versions.items()
- if info.get("status") not in ("ok", "unavailable")
- }
-
-
-# EOF