diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index c121122..351eae0 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -3,6 +3,9 @@ import platform import sys import warnings +from collections import deque +from contextlib import suppress +from pathlib import Path from importlib.metadata import version as _metadata_version, PackageNotFoundError try: @@ -36,6 +39,16 @@ # Known LaTeX engines included in TinyTeX _LATEX_ENGINES = ["pdflatex", "xelatex", "lualatex", "latex"] +# Known TeX Live platform-architecture directory names +_KNOWN_ARCHS = { + "x86_64-linux", + "aarch64-linux", + "i386-linux", + "universal-darwin", + "x86_64-darwinlegacy", + "windows", +} + # Global cache __tinytex_path = None @@ -74,6 +87,33 @@ # --- Path resolution --- +_HOME = Path.home() + + +def _xdg_data_home(): + return Path(os.getenv("XDG_DATA_HOME", _HOME / ".local/share")) + + +def _upstream_tinytex_dir(): + """Return the default TinyTeX install directory used by the official + installer and by R's ``tinytex`` package (``install_tinytex``).""" + if sys.platform == "win32": + return Path(os.getenv("APPDATA", _HOME / "AppData/Roaming")) / "TinyTeX" + if sys.platform == "darwin": + return _HOME / "Library/TinyTeX" + return _HOME / ".TinyTeX" + + +def candidate_tinytex_dirs(): + """Return TinyTeX install directories to search, in priority order.""" + return [ + DEFAULT_TARGET_FOLDER, + _upstream_tinytex_dir(), + # The XDG Base Directory Specification gives users a standard place to + # keep an install; some move it there voluntarily. + _xdg_data_home() / "TinyTeX", + ] + def get_tinytex_path(base=None): """Return the resolved path to the TinyTeX bin directory. @@ -86,15 +126,9 @@ def get_tinytex_path(base=None): Raises: RuntimeError: If TinyTeX is not found. """ - if __tinytex_path: + if not base and __tinytex_path: return __tinytex_path - path_to_resolve = DEFAULT_TARGET_FOLDER - if base: - path_to_resolve = base - if os.environ.get("PYTINYTEX_TINYTEX"): - path_to_resolve = os.environ["PYTINYTEX_TINYTEX"] - - ensure_tinytex_installed(path_to_resolve) + ensure_tinytex_installed(base or os.getenv("PYTINYTEX_TINYTEX")) return __tinytex_path @@ -109,7 +143,7 @@ def ensure_tinytex_installed(path=None): Args: path: Path to check for TinyTeX. Defaults to the cached path or - DEFAULT_TARGET_FOLDER. + automatic discovery of an existing install. Returns: True if TinyTeX is installed. @@ -119,9 +153,12 @@ def ensure_tinytex_installed(path=None): ``download_tinytex()`` to install it first. """ global __tinytex_path - if not path: - path = __tinytex_path or DEFAULT_TARGET_FOLDER - __tinytex_path = _resolve_path(path) + if path: + __tinytex_path = _resolve_tinytex([path]) + elif __tinytex_path: + return True + else: + __tinytex_path = _resolve_tinytex(candidate_tinytex_dirs()) # Ensure the resolved bin directory is on PATH for this process _add_to_path(__tinytex_path) return True @@ -230,41 +267,69 @@ def _get_platform_arch(): return arch_map.get(machine, machine + "-linux") -def _resolve_path(path): - try: +_ARCH_MISMATCH = ( + "TinyTeX architecture mismatch. The wrong binary may have " + "been downloaded.\n{0}\nYou can point at another installation " + "with --tinytex / PYTINYTEX_TINYTEX." +) + + +def _scan_candidates(paths): + issues = {} + pending = deque(paths) + while pending: + path = pending.popleft() + if not os.path.isdir(path): + continue # early exit if _find_file(path, "tlmgr"): - return path + return path, issues if os.path.isdir(os.path.join(path, "bin")): - return _resolve_path(os.path.join(path, "bin")) - entries = [e for e in os.listdir(path) if os.path.isdir(os.path.join(path, e))] + pending.appendleft(os.path.join(path, "bin")) + continue + entries = [] + with suppress(FileNotFoundError): + entries = [ + e for e in os.listdir(path) if os.path.isdir(os.path.join(path, e)) + ] # Prefer the directory matching the current platform architecture expected_arch = _get_platform_arch() if expected_arch in entries: - return _resolve_path(os.path.join(path, expected_arch)) + pending.appendleft(os.path.join(path, expected_arch)) + continue # Only fall back to a single entry if it's not an architecture mismatch - _known_archs = { - "x86_64-linux", - "aarch64-linux", - "i386-linux", - "universal-darwin", - "x86_64-darwinlegacy", - "windows", - } if len(entries) == 1: entry = entries[0] - if entry in _known_archs and entry != expected_arch: - raise RuntimeError( - f"TinyTeX architecture mismatch: found '{entry}' but " - f"expected '{expected_arch}'. The wrong binary may have " - f"been downloaded." - ) - return _resolve_path(os.path.join(path, entry)) - except FileNotFoundError: - pass - raise RuntimeError( - f"Unable to resolve TinyTeX path.\n" - f"Tried {path}.\n" - f"You can install TinyTeX using pytinytex.download_tinytex()" + if entry in _KNOWN_ARCHS and entry != expected_arch: + message = f"found '{entry}' but expected '{expected_arch}' at {path}" + issues.setdefault(_ARCH_MISMATCH, []).append(message) + continue + pending.appendleft(os.path.join(path, entry)) + return None, issues + + +def _resolve_tinytex(candidates): + """Return the first resolvable TinyTeX path among candidates, else raise.""" + assert len(candidates) > 0 # sanity check, removed by -O + found, issues = _scan_candidates(candidates) + message = _render_issues(issues) + tried = "\n".join(f" - {path}" for path in candidates) + generic = ( + "Unable to resolve TinyTeX path.\n" + f"Tried:\n{tried}\n" + "You can install TinyTeX using pytinytex.download_tinytex(), " + "or point at an existing install with --tinytex / PYTINYTEX_TINYTEX." + ) + if found and message: + logger.warning(message) + if not found: + raise RuntimeError(message or generic) + return found + + +def _render_issues(issues): + return "\n\n".join( + category.format("\n".join(f" - {detail}" for detail in details)) + for category, details in issues.items() ) diff --git a/pytinytex/cli.py b/pytinytex/cli.py index 3ccfefc..2b7aeef 100644 --- a/pytinytex/cli.py +++ b/pytinytex/cli.py @@ -4,6 +4,7 @@ import argparse import logging +import os import sys logger = logging.getLogger("pytinytex") @@ -32,6 +33,11 @@ def main(argv=None): prog="pytinytex", description="Manage TinyTeX installations and compile LaTeX documents.", ) + parser.add_argument( + "--tinytex", + default=os.environ.get("PYTINYTEX_TINYTEX") or None, + help="Path to an existing TinyTeX install (defaults to PYTINYTEX_TINYTEX environment variable)", + ) sub = parser.add_subparsers(dest="command") # compile @@ -109,12 +115,19 @@ def main(argv=None): # uninstall p_uninstall = sub.add_parser("uninstall", help="Remove TinyTeX installation") p_uninstall.add_argument( - "--path", default=None, help="Path to TinyTeX installation to remove" + "--path", + default=None, + help="Path to TinyTeX installation to remove (defers by default to --tinytex or PYTINYTEX_TINYTEX)", ) args = parser.parse_args(argv) _setup_logging() + if args.tinytex: + # Mirror --tinytex into PYTINYTEX_TINYTEX so library code that only + # reads the environment still honours the flag. + os.environ["PYTINYTEX_TINYTEX"] = args.tinytex + if not args.command: parser.print_help() return 1 @@ -188,14 +201,16 @@ def main(argv=None): return 1 elif args.command == "download": + download_kwargs = {"target_folder": args.tinytex} if args.tinytex else {} pytinytex.download_tinytex( version=getattr(args, "version", "latest"), variation=None if args.variation == "none" else int(args.variation), + **download_kwargs, ) print("Done.") elif args.command == "uninstall": - pytinytex.uninstall(args.path) + pytinytex.uninstall(args.path or args.tinytex) print("TinyTeX uninstalled.") except RuntimeError as e: diff --git a/readme.md b/readme.md index 9b54002..ef1ca72 100644 --- a/readme.md +++ b/readme.md @@ -172,6 +172,7 @@ pytinytex download --variation none # Compile a document pytinytex compile paper.tex +pytinytex --tinytex /path/to/TinyTeX compile paper.tex pytinytex compile paper.tex --engine xelatex --runs 2 --auto-install # Package management @@ -190,6 +191,27 @@ pytinytex version python -m pytinytex doctor ``` +## Installing TinyTeX + +`pytinytex download` installs to `~/.pytinytex` by default. Pass `--tinytex` +(or set `PYTINYTEX_TINYTEX`) to install elsewhere. + +``` +pytinytex download +pytinytex --tinytex /path/to/TinyTeX download +``` + +## Finding an existing TinyTeX + +When no explicit path (`--tinytex` or `PYTINYTEX_TINYTEX`) is given, commands +discover an existing install in this order: + +1. `~/.pytinytex` (the PyTinyTeX default) +2. The default TinyTeX location used by the official installer and R's + `tinytex` package (macOS `~/Library/TinyTeX`, Windows `%APPDATA%/TinyTeX`, + Linux `~/.TinyTeX`) +3. `$XDG_DATA_HOME/TinyTeX` (default `~/.local/share/TinyTeX`) + ## Integrating with pypandoc PyTinyTeX pairs naturally with [pypandoc](https://pypi.org/project/pypandoc/) for converting Markdown, HTML, and other formats to PDF: diff --git a/tests/test_cli.py b/tests/test_cli.py index cbaa4ad..ea1df45 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,12 @@ """Tests for the CLI module.""" +import os +import shutil +from unittest import mock +from unittest.mock import Mock + +import pytest +import pytinytex from pytinytex.cli import main @@ -22,3 +29,69 @@ def test_cli_help_flag(capsys): main(["--help"]) except SystemExit: pass # argparse calls sys.exit(0) on --help + + +def test_cli_tinytex_sets_env(monkeypatch): + captured = {} + + def fake_version(): + captured["env"] = os.environ.get("PYTINYTEX_TINYTEX") + return "tlmgr revision 12345" + + monkeypatch.setattr("pytinytex.get_version", fake_version) + result = main(["--tinytex", "/tmp/opt/tinytext", "version"]) + assert result == 0 + assert captured["env"] == "/tmp/opt/tinytext" + + +def test_cli_download_default_target(monkeypatch): + monkeypatch.delenv("PYTINYTEX_TINYTEX", raising=False) + with mock.patch("pytinytex.download_tinytex") as dl: + result = main(["download"]) + assert result == 0 + dl.assert_called_once() + assert "target_folder" not in dl.call_args.kwargs + + +def test_cli_download_honours_tinytex(monkeypatch): + with mock.patch("pytinytex.download_tinytex") as dl: + result = main(["--tinytex", "/tmp/opt/tinytext", "download"]) + assert result == 0 + dl.assert_called_once_with( + version="latest", variation=1, target_folder="/tmp/opt/tinytext" + ) + + +def test_cli_help_documents_tinytex(capsys): + try: + main(["--help"]) + except SystemExit: + pass + assert "--tinytex" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "source", + [ + lambda path: (["--tinytex", path], {}), + lambda path: ([], {"PYTINYTEX_TINYTEX": path}), + ], + ids=["flag", "env"], +) +def test_cli_uninstall_honours_tinytex(monkeypatch, tmp_path, source): + pytinytex.clear_path_cache() + tinytex_install = tmp_path / "tinytex" + (tinytex_install / "bin").mkdir(parents=True) + (tinytex_install / "bin/tlmgr").touch() + path = os.fspath(tinytex_install) + + rmtree = Mock() + monkeypatch.setattr(shutil, "rmtree", rmtree) + cli_args, env = source(path) + for name, value in env.items(): + monkeypatch.setenv(name, value) + + result = main(cli_args + ["uninstall"]) + + assert result == 0 + rmtree.assert_called_once_with(tinytex_install) diff --git a/tests/test_tinytex_path_resolver.py b/tests/test_tinytex_path_resolver.py index 803205c..fe1e221 100644 --- a/tests/test_tinytex_path_resolver.py +++ b/tests/test_tinytex_path_resolver.py @@ -1,5 +1,8 @@ import os +import random +import string import warnings +from pathlib import Path import pytest @@ -8,9 +11,9 @@ def test_failing_resolver(download_tinytex): # noqa - with pytest.raises(RuntimeError): - pytinytex._resolve_path("failing") - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="Unable to resolve TinyTeX path"): + pytinytex._resolve_tinytex(["failing"]) + with pytest.raises(RuntimeError, match="Unable to resolve TinyTeX path"): pytinytex.ensure_tinytex_installed("failing") @@ -49,3 +52,142 @@ def test_get_pdf_latex_engine_deprecated(download_tinytex): # noqa assert issubclass(w[0].category, DeprecationWarning) assert "deprecated" in str(w[0].message).lower() assert result == pytinytex.get_pdflatex_engine() + + +def test_candidate_tinytex_dirs_order(monkeypatch): + monkeypatch.setattr(pytinytex, "_xdg_data_home", lambda: Path("/tmp/xdg")) + monkeypatch.setattr( + pytinytex, "_upstream_tinytex_dir", lambda: Path("/tmp/upstream") + ) + assert pytinytex.candidate_tinytex_dirs() == [ + pytinytex.DEFAULT_TARGET_FOLDER, + Path("/tmp/upstream"), + Path("/tmp/xdg/TinyTeX"), + ] + + +def test_xdg_data_home_env(monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", "/tmp/custom/data") + assert pytinytex._xdg_data_home() == Path("/tmp/custom/data") + + +def test_xdg_data_home_default(monkeypatch): + fake_home = Path("/tmp/fake-home") + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setattr(pytinytex, "_HOME", fake_home) + assert pytinytex._xdg_data_home() == fake_home / ".local/share" + + +def test_upstream_tinytex_dir(monkeypatch): + fake_home = Path("/tmp/fake-home") + fake_appdata = Path("/tmp/fake-appdata/AppData/Roaming") + monkeypatch.setattr(pytinytex, "_HOME", fake_home) + monkeypatch.setattr("sys.platform", "darwin", raising=False) + assert pytinytex._upstream_tinytex_dir() == fake_home / "Library/TinyTeX" + monkeypatch.setattr("sys.platform", "linux", raising=False) + assert pytinytex._upstream_tinytex_dir() == fake_home / ".TinyTeX" + monkeypatch.setattr("sys.platform", "win32", raising=False) + monkeypatch.setenv("APPDATA", os.fspath(fake_appdata)) + assert pytinytex._upstream_tinytex_dir() == fake_appdata / "TinyTeX" + + +def test_ensure_discovers_existing(monkeypatch, tmp_path): + pytinytex.clear_path_cache() + found = tmp_path / "tinytex" + found.mkdir() + (found / "bin").mkdir() + (found / "bin/tlmgr").touch() + missing = tmp_path / "missing" + monkeypatch.setattr(pytinytex, "candidate_tinytex_dirs", lambda: [missing, found]) + assert pytinytex.ensure_tinytex_installed() is True + assert pytinytex.__tinytex_path == os.fspath(found / "bin") + + +def test_ensure_failure_lists_path(monkeypatch, tmp_path): + pytinytex.clear_path_cache() + rng = random.Random(0) + dirs = [ + tmp_path / "".join(rng.choices(string.ascii_lowercase, k=5)) for _ in range(10) + ] + monkeypatch.setattr(pytinytex, "candidate_tinytex_dirs", lambda: dirs) + with pytest.raises(RuntimeError, match="Unable to resolve TinyTeX path") as exc: + pytinytex.ensure_tinytex_installed() + for d in dirs: + assert os.fspath(d) in str(exc.value) + + +def test_get_tinytex_path_explicit_beats_env(monkeypatch, tmp_path): + pytinytex.clear_path_cache() + explicit = tmp_path / "explicit" + explicit.mkdir() + (explicit / "bin").mkdir() + (explicit / "bin/tlmgr").touch() + monkeypatch.setenv("PYTINYTEX_TINYTEX", os.fspath(tmp_path / "inexistent")) + assert pytinytex.get_tinytex_path(base=explicit) == os.fspath(explicit / "bin") + + +def test_get_tinytex_path_explicit_beats_cache(tmp_path): + cached = tmp_path / "cached" + cached.mkdir() + (cached / "bin").mkdir() + (cached / "bin/tlmgr").touch() + explicit = tmp_path / "explicit" + explicit.mkdir() + (explicit / "bin").mkdir() + (explicit / "bin/tlmgr").touch() + # populate the cache from the first install + assert pytinytex.get_tinytex_path(base=cached) == os.fspath(cached / "bin") + assert pytinytex.__tinytex_path == os.fspath(cached / "bin") + # a later explicit base must beat the stale cache + assert pytinytex.get_tinytex_path(base=explicit) == os.fspath(explicit / "bin") + + +def test_get_tinytex_path_inexistent_explicit_raises(monkeypatch, tmp_path): + pytinytex.clear_path_cache() + with pytest.raises(RuntimeError, match="Unable to resolve TinyTeX path"): + pytinytex.get_tinytex_path(base=os.fspath(tmp_path / "inexistent")) + + +@pytest.mark.parametrize( + "tlmgr_path", + [ + "bin/tlmgr", + "bin/{arch}/tlmgr", + "nested/bin/tlmgr", + "nested/bin/{arch}/tlmgr", + ], +) +def test_get_tinytex_path_discovers_default(monkeypatch, tmp_path, tlmgr_path): + pytinytex.clear_path_cache() + found = tmp_path / "tinytex" + monkeypatch.setattr(pytinytex, "_get_platform_arch", lambda: "fakearch") + monkeypatch.setattr(pytinytex, "candidate_tinytex_dirs", lambda: [found]) + monkeypatch.delenv("PYTINYTEX_TINYTEX", raising=False) + tlmgr = found / tlmgr_path.format(arch="fakearch") + tlmgr.parent.mkdir(parents=True) + tlmgr.touch() + assert pytinytex.get_tinytex_path() == os.fspath(tlmgr.parent) + + +def test_architecture_mismatch_raises(monkeypatch, tmp_path): + pytinytex.clear_path_cache() + monkeypatch.setattr(pytinytex, "_get_platform_arch", lambda: "universal-darwin") + d = tmp_path / "wrong" + d.mkdir() + (d / "x86_64-linux").mkdir() + with pytest.raises(RuntimeError, match="architecture mismatch"): + pytinytex._resolve_tinytex([d]) + + +def test_auto_discovery_skips_wrong_arch(monkeypatch, tmp_path): + pytinytex.clear_path_cache() + wrong = tmp_path / "wrong" + good = tmp_path / "good" + (wrong / "x86_64-linux").mkdir(parents=True) + (good / "bin").mkdir(parents=True) + (good / "bin/tlmgr").touch() + monkeypatch.setattr(pytinytex, "_get_platform_arch", lambda: "aarch64-linux") + monkeypatch.setattr(pytinytex, "candidate_tinytex_dirs", lambda: [wrong, good]) + + assert pytinytex.ensure_tinytex_installed() is True + assert pytinytex.__tinytex_path == os.fspath(good / "bin")