"""Python installation helper for the standalone nSTAT package."""
from __future__ import annotations
import argparse
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
from .data_manager import (
FIGSHARE_DOI_URL,
PAPER_DOI_URL,
ensure_example_data,
get_data_dir,
get_example_data_info,
)
def _normalize_download_mode(raw_mode: str | bool) -> str:
if isinstance(raw_mode, bool):
return "always" if raw_mode else "never"
mode = str(raw_mode).strip().lower()
if mode in {"always", "prompt", "never"}:
return mode
raise ValueError("download_example_data must be true/false or one of: always, prompt, never")
def _should_prompt_for_example_data(info: dict[str, Any]) -> bool:
if not sys.stdin.isatty() or not sys.stdout.isatty():
return False
prompt = (
"nSTAT example data was not found.\n\n"
f"Download the paper-example dataset into:\n{info['data_dir']}\n\n"
f"Dataset DOI: {FIGSHARE_DOI_URL}\n"
"Download now? [y/N]: "
)
try:
answer = input(prompt)
except EOFError:
return False
return answer.strip().lower() in {"y", "yes"}
def _rebuild_doc_search(repo_root: Path) -> dict[str, Any]:
docs_dir = repo_root / "docs"
output_dir = docs_dir / "_build" / "html"
search_index = output_dir / "searchindex.js"
report: dict[str, Any] = {
"requested": True,
"source_dir": str(docs_dir),
"output_dir": str(output_dir),
"search_index": str(search_index),
}
if not (docs_dir / "conf.py").exists():
report["status"] = "skipped"
report["reason"] = "Docs configuration was not found."
return report
if importlib.util.find_spec("sphinx") is None:
report["status"] = "skipped"
report["reason"] = "Sphinx is not installed; install nstat-toolbox[dev] to rebuild docs search."
return report
proc = subprocess.run(
[sys.executable, "-m", "sphinx", "-q", "-b", "html", "docs", str(output_dir)],
cwd=repo_root,
check=False,
capture_output=True,
text=True,
)
if proc.returncode != 0:
report["status"] = "failed"
report["error"] = proc.stderr.strip() or proc.stdout.strip() or "Unknown sphinx build failure."
return report
report["status"] = "rebuilt"
report["is_available"] = search_index.exists()
if not search_index.exists():
report["status"] = "failed"
report["error"] = f"Expected search index was not created: {search_index}"
return report
[docs]
def nstat_install(
*,
rebuild_doc_search: bool = True,
clean_user_path_prefs: bool = False,
download_example_data: str | bool = "prompt",
) -> dict[str, Any]:
"""Configure the Python package and optionally install example data.
Parameters
----------
rebuild_doc_search : bool, default True
Rebuild the Sphinx HTML search index under ``docs/_build/html``. The
step is skipped (and reported as such) when the docs configuration or
Sphinx is not available.
clean_user_path_prefs : bool, default False
MATLAB-compatibility flag. It is accepted and recorded in the
report, but has no effect in Python.
download_example_data : {'prompt', 'always', 'never'} or bool, default 'prompt'
Policy for the ~150 MB figshare paper dataset. ``'always'`` downloads
it when it is not already installed; ``'never'`` never downloads;
``'prompt'`` asks interactively and downloads only on ``y`` or
``yes`` (no download happens when stdin or stdout is not a terminal).
``True`` is equivalent to ``'always'`` and ``False`` to ``'never'``;
strings are case-insensitive. Nothing is downloaded if the data is
already present.
Returns
-------
dict
JSON-serialisable report with the keys ``repo_root``,
``package_root``, ``rebuild_doc_search``, ``clean_user_path_prefs``,
``path_preferences``, ``download_example_data`` (the normalised
mode), ``example_data``, ``doc_search`` and ``notes``. A failed
download is recorded under ``report["example_data"]["error"]`` rather
than raised.
Raises
------
ValueError
If ``download_example_data`` is not a bool or one of ``'always'``,
``'prompt'`` or ``'never'``.
"""
mode = _normalize_download_mode(download_example_data)
repo_root = Path(__file__).resolve().parents[1]
info = get_example_data_info(repo_root)
data_dir = get_data_dir()
report: dict[str, Any] = {
"repo_root": str(repo_root),
"package_root": str(Path(__file__).resolve().parent),
"rebuild_doc_search": bool(rebuild_doc_search),
"clean_user_path_prefs": bool(clean_user_path_prefs),
"path_preferences": {
"requested": bool(clean_user_path_prefs),
"status": "not_applicable",
"reason": "Python packaging/import resolution replaces MATLAB user path preference cleanup.",
},
"download_example_data": mode,
"example_data": {
"data_dir": str(data_dir),
"is_installed": bool(info.is_installed or get_example_data_info(data_dir).is_installed),
"figshare_doi": FIGSHARE_DOI_URL,
"paper_doi": PAPER_DOI_URL,
"required_files": [str(path) for path in info.required_files],
},
"notes": [],
}
if rebuild_doc_search:
doc_search = _rebuild_doc_search(repo_root)
report["doc_search"] = doc_search
if doc_search["status"] == "rebuilt":
report["notes"].append("Rebuilt docs HTML search index.")
elif doc_search["status"] == "skipped":
report["notes"].append(f"Docs search rebuild skipped: {doc_search['reason']}")
else:
report["notes"].append("Docs search rebuild failed.")
else:
report["doc_search"] = {
"requested": False,
"status": "skipped",
"reason": "Disabled by caller.",
}
if clean_user_path_prefs:
report["notes"].append("Clean user path preferences is a MATLAB-only option and is ignored in Python.")
try:
if info.is_installed:
report["example_data"]["is_installed"] = True
report["example_data"]["data_dir"] = str(info.data_dir)
report["notes"].append("Example data already present.")
elif mode == "always":
path = ensure_example_data(download=True)
report["example_data"]["is_installed"] = True
report["example_data"]["data_dir"] = str(path)
report["notes"].append("Downloaded example data.")
elif mode == "prompt":
if _should_prompt_for_example_data(report["example_data"]):
path = ensure_example_data(download=True)
report["example_data"]["is_installed"] = True
report["example_data"]["data_dir"] = str(path)
report["notes"].append("Downloaded example data after prompt.")
else:
report["notes"].append("Example data not installed; run with download_example_data=True to install.")
else:
report["notes"].append("Example data not installed; run with download_example_data=True to install.")
except Exception as exc: # noqa: BLE001
report["example_data"]["error"] = str(exc)
report["notes"].append("Example data installation failed.")
return report
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--download-example-data",
default="prompt",
help="true/false or always/prompt/never",
)
parser.add_argument("--no-rebuild-doc-search", action="store_true")
parser.add_argument(
"--clean-user-path-prefs",
action="store_true",
help="MATLAB-compatibility no-op; Python does not maintain MATLAB-style user path preferences.",
)
args = parser.parse_args()
raw_mode: str | bool
mode_text = str(args.download_example_data).strip().lower()
if mode_text in {"true", "1", "yes", "on"}:
raw_mode = True
elif mode_text in {"false", "0", "no", "off"}:
raw_mode = False
else:
raw_mode = args.download_example_data
report = nstat_install(
rebuild_doc_search=not args.no_rebuild_doc_search,
clean_user_path_prefs=args.clean_user_path_prefs,
download_example_data=raw_mode,
)
print(json.dumps(report, indent=2))
example_data_failed = "error" in report.get("example_data", {})
doc_search_failed = report.get("doc_search", {}).get("status") == "failed"
return 0 if not (example_data_failed or doc_search_failed) else 1
if __name__ == "__main__":
raise SystemExit(main())