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 = ` - `; - 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.total}
Total
-
${summary.ok}
OK
-
${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 - - - - -
-
-
-

SciTeX Version Dashboard

- Loading... -
-
- - - - -
-
- - - -
- - -
- -
-
-
- -
-
-
-

Loading version data...

-
- - - - -""" - - -def get_error_html(error: str) -> str: - """Generate error page HTML.""" - return f""" - - - Error - SciTeX Dashboard - - - -
-

Error

-

{error}

-
- - -""" - - -# 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