diff --git a/pyproject.toml b/pyproject.toml index 95a42fe18..16aadb996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ # App SDK — unified file storage for local + cloud "scitex-app==0.2.8", # Delegated core modules (thin re-exports) - "scitex-dev==0.15.0", + "scitex-dev>=0.16.0", "scitex-io==0.2.19", "scitex-stats==0.2.23", "scitex-audio==0.2.13", @@ -82,7 +82,7 @@ dependencies = [ "scitex-path==0.1.7", "scitex-repro==0.1.6", "scitex-compat==0.1.8", - "scitex-etc==0.1.10", + "scitex-etc>=0.2.0", # media shipped here (>=0.2.0); scitex.media re-exports scitex_etc.media "scitex-genai==0.1.0", "scitex-gists==0.1.7", "scitex-audit==0.1.7", @@ -128,13 +128,10 @@ scitex = "scitex" [project.entry-points."scitex_dev.skills"] scitex = "scitex" -[project.entry-points."scitex_dev.linter.plugins"] -scitex = "scitex._linter_plugin:get_plugin" - -# Legacy entry-point group (dual-register during scitex-linter -# deprecation window — will be dropped after the final shim release). -[project.entry-points."scitex_linter.plugins"] -scitex = "scitex._linter_plugin:get_plugin" +# NOTE: the umbrella no longer ships linter rules. STX-I/STX-S rules now live +# in scitex-dev (>=0.16.0, `scitex_dev.linter._rules`); the in-tree +# `_linter_plugin.py` + its `scitex_dev.linter.plugins` / `scitex_linter.plugins` +# entry points were dropped (Phase B). # ============================================ # Optional Dependencies - Module-Oriented @@ -225,11 +222,20 @@ cli = [ "GitPython", ] -# Cloud Module - Cloud service integration -# Use: pip install scitex[cloud] +# Cloud Module - Cloud service integration (OPTIONAL peer: scitex-hub) +# Use: pip install scitex[cloud] (alias of scitex[hub]) +# Kept OUT of [all] so CI's matrix skips cloud/module/project when scitex-hub +# (not yet released) is absent. scitex.cloud/.module/.project degrade with a +# friendly install hint when scitex-hub is missing. cloud = [ "matplotlib", - "scitex-cloud==0.17.0", + "scitex-hub>=0.19.0", +] + +# Hub Module - scitex-hub (formerly scitex-cloud) optional peer. +# Powers scitex.cloud / scitex.module / scitex.project. +hub = [ + "scitex-hub>=0.19.0", ] # Compat Module - Compatibility utilities @@ -352,7 +358,7 @@ gen = [ # Use: pip install scitex[etc] etc = [ "readchar", - "scitex-etc==0.1.10", + "scitex-etc>=0.2.0", ] # Events Module - Event system @@ -434,8 +440,8 @@ logging = [ "scitex-logging==0.1.7", ] -# Media Module - Media display utilities -# Use: pip install scitex[media] +# Media Module - Media display utilities (always available: scitex-etc is a +# core dep). scitex.media re-exports scitex_etc.media (shipped in >=0.2.0). media = [] # ML Module - Machine learning @@ -444,9 +450,11 @@ ml = [ "scitex-ml==0.2.0", ] -# Module Module - Module management +# Module Module - Module management (OPTIONAL peer: scitex-hub.module) # Use: pip install scitex[module] -module = [] +module = [ + "scitex-hub>=0.19.0", +] # MSWord Module - MS Word document handling # Use: pip install scitex[msword] @@ -529,9 +537,11 @@ plt = [ "figrecipe==0.28.13", ] -# Project Module - Project management +# Project Module - Project management (OPTIONAL peer: scitex-hub.project) # Use: pip install scitex[project] -project = [] +project = [ + "scitex-hub>=0.19.0", +] # Repro Module - Reproducibility tools # Use: pip install scitex[repro] @@ -712,15 +722,10 @@ types = [ # Use: pip install scitex[ui] ui = ["scitex-ui==0.5.1"] -# Utils Module - General utilities -# Use: pip install scitex[utils] -utils = [ - "h5py", - "natsort", - "matplotlib", - "xarray", - "tqdm", -] +# Utils Module - REMOVED (Phase B). The in-tree scitex.utils grab-bag was +# deleted; its public helpers live in their SoC owners (compress_hdf5 → +# scitex-io, count_grids/yield_grids/search → scitex-etc, notify → +# scitex-notification), all already core deps. Import from the owning package. # Verify Module - Verification utilities # Use: pip install scitex[verify] @@ -914,11 +919,14 @@ dev = [ "scitex-audit==0.1.7", "scitex-browser==0.1.15", "scitex-clew==0.2.14", - "scitex-cloud==0.17.0", + # NOTE: scitex-hub (optional peer powering cloud/module/project; formerly + # scitex-cloud) is intentionally NOT in [dev] — it is unreleased, so listing + # it here would break CI's `.[all,dev]` resolve. Install explicitly via + # `pip install scitex[cloud]` when scitex-hub is available. "scitex-container==0.2.1", "scitex-dataset==0.3.10", - "scitex-dev==0.15.0", - "scitex-etc==0.1.10", + "scitex-dev>=0.16.0", + "scitex-etc>=0.2.0", "scitex-genai==0.1.0", "scitex-io==0.2.19", "scitex-notification==0.2.8", @@ -984,7 +992,9 @@ all = [ "scitex[capture]", "scitex[clew]", "scitex[cli]", - "scitex[cloud]", + # NOTE: scitex[cloud] / scitex[module] / scitex[project] (scitex-hub) are + # OPTIONAL peers deliberately kept OUT of [all] so CI's matrix runs without + # the unreleased scitex-hub. Install explicitly: pip install scitex[cloud]. "scitex[compat]", "scitex[config]", "scitex[container]", @@ -1011,7 +1021,6 @@ all = [ "scitex[logging]", "scitex[media]", "scitex[ml]", - "scitex[module]", "scitex[msword]", "scitex[nn]", "scitex[notebook]", @@ -1021,7 +1030,6 @@ all = [ "scitex[path]", "scitex[pd]", "scitex[plt]", - "scitex[project]", "scitex[repro]", "scitex[reproduce]", "scitex[resource]", @@ -1041,7 +1049,6 @@ all = [ "scitex[tunnel]", "scitex[types]", "scitex[ui]", - "scitex[utils]", "scitex[verify]", "scitex[web]", "scitex[writer]", @@ -1054,7 +1061,7 @@ all = [ # ============================================ # Tool Configurations # ============================================ -linter = ["scitex-dev==0.15.0"] +linter = ["scitex-dev>=0.16.0"] orochi = ["scitex-orochi==0.16.3"] agent-container = ["scitex-agent-container==0.21.3"] diff --git a/src/scitex/__init__.py b/src/scitex/__init__.py index 4c8a3ddb3..20b5fe730 100755 --- a/src/scitex/__init__.py +++ b/src/scitex/__init__.py @@ -103,7 +103,6 @@ datetime = _LazyModule("datetime", external="scitex_datetime") dt = datetime # Shorter alias — same lazy-loaded module instance. types = _LazyModule("types", external="scitex_types") -utils = _LazyModule("utils") etc = _LazyModule("etc", external="scitex_etc") context = _LazyModule("context", external="scitex_context") dev = _LazyModule("dev") @@ -114,11 +113,19 @@ logging = _LazyModule("logging", external="scitex_logging") session = _CallableModuleWrapper("session", main_decorator_name="session") session._setup_persistence("scitex", "session") -module = _CallableModuleWrapper("module", main_decorator_name="module") +# `module` is an OPTIONAL peer: it proxies the `module` callable from +# scitex_hub.module (scitex-hub is NOT a hard dep). Callability +# (`@scitex.module(...)`) is preserved; a missing scitex-hub raises a friendly +# ImportError on first use rather than at `import scitex`. +module = _CallableModuleWrapper( + "module", main_decorator_name="module", external="scitex_hub.module" +) module._setup_persistence("scitex", "module") capture = _LazyModule("capture", external="scitex_capture") template = _LazyModule("template", external="scitex_template") -cloud = _LazyModule("cloud") +# `cloud`/`project` are OPTIONAL peers proxying scitex-hub (NOT a hard dep). +# When scitex-hub is absent, attribute access raises a friendly install hint. +cloud = _LazyModule("cloud", external="scitex_hub") tunnel = _LazyModule("tunnel", external="scitex_ssh") # tunnel merged into scitex-ssh config = _LazyModule("config", external="scitex_config") audio = _LazyModule("audio", external="scitex_audio") @@ -151,13 +158,16 @@ compat = _LazyModule("compat", external="scitex_compat") # Compatibility utilities audit = _LazyModule("audit", external="scitex_audit") # Security auditing events = _LazyModule("events", external="scitex_events") # Event system -media = _LazyModule("media") # Media utilities +media = _LazyModule( + "media", external="scitex_etc.media" +) # in-tree dir removed; shipped in scitex-etc (>=0.2.0) +# `project` is an OPTIONAL peer proxying scitex_hub.project (scitex-hub is NOT a +# hard dep). A missing scitex-hub raises a friendly install hint on first access. +project = _LazyModule("project", external="scitex_hub.project") cli = _LazyModule("cli") # Command-line interface -linter = _LazyModule( - "linter" -) # AST-based linter; in-tree linter.py shim delegates to scitex_dev.linter -# (the `scitex_linter` distribution is an archived re-export shim). The -# more-consistent scitex.dev.linter resolves to the same engine via dev→scitex_dev. +# scitex.linter — the umbrella ships no linter module; the AST linter engine +# lives in scitex-dev (>=0.16.0). Use `scitex.dev.linter` (resolves to +# scitex_dev.linter via the dev→scitex_dev alias) or the `scitex-dev linter` CLI. clew = _LazyModule( "clew", external="scitex_clew" ) # Hash-based verification (in-tree dir removed; pure re-export of scitex_clew) @@ -265,12 +275,12 @@ def __getattr__(name): "datetime", "dt", "types", - "utils", "etc", "context", "dev", "gists", "cloud", + "project", "tunnel", "config", "audio", @@ -298,7 +308,6 @@ def __getattr__(name): "notification", "clew", "notebook", - "linter", "PATHS", "__version__", ] diff --git a/src/scitex/_linter_plugin.py b/src/scitex/_linter_plugin.py deleted file mode 100755 index aa63fd738..000000000 --- a/src/scitex/_linter_plugin.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Linter plugin for the `scitex` umbrella — import + structure rules. - -Owns the rules that talk about scitex umbrella API (`stx.io`, `stx.plt`, -`stx.stats`, `@stx.session`, `import scitex as stx`): - -- STX-I001-I007 — import hygiene (use stx.* instead of raw matplotlib / - scipy / numpy / pandas / pickle / random / logging) -- STX-S001-S008 — structure / `@stx.session` (decorator, main guard, - argparse, exit code, INJECTED params, CONFIG naming, magic numbers) - -Registered via the `scitex_dev.linter.plugins` entry point so -`scitex-dev linter` discovers them automatically when scitex is -installed. The legacy `scitex_linter.plugins` group is also accepted -(dual-registered in pyproject.toml during the deprecation window). - -These were lifted out of the `scitex-dev` engine in the per-package -rule migration so an `@stx.session` rename or a `stx.io` API change -forces a same-PR rule update — the rule + the API now live together. -""" - - -def get_plugin(): - """Return scitex umbrella's linter rules.""" - from scitex_dev.linter._rules._base import Rule - - # ------------------------------------------------------------------ - # I — Import hygiene - # ------------------------------------------------------------------ - I001 = Rule( - id="STX-I001", - severity="warning", - category="import", - message="Use `stx.plt` instead of importing matplotlib.pyplot directly", - suggestion="Replace with `stx.plt` (or `plt` injected by @stx.session).", - requires="scitex", - ) - I002 = Rule( - id="STX-I002", - severity="warning", - category="import", - message="Use `stx.stats` instead of importing scipy.stats directly", - suggestion="Replace with `stx.stats` which adds effect sizes, CI, and power analysis.", - requires="scitex", - ) - I003 = Rule( - id="STX-I003", - severity="warning", - category="import", - message="Use `stx.io` instead of pickle for file I/O", - suggestion="Replace with `stx.io.save(obj, 'file.pkl')` / `stx.io.load('file.pkl')`.", - requires="scitex", - ) - I004 = Rule( - id="STX-I004", - severity="warning", - category="import", - message="Use `stx.io` for CSV/DataFrame I/O instead of pandas I/O functions", - suggestion="Replace `pd.read_csv()` with `stx.io.load()`, `df.to_csv()` with `stx.io.save()`.", - requires="scitex", - ) - I005 = Rule( - id="STX-I005", - severity="warning", - category="import", - message="Use `stx.io` for array I/O instead of numpy save/load", - suggestion="Replace `np.save()`/`np.load()` with `stx.io.save()`/`stx.io.load()`.", - requires="scitex", - ) - I006 = Rule( - id="STX-I006", - severity="info", - category="import", - message="Use `rngg` (injected by @stx.session) for reproducible randomness", - suggestion="Remove `import random` and use `rngg` from @stx.session injection.", - requires="scitex", - ) - I007 = Rule( - id="STX-I007", - severity="warning", - category="import", - message="Use `logger` (injected by @stx.session) instead of logging module", - suggestion="Remove `import logging` and use `logger` from @stx.session injection.", - requires="scitex", - ) - - # ------------------------------------------------------------------ - # S — Structure / @stx.session - # ------------------------------------------------------------------ - S001 = Rule( - id="STX-S001", - severity="error", - category="structure", - message="Missing @stx.session or @stx.module decorator on main function", - suggestion=( - "Add @stx.session (for scripts) or @stx.module (for cloud modules).\n" - " @stx.session\n" - " def main(...):\n" - " return 0\n" - "If this is library code (not a script), add its directory to library_dirs:\n" - " [tool.scitex-linter]\n" - ' library_dirs = ["src", "tests", "apps", "config", "docs"]\n' - " Or: SCITEX_DEV_LINTER_NON_SCRIPT_DIRS=src,tests,apps,config,docs" - ), - requires="scitex", - ) - S002 = Rule( - id="STX-S002", - severity="error", - category="structure", - message="Missing `if __name__ == '__main__'` guard", - suggestion=( - "Add `if __name__ == '__main__': main()` at the end of the script.\n" - "If this is library code (not a script), add its directory to library_dirs:\n" - " [tool.scitex-linter]\n" - ' library_dirs = ["src", "tests", "apps", "config", "docs"]\n' - " Or: SCITEX_DEV_LINTER_NON_SCRIPT_DIRS=src,tests,apps,config,docs" - ), - ) - S003 = Rule( - id="STX-S003", - severity="error", - category="structure", - message="argparse detected — @stx.session auto-generates CLI from function signature", - suggestion=( - "Remove `import argparse` and define parameters as function arguments:\n" - " @stx.session\n" - " def main(data_path: str, threshold: float = 0.5):\n" - " # Auto-generates: --data-path, --threshold" - ), - requires="scitex", - ) - S004 = Rule( - id="STX-S004", - severity="warning", - category="structure", - message="@stx.session function should return an integer exit code", - suggestion="Add `return 0` for success at the end of your session function.", - requires="scitex", - ) - S005 = Rule( - id="STX-S005", - severity="warning", - category="structure", - message="Missing `import scitex as stx`", - suggestion="Add `import scitex as stx` to use SciTeX modules.", - requires="scitex", - ) - S006 = Rule( - id="STX-S006", - severity="warning", - category="structure", - message="@stx.session function missing explicit INJECTED parameters", - suggestion=( - "Declare auto-injected values explicitly in the function signature:\n" - " @stx.session\n" - " def main(\n" - " CONFIG=stx.session.INJECTED,\n" - " plt=stx.session.INJECTED,\n" - " COLORS=stx.session.INJECTED,\n" - " rngg=stx.session.INJECTED,\n" - " logger=stx.session.INJECTED,\n" - " ):\n" - " return 0" - ), - requires="scitex", - ) - S007 = Rule( - id="STX-S007", - severity="warning", - category="structure", - message="load_configs() result should be assigned to an UPPER_CASE variable", - suggestion=( - "Use UPPER_CASE for config variables — they hold project constants:\n" - " CONFIG = load_configs() # good\n" - " config = load_configs() # bad — looks like a local variable" - ), - ) - S008 = Rule( - id="STX-S008", - severity="info", - category="structure", - message="Magic number in module scope — consider centralizing in config/", - suggestion=( - "Move hard-coded values to config/*.yaml and load with load_configs():\n" - " # config/MODEL.yaml\n" - " HIDDEN_DIM: 256\n" - " DROPOUT: 0.3\n" - "\n" - " # script.py\n" - " CONFIG = load_configs()\n" - " CONFIG.MODEL.HIDDEN_DIM # 256" - ), - ) - - return { - "rules": [ - I001, - I002, - I003, - I004, - I005, - I006, - I007, - S001, - S002, - S003, - S004, - S005, - S006, - S007, - S008, - ], - "call_rules": {}, - "axes_hints": {}, - "checkers": [], - } diff --git a/src/scitex/cloud/__init__.py b/src/scitex/cloud/__init__.py deleted file mode 100755 index de09e124f..000000000 --- a/src/scitex/cloud/__init__.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: 2026-02-05 -# File: scitex/cloud/__init__.py - -"""SciTeX Cloud - Web service integration. - -This module delegates to the ``scitex-hub`` package (Django web -application; formerly ``scitex-cloud``). Install separately: -pip install scitex-hub - -Architecture: - scitex (hub) → stx.cloud → scitex_hub (spoke package) - -Example: - >>> import scitex as stx - >>> stx.cloud.get_version() - '0.18.0' - >>> stx.cloud.health_check() - {'status': 'healthy', ...} -""" - -from __future__ import annotations - -__all__ = [ - "get_version", - "health_check", - "get_context", - "eval_js", - "ui_action", - "AVAILABLE", -] - -AVAILABLE = False -_import_error_msg = None - -try: - from scitex_hub import CloudClient as _Client - from scitex_hub import get_version, health_check - - def get_context(page: str = "", **kw) -> dict: - """Get web app context: username, page, skills, available actions.""" - return _Client(**kw).get_context(page) - - def eval_js(code: str, timeout: int = 10, **kw) -> dict: - """Evaluate JavaScript in user's browser.""" - return _Client(**kw).eval_js(code, timeout) - - def ui_action(steps: list, delay_ms: int = 900, **kw) -> dict: - """Drive browser UI: navigate, highlight, click, fill, scroll.""" - return _Client(**kw).ui_action(steps, delay_ms) - - AVAILABLE = True -except ImportError as e: - _import_error_msg = str(e) - - def _raise_import() -> None: - raise ImportError( - "scitex-hub package not installed. " - "Install with: pip install scitex-hub\n" - f"Original error: {_import_error_msg}" - ) - - def get_version() -> str: - """Get scitex-hub version (requires scitex-hub package).""" - _raise_import() - - def health_check() -> dict: - """Check scitex-hub health (requires scitex-hub package).""" - _raise_import() - - def get_context(page: str = "", **kw) -> dict: - """Get web app context (requires scitex-hub package).""" - _raise_import() - - def eval_js(code: str, timeout: int = 10, **kw) -> dict: - """Evaluate JS in browser (requires scitex-hub package).""" - _raise_import() - - def ui_action(steps: list, delay_ms: int = 900, **kw) -> dict: - """Drive browser UI (requires scitex-hub package).""" - _raise_import() - - -# EOF diff --git a/src/scitex/cloud/_skills/SKILL.md b/src/scitex/cloud/_skills/SKILL.md deleted file mode 100644 index c3b5908ef..000000000 --- a/src/scitex/cloud/_skills/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: stx.cloud -description: SciTeX Cloud web service integration — health monitoring, web app context, JavaScript evaluation, browser UI control, and matplotlib inline display. Delegates to the optional scitex-cloud package. -user-invocable: false ---- - -# stx.cloud - -Thin wrapper over the `scitex-cloud` spoke package (Django web application). All implementation lives in `scitex_cloud`; this module sets branding env vars, re-exports the public API, and provides stub functions that raise `ImportError` when the package is absent. - -**Install the spoke:** `pip install scitex-cloud` - -## Sub-skills - -### Setup and Availability -- [availability.md](availability.md) — `AVAILABLE` flag, optional dependency pattern, env branding, installation - -### Core API -- [health-and-version.md](health-and-version.md) — `health_check()`, `get_version()` -- [context.md](context.md) — `get_context(page, **kw)`: username, page state, available actions -- [browser-control.md](browser-control.md) — `eval_js(code, timeout, **kw)`, `ui_action(steps, delay_ms, **kw)` - -### Integration -- [matplotlib-hook.md](matplotlib-hook.md) — `install_matplotlib_hook()`, `uninstall_matplotlib_hook()`: inline figure display in headless cloud sessions - -## Public API Summary - -```python -import scitex as stx - -stx.cloud.AVAILABLE # bool — True only when scitex-cloud is installed - -stx.cloud.get_version() # -> str -stx.cloud.health_check() # -> dict - -stx.cloud.get_context(page="", **kw) # -> dict -stx.cloud.eval_js(code, timeout=10, **kw) # -> dict -stx.cloud.ui_action(steps, delay_ms=900, **kw) # -> dict -``` - -## Quick Start - -```python -import scitex as stx - -if not stx.cloud.AVAILABLE: - raise RuntimeError("pip install scitex-cloud") - -# Verify connection -status = stx.cloud.health_check() -assert status["status"] == "healthy" - -# Get current page context -ctx = stx.cloud.get_context() -print(ctx["username"], ctx["actions"]) - -# Read from the browser -result = stx.cloud.eval_js("document.title") - -# Drive UI -stx.cloud.ui_action([ - {"action": "click", "selector": "#submit-btn"}, -]) -``` diff --git a/src/scitex/cloud/_skills/availability.md b/src/scitex/cloud/_skills/availability.md deleted file mode 100644 index 26e513ab0..000000000 --- a/src/scitex/cloud/_skills/availability.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -description: How stx.cloud handles optional scitex-cloud dependency — AVAILABLE flag, graceful degradation, and installation. ---- - -# Cloud Availability and Optional Dependency - -`stx.cloud` delegates all implementation to the separate `scitex-cloud` package (Django web application). The hub package (`scitex`) never hard-requires it. - -## AVAILABLE Flag - -```python -import scitex as stx - -if stx.cloud.AVAILABLE: - status = stx.cloud.health_check() -else: - print("scitex-cloud not installed") -``` - -`stx.cloud.AVAILABLE` is `True` only when `scitex-cloud` is importable. It is set at module import time and never changes after that. - -## Behavior When Not Installed - -Every public function raises `ImportError` with an actionable message: - -``` -ImportError: scitex-cloud package not installed. -Install with: pip install scitex-cloud -Original error: No module named 'scitex_cloud' -``` - -No silent failures. No fallback behavior. - -## Installation - -```bash -pip install scitex-cloud -``` - -## Environment Branding - -`stx.cloud.__init__` sets two environment variables before attempting to import `scitex-cloud`. These allow the downstream package to display the correct brand name in logs and UI: - -| Variable | Default value | -|----------|---------------| -| `SCITEX_CLOUD_BRAND` | `"scitex.cloud"` | -| `SCITEX_CLOUD_ALIAS` | `"cloud"` | - -These are set with `os.environ.setdefault`, so they can be overridden by the caller before importing `scitex`. - -## Architecture - -``` -scitex (hub) - └── stx.cloud (thin wrapper) - └── scitex_cloud (spoke package, Django app) - └── scitex_cloud.api.CloudClient -``` - -All logic lives in `scitex_cloud`. The wrapper in `stx.cloud` only: -1. Sets brand env vars -2. Imports and re-exports `get_version`, `health_check` -3. Wraps `CloudClient` methods as module-level functions -4. Provides stub functions that raise `ImportError` when the package is absent diff --git a/src/scitex/cloud/_skills/browser-control.md b/src/scitex/cloud/_skills/browser-control.md deleted file mode 100644 index bd5db965a..000000000 --- a/src/scitex/cloud/_skills/browser-control.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -description: Evaluate JavaScript in the user's browser and drive UI interactions with eval_js() and ui_action(). ---- - -# Browser Control - -## eval_js - -Evaluates a JavaScript expression or statement in the user's connected browser and returns the result. - -```python -eval_js(code: str, timeout: int = 10, **kw) -> dict -``` - -**Parameters** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `code` | `str` | required | JavaScript code to evaluate in the browser. | -| `timeout` | `int` | `10` | Seconds to wait for the result before raising. | -| `**kw` | any | — | Forwarded to `CloudClient.__init__`. | - -**Returns:** `dict` — result of the JavaScript evaluation as returned by `scitex_cloud`. - -**Implementation:** `_Client(**kw).eval_js(code, timeout)` - -### Examples - -```python -import scitex as stx - -# Read the page title -result = stx.cloud.eval_js("document.title") - -# Get current URL -result = stx.cloud.eval_js("window.location.href") - -# Read a DOM element's text -result = stx.cloud.eval_js("document.querySelector('#status').innerText") - -# Execute multi-line JS -result = stx.cloud.eval_js(""" - const items = document.querySelectorAll('.item'); - Array.from(items).map(el => el.textContent); -""") - -# With custom timeout -result = stx.cloud.eval_js("longRunningFunction()", timeout=30) -``` - ---- - -## ui_action - -Drives browser UI actions programmatically: navigation, highlight, click, fill, scroll, etc. - -```python -ui_action(steps: list, delay_ms: int = 900, **kw) -> dict -``` - -**Parameters** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `steps` | `list` | required | List of action step dicts. Each dict describes one browser action. | -| `delay_ms` | `int` | `900` | Milliseconds to wait between steps. | -| `**kw` | any | — | Forwarded to `CloudClient.__init__`. | - -**Returns:** `dict` — result summary from `scitex_cloud`. - -**Implementation:** `_Client(**kw).ui_action(steps, delay_ms)` - -### Step Format - -Each step is a dict. The exact keys are defined by `scitex_cloud`. Common action types include `navigate`, `click`, `fill`, `scroll`, and `highlight`. - -### Examples - -```python -import scitex as stx - -# Single click -stx.cloud.ui_action([ - {"action": "click", "selector": "#submit-btn"} -]) - -# Multi-step: navigate then fill a form -stx.cloud.ui_action([ - {"action": "navigate", "url": "/dashboard"}, - {"action": "fill", "selector": "#search-input", "value": "my query"}, - {"action": "click", "selector": "#search-btn"}, -], delay_ms=500) - -# With custom delay between steps -stx.cloud.ui_action( - steps=[ - {"action": "scroll", "selector": "#results", "direction": "down"}, - ], - delay_ms=200, -) -``` - ---- - -## Notes - -- Both functions require an active browser connection managed by `scitex-cloud`. -- `**kw` passes authentication or connection options to `CloudClient` — see `scitex-cloud` docs for supported kwargs. -- Raises `ImportError` if `scitex-cloud` is not installed (see [availability.md](availability.md)). diff --git a/src/scitex/cloud/_skills/context.md b/src/scitex/cloud/_skills/context.md deleted file mode 100644 index 4bd39a231..000000000 --- a/src/scitex/cloud/_skills/context.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -description: Retrieve the current web app context (username, page state, available actions) with get_context(). ---- - -# Web App Context - -## get_context - -Returns the current state of the user's active web app session: who is logged in, what page is open, what skills and actions are available. - -```python -get_context(page: str = "", **kw) -> dict -``` - -**Parameters** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `page` | `str` | `""` | Page name or URL fragment to query context for. Pass `""` for the current page. | -| `**kw` | any | — | Forwarded to `CloudClient.__init__` (e.g., auth credentials, base URL). | - -**Returns:** `dict` — keys include at minimum `"username"`, `"page"`, and `"actions"`. Exact shape is defined by `scitex_cloud`. - -**Implementation:** `_Client(**kw).get_context(page)` where `_Client` is `scitex_cloud.api.CloudClient`. - ---- - -## Examples - -```python -import scitex as stx - -# Current page context -ctx = stx.cloud.get_context() -print(ctx["username"]) # logged-in user -print(ctx["actions"]) # list of available actions on current page - -# Context for a specific page -ctx = stx.cloud.get_context("dashboard") -print(ctx["page"]) -``` - ---- - -## Notes - -- Requires the browser/client to be connected to scitex-cloud. -- The `**kw` forwarded to `CloudClient` allows passing custom host, port, auth tokens, etc. — see `scitex-cloud` documentation for supported kwargs. -- Raises `ImportError` if `scitex-cloud` is not installed (see [availability.md](availability.md)). diff --git a/src/scitex/cloud/_skills/health-and-version.md b/src/scitex/cloud/_skills/health-and-version.md deleted file mode 100644 index 154734ace..000000000 --- a/src/scitex/cloud/_skills/health-and-version.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -description: Check scitex-cloud service health and retrieve version with health_check() and get_version(). ---- - -# Health Check and Version - -## get_version - -Returns the version string of the installed `scitex-cloud` package. - -```python -get_version() -> str -``` - -**Parameters:** none - -**Returns:** version string, e.g. `"0.7.0a0"` - -**Source:** Re-exported directly from `scitex_cloud.get_version`. - -```python -import scitex as stx - -version = stx.cloud.get_version() -print(version) # "0.7.0a0" -``` - ---- - -## health_check - -Verifies the cloud service is running and reachable. - -```python -health_check() -> dict -``` - -**Parameters:** none - -**Returns:** dict with at minimum a `"status"` key. - -```python -import scitex as stx - -status = stx.cloud.health_check() -# {"status": "healthy", ...} -``` - -**Source:** Re-exported directly from `scitex_cloud.health_check`. - ---- - -## Pattern: Guard with AVAILABLE - -```python -import scitex as stx - -if not stx.cloud.AVAILABLE: - raise RuntimeError("scitex-cloud required but not installed") - -status = stx.cloud.health_check() -assert status["status"] == "healthy", f"Cloud unhealthy: {status}" - -version = stx.cloud.get_version() -print(f"Connected to scitex-cloud {version}") -``` diff --git a/src/scitex/cloud/_skills/matplotlib-hook.md b/src/scitex/cloud/_skills/matplotlib-hook.md deleted file mode 100644 index d018a1c9e..000000000 --- a/src/scitex/cloud/_skills/matplotlib-hook.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: Automatic matplotlib integration for cloud environments — inline figure display via install_matplotlib_hook() and uninstall_matplotlib_hook(). ---- - -# Matplotlib Hook - -`scitex/cloud/_matplotlib_hook.py` patches matplotlib to display figures inline when running inside a cloud (headless) environment. It is not imported by `cloud/__init__.py` by default; it must be imported or installed explicitly. - -## install_matplotlib_hook - -Patches `matplotlib.figure.Figure.savefig` and `matplotlib.pyplot.show` to emit inline image markers when in a cloud environment. - -```python -install_matplotlib_hook() -> None -``` - -**Parameters:** none - -**Behavior:** -- Idempotent: calling it multiple times has no effect after the first call (guarded by `_hooked` flag). -- Hooks two entry points: - - `Figure.savefig` — after saving, calls `emit_inline_image(fname)` if in cloud environment. - - `plt.show` — in cloud environment, saves all open figures to `/scitex/temp//figure_.png` and emits inline image markers. Does NOT call the original `plt.show()` in cloud (headless). In non-cloud environments, delegates to the original `plt.show()`. -- Safe: if `matplotlib` is not installed, the function silently returns. - -```python -from scitex.cloud._matplotlib_hook import install_matplotlib_hook - -install_matplotlib_hook() - -import matplotlib.pyplot as plt -fig, ax = plt.subplots() -ax.plot([1, 2, 3], [4, 5, 6]) -plt.show() # Saves to project_root/scitex/temp//figure_1.png and emits inline marker -``` - ---- - -## uninstall_matplotlib_hook - -Restores the original `Figure.savefig` and `plt.show` functions. - -```python -uninstall_matplotlib_hook() -> None -``` - -**Parameters:** none - -**Behavior:** -- Idempotent: no-op if hooks were not installed. -- Restores both `Figure.savefig` and `plt.show` to their pre-hook originals. -- Safe: if `matplotlib` is not installed, silently returns. - -```python -from scitex.cloud._matplotlib_hook import uninstall_matplotlib_hook - -uninstall_matplotlib_hook() -# matplotlib now behaves normally -``` - ---- - -## Auto-install Behavior - -The module auto-installs hooks at import time if `is_cloud_environment()` returns `True`: - -```python -# Bottom of _matplotlib_hook.py: -from scitex.cloud import is_cloud_environment -if is_cloud_environment(): - install_matplotlib_hook() -``` - -This means importing `scitex.cloud._matplotlib_hook` in a cloud session automatically enables inline figure display. - ---- - -## Output Path - -When `plt.show()` is called in cloud mode, figures are saved to: - -``` -/scitex/temp//figure_.png -``` - -The inline image marker emitted uses the project-relative path: - -``` -scitex/temp/20260325_142300/figure_1.png -``` - ---- - -## Notes - -- `is_cloud_environment()`, `emit_inline_image()`, and `get_project_root()` are defined in `scitex_cloud` (the spoke package). They are not available when `scitex-cloud` is not installed. -- The hook is intentionally NOT auto-applied by `stx.cloud.__init__` — the user or the cloud runtime must trigger it explicitly. -- `__all__` exports only `install_matplotlib_hook` and `uninstall_matplotlib_hook`. diff --git a/src/scitex/linter.py b/src/scitex/linter.py deleted file mode 100755 index a8c32c967..000000000 --- a/src/scitex/linter.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""SciTeX Linter — thin wrapper delegating to scitex_dev.linter. - -Usage: - import scitex as stx - issues = stx.linter.lint_file("script.py") - -The engine moved from the (now archived) `scitex-linter` package into -`scitex_dev.linter` as part of the per-package rule migration. This -shim re-exports the public surface so existing `stx.linter.X` callers -keep working unchanged. -""" - -import os as _os - -# Set branding (consumed by scitex_dev.linter for CLI prog_name etc.) -_os.environ.setdefault("SCITEX_DEV_LINTER_BRAND", "scitex.linter") -_os.environ.setdefault("SCITEX_DEV_LINTER_ALIAS", "linter") - -try: - from scitex_dev.linter.checker import lint_file, lint_source - from scitex_dev.linter.formatter import format_issue, format_summary, to_json - from scitex_dev.linter.rules import ALL_RULES -except ImportError: - - def lint_file(*args, **kwargs): - raise ImportError( - "scitex-dev is required. Install with: pip install scitex-dev" - ) - - def lint_source(*args, **kwargs): - raise ImportError( - "scitex-dev is required. Install with: pip install scitex-dev" - ) - - format_issue = None - format_summary = None - to_json = None - ALL_RULES = {} - -__all__ = [ - "lint_file", - "lint_source", - "format_issue", - "format_summary", - "to_json", - "ALL_RULES", -] - -# EOF diff --git a/src/scitex/media/__init__.py b/src/scitex/media/__init__.py deleted file mode 100755 index 0d4acaa75..000000000 --- a/src/scitex/media/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -"""scitex.media — Media handling (detection, rendering, display). - -Submodules: - render — Detect, classify, and format media for various targets - (chat pane, terminal overlay, markdown embed). - -Usage: - from scitex.media import render - - refs = render.detect(text, root_path="/home/user/proj") - render.show("figure.png", target="terminal") -""" - -from . import render # noqa: F401 - -__all__ = ["render"] - -# EOF diff --git a/src/scitex/media/_skills/SKILL.md b/src/scitex/media/_skills/SKILL.md deleted file mode 100644 index 52540a5f9..000000000 --- a/src/scitex/media/_skills/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: stx.media -description: Media handling — detect file paths in text, classify by type, and render to terminal/chat/markdown. ---- - -# stx.media — Skills Index - -The `stx.media` module provides utilities for detecting, classifying, and displaying media files. It is organized into the `render` submodule. - -## Sub-skills - -| File | Description | -|------|-------------| -| [render-detect-classify-show.md](render-detect-classify-show.md) | Detect paths in text, classify by type, show in terminal/markdown/chat | - -## Quick Reference - -```python -from scitex.media import render - -# Classify a single file -render.classify("fig.png") # {"type": "image", "path": "fig.png", "ext": ".png"} - -# Detect media refs in tool output -refs = render.detect(tool_output, root_path="/home/user/proj") - -# Display to terminal (OSC escape) -render.show("fig.png", target="terminal") - -# Markdown embed -md = render.show("fig.png", target="markdown") # "![fig.png](fig.png)" -``` - -## Exports (via stx.media.render) - -- `classify(path)` → `dict | None` -- `detect(text, root_path)` → `list[dict]` -- `show(path, target, root_path, alt)` → `str` -- `MEDIA_EXTENSIONS` — immutable mapping of type → frozenset of extensions diff --git a/src/scitex/media/_skills/render-detect-classify-show.md b/src/scitex/media/_skills/render-detect-classify-show.md deleted file mode 100644 index 9541b35ba..000000000 --- a/src/scitex/media/_skills/render-detect-classify-show.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -description: Detect media file paths in text output, classify by type, and display to terminal, chat, or markdown. ---- - -# stx.media.render — Detect, Classify, and Show Media - -`stx.media.render` provides three focused functions for working with media file references in tool output and scripts. - -## classify - -Classify a file path by media type based on its extension. - -```python -from scitex.media import render - -ref = render.classify("figures/plot.png") -# {"type": "image", "path": "figures/plot.png", "ext": ".png"} - -ref = render.classify("data.csv") -# {"type": "csv", "path": "data.csv", "ext": ".csv"} - -ref = render.classify("unknown.xyz") -# None — unrecognized extension -``` - -Supported media types and their extensions: - -| Type | Extensions | -|------|-----------| -| `image` | `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.bmp` | -| `audio` | `.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.webm` | -| `video` | `.mp4`, `.webm`, `.avi`, `.mov`, `.mkv` | -| `pdf` | `.pdf` | -| `csv` | `.csv`, `.tsv` | -| `plotly` | `.html` | -| `mermaid` | `.mmd`, `.mermaid` | -| `graphviz` | `.dot`, `.gv` | - -## detect - -Extract media file references from arbitrary text by scanning for absolute paths that start with a given root. - -```python -from scitex.media import render - -text = "Saved to /home/user/proj/figures/plot.png and /home/user/proj/data.csv" -refs = render.detect(text, root_path="/home/user/proj") -# [ -# {"type": "image", "path": "figures/plot.png", "ext": ".png"}, -# {"type": "csv", "path": "data.csv", "ext": ".csv"}, -# ] -``` - -- Deduplicates paths (same path seen twice → one entry) -- Strips trailing punctuation (`.`, `,`, `)`, etc.) from matched paths -- Returns empty list if `root_path` is `None` or `text` is empty - -## show - -Display a media file to a specific rendering target. - -```python -from scitex.media import render - -# Terminal: prints OSC escape \033]9998;media:\007 to stdout -osc = render.show("figure.png", target="terminal") - -# Markdown: returns embed string -md = render.show("figure.png", target="markdown") -# "![figure.png](figure.png)" - -md = render.show("data.csv", target="markdown") -# "[data.csv](data.csv)" — non-image types use link syntax - -# Chat: returns JSON-encoded MediaRef dict for AI chat SSE stream -import json -ref = json.loads(render.show("figure.png", target="chat", root_path="/home/user/proj")) -# {"type": "image", "path": "figure.png", "ext": ".png"} -``` - -## CLI - -```bash -python -m scitex.media.render show figure.png --target terminal -python -m scitex.media.render classify data.csv -python -m scitex.media.render detect "Saved /proj/fig.png" --root /proj -``` - -## MEDIA_EXTENSIONS constant - -```python -from scitex.media.render import MEDIA_EXTENSIONS - -# Immutable mapping: media_type -> frozenset of extensions -print(MEDIA_EXTENSIONS["image"]) -# frozenset({'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp'}) -``` diff --git a/src/scitex/media/_skills/render.md b/src/scitex/media/_skills/render.md deleted file mode 100644 index 6e6d7b19e..000000000 --- a/src/scitex/media/_skills/render.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -description: Detect media file references in text with render.detect(), display files in terminal/markdown/chat targets with render.show(), and classify file types with render.classify(). ---- - -# Media Render - -`from scitex.media import render` - ---- - -## render.detect - -Scan a text string for media file references and return structured metadata. - -```python -render.detect(text: str, root_path: str | None = None) -> list[dict] -``` - -```python -from scitex.media import render - -text = "Saved /home/user/proj/results/figure.png and data.csv" -refs = render.detect(text, root_path="/home/user/proj") -# [{'type': 'image', 'path': '/home/user/proj/results/figure.png'}, ...] -``` - ---- - -## render.show - -Display a media file in the specified target environment. - -```python -render.show(path: str, target: str = "terminal") -> None -``` - -| `target` | Output | -|----------|--------| -| `"terminal"` | OSC escape sequence (inline image in terminal) | -| `"markdown"` | `![filename](path)` | -| `"chat"` | Formatted for AI chat pane | - -```python -from scitex.media import render - -# Display in terminal (requires iTerm2 / Kitty / etc.) -render.show("figure.png") - -# Get markdown embed -render.show("figure.png", target="markdown") -``` - ---- - -## render.classify - -Classify a file by its extension and return a media-type dict. - -```python -render.classify(path: str) -> dict -``` - -```python -from scitex.media import render - -info = render.classify("results.csv") -# {'type': 'csv', 'path': 'results.csv', 'ext': '.csv'} - -info = render.classify("plot.png") -# {'type': 'image', 'path': 'plot.png', 'ext': '.png'} -``` - ---- - -## MEDIA_EXTENSIONS - -Dict mapping extension → media type. - -```python -from scitex.media.render import MEDIA_EXTENSIONS -print(MEDIA_EXTENSIONS) -# {'.png': 'image', '.jpg': 'image', '.mp4': 'video', '.csv': 'csv', ...} -``` - ---- - -## CLI - -```bash -python -m scitex.media.render show figure.png --target terminal -python -m scitex.media.render classify data.csv -python -m scitex.media.render detect "Saved /proj/fig.png" --root /proj -``` diff --git a/src/scitex/media/render/__init__.py b/src/scitex/media/render/__init__.py deleted file mode 100755 index 6388a2693..000000000 --- a/src/scitex/media/render/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""scitex.media.render — Unified media rendering for chat, terminal, and files. - -Detect, classify, and format media for various targets: AI chat pane, -terminal overlay (OSC escape), or markdown embed. - -Usage: - from scitex.media import render - - # Detect media in text - refs = render.detect(tool_output, root_path="/home/user/proj") - - # Display in terminal (OSC escape) - render.show("figure.png") # prints OSC escape to stdout - - # Display as markdown - render.show("figure.png", target="markdown") # → "![figure.png](figure.png)" - - # Classify a file - ref = render.classify("data.csv") # → {"type": "csv", ...} - -CLI: - python -m scitex.media.render show figure.png --target terminal - python -m scitex.media.render classify data.csv - python -m scitex.media.render detect "Saved /proj/fig.png" --root /proj -""" - -from ._classify import MEDIA_EXTENSIONS, classify -from ._detect import detect -from ._show import show - -__all__ = [ - "classify", - "detect", - "show", - "MEDIA_EXTENSIONS", -] - -# EOF diff --git a/src/scitex/media/render/__main__.py b/src/scitex/media/render/__main__.py deleted file mode 100755 index cfa32cd8d..000000000 --- a/src/scitex/media/render/__main__.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -"""CLI entry point for scitex.media.render. - -Usage: - python -m scitex.media.render show figure.png --target terminal - python -m scitex.media.render classify data.csv - python -m scitex.media.render detect "Saved to /proj/fig.png" --root /proj -""" - -import argparse -import json - - -def main(): - parser = argparse.ArgumentParser( - description="SciTeX Media Render — detect, classify, and display media" - ) - subparsers = parser.add_subparsers(dest="command", help="Commands") - - # show - show_p = subparsers.add_parser("show", help="Display a media file") - show_p.add_argument("path", help="File path to display") - show_p.add_argument( - "-t", - "--target", - choices=["terminal", "chat", "markdown"], - default="terminal", - help="Render target (default: terminal)", - ) - show_p.add_argument("--root", help="Project root for chat target") - show_p.add_argument("--alt", default="", help="Alt text for markdown images") - - # classify - cls_p = subparsers.add_parser("classify", help="Classify a file by media type") - cls_p.add_argument("path", help="File path to classify") - - # detect - det_p = subparsers.add_parser("detect", help="Detect media refs in text") - det_p.add_argument("text", help="Text to scan for media paths") - det_p.add_argument("--root", required=True, help="Project root path") - - args = parser.parse_args() - - if args.command == "show": - from . import show - - result = show(args.path, target=args.target, root_path=args.root, alt=args.alt) - if args.target != "terminal": - print(result) - - elif args.command == "classify": - from . import classify - - ref = classify(args.path) - print(json.dumps(ref, indent=2) if ref else "null") - - elif args.command == "detect": - from . import detect - - refs = detect(args.text, root_path=args.root) - print(json.dumps(refs, indent=2)) - - else: - parser.print_help() - - -if __name__ == "__main__": - main() - -# EOF diff --git a/src/scitex/media/render/_classify.py b/src/scitex/media/render/_classify.py deleted file mode 100755 index 7f4ad06e0..000000000 --- a/src/scitex/media/render/_classify.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -"""Classify files by media type based on extension.""" - -from __future__ import annotations - -import os -from types import MappingProxyType -from typing import Any - -MEDIA_EXTENSIONS: MappingProxyType[str, frozenset[str]] = MappingProxyType( - { - "image": frozenset({".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp"}), - "audio": frozenset({".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".webm"}), - "video": frozenset({".mp4", ".webm", ".avi", ".mov", ".mkv"}), - "pdf": frozenset({".pdf"}), - "csv": frozenset({".csv", ".tsv"}), - "plotly": frozenset({".html"}), - "mermaid": frozenset({".mmd", ".mermaid"}), - "graphviz": frozenset({".dot", ".gv"}), - } -) - -# Reverse lookup: extension → media type -_EXT_TO_TYPE: dict[str, str] = {} -for _media_type, _exts in MEDIA_EXTENSIONS.items(): - for _ext in _exts: - _EXT_TO_TYPE[_ext] = _media_type - - -def classify(path: str) -> dict[str, Any] | None: - """Classify a file path by media type. - - Args: - path: File path (absolute or relative). - - Returns - ------- - {"type": "image", "path": "fig.png", "ext": ".png"} or None - if the extension is not recognized. - - Examples - -------- - >>> classify("figures/plot.png") - {'type': 'image', 'path': 'figures/plot.png', 'ext': '.png'} - >>> classify("data.csv") - {'type': 'csv', 'path': 'data.csv', 'ext': '.csv'} - >>> classify("unknown.xyz") is None - True - """ - ext = os.path.splitext(path)[1].lower() - media_type = _EXT_TO_TYPE.get(ext) - if media_type is None: - return None - return {"type": media_type, "path": path, "ext": ext} - - -# EOF diff --git a/src/scitex/media/render/_detect.py b/src/scitex/media/render/_detect.py deleted file mode 100755 index ce48cf2d3..000000000 --- a/src/scitex/media/render/_detect.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -"""Detect media file references in text output.""" - -from __future__ import annotations - -import re -from typing import Any - -from ._classify import classify - - -def detect(text: str, root_path: str | None = None) -> list[dict[str, Any]]: - """Extract media file references from tool output text. - - Scans for absolute paths starting with *root_path*, strips the prefix - to get a relative path, and classifies by file extension. - - Args: - text: Tool output or any text containing file paths. - root_path: Project root to match paths against. - When None, returns empty list. - - Returns - ------- - List of MediaRef dicts:: - - [{"type": "image", "path": "figures/plot.png", "ext": ".png"}] - - Examples - -------- - >>> detect("Saved to /home/u/proj/fig.png", "/home/u/proj") - [{'type': 'image', 'path': 'fig.png', 'ext': '.png'}] - >>> detect("no paths here") - [] - """ - if not root_path or not text: - return [] - - refs: list[dict[str, Any]] = [] - seen: set[str] = set() - - # Match absolute paths: root followed by any non-whitespace chars. - # Strip trailing punctuation that is likely sentence-level, not filename. - pattern = re.escape(root_path.rstrip("/")) + r"(/\S+)" - for match in re.finditer(pattern, text): - rel_path = match.group(1).rstrip(".,;:!?)\"'").lstrip("/") - if rel_path in seen: - continue - - ref = classify(rel_path) - if ref is not None: - seen.add(rel_path) - refs.append(ref) - - return refs - - -# EOF diff --git a/src/scitex/media/render/_show.py b/src/scitex/media/render/_show.py deleted file mode 100755 index 1a41149a4..000000000 --- a/src/scitex/media/render/_show.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Display media to a target (terminal, chat, markdown).""" - -from __future__ import annotations - -import json -import sys - -from ._targets import _to_chat, _to_markdown, _to_terminal - - -def show( - path: str, - target: str = "terminal", - root_path: str | None = None, - alt: str = "", -) -> str: - """Display a media file to the specified target. - - Args: - path: File path to display. - target: One of ``"terminal"``, ``"chat"``, ``"markdown"``. - root_path: Project root for chat target (makes path relative). - alt: Alt text for markdown image embeds. - - Returns - ------- - Formatted output string for the target. - For ``"terminal"``, also prints the OSC escape to stdout. - - Raises - ------ - ValueError: If *target* is not recognized. - - Examples - -------- - >>> show("fig.png", target="markdown") - '![fig.png](fig.png)' - """ - if target == "terminal": - osc = _to_terminal(path) - sys.stdout.write(osc) - sys.stdout.flush() - return osc - elif target == "chat": - ref = _to_chat(path, root_path=root_path) - return json.dumps(ref) - elif target == "markdown": - return _to_markdown(path, alt=alt) - else: - raise ValueError( - f"Unknown target: {target!r}. Choose from: terminal, chat, markdown" - ) - - -# EOF diff --git a/src/scitex/media/render/_targets.py b/src/scitex/media/render/_targets.py deleted file mode 100755 index faef6585c..000000000 --- a/src/scitex/media/render/_targets.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -"""Target-specific rendering formatters.""" - -from __future__ import annotations - -import base64 -import json -import os -from typing import Any - -from ._classify import classify - - -def _to_terminal(path: str) -> str: - """Format media as OSC escape for terminal display. - - The OSC escape ``\\033]9998;media:\\007`` is intercepted - by the SciTeX terminal frontend and displayed as a floating overlay. - - Args: - path: File path (resolved to absolute). - - Returns - ------- - OSC escape string ready to print to stdout. - - Examples - -------- - >>> esc = _to_terminal("figure.png") - >>> esc.startswith("\\033]9998;media:") - True - """ - abs_path = os.path.realpath(path) - ref = classify(abs_path) or {"type": "file", "path": abs_path, "ext": ""} - ref["url"] = abs_path - payload = base64.b64encode(json.dumps(ref).encode()).decode() - return f"\033]9998;media:{payload}\007" - - -def _to_chat(path: str, root_path: str | None = None) -> dict[str, Any]: - """Format media as dict for AI chat pane SSE events. - - Args: - path: File path. - root_path: If provided, path is made relative to this root. - - Returns - ------- - MediaRef dict: {"type": , "path": , "ext": } - - Examples - -------- - >>> _to_chat("figures/plot.png") - {'type': 'image', 'path': 'figures/plot.png', 'ext': '.png'} - """ - display_path = path - if root_path and os.path.isabs(path): - root = root_path.rstrip("/") - if path.startswith(root + "/"): - display_path = path[len(root) + 1 :] - - ref = classify(display_path) - if ref is None: - return {"type": "file", "path": display_path, "ext": ""} - return ref - - -def _to_markdown(path: str, alt: str = "") -> str: - """Format media as markdown embed string. - - Images use ``![alt](path)`` syntax. Other types use ``[filename](path)``. - - Args: - path: File path. - alt: Alt text for images (default: filename). - - Returns - ------- - Markdown string. - - Examples - -------- - >>> _to_markdown("figure.png") - '![figure.png](figure.png)' - >>> _to_markdown("data.csv") - '[data.csv](data.csv)' - """ - ref = classify(path) - filename = os.path.basename(path) - alt = alt or filename - - if ref and ref["type"] == "image": - return f"![{alt}]({path})" - return f"[{filename}]({path})" - - -# EOF diff --git a/src/scitex/media/render/mcp_server.py b/src/scitex/media/render/mcp_server.py deleted file mode 100755 index 0627b646a..000000000 --- a/src/scitex/media/render/mcp_server.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""MCP tools for scitex.media.render — thin wrappers around Python API.""" - -from __future__ import annotations - -import json -from typing import Optional - -from scitex_dev import try_import_optional - -FastMCP = try_import_optional("fastmcp", "FastMCP", pkg="scitex") -FASTMCP_AVAILABLE = FastMCP is not None - -__all__ = ["mcp", "FASTMCP_AVAILABLE"] - -if FASTMCP_AVAILABLE: - mcp = FastMCP( - name="scitex-media-render", - instructions=( - "Media rendering tools for SciTeX. " - "Detect, classify, and display media files in chat, terminal, or markdown." - ), - ) - - @mcp.tool() - def render_show( - path: str, - target: str = "terminal", - root_path: Optional[str] = None, - alt: str = "", - ) -> str: - """Display a media file in the specified target. - - Args: - path: File path to display. - target: "terminal" (OSC overlay), "chat" (inline dict), "markdown" (embed). - root_path: Project root for chat target (makes path relative). - alt: Alt text for markdown images. - """ - from . import show - - return show(path, target=target, root_path=root_path, alt=alt) - - @mcp.tool() - def render_detect(text: str, root_path: Optional[str] = None) -> str: - """Detect media file references in text. - - Args: - text: Text containing file paths. - root_path: Project root to match against. - """ - from . import detect - - return json.dumps(detect(text, root_path=root_path), indent=2) - - @mcp.tool() - def render_classify(path: str) -> str: - """Classify a file by media type. - - Args: - path: File path to classify. - """ - from . import classify - - ref = classify(path) - return json.dumps(ref, indent=2) if ref else "null" - -else: - mcp = None - - -def main(): - """Entry point for MCP server.""" - if not FASTMCP_AVAILABLE: - import sys - - print("MCP server requires fastmcp: pip install fastmcp") - sys.exit(1) - mcp.run(transport="stdio") - - -if __name__ == "__main__": - main() - -# EOF diff --git a/src/scitex/module/__init__.py b/src/scitex/module/__init__.py deleted file mode 100755 index 2a417c141..000000000 --- a/src/scitex/module/__init__.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 -"""SciTeX Module — backward-compatibility shim. - -Module management has moved to scitex_cloud.module. -This shim re-exports everything so existing code continues to work. - -Usage (preferred — new code should use this): - from scitex_cloud.module import module, output, html, INJECTED - -Usage (legacy — still works): - import scitex as stx - @stx.module(...) -""" - -from __future__ import annotations - -import warnings - -warnings.warn( - "scitex.module is deprecated. Use scitex_cloud.module instead.", - DeprecationWarning, - stacklevel=2, -) - -try: - from scitex_cloud.module import ( - INJECTED, - ModuleManifest, - ModuleOutput, - ModuleOutputCollector, - html, - module, - output, - render_output, - render_outputs, - ) -except ImportError: - # Fallback: scitex_cloud not installed — use local copies - from ._decorator import module - from ._manifest import ModuleManifest - from ._output import ModuleOutput, ModuleOutputCollector, html, output - from ._renderer import render_output, render_outputs - - class _InjectedSentinel: - def __repr__(self): - return "" - - INJECTED = _InjectedSentinel() - -__all__ = [ - "INJECTED", - "module", - "ModuleManifest", - "ModuleOutput", - "ModuleOutputCollector", - "output", - "html", - "render_output", - "render_outputs", -] - -# EOF diff --git a/src/scitex/module/_decorator.py b/src/scitex/module/_decorator.py deleted file mode 100755 index 9790ca73c..000000000 --- a/src/scitex/module/_decorator.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: "2026-02-23" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/module/_decorator.py - -from __future__ import annotations - -"""The @stx.module decorator for marking functions as SciTeX workspace modules.""" - -import functools -import inspect -from typing import Any, Callable - -from . import INJECTED -from ._manifest import ModuleManifest -from ._output import ModuleOutputCollector - - -def module( - func: Callable = None, - *, - label: str = "", - icon: str = "fa-puzzle-piece", - category: str = "other", - description: str = "", - version: str = "0.1.0", - dependencies: list | None = None, - min_scitex_version: str = "", -) -> Callable: - """Decorator to mark a function as a SciTeX workspace module. - - The decorated function can declare parameters with default=INJECTED; - the module runner will supply *project*, *plt*, and *logger* at - execution time. - - Args: - func: The function being decorated (set automatically). - label: Human-readable display name. Defaults to the function name - with underscores replaced by spaces and title-cased. - icon: FontAwesome icon class for the module card. - category: Module category (writing, visualization, data, analysis, - reference, utility, other). - description: Short description. Falls back to the first line of the - function docstring when empty. - version: Semantic version for this module. - dependencies: Extra pip packages required. - min_scitex_version: Minimum scitex version. - - Example:: - - import scitex as stx - - @stx.module(label="EEG Viewer", icon="fa-brain", category="visualization") - def eeg_viewer(project=stx.module.INJECTED, plt=stx.module.INJECTED): - stx.module.output("

Hello

", title="Greeting") - """ - - def decorator(fn: Callable) -> Callable: - # Build manifest - _label = label or fn.__name__.replace("_", " ").title() - _description = description - if not _description and fn.__doc__: - _description = fn.__doc__.strip().split("\n")[0] - - manifest = ModuleManifest( - name=fn.__name__, - label=_label, - icon=icon, - category=category, - description=_description, - version=version, - dependencies=dependencies or [], - min_scitex_version=min_scitex_version, - ) - - @functools.wraps(fn) - def wrapper(*args, **kwargs): - """Execute the module function, collecting outputs.""" - ModuleOutputCollector.clear() - try: - result = fn(*args, **kwargs) - except Exception: - ModuleOutputCollector.clear() - raise - outputs = ModuleOutputCollector.get_current() - ModuleOutputCollector.clear() - return result, outputs - - # Attach metadata so the runner can discover it - wrapper._is_stx_module = True - wrapper._manifest = manifest - wrapper._func = fn - - return wrapper - - # Support both @stx.module and @stx.module(...) - if func is not None: - return decorator(func) - return decorator - - -def _inject_params(fn: Callable, provided: dict[str, Any]) -> dict[str, Any]: - """Build kwargs for *fn*, injecting values where the default is INJECTED. - - Args: - fn: The original (unwrapped) function. - provided: Dict of injectable name -> value (e.g. project, plt, logger). - - Returns - ------- - Dict of keyword arguments ready to be passed to *fn*. - """ - sig = inspect.signature(fn) - kwargs: dict[str, Any] = {} - for name, param in sig.parameters.items(): - if param.default is not inspect.Parameter.empty and isinstance( - param.default, type(INJECTED) - ): - if name in provided: - kwargs[name] = provided[name] - # Non-INJECTED params with defaults are left for the caller or the default - return kwargs - - -# EOF diff --git a/src/scitex/module/_manifest.py b/src/scitex/module/_manifest.py deleted file mode 100755 index a72bcff61..000000000 --- a/src/scitex/module/_manifest.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: "2026-02-23" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/module/_manifest.py - -from __future__ import annotations - -"""Module manifest dataclass describing metadata for a SciTeX module.""" - -from dataclasses import dataclass, field - -VALID_CATEGORIES = ( - "writing", - "visualization", - "data", - "analysis", - "reference", - "utility", - "other", -) - - -@dataclass -class ModuleManifest: - """Metadata for a SciTeX workspace module. - - Attributes - ---------- - name: Auto-generated slug from the decorated function name. - label: Human-readable display name shown in the UI. - icon: FontAwesome class (e.g. "fa-brain"). - category: One of the VALID_CATEGORIES. - description: Short description of what the module does. - version: Semantic version string. - dependencies: Extra pip packages required by this module. - min_scitex_version: Minimum scitex version required (empty = any). - """ - - name: str = "" - label: str = "" - icon: str = "fa-puzzle-piece" - category: str = "other" - description: str = "" - version: str = "0.1.0" - dependencies: list = field(default_factory=list) - min_scitex_version: str = "" - - def __post_init__(self): - if self.category not in VALID_CATEGORIES: - raise ValueError( - f"Invalid category '{self.category}'. " - f"Must be one of: {', '.join(VALID_CATEGORIES)}" - ) - - def to_dict(self) -> dict: - """Serialize manifest to a plain dictionary.""" - return { - "name": self.name, - "label": self.label, - "icon": self.icon, - "category": self.category, - "description": self.description, - "version": self.version, - "dependencies": list(self.dependencies), - "min_scitex_version": self.min_scitex_version, - } - - @classmethod - def from_dict(cls, data: dict) -> ModuleManifest: - """Construct a ModuleManifest from a dictionary.""" - return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) - - -# EOF diff --git a/src/scitex/module/_output.py b/src/scitex/module/_output.py deleted file mode 100755 index beaab2637..000000000 --- a/src/scitex/module/_output.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: "2026-02-23" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/module/_output.py - -from __future__ import annotations - -"""Thread-local output collection for SciTeX module execution.""" - -import threading -from dataclasses import dataclass, field -from typing import Any - - -class _SafeHtml: - """Wrapper marking a string as pre-sanitized HTML. - - The renderer will emit this content as-is without escaping. - """ - - def __init__(self, content: str): - self._content = str(content) - - @property - def content(self) -> str: - return self._content - - def __str__(self) -> str: - return self._content - - def __repr__(self) -> str: - truncated = ( - self._content[:60] + "..." if len(self._content) > 60 else self._content - ) - return f"_SafeHtml({truncated!r})" - - -_OUTPUT_TYPE_AUTO = "" - - -@dataclass -class ModuleOutput: - """Single output item produced by a module function. - - Attributes - ---------- - value: The output object (Figure, DataFrame, str, dict, or HTML). - title: Optional display title for this output. - output_type: Detected type string. Auto-detected when left empty. - """ - - value: Any = None - title: str = "" - output_type: str = field(default=_OUTPUT_TYPE_AUTO) - - def __post_init__(self): - if self.output_type == _OUTPUT_TYPE_AUTO: - self.output_type = _detect_type(self.value) - - -def _detect_type(value: Any) -> str: - """Infer a human-readable output type from value.""" - if isinstance(value, _SafeHtml): - return "html" - - # Check for matplotlib Figure without importing matplotlib eagerly - type_name = type(value).__name__ - module_name = type(value).__module__ or "" - if "matplotlib" in module_name and type_name == "Figure": - return "figure" - - # Check for pandas DataFrame - if "pandas" in module_name and type_name == "DataFrame": - return "table" - - if isinstance(value, dict): - return "json" - - if isinstance(value, str): - return "text" - - # Fallback - return "text" - - -class ModuleOutputCollector: - """Thread-local collector that accumulates outputs during module execution. - - Each thread maintains its own independent list so concurrent module - executions do not interfere with each other. - """ - - _local = threading.local() - - @classmethod - def get_current(cls) -> list[ModuleOutput]: - """Return the output list for the current thread.""" - if not hasattr(cls._local, "outputs"): - cls._local.outputs = [] - return list(cls._local.outputs) - - @classmethod - def add(cls, value: Any, title: str = "") -> None: - """Append an output item for the current thread.""" - if not hasattr(cls._local, "outputs"): - cls._local.outputs = [] - cls._local.outputs.append(ModuleOutput(value=value, title=title)) - - @classmethod - def clear(cls) -> None: - """Discard all collected outputs for the current thread.""" - cls._local.outputs = [] - - -def output(value: Any, title: str = "") -> None: - """Add an output to the current module execution. - - This is the primary API researchers call inside their module function - to register figures, tables, text, or HTML for display. - - Args: - value: Figure, DataFrame, string, dict, or _SafeHtml instance. - title: Optional display title. - """ - ModuleOutputCollector.add(value, title) - - -def html(content: str) -> _SafeHtml: - """Mark a string as safe HTML so the renderer emits it without escaping. - - Args: - content: Raw HTML string. - - Returns - ------- - _SafeHtml wrapper. - """ - return _SafeHtml(content) - - -# EOF diff --git a/src/scitex/module/_renderer.py b/src/scitex/module/_renderer.py deleted file mode 100755 index e257ba37f..000000000 --- a/src/scitex/module/_renderer.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: "2026-02-23" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/module/_renderer.py - -from __future__ import annotations - -"""Converts collected ModuleOutput items to serializable dictionaries.""" - -import base64 -import io -import json -from typing import Any - -from ._output import ModuleOutput, _SafeHtml - - -def render_output(item: ModuleOutput) -> dict[str, Any]: - """Convert a single ModuleOutput to a serializable dict. - - The returned dict always contains: - - ``type``: one of figure, table, text, html, json - - ``title``: display title (may be empty) - - ``data``: the serialized payload - - Args: - item: A ModuleOutput instance. - - Returns - ------- - Dictionary suitable for JSON serialization. - """ - renderer = _RENDERERS.get(item.output_type, _render_text) - return { - "type": item.output_type, - "title": item.title, - "data": renderer(item.value), - } - - -def render_outputs(items: list[ModuleOutput]) -> list[dict[str, Any]]: - """Render a list of ModuleOutput items. - - Args: - items: List of ModuleOutput instances. - - Returns - ------- - List of serializable dicts. - """ - return [render_output(item) for item in items] - - -# -- Individual renderers ---------------------------------------------------- - - -def _render_figure(value: Any) -> str: - """Render a matplotlib Figure to a base64-encoded PNG string.""" - buf = io.BytesIO() - value.savefig(buf, format="png", bbox_inches="tight", dpi=150) - buf.seek(0) - encoded = base64.b64encode(buf.read()).decode("ascii") - buf.close() - return f"data:image/png;base64,{encoded}" - - -def _render_table(value: Any) -> str: - """Render a pandas DataFrame to an HTML table string.""" - try: - return value.to_html( - classes="table table-striped table-sm", - index=True, - border=0, - max_rows=200, - ) - except Exception: - return str(value) - - -def _render_text(value: Any) -> str: - """Render a plain string (or fallback for unknown types).""" - return str(value) - - -def _render_html(value: Any) -> str: - """Render a _SafeHtml wrapper or raw HTML string.""" - if isinstance(value, _SafeHtml): - return value.content - return str(value) - - -def _render_json(value: Any) -> str: - """Render a dict as a formatted JSON string.""" - try: - return json.dumps(value, indent=2, default=str) - except (TypeError, ValueError): - return str(value) - - -_RENDERERS: dict[str, Any] = { - "figure": _render_figure, - "table": _render_table, - "text": _render_text, - "html": _render_html, - "json": _render_json, -} - - -# EOF diff --git a/src/scitex/module/_runner.py b/src/scitex/module/_runner.py deleted file mode 100755 index c8fec7ffa..000000000 --- a/src/scitex/module/_runner.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: "2026-02-23" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/module/_runner.py - -from __future__ import annotations - -"""Execution engine for SciTeX workspace modules. - -Imports a module .py file, locates the @stx.module decorated function, -calls it with injected parameters, collects outputs, and serializes the -results to an output directory. - -Can be invoked as:: - - python -m scitex.module._runner --project-path --output-dir -""" - -import importlib.util -import json -import logging -import sys -from pathlib import Path -from typing import Any - -from ._decorator import _inject_params -from ._renderer import render_outputs - -logger = logging.getLogger(__name__) - - -def discover_module_func(module_path: str | Path) -> Any: - """Import a .py file and return the first @stx.module decorated callable. - - Args: - module_path: Filesystem path to the module .py file. - - Returns - ------- - The decorated wrapper function (has ``_is_stx_module`` attribute). - - Raises - ------ - FileNotFoundError: If the path does not exist. - ValueError: If no @stx.module function is found in the file. - """ - module_path = Path(module_path) - if not module_path.exists(): - raise FileNotFoundError(f"Module file not found: {module_path}") - - spec = importlib.util.spec_from_file_location( - f"stx_module_{module_path.stem}", str(module_path) - ) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - - for attr_name in dir(mod): - obj = getattr(mod, attr_name) - if callable(obj) and getattr(obj, "_is_stx_module", False): - return obj - - raise ValueError(f"No @stx.module decorated function found in {module_path}") - - -def run_module( - module_path: str | Path, - project_path: str | Path, - output_dir: str | Path, -) -> dict[str, Any]: - """Execute a module and write serialized results to *output_dir*. - - Args: - module_path: Path to the module .py file. - project_path: Path to the SciTeX project directory. - output_dir: Directory where result JSON and artifacts are written. - - Returns - ------- - Dict with keys ``manifest``, ``outputs``, ``error``. - """ - module_path = Path(module_path) - project_path = Path(project_path) - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - wrapper = discover_module_func(module_path) - manifest = wrapper._manifest - original_fn = wrapper._func - - # Prepare injectable values - injectables = _build_injectables(project_path) - kwargs = _inject_params(original_fn, injectables) - - # Execute - error_msg = "" - outputs_rendered: list[dict] = [] - try: - _result, outputs = wrapper(**kwargs) - outputs_rendered = render_outputs(outputs) - except Exception as exc: - logger.exception("Module execution failed: %s", exc) - error_msg = str(exc) - - result = { - "manifest": manifest.to_dict(), - "outputs": outputs_rendered, - "error": error_msg, - } - - # Write result JSON - result_path = output_dir / "result.json" - result_path.write_text(json.dumps(result, indent=2, default=str)) - logger.info("Module output written to %s", result_path) - - return result - - -def _build_injectables(project_path: Path) -> dict[str, Any]: - """Build the dict of values available for INJECTED parameter injection. - - Args: - project_path: The project directory path to inject. - - Returns - ------- - Dict mapping parameter names to their injectable values. - """ - injectables: dict[str, Any] = { - "project": project_path, - } - - # Inject matplotlib.pyplot if available - try: - import matplotlib - - matplotlib.use("Agg") - import matplotlib.pyplot as plt - - injectables["plt"] = plt - except ImportError: - pass - - # Inject a logger - injectables["logger"] = logging.getLogger("stx.module.user") - - return injectables - - -# -- CLI entry point --------------------------------------------------------- - - -def _cli_main() -> None: - """Entry point for ``python -m scitex.module._runner``.""" - import argparse - - parser = argparse.ArgumentParser(description="Run a SciTeX workspace module.") - parser.add_argument("module_path", help="Path to the module .py file") - parser.add_argument( - "--project-path", - required=True, - help="Path to the SciTeX project directory", - ) - parser.add_argument( - "--output-dir", - required=True, - help="Directory for output JSON and artifacts", - ) - args = parser.parse_args() - - result = run_module(args.module_path, args.project_path, args.output_dir) - if result["error"]: - print(f"ERROR: {result['error']}", file=sys.stderr) - sys.exit(1) - print(f"OK: {len(result['outputs'])} output(s) written") - - -if __name__ == "__main__": - _cli_main() - - -# EOF diff --git a/src/scitex/module/_skills/SKILL.md b/src/scitex/module/_skills/SKILL.md deleted file mode 100644 index 67a608e87..000000000 --- a/src/scitex/module/_skills/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: stx.module -description: Mark functions as SciTeX workspace modules with the @module decorator and collect structured outputs. Backward-compatibility shim — new code should use scitex_cloud.module. ---- - -# stx.module — Skills Index - -`stx.module` is a backward-compatibility shim for `scitex_cloud.module`. New code should import from `scitex_cloud.module` directly. - -## Sub-skills - -| File | Description | -|------|-------------| -| [module-decorator.md](module-decorator.md) | @module decorator, output/html helpers, INJECTED sentinel, ModuleManifest, CLI runner | - -## Quick Reference - -```python -from scitex.module import module, output, html, INJECTED - -@module(label="My Analysis", category="analysis") -def run(project=INJECTED, plt=INJECTED, logger=INJECTED): - fig, ax = plt.subplots() - output(fig, title="Result") - -# Preferred (new code) -from scitex_cloud.module import module, output, html, INJECTED -``` diff --git a/src/scitex/module/_skills/decorator.md b/src/scitex/module/_skills/decorator.md deleted file mode 100644 index dfbc63de9..000000000 --- a/src/scitex/module/_skills/decorator.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: Decorate Python functions as SciTeX cloud modules with @module(), inject parameters automatically with INJECTED, and describe module metadata with ModuleManifest. ---- - -# Module Decorator - -**Preferred import** (new code): -```python -from scitex_cloud.module import module, INJECTED, ModuleManifest -``` - -**Legacy import** (still works, emits DeprecationWarning): -```python -import scitex as stx -stx.module.module # delegate to scitex_cloud.module -``` - ---- - -## @module - -Decorator that registers a function as a SciTeX cloud module. - -```python -from scitex_cloud.module import module, INJECTED - -@module( - name="my_analysis", - description="Run EEG analysis pipeline", - version="1.0.0", -) -def run(data_path: str, CONFIG=INJECTED, logger=INJECTED): - """Analyze EEG data.""" - data = stx.io.load(data_path) - # ... analysis ... - return stx.module.output(result_df, "results") -``` - ---- - -## INJECTED - -Sentinel value used as a default parameter to signal that the SciTeX runtime should inject the value automatically (e.g., `CONFIG`, `logger`, `rng`). - -```python -from scitex_cloud.module import INJECTED - -def my_func(CONFIG=INJECTED): - print(CONFIG.learning_rate) -``` - ---- - -## ModuleManifest - -Describes a module's inputs, outputs, and metadata for the SciTeX cloud registry. - -```python -from scitex_cloud.module import ModuleManifest - -manifest = ModuleManifest( - name="my_analysis", - inputs=["data_path"], - outputs=["results"], - version="1.0.0", -) -``` diff --git a/src/scitex/module/_skills/module-decorator.md b/src/scitex/module/_skills/module-decorator.md deleted file mode 100644 index cdf20c53d..000000000 --- a/src/scitex/module/_skills/module-decorator.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -description: Mark functions as SciTeX workspace modules with the @module decorator and collect structured outputs. ---- - -# stx.module — @module Decorator - -> **Deprecated.** `scitex.module` is a backward-compatibility shim. New code should import from `scitex_cloud.module`. - -The `@module` decorator transforms a function into a SciTeX workspace module. The workspace runner discovers decorated functions, injects runtime values, collects outputs, and serializes results. - -## @module decorator - -```python -from scitex.module import module, output, html, INJECTED - -@module( - label="EEG Viewer", # display name in UI - icon="fa-brain", # FontAwesome icon class - category="visualization", # one of: writing, visualization, data, analysis, - # reference, utility, other - description="Plots EEG signals", - version="1.0.0", - dependencies=["mne"], -) -def eeg_viewer( - project=INJECTED, # injected: Path to project directory - plt=INJECTED, # injected: matplotlib.pyplot (Agg backend) - logger=INJECTED, # injected: logging.Logger -): - """Shows EEG data from the project.""" - data_path = project / "data/eeg.npy" - # ... analysis ... - fig, ax = plt.subplots() - ax.plot(eeg_data) - output(fig, title="EEG Signal") # register a figure - output(summary_df, title="Summary Stats") # register a DataFrame - output(html("Done"), title="Status") # register HTML -``` - -## output and html - -Inside a `@module` function, call `output()` to register items for display: - -```python -from scitex.module import output, html - -output(fig, title="My Figure") # matplotlib Figure → base64 PNG -output(df, title="Results Table") # DataFrame → HTML table -output("Analysis complete", title="Log") # str → text -output({"k": 1}, title="Config") # dict → JSON -output(html("note"), title="") # _SafeHtml → raw HTML -``` - -Auto-detected types: `figure`, `table`, `text`, `json`, `html`. - -## INJECTED sentinel - -Parameters with `default=INJECTED` are filled by the module runner at execution time: - -| Parameter name | Value injected | -|----------------|---------------| -| `project` | `Path` to the project directory | -| `plt` | `matplotlib.pyplot` with Agg backend | -| `logger` | `logging.Logger("stx.module.user")` | - -## ModuleManifest - -The manifest is attached to the wrapper as `wrapper._manifest`: - -```python -manifest = eeg_viewer._manifest -print(manifest.name) # "eeg_viewer" -print(manifest.label) # "EEG Viewer" -print(manifest.category) # "visualization" -manifest.to_dict() # JSON-serializable dict -``` - -## Running a module file - -```bash -python -m scitex.module._runner path/to/module.py \ - --project-path /path/to/project \ - --output-dir /tmp/module_out -``` - -Writes `result.json` containing `manifest`, `outputs`, `error`. diff --git a/src/scitex/module/_skills/output.md b/src/scitex/module/_skills/output.md deleted file mode 100644 index 5d2b09a37..000000000 --- a/src/scitex/module/_skills/output.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -description: Return typed module outputs with output(), embed HTML content with html(), and render outputs for display with render_output() and render_outputs(). ---- - -# Output System - -## output - -Wrap a return value with a named label for the module output system. - -```python -output(data, name: str, mime_type: str | None = None) -> ModuleOutput -``` - -```python -from scitex_cloud.module import output -import pandas as pd - -result_df = pd.DataFrame({"score": [0.92, 0.88]}) -return output(result_df, "classification_results") -``` - ---- - -## html - -Wrap an HTML string as a module output. - -```python -html(content: str, name: str = "html") -> ModuleOutput -``` - -```python -from scitex_cloud.module import html - -return html("

Analysis complete

See attached CSV.

", name="summary") -``` - ---- - -## render_output / render_outputs - -Render a single `ModuleOutput` or list of them to HTML/markdown for display. - -```python -render_output(out: ModuleOutput) -> str -render_outputs(outputs: list[ModuleOutput]) -> str -``` - -```python -from scitex_cloud.module import render_outputs - -html_str = render_outputs([result_output, plot_output]) -print(html_str) -``` - ---- - -## ModuleOutput / ModuleOutputCollector - -`ModuleOutput`: dataclass wrapping a named result with optional MIME type. -`ModuleOutputCollector`: accumulates multiple outputs from a single module call. diff --git a/src/scitex/project/__init__.py b/src/scitex/project/__init__.py deleted file mode 100755 index 0d798397c..000000000 --- a/src/scitex/project/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: 2026-02-19 -# File: scitex/project/__init__.py -"""SciTeX project file operations.""" - -from __future__ import annotations - -# EOF diff --git a/src/scitex/project/_mcp/__init__.py b/src/scitex/project/_mcp/__init__.py deleted file mode 100755 index 3914dec9b..000000000 --- a/src/scitex/project/_mcp/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: 2026-02-19 -# File: scitex/project/_mcp/__init__.py -"""MCP handlers for project file operations.""" - -# EOF diff --git a/src/scitex/project/_mcp/handlers.py b/src/scitex/project/_mcp/handlers.py deleted file mode 100755 index c86b2cf0e..000000000 --- a/src/scitex/project/_mcp/handlers.py +++ /dev/null @@ -1,390 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: 2026-02-27 -# File: scitex/project/_mcp/handlers.py -""" -Project file operation handlers for MCP tools. - -Security: all operations are constrained to paths under ALLOWED_DATA_ROOT. -Path traversal (../) is blocked at resolution time. -""" - -from __future__ import annotations - -import asyncio -import fnmatch -import os -import subprocess -from pathlib import Path -from typing import Any - -# Configurable via environment — default matches Docker container layout -ALLOWED_DATA_ROOT = os.environ.get("SCITEX_PROJECT_DATA_ROOT", "/app/data/users") - - -def _resolve_safe(root_path: str, relative_path: str = "") -> Path: - """ - Resolve a path within root_path, raising ValueError on any violation. - - Checks: - 1. root_path must be under ALLOWED_DATA_ROOT - 2. Resolved target must be under root_path (no path traversal) - """ - root = Path(root_path).resolve() - allowed = Path(ALLOWED_DATA_ROOT).resolve() - - if not str(root).startswith(str(allowed)): - raise ValueError( - f"root_path '{root}' is not under allowed data root '{allowed}'. " - "Project paths must be within the configured data directory." - ) - - if relative_path and relative_path not in (".", ""): - target = (root / relative_path).resolve() - else: - target = root - - if not str(target).startswith(str(root)): - raise ValueError( - f"Path traversal detected: '{relative_path}' escapes project root." - ) - - return target - - -def _build_tree(path: Path, max_depth: int, current_depth: int = 0) -> list[dict]: - """Recursively build a file tree structure.""" - if current_depth >= max_depth: - return [] - - entries = [] - try: - items = sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)) - except PermissionError: - return [] - - for item in items: - # Skip hidden files/dirs and common noise - if item.name.startswith(".") or item.name in ( - "__pycache__", - "node_modules", - ".git", - ): - continue - entry: dict[str, Any] = { - "name": item.name, - "type": "file" if item.is_file() else "dir", - } - if item.is_dir(): - entry["children"] = _build_tree(item, max_depth, current_depth + 1) - else: - entry["size"] = item.stat().st_size - entries.append(entry) - - return entries - - -async def list_files_handler( - root_path: str, - relative_path: str = ".", - max_depth: int = 3, -) -> dict: - """List files and directories within the project.""" - try: - target = _resolve_safe(root_path, relative_path) - except ValueError as e: - return {"success": False, "error": str(e)} - - if not target.exists(): - return {"success": False, "error": f"Path does not exist: {relative_path}"} - if not target.is_dir(): - return {"success": False, "error": f"Not a directory: {relative_path}"} - - tree = _build_tree(target, max_depth=max(1, min(max_depth, 6))) - return { - "success": True, - "path": str(target.relative_to(Path(root_path).resolve())), - "tree": tree, - } - - -async def read_file_handler( - root_path: str, - relative_path: str, - max_bytes: int = 65536, -) -> dict: - """Read file content from the project.""" - try: - target = _resolve_safe(root_path, relative_path) - except ValueError as e: - return {"success": False, "error": str(e)} - - if not target.exists(): - return {"success": False, "error": f"File not found: {relative_path}"} - if not target.is_file(): - return {"success": False, "error": f"Not a file: {relative_path}"} - - size = target.stat().st_size - truncated = size > max_bytes - - try: - with open(target, encoding="utf-8", errors="replace") as f: - content = f.read(max_bytes) - except Exception as e: - return {"success": False, "error": f"Cannot read file: {e}"} - - return { - "success": True, - "path": relative_path, - "content": content, - "size_bytes": size, - "truncated": truncated, - "truncated_at_bytes": max_bytes if truncated else None, - } - - -async def write_file_handler( - root_path: str, - relative_path: str, - content: str, -) -> dict: - """Write content to a file in the project (creates parent dirs as needed).""" - try: - target = _resolve_safe(root_path, relative_path) - except ValueError as e: - return {"success": False, "error": str(e)} - - try: - target.parent.mkdir(parents=True, exist_ok=True) - with open(target, "w", encoding="utf-8") as f: - f.write(content) - except Exception as e: - return {"success": False, "error": f"Cannot write file: {e}"} - - return { - "success": True, - "path": relative_path, - "size_bytes": target.stat().st_size, - } - - -async def search_files_handler( - root_path: str, - name_pattern: str = "", - content_pattern: str = "", - relative_path: str = ".", - max_results: int = 50, -) -> dict: - """Search project files by name glob and/or content substring.""" - try: - search_root = _resolve_safe(root_path, relative_path) - except ValueError as e: - return {"success": False, "error": str(e)} - - if not search_root.exists(): - return {"success": False, "error": f"Directory not found: {relative_path}"} - - if not name_pattern and not content_pattern: - return {"success": False, "error": "Provide name_pattern or content_pattern"} - - matches = [] - root_resolved = Path(root_path).resolve() - - for item in search_root.rglob("*"): - if len(matches) >= max_results: - break - # Skip hidden and noisy items — check relative path only, not absolute prefix - rel_parts = item.relative_to(search_root).parts - if any( - p.startswith(".") or p in ("__pycache__", "node_modules") for p in rel_parts - ): - continue - if not item.is_file(): - continue - - name_ok = not name_pattern or fnmatch.fnmatch(item.name, name_pattern) - if not name_ok: - continue - - if content_pattern: - try: - text = item.read_text(encoding="utf-8", errors="replace") - if content_pattern not in text: - continue - # Find first matching line for context - for lineno, line in enumerate(text.splitlines(), 1): - if content_pattern in line: - match_line = lineno - match_preview = line.strip()[:120] - break - else: - match_line, match_preview = 0, "" - except Exception: - continue - matches.append( - { - "path": str(item.relative_to(root_resolved)), - "line": match_line, - "preview": match_preview, - } - ) - else: - matches.append({"path": str(item.relative_to(root_resolved))}) - - return { - "success": True, - "matches": matches, - "count": len(matches), - "truncated": len(matches) >= max_results, - } - - -async def exec_python_handler( - root_path: str, - code: str, - timeout: int = 30, -) -> dict: - """Execute Python code in the project directory. - - Runs the code as a subprocess with cwd=root_path. - Captures stdout, stderr, and lists any new files created. - """ - try: - root = _resolve_safe(root_path) - except ValueError as e: - return {"success": False, "error": str(e)} - - if not root.exists(): - return {"success": False, "error": f"Project root not found: {root_path}"} - - timeout = max(5, min(timeout, 60)) - - # Snapshot files before execution for detecting new files - before = set() - for item in root.rglob("*"): - if item.is_file(): - before.add(str(item.relative_to(root))) - - try: - proc = await asyncio.create_subprocess_exec( - "python3", - "-c", - code, - cwd=str(root), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, - ) - stdout_bytes, stderr_bytes = await asyncio.wait_for( - proc.communicate(), timeout=timeout - ) - except asyncio.TimeoutError: - proc.kill() - return { - "success": False, - "error": f"Execution timed out after {timeout}s", - } - except FileNotFoundError: - return {"success": False, "error": "python3 not found in container"} - except Exception as e: - return {"success": False, "error": f"Execution failed: {e}"} - - stdout = stdout_bytes.decode("utf-8", errors="replace")[:8192] - stderr = stderr_bytes.decode("utf-8", errors="replace")[:4096] - - # Detect file changes (new, deleted, moved) - after = set() - for item in root.rglob("*"): - if item.is_file(): - after.add(str(item.relative_to(root))) - created = after - before - deleted = before - after - # Filter out moves: if basename exists in both created and deleted, it's a move - deleted_basenames = {Path(p).name for p in deleted} - truly_new = sorted(p for p in created if Path(p).name not in deleted_basenames) - moved = sorted(p for p in created if Path(p).name in deleted_basenames) - - return { - "success": proc.returncode == 0, - "exit_code": proc.returncode, - "stdout": stdout, - "stderr": stderr, - "new_files": truly_new, - "moved_files": moved, - "deleted_files": sorted(deleted), - } - - -async def exec_shell_handler( - root_path: str, - command: str, - timeout: int = 30, -) -> dict: - """Execute a shell command in the project directory. - - Runs the command via /bin/bash with cwd=root_path. - Captures stdout, stderr, and lists any new files created. - """ - try: - root = _resolve_safe(root_path) - except ValueError as e: - return {"success": False, "error": str(e)} - - if not root.exists(): - return {"success": False, "error": f"Project root not found: {root_path}"} - - timeout = max(5, min(timeout, 60)) - - # Snapshot files before execution for detecting new files - before = set() - for item in root.rglob("*"): - if item.is_file(): - before.add(str(item.relative_to(root))) - - try: - proc = await asyncio.create_subprocess_exec( - "/bin/bash", - "-c", - command, - cwd=str(root), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - stdout_bytes, stderr_bytes = await asyncio.wait_for( - proc.communicate(), timeout=timeout - ) - except asyncio.TimeoutError: - proc.kill() - return { - "success": False, - "error": f"Execution timed out after {timeout}s", - } - except Exception as e: - return {"success": False, "error": f"Execution failed: {e}"} - - stdout = stdout_bytes.decode("utf-8", errors="replace")[:8192] - stderr = stderr_bytes.decode("utf-8", errors="replace")[:4096] - - # Detect file changes (new, deleted, moved) - after = set() - for item in root.rglob("*"): - if item.is_file(): - after.add(str(item.relative_to(root))) - created = after - before - deleted = before - after - # Filter out moves: if basename exists in both created and deleted, it's a move - deleted_basenames = {Path(p).name for p in deleted} - truly_new = sorted(p for p in created if Path(p).name not in deleted_basenames) - moved = sorted(p for p in created if Path(p).name in deleted_basenames) - - return { - "success": proc.returncode == 0, - "exit_code": proc.returncode, - "stdout": stdout, - "stderr": stderr, - "new_files": truly_new, - "moved_files": moved, - "deleted_files": sorted(deleted), - } - - -# EOF diff --git a/src/scitex/project/_skills/SKILL.md b/src/scitex/project/_skills/SKILL.md deleted file mode 100644 index c312c34ad..000000000 --- a/src/scitex/project/_skills/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: stx.project -description: SciTeX project file operations — secure MCP handlers for list, read, write, search, and execute within project sandboxes. ---- - -# stx.project — Skills Index - -Provides MCP tool handlers for sandboxed project file operations. All paths are constrained to `ALLOWED_DATA_ROOT` with path traversal protection. - -## Sub-skills - -| File | Description | -|------|-------------| -| [mcp-file-ops.md](mcp-file-ops.md) | list_files, read_file, write_file, search_files, exec_python, exec_shell handlers; security model | - -## Quick Reference - -```python -# Via MCP tools (preferred) -# project_list_files, project_read_file, project_write_file, -# project_search_files, project_exec_python, project_exec_shell - -# Directly (async) -from scitex.project._mcp.handlers import ( - list_files_handler, read_file_handler, write_file_handler, - search_files_handler, exec_python_handler, exec_shell_handler, -) -import asyncio - -result = asyncio.run(list_files_handler("/app/data/users/alice/proj")) -``` - -## Related modules - -| Task | Module | -|---|---| -| Project scaffolding | `stx.template` | -| Experiment lifecycle | `stx.session` | -| File I/O | `stx.io` | -| Path management | `stx.path` | diff --git a/src/scitex/project/_skills/mcp-file-ops.md b/src/scitex/project/_skills/mcp-file-ops.md deleted file mode 100644 index 4a0252fca..000000000 --- a/src/scitex/project/_skills/mcp-file-ops.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -description: Secure project file operations (list, read, write, search, exec) exposed as MCP tools with path traversal protection. ---- - -# stx.project — MCP File Operations - -`stx.project._mcp.handlers` provides async handlers that back the MCP tools `project_list_files`, `project_read_file`, `project_write_file`, `project_search_files`, `project_exec_python`, and `project_exec_shell`. - -All operations are sandboxed to paths under `ALLOWED_DATA_ROOT` (default `/app/data/users`, overridable via `SCITEX_PROJECT_DATA_ROOT` environment variable). - -## Security model - -Every handler calls `_resolve_safe(root_path, relative_path)` which: -1. Verifies `root_path` is under `ALLOWED_DATA_ROOT` -2. Verifies the resolved target is under `root_path` (no `../` traversal) -3. Raises `ValueError` on any violation (never silently allows) - -## list_files_handler - -List files and directories as a tree. - -```python -from scitex.project._mcp.handlers import list_files_handler -import asyncio - -result = asyncio.run(list_files_handler( - root_path="/app/data/users/alice/myproject", - relative_path=".", - max_depth=3, # 1–6, default 3 -)) -# { -# "success": True, -# "path": ".", -# "tree": [ -# {"name": "data", "type": "dir", "children": [...]}, -# {"name": "analysis.py", "type": "file", "size": 1234}, -# ] -# } -``` - -Skips hidden files, `__pycache__`, `.git`, `node_modules`. - -## read_file_handler - -Read text file content (UTF-8, with `errors="replace"`). - -```python -result = asyncio.run(read_file_handler( - root_path="/app/data/users/alice/myproject", - relative_path="results/summary.csv", - max_bytes=65536, # default 64 KiB -)) -# { -# "success": True, -# "path": "results/summary.csv", -# "content": "...", -# "size_bytes": 1200, -# "truncated": False, -# } -``` - -## write_file_handler - -Write a text file, creating parent directories as needed. - -```python -result = asyncio.run(write_file_handler( - root_path="/app/data/users/alice/myproject", - relative_path="notes/todo.md", - content="# TODO\n- Check results\n", -)) -# {"success": True, "path": "notes/todo.md", "size_bytes": 22} -``` - -## search_files_handler - -Search by filename glob and/or content substring. - -```python -result = asyncio.run(search_files_handler( - root_path="/app/data/users/alice/myproject", - name_pattern="*.py", - content_pattern="import scitex", - relative_path=".", - max_results=50, -)) -# { -# "success": True, -# "matches": [ -# {"path": "analysis.py", "line": 3, "preview": "import scitex as stx"}, -# ], -# "count": 1, -# "truncated": False, -# } -``` - -At least one of `name_pattern` or `content_pattern` must be provided. - -## exec_python_handler - -Execute Python code as a subprocess with `cwd=root_path`. Detects new/deleted/moved files. - -```python -result = asyncio.run(exec_python_handler( - root_path="/app/data/users/alice/myproject", - code="import scitex as stx; stx.io.save([1,2,3], 'test.npy')", - timeout=30, # 5–60 s, default 30 -)) -# { -# "success": True, -# "exit_code": 0, -# "stdout": "", -# "stderr": "", -# "new_files": ["test.npy"], -# "moved_files": [], -# "deleted_files": [], -# } -``` - -## exec_shell_handler - -Execute a shell command via `/bin/bash -c` with `cwd=root_path`. - -```python -result = asyncio.run(exec_shell_handler( - root_path="/app/data/users/alice/myproject", - command="ls -la results/", - timeout=30, -)) -``` - -Response format is identical to `exec_python_handler`. - -## MCP tool names - -| Handler | MCP tool | -|---|---| -| `list_files_handler` | `project_list_files` | -| `read_file_handler` | `project_read_file` | -| `write_file_handler` | `project_write_file` | -| `search_files_handler` | `project_search_files` | -| `exec_python_handler` | `project_exec_python` | -| `exec_shell_handler` | `project_exec_shell` | diff --git a/src/scitex/project/_skills/mcp-files.md b/src/scitex/project/_skills/mcp-files.md deleted file mode 100644 index 1f9d2be8c..000000000 --- a/src/scitex/project/_skills/mcp-files.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -description: MCP tools for project-scoped file reading, writing, listing, searching, and code execution — available to AI agents via the SciTeX MCP server. ---- - -# MCP File Operations - -These are MCP tools exposed by the SciTeX server. They operate relative to the configured project root. - -## project_read_file - -Read a file from the project. - -``` -mcp__scitex__project_read_file(path: str) -> str -``` - -## project_write_file - -Write content to a file in the project. - -``` -mcp__scitex__project_write_file(path: str, content: str) -> dict -``` - -## project_list_files - -List files in a project directory. - -``` -mcp__scitex__project_list_files(directory: str = ".") -> list[str] -``` - -## project_search_files - -Search for files by name pattern or content. - -``` -mcp__scitex__project_search_files(query: str, directory: str = ".") -> list[str] -``` - -## project_exec_python - -Execute a Python script within the project context. - -``` -mcp__scitex__project_exec_python(script: str, timeout: int = 30) -> dict -``` - -Returns `{'stdout': ..., 'stderr': ..., 'exit_code': ...}`. - -## project_exec_shell - -Execute a shell command in the project root. - -``` -mcp__scitex__project_exec_shell(command: str, timeout: int = 30) -> dict -``` - -## Usage by AI agents - -```python -# Example: AI agent reads and modifies a config file -content = mcp__scitex__project_read_file("config/settings.yaml") -updated = content.replace("lr: 0.001", "lr: 0.0001") -mcp__scitex__project_write_file("config/settings.yaml", updated) - -# Run tests and check output -result = mcp__scitex__project_exec_shell("pytest tests/ -x -q") -print(result["stdout"]) -``` diff --git a/src/scitex/re_export.py b/src/scitex/re_export.py index ba6433913..2d603703b 100755 --- a/src/scitex/re_export.py +++ b/src/scitex/re_export.py @@ -136,9 +136,15 @@ def main(): pass scitex.session.start(...) # Access other functions """ - def __init__(self, module_name, main_decorator_name="session"): + def __init__(self, module_name, main_decorator_name="session", external=None): self._module_name = module_name self._main_decorator_name = main_decorator_name + # If `external` is given, the wrapper proxies an external package (e.g. + # "scitex_hub.module") instead of the in-tree `scitex.` submodule. + # Used for optional peers (scitex-hub) so callability (`scitex.module(...)`) + # is preserved while the in-tree dir is deleted. A missing peer raises a + # friendly ImportError on first access/call rather than at `import scitex`. + self._external = external self._module = None self._parent_name = None self._attr_name = None @@ -151,10 +157,23 @@ def _setup_persistence(self, parent_name, attr_name): def _load_module(self): """Lazy load the actual module.""" if self._module is None: - # Import the module - self._module = importlib.import_module( - f".{self._module_name}", package="scitex" - ) + # Import the module (external peer or in-tree submodule) + if self._external is not None: + try: + self._module = importlib.import_module(self._external) + except ImportError as exc: + # Optional peer missing → friendly install hint, mirroring + # the `_make_missing_peer_stub` contract used elsewhere. + peer = self._external.split(".", 1)[0] + raise ImportError( + f"scitex.{self._module_name} requires `{peer}`. " + f"Install with: pip install 'scitex[{self._module_name}]' " + f"(or: pip install {peer.replace('_', '-')})" + ) from exc + else: + self._module = importlib.import_module( + f".{self._module_name}", package="scitex" + ) # Restore ourselves in the parent module's __dict__ to prevent replacement if self._parent_name and self._attr_name: @@ -226,6 +245,7 @@ def __repr__(self): "ml": "scitex_ml", "genai": "scitex_genai", "etc": "scitex_etc", + "media": "scitex_etc.media", # in-tree dir removed; shipped in scitex-etc (>=0.2.0) "gists": "scitex_gists", "audit": "scitex_audit", "compat": "scitex_compat", @@ -303,6 +323,13 @@ def register_external_lazy_modules() -> None: "errors": "scitex_logging", # error taxonomy lives in scitex-logging; in-tree shim removed "torch": "scitex_linalg", # torch numerics (apply_to + nan reductions) live in scitex-linalg "dt": "scitex_datetime", # legacy short for the dt module → standalone scitex-datetime + # OPTIONAL peers — scitex-hub is NOT a hard dep (kept out of `[all]`). When + # absent, the alias finder returns a `_make_missing_peer_stub` so + # `import scitex.cloud` (and `.module` / `.project`) raise a friendly + # install hint instead of crashing `import scitex`. + "cloud": "scitex_hub", + "module": "scitex_hub.module", + "project": "scitex_hub.project", } diff --git a/src/scitex/utils/__init__.py b/src/scitex/utils/__init__.py deleted file mode 100755 index 6be5c8897..000000000 --- a/src/scitex/utils/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -"""Scitex utils — thin re-export aggregator. - -This is a grab-bag namespace with no single owning package. Each public -helper now lives in its SoC-correct standalone and is re-exported here for -backward compatibility (``stx.utils.`` keeps working): - -- ``compress_hdf5`` → scitex-io -- ``count_grids`` / ``yield_grids`` / ``search`` → scitex-etc -- ``notify`` (+ ``_send_gmail`` / ``_gen_footer`` / host helpers) → scitex-notification - -New code should import from the owning package directly. The in-tree -implementations were removed (the migration moved them to the standalones). -""" - -from scitex_etc import count_grids, search, yield_grids -from scitex_io import compress_hdf5 -from scitex_notification import notify - -# Backward-compat private aliases (were exposed as private names by the old -# in-tree utils; unused outside this module, kept to avoid any surprise). -from scitex_notification._notify_legacy import ansi_escape as _ansi_escape -from scitex_notification._notify_legacy import gen_footer as _gen_footer -from scitex_notification._notify_legacy import get_git_branch as _get_git_branch -from scitex_notification._notify_legacy import get_hostname as _get_hostname -from scitex_notification._notify_legacy import get_username as _get_username -from scitex_notification._notify_legacy import send_gmail as _send_gmail - -__all__ = [ - # Public API (re-exported from owning standalones) - "compress_hdf5", - "count_grids", - "yield_grids", - "notify", - "search", -] diff --git a/src/scitex/utils/_skills/SKILL.md b/src/scitex/utils/_skills/SKILL.md deleted file mode 100644 index b99248f6d..000000000 --- a/src/scitex/utils/_skills/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: stx.utils -description: Index of skill files for the scitex.utils module. ---- - -# stx.utils — Skills Index - -The `stx.utils` module provides miscellaneous utilities for scientific workflows. - -## Public API - -```python -import scitex as stx - -stx.utils.compress_hdf5(...) # HDF5 compression -stx.utils.yield_grids(...) # Parameter grid iteration -stx.utils.count_grids(...) # Count grid combinations -stx.utils.notify(...) # Email notifications -stx.utils.search(...) # Regex string search -``` - -## Sub-skills - -| File | Feature | Key functions | -|------|---------|---------------| -| [compress-hdf5.md](compress-hdf5.md) | Compress HDF5 files with gzip | `compress_hdf5` | -| [grid-search.md](grid-search.md) | Enumerate parameter combinations | `yield_grids`, `count_grids` | -| [notify.md](notify.md) | Send email notifications from scripts | `notify` | -| [search.md](search.md) | Regex search over string collections | `search` | diff --git a/src/scitex/utils/_skills/compress-hdf5.md b/src/scitex/utils/_skills/compress-hdf5.md deleted file mode 100644 index 7354ad591..000000000 --- a/src/scitex/utils/_skills/compress-hdf5.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: Compress existing HDF5 files with gzip, preserving all datasets, groups, and attributes. ---- - -# stx.utils.compress_hdf5 - -Rewrite an HDF5 file with gzip compression applied to every dataset, preserving the full group hierarchy, dataset chunking, and all metadata attributes. - -## Signature - -```python -compress_hdf5( - input_file: str, - output_file: str | None = None, - compression_level: int = 4, -) -> str -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `input_file` | str | required | Path to the source HDF5 file | -| `output_file` | str or None | None | Destination path. If None, appends `.compressed` before the extension | -| `compression_level` | int | 4 | gzip level 1–9 (higher = smaller file, slower) | - -### Returns - -`str` — path to the output compressed file. - -### Behaviour notes - -- When `output_file` is None the output is named `.compressed` (e.g. `data.h5` → `data.compressed.h5`). -- Datasets with more than 10 000 000 rows are copied in 5 000 000-row chunks to keep memory usage bounded. -- Original chunking is preserved when the source dataset was already chunked; otherwise h5py chooses chunk sizes automatically. -- All file-level and dataset-level attributes are copied verbatim. -- Prints progress and final size comparison to stdout. - -## Dependencies - -`h5py` is required and must be installed separately. `tqdm` is optional — if installed, chunk progress bars are shown for very large (> 1 000 000 row) datasets. - -## Example - -```python -import scitex as stx - -# Compress with default level 4 -out = stx.utils.compress_hdf5("recordings.h5") -# -> writes recordings.compressed.h5, prints size comparison - -# Explicit output path and higher compression -out = stx.utils.compress_hdf5( - "recordings.h5", - output_file="recordings_v2.h5", - compression_level=7, -) -``` - -## CLI - -The module is also runnable as a script: - -```bash -python -m scitex.utils._compress_hdf5 recordings.h5 -python -m scitex.utils._compress_hdf5 recordings.h5 --output_file out.h5 --compression 7 -``` diff --git a/src/scitex/utils/_skills/grid-search.md b/src/scitex/utils/_skills/grid-search.md deleted file mode 100644 index 41be9bf5b..000000000 --- a/src/scitex/utils/_skills/grid-search.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -description: Enumerate every combination of a parameter grid for hyperparameter or condition sweeps. ---- - -# stx.utils — Grid Search Utilities - -Two functions for exhaustive parameter-grid iteration: `yield_grids` yields each combination as a dict, `count_grids` returns the total count without iterating. - -## Signatures - -```python -yield_grids(params_grid: dict, random: bool = False) -> Generator[dict, None, None] - -count_grids(params_grid: dict) -> int -``` - -### Parameters — yield_grids - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `params_grid` | dict | required | Keys are parameter names; values are lists of candidate values | -| `random` | bool | False | If True, shuffle the combination order before yielding | - -### Returns — yield_grids - -Generator that yields one `dict` per combination. Each dict maps every key in `params_grid` to one of its candidate values. - -### Parameters — count_grids - -| Parameter | Type | Description | -|-----------|------|-------------| -| `params_grid` | dict | Same structure as for `yield_grids` | - -### Returns — count_grids - -`int` — product of the lengths of all value lists (total number of combinations). - -## Implementation detail - -`yield_grids` builds the full Cartesian product via `itertools.product`, optionally shuffles with `random.shuffle`, then yields dicts constructed with `zip`. The entire combination list is materialised in memory before yielding begins. - -## Examples - -### Basic sweep - -```python -import scitex as stx - -params_grid = { - "lr": [1e-4, 1e-3, 1e-2], - "batch_size": [32, 64, 128], - "dropout": [0.1, 0.3, 0.5], -} - -# How many runs? -n = stx.utils.count_grids(params_grid) -print(f"Total combinations: {n}") # 27 - -# Iterate in order -for params in stx.utils.yield_grids(params_grid): - print(params) - # {'lr': 0.0001, 'batch_size': 32, 'dropout': 0.1} - # {'lr': 0.0001, 'batch_size': 32, 'dropout': 0.3} - # ... -``` - -### Random sweep (early stopping friendly) - -```python -for params in stx.utils.yield_grids(params_grid, random=True): - result = train(**params) - if result["val_acc"] > 0.95: - break # stop early — combinations were randomised so no ordering bias -``` - -### Large machine-learning grid - -```python -params_grid = { - "batch_size": [2**i for i in range(3, 7)], # 8, 16, 32, 64 - "n_channels": [2**i for i in range(3, 7)], # 8, 16, 32, 64 - "seq_len": [2**i for i in range(8, 13)], # 256 … 4096 - "precision": ["fp16", "fp32"], - "device": ["cpu", "cuda"], -} - -print(stx.utils.count_grids(params_grid)) # 320 - -for p in stx.utils.yield_grids(params_grid, random=True): - benchmark(**p) -``` diff --git a/src/scitex/utils/_skills/notify.md b/src/scitex/utils/_skills/notify.md deleted file mode 100644 index f7a69ff8f..000000000 --- a/src/scitex/utils/_skills/notify.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -description: Send email notifications from long-running scripts with auto-detected sender, subject, and system footer. ---- - -# stx.utils.notify - -Send an email notification from a running Python script. Sender address, password, and recipient are read from environment variables so no credentials appear in source code. A system footer with hostname, username, script name, and scitex version is appended automatically. - -## Signature - -```python -notify( - subject: str = "", - message: str = ":)", - file: str | None = None, - ID: str | None = "auto", - sender_name: str | None = None, - recipient_email: str | None = None, - cc: str | list[str] | None = None, - attachment_paths: list[str] | None = None, - verbose: bool = False, -) -> None -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `subject` | str | `""` | Email subject line (script name is prepended automatically) | -| `message` | str | `":)"` | Body text; any value is coerced to `str` | -| `file` | str or None | None | Override the detected script name in subject/footer | -| `ID` | str or None | `"auto"` | Unique message ID appended to subject; `"auto"` generates one via `scitex.repro.gen_ID` | -| `sender_name` | str or None | None | Display name for the From header | -| `recipient_email` | str or None | None | Recipient address; falls back to `SCITEX_SCHOLAR_EMAIL_RECIPIENT` env var | -| `cc` | str or list[str] or None | None | CC address(es) | -| `attachment_paths` | list[str] or None | None | Paths to attach; `.log` files are ANSI-stripped before attaching | -| `verbose` | bool | False | Print send confirmation to stdout | - -## Environment variables - -| Variable | Purpose | -|----------|---------| -| `SCITEX_SCHOLAR_EMAIL_NOREPLY` | Sender address (primary) | -| `SCITEX_SCHOLAR_FROM_EMAIL_ADDRESS` | Sender address (deprecated fallback) | -| `SCITEX_EMAIL_NOREPLY` | Sender address (fallback) | -| `SCITEX_EMAIL_AGENT` | Sender address (last fallback, default `no-reply@scitex.ai`) | -| `SCITEX_SCHOLAR_EMAIL_PASSWORD` | SMTP password (primary) | -| `SCITEX_SCHOLAR_FROM_EMAIL_PASSWORD` | SMTP password (deprecated fallback) | -| `SCITEX_EMAIL_PASSWORD` | SMTP password (fallback) | -| `SCITEX_SCHOLAR_EMAIL_RECIPIENT` | Recipient address (primary) | -| `SCITEX_SCHOLAR_TO_EMAIL_ADDRESS` | Recipient address (deprecated fallback) | - -SMTP server is auto-detected from the sender address: `@gmail.com` uses `smtp.gmail.com:587`; all other addresses use the server configured via `SCITEX_SCHOLAR_FROM_EMAIL_SMTP_SERVER` (default `mail1030.onamae.ne.jp:587`). - -## Auto-generated footer - -Every message gets a footer block of the form: - -``` ------------------------------- -Sent via -- Host: ywatanabe@machine01 -- Script: train.py -- Source: scitex v2.26.0 (github.com/ywatanabe1989/scitex/blob/main/src/scitex/...) ------------------------------- -``` - -## Examples - -### Minimal — notify on completion - -```python -import scitex as stx - -# ... long training loop ... - -stx.utils.notify( - subject="Training complete", - message=f"Final accuracy: {acc:.4f}", -) -``` - -### Attach a log file - -```python -stx.utils.notify( - subject="Experiment finished", - message="See attached log.", - attachment_paths=["experiment_out/run.log"], - verbose=True, -) -``` - -### Explicit recipient and CC - -```python -stx.utils.notify( - subject="Batch job done", - message="All 500 jobs completed successfully.", - recipient_email="pi@lab.edu", - cc=["student@lab.edu", "backup@lab.edu"], -) -``` - -### Suppress auto-generated ID - -```python -stx.utils.notify( - subject="Quick ping", - message="Still running.", - ID=None, # omit ID from subject line -) -``` - -## Underlying send_gmail - -`notify` is a convenience wrapper. For direct SMTP control use the internal `_send_gmail`: - -```python -from scitex.utils._email import send_gmail - -send_gmail( - sender_gmail="agent@lab.edu", - sender_password="...", - recipient_email="user@lab.edu", - subject="Direct send", - message="Body text", - smtp_server="smtp.lab.edu", - smtp_port=587, - attachment_paths=["report.pdf"], - verbose=True, -) -``` diff --git a/src/scitex/utils/_skills/search.md b/src/scitex/utils/_skills/search.md deleted file mode 100644 index 8dc7a58e5..000000000 --- a/src/scitex/utils/_skills/search.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -description: Regex-based multi-pattern search over a list of strings, returning matched indices and values. ---- - -# stx.utils.search - -Search one or more regex patterns over a collection of strings. Returns matched indices and matched values, with optional boolean-array output and an exactness toggle. - -## Signature - -```python -search( - patterns: str | list[str] | np.ndarray | pd.Series | pd.Index, - strings: str | list[str] | np.ndarray | pd.Series | pd.Index, - only_perfect_match: bool = False, - as_bool: bool = False, - ensure_one: bool = False, -) -> tuple[list[int] | np.ndarray, list[str]] -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `patterns` | str or sequence | required | Pattern(s) to search for. Standard Python regex syntax. | -| `strings` | str or sequence | required | Strings to search in. Accepts str, list, ndarray, pd.Series, pd.Index. | -| `only_perfect_match` | bool | False | If True, require exact string equality instead of `re.search` | -| `as_bool` | bool | False | If True, return a boolean ndarray instead of an index list | -| `ensure_one` | bool | False | If True, raise AssertionError unless exactly one match is found | - -### Returns - -A 2-tuple `(indices_or_bool, matched_strings)`: - -- When `as_bool=False` (default): `(list[int], list[str])` — indices are naturally sorted via `natsorted`. -- When `as_bool=True`: `(np.ndarray[bool], list[str])` — length equals `len(strings)`. - -## Behaviour details - -- Multiple patterns are OR-combined: a string is included if it matches any pattern. -- `re.search` is used (substring match), so `"orange"` matches `"orange_juice"`. -- Input sequences are normalised internally — numpy arrays, pandas Series/Index, and dict key-views all work. -- Returned indices are deduplicated and sorted with `natsort` for natural ordering. - -## Examples - -### Single pattern - -```python -import scitex as stx - -strings = ["apple", "orange", "apple_juice", "banana", "orange_juice"] - -idx, matched = stx.utils.search("orange", strings) -# idx -> [1, 4] -# matched -> ["orange", "orange_juice"] -``` - -### Multiple patterns - -```python -idx, matched = stx.utils.search(["orange", "banana"], strings) -# idx -> [1, 3, 4] -# matched -> ["orange", "banana", "orange_juice"] -``` - -### Boolean output - -```python -mask, matched = stx.utils.search("orange", strings, as_bool=True) -# mask -> array([False, True, False, False, True]) -filtered = [s for s, m in zip(strings, mask) if m] -``` - -### Exact match only - -```python -idx, matched = stx.utils.search("orange", strings, only_perfect_match=True) -# idx -> [1] -# matched -> ["orange"] (orange_juice excluded) -``` - -### Enforce single match - -```python -# Useful when you expect exactly one result -idx, matched = stx.utils.search("^banana$", strings, ensure_one=True) -# Raises AssertionError if 0 or 2+ matches -``` - -### Searching pandas columns - -```python -import pandas as pd - -df = pd.DataFrame({"ch": ["Fz", "Cz", "Pz", "Fp1", "Fp2"]}) -idx, matched = stx.utils.search("^F", df["ch"]) -# idx -> [0, 3, 4] -# matched -> ["Fz", "Fp1", "Fp2"] -``` - -### Searching numpy arrays - -```python -import numpy as np - -labels = np.array(["train_loss", "val_loss", "train_acc", "val_acc"]) -idx, matched = stx.utils.search("val", labels) -# idx -> [1, 3] -# matched -> ["val_loss", "val_acc"] -``` diff --git a/tests/integration/test_cross_package_imports.py b/tests/integration/test_cross_package_imports.py index eba100c9c..f6a7f56c6 100755 --- a/tests/integration/test_cross_package_imports.py +++ b/tests/integration/test_cross_package_imports.py @@ -92,7 +92,6 @@ "scitex.os", "scitex.path", "scitex.plt", - "scitex.project._mcp.handlers", "scitex.repro", "scitex.resource", "scitex.scholar._mcp.handlers", @@ -118,7 +117,6 @@ "scitex.tunnel", "scitex.types", "scitex.usage", - "scitex.utils._notify", "scitex.web", "scitex.web.download_images", "scitex.writer._verify_tree_structure", @@ -134,10 +132,6 @@ "scitex_clew._mcp.tools", "scitex_clew._registry", "scitex_clew._tracker", - "scitex_cloud", - "scitex_cloud._mcp_server", - "scitex_cloud.api", - "scitex_cloud.module", "scitex_container", "scitex_container.apptainer", "scitex_dataset", @@ -201,11 +195,6 @@ "scitex_io._save_modules._torch", "scitex_io._save_modules._yaml", "scitex_io._save_modules._zarr", - "scitex_linter", - "scitex_linter._mcp.tools", - "scitex_linter.checker", - "scitex_linter.formatter", - "scitex_linter.rules", "scitex_ml", "scitex_notification", "scitex_notification._backends", diff --git a/tests/media/__init__.py b/tests/media/__init__.py deleted file mode 100755 index e69de29bb..000000000 diff --git a/tests/media/render/__init__.py b/tests/media/render/__init__.py deleted file mode 100755 index e69de29bb..000000000 diff --git a/tests/media/render/test_classify.py b/tests/media/render/test_classify.py deleted file mode 100755 index 2decaeb7e..000000000 --- a/tests/media/render/test_classify.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.media.render._classify.""" - -import pytest - -from scitex.media.render import MEDIA_EXTENSIONS, classify - - -class TestClassify: - """Test file type classification.""" - - @pytest.mark.parametrize( - "path,expected_type", - [ - ("figure.png", "image"), - ("plot.jpg", "image"), - ("photo.jpeg", "image"), - ("anim.gif", "image"), - ("icon.svg", "image"), - ("img.webp", "image"), - ("img.bmp", "image"), - ("paper.pdf", "pdf"), - ("data.csv", "csv"), - ("results.tsv", "csv"), - ("chart.html", "plotly"), - ("diagram.mmd", "mermaid"), - ], - ) - def test_known_extensions(self, path, expected_type): - ref = classify(path) - assert ref is not None - assert ref["type"] == expected_type - assert ref["path"] == path - - def test_unknown_extension_returns_none(self): - assert classify("readme.txt") is None - assert classify("script.py") is None - assert classify("noext") is None - - def test_case_insensitive(self): - ref = classify("FIGURE.PNG") - assert ref is not None - assert ref["type"] == "image" - - def test_nested_path(self): - ref = classify("figures/sub/plot.png") - assert ref is not None - assert ref["path"] == "figures/sub/plot.png" - - def test_media_extensions_complete(self): - """All extension sets are non-empty.""" - for media_type, exts in MEDIA_EXTENSIONS.items(): - assert len(exts) > 0, f"{media_type} has no extensions" - - def test_media_extensions_immutable(self): - """MEDIA_EXTENSIONS cannot be mutated.""" - with pytest.raises(TypeError): - MEDIA_EXTENSIONS["new_type"] = frozenset({".xyz"}) - - -# EOF diff --git a/tests/media/render/test_detect.py b/tests/media/render/test_detect.py deleted file mode 100755 index 52fbf8bcb..000000000 --- a/tests/media/render/test_detect.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.media.render._detect.""" - -import pytest - -from scitex.media.render import detect - - -class TestDetect: - """Test media detection from text.""" - - def test_basic_detection(self): - text = "Saved to /home/user/proj/figures/plot.png" - refs = detect(text, root_path="/home/user/proj") - assert len(refs) == 1 - assert refs[0]["type"] == "image" - assert refs[0]["path"] == "figures/plot.png" - assert refs[0]["ext"] == ".png" - - def test_multiple_refs(self): - text = "Created /home/user/proj/fig.png and /home/user/proj/data.csv" - refs = detect(text, root_path="/home/user/proj") - assert len(refs) == 2 - types = {r["type"] for r in refs} - assert types == {"image", "csv"} - - def test_dedup(self): - text = "/home/user/proj/fig.png and again /home/user/proj/fig.png" - refs = detect(text, root_path="/home/user/proj") - assert len(refs) == 1 - - def test_no_root_returns_empty(self): - assert detect("some text", root_path=None) == [] - - def test_empty_text_returns_empty(self): - assert detect("", root_path="/root") == [] - - def test_no_match(self): - text = "No file paths here" - assert detect(text, root_path="/home/user/proj") == [] - - def test_ignores_non_media_extensions(self): - text = "Wrote /home/user/proj/script.py" - assert detect(text, root_path="/home/user/proj") == [] - - def test_trailing_slash_on_root(self): - text = "File at /home/user/proj/fig.png" - refs = detect(text, root_path="/home/user/proj/") - assert len(refs) == 1 - assert refs[0]["path"] == "fig.png" - - def test_filename_with_parens(self): - text = "Saved /home/user/proj/Figure(v2).png done" - refs = detect(text, root_path="/home/user/proj") - assert len(refs) == 1 - assert refs[0]["path"] == "Figure(v2).png" - - def test_filename_with_brackets(self): - text = "Saved /home/user/proj/data[final].csv done" - refs = detect(text, root_path="/home/user/proj") - assert len(refs) == 1 - assert refs[0]["path"] == "data[final].csv" - - def test_trailing_period_stripped(self): - text = "Saved to /home/user/proj/fig.png." - refs = detect(text, root_path="/home/user/proj") - assert len(refs) == 1 - assert refs[0]["path"] == "fig.png" - - def test_trailing_comma_stripped(self): - text = "Created /home/user/proj/a.png, /home/user/proj/b.csv" - refs = detect(text, root_path="/home/user/proj") - assert len(refs) == 2 - - -# EOF diff --git a/tests/media/render/test_show.py b/tests/media/render/test_show.py deleted file mode 100755 index bcf2bfabe..000000000 --- a/tests/media/render/test_show.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.media.render._show.""" - -import json - -import pytest - -from scitex.media.render import show - - -class TestShow: - """Test show() dispatching to targets.""" - - def test_markdown_target(self): - result = show("figure.png", target="markdown") - assert "![" in result - assert "figure.png" in result - - def test_chat_target(self): - result = show("data.csv", target="chat") - parsed = json.loads(result) - assert parsed["type"] == "csv" - - def test_terminal_target(self, capsys): - result = show("/tmp/test.png", target="terminal") - assert result.startswith("\033]9998;media:") - captured = capsys.readouterr() - assert "\033]9998;media:" in captured.out - - def test_invalid_target(self): - with pytest.raises(ValueError, match="Unknown target"): - show("fig.png", target="invalid") - - -# EOF diff --git a/tests/media/render/test_targets.py b/tests/media/render/test_targets.py deleted file mode 100755 index 7e08eb23e..000000000 --- a/tests/media/render/test_targets.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.media.render._targets.""" - -import base64 -import json - -import pytest - -from scitex.media.render._targets import _to_chat, _to_markdown, _to_terminal - - -class TestToTerminal: - """Test OSC escape generation.""" - - def test_osc_format(self): - osc = _to_terminal("/tmp/test.png") - assert osc.startswith("\033]9998;media:") - assert osc.endswith("\007") - - def test_osc_payload_decodable(self): - osc = _to_terminal("/tmp/test.png") - b64 = osc[len("\033]9998;media:") : -1] - payload = json.loads(base64.b64decode(b64)) - assert payload["type"] == "image" - assert "url" in payload - - def test_unknown_extension(self): - osc = _to_terminal("/tmp/readme.txt") - b64 = osc[len("\033]9998;media:") : -1] - payload = json.loads(base64.b64decode(b64)) - assert payload["type"] == "file" - - -class TestToChat: - """Test chat SSE event formatting.""" - - def test_basic(self): - ref = _to_chat("figures/plot.png") - assert ref["type"] == "image" - assert ref["path"] == "figures/plot.png" - assert ref["ext"] == ".png" - - def test_relative_from_root(self): - ref = _to_chat("/home/user/proj/fig.png", root_path="/home/user/proj") - assert ref["path"] == "fig.png" - - def test_unknown_type(self): - ref = _to_chat("readme.txt") - assert ref["type"] == "file" - - -class TestToMarkdown: - """Test markdown embed formatting.""" - - def test_image(self): - md = _to_markdown("figure.png") - assert md == "![figure.png](figure.png)" - - def test_image_with_alt(self): - md = _to_markdown("figure.png", alt="My plot") - assert md == "![My plot](figure.png)" - - def test_non_image(self): - md = _to_markdown("data.csv") - assert md == "[data.csv](data.csv)" - - def test_pdf(self): - md = _to_markdown("paper.pdf") - assert md == "[paper.pdf](paper.pdf)" - - -# EOF