Source code for scitex_writer

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Timestamp: 2026-01-27
# File: src/scitex_writer/__init__.py

"""SciTeX Writer - LaTeX manuscript compilation system with MCP server.

Four Interfaces:
    - Python API: import scitex_writer as sw
    - CLI: scitex-writer <command>
    - GUI: scitex-writer gui (browser-based editor)
    - MCP: 38 tools for AI agents

Modules:
    - claim: Traceable scientific assertions (stats, figures, citations)
    - compile: Compile manuscripts to PDF
    - export: Export manuscript for arXiv submission
    - migration: Import from / export to external platforms (Overleaf)
    - project: Clone, info, get_pdf
    - tables: List, add, remove, csv_to_latex
    - figures: List, add, remove, convert
    - bib: List, add, remove, merge
    - guidelines: IMRAD writing tips
    - prompts: AI2 Asta integration
    - gui: Browser-based editor (Django)
"""

from __future__ import annotations

from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
from importlib.metadata import version as _version

try:
    __version__ = _version("scitex-writer")
except _PackageNotFoundError:
    # Fallback: parse pyproject.toml (single source of truth)
    from pathlib import Path as _Path

    _pyproject = _Path(__file__).parent.parent.parent / "pyproject.toml"
    __version__ = "0.0.0+local"
    if _pyproject.exists():
        with open(_pyproject) as _f:
            for _line in _f:
                if _line.startswith("version"):
                    __version__ = _line.split("=")[1].strip().strip('"')
                    break

# ---------------------------------------------------------------------------
# Lazy public surface (PEP 562 module __getattr__).
#
# `import scitex_writer` must stay cheap: a bare import that eagerly pulled in
# every submodule (Django, fastmcp, scitex-dev, …) cost ~700ms over the
# bare-interpreter baseline, tripping the audit-cli §10 startup-speed rule and
# slowing Click tab-completion (the CLI re-runs the program once per Tab). We
# defer each name to first access instead; `sw.Writer`, `sw.compile`,
# `from scitex_writer import migration`, etc. all resolve on demand with no
# change to the public API.
# ---------------------------------------------------------------------------

# Submodules exposed directly as attributes (`sw.<name>`); imported on demand.
# Dict (name -> relative module path) so the API auditor's PA-102 dispatch-
# table detector recognises every key as a bound name (PEP 562 lazy-load).
_LAZY_SUBMODULES = {
    "bib": ".bib",
    "checks": ".checks",
    "citation_style": ".citation_style",
    "claim": ".claim",
    "compile": ".compile",
    "count_words": ".count_words",
    "export": ".export",
    "figures": ".figures",
    "guidelines": ".guidelines",
    "migration": ".migration",
    "project": ".project",
    "prompts": ".prompts",
    "tables": ".tables",
    "update": ".update",
}

# Public symbols → (submodule, attribute-in-submodule).
_LAZY_SYMBOLS = {
    "Writer": (".writer", "Writer"),
    "gui": ("._django._server", "run"),
    "usage": ("._usage", "get_usage"),
    # Re-exported dataclasses (kept for introspection / back-compat).
    "_CompilationResult": ("._dataclasses", "CompilationResult"),
    "_ManuscriptTree": ("._dataclasses", "ManuscriptTree"),
    "_RevisionTree": ("._dataclasses", "RevisionTree"),
    "_SupplementaryTree": ("._dataclasses", "SupplementaryTree"),
}


def __getattr__(name):
    import importlib

    submodule_path = _LAZY_SUBMODULES.get(name)
    if submodule_path is not None:
        module = importlib.import_module(submodule_path, __name__)
        globals()[name] = module  # cache so subsequent access skips __getattr__
        return module
    target = _LAZY_SYMBOLS.get(name)
    if target is not None:
        submodule, attr = target
        value = getattr(importlib.import_module(submodule, __name__), attr)
        globals()[name] = value
        return value
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def __dir__():
    return sorted(set(globals()) | set(__all__) | set(_LAZY_SUBMODULES))


[docs] def ensure_workspace(project_dir, git_strategy="child", **kwargs): """Ensure writer workspace exists at {project_dir}/.scitex/writer/. If the directory already exists, returns the path without modification. If not, clones the full scitex-writer template. Parameters ---------- project_dir : str or Path Root project directory. Writer workspace will be at ``{project_dir}/.scitex/writer/`` (hidden, dotfile convention). git_strategy : str, optional Git initialization strategy ('child', 'parent', 'origin', None). **kwargs Forwarded to Writer constructor (branch, tag, etc.). Returns ------- pathlib.Path Path to the writer workspace directory. """ from pathlib import Path from .writer import Writer writer_path = Path(project_dir) / ".scitex" / "writer" if writer_path.exists() and any(writer_path.iterdir()): _maybe_scaffold_paper_symlink(Path(project_dir)) return writer_path Writer(str(writer_path), git_strategy=git_strategy, **kwargs) _maybe_scaffold_paper_symlink(Path(project_dir)) return writer_path
def _resolve_paper_symlink_level(project_dir): """Resolve the paper-symlink severity level (env + config; default ``off``). Mirrors the precedence used by scripts/python/check_paper_symlink.py but is kept dependency-free here to avoid an import cycle: env ``SCITEX_WRITER_PAPER_SYMLINK`` then ``~/.scitex/writer/config.yaml``'s ``paper_symlink.level``, defaulting to ``warn``. """ import os from pathlib import Path levels = ("off", "warn", "error", "repair") env = os.environ.get("SCITEX_WRITER_PAPER_SYMLINK", "").strip().lower() if env in levels: return env user_cfg = Path.home() / ".scitex" / "writer" / "config.yaml" if user_cfg.exists(): try: import yaml data = yaml.safe_load(user_cfg.read_text(encoding="utf-8")) or {} block = data.get("paper_symlink") if isinstance(data, dict) else None level = block.get("level") if isinstance(block, dict) else None if isinstance(level, str) and level.lower() in levels: return level.lower() except Exception: pass return "warn" def _maybe_scaffold_paper_symlink(project_dir): """Create the ``paper -> .scitex/writer`` link ONLY when level == ``repair``. The link is a private convention; we scaffold it only when the user has actively opted in (level ``repair``). Idempotent and never clobbers an existing ``paper`` entry of any kind. """ import os from pathlib import Path if _resolve_paper_symlink_level(project_dir) != "repair": return project_dir = Path(project_dir) link = project_dir / "paper" canonical = project_dir / ".scitex" / "writer" # Never clobber an existing entry (symlink, dir, or file). if link.exists() or link.is_symlink(): return if not canonical.exists(): return try: os.symlink(".scitex/writer", link, target_is_directory=True) except OSError: # Best-effort scaffold; the check/repair command is the primary path. pass __all__ = [ "__version__", "usage", # Modules "checks", "citation_style", "claim", "compile", "count_words", "export", "project", "tables", "figures", "bib", "guidelines", "prompts", "migration", "update", # Writer class "Writer", "ensure_workspace", # GUI "gui", ] # EOF