diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index c121122..5c45c72 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -3,6 +3,7 @@ import platform import sys import warnings +from pathlib import Path from importlib.metadata import version as _metadata_version, PackageNotFoundError try: @@ -74,6 +75,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. @@ -88,13 +116,12 @@ def get_tinytex_path(base=None): """ if __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) + elif os.getenv("PYTINYTEX_TINYTEX"): + ensure_tinytex_installed(os.environ["PYTINYTEX_TINYTEX"]) + else: + ensure_tinytex_installed() return __tinytex_path @@ -109,7 +136,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 +146,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 = _find_resolved([path]) + elif __tinytex_path: + return True + else: + __tinytex_path = _find_resolved(candidate_tinytex_dirs()) # Ensure the resolved bin directory is on PATH for this process _add_to_path(__tinytex_path) return True @@ -230,17 +260,19 @@ def _get_platform_arch(): return arch_map.get(machine, machine + "-linux") -def _resolve_path(path): +def _attempt_resolve_path(path): + if not os.path.isdir(path): + return None # early exit try: if _find_file(path, "tlmgr"): return path if os.path.isdir(os.path.join(path, "bin")): - return _resolve_path(os.path.join(path, "bin")) + return _attempt_resolve_path(os.path.join(path, "bin")) 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)) + return _attempt_resolve_path(os.path.join(path, expected_arch)) # Only fall back to a single entry if it's not an architecture mismatch _known_archs = { "x86_64-linux", @@ -256,15 +288,27 @@ def _resolve_path(path): raise RuntimeError( f"TinyTeX architecture mismatch: found '{entry}' but " f"expected '{expected_arch}'. The wrong binary may have " - f"been downloaded." + "been downloaded.\nYou can point at another installation " + "with --tinytex / PYTINYTEX_TINYTEX." ) - return _resolve_path(os.path.join(path, entry)) + return _attempt_resolve_path(os.path.join(path, entry)) except FileNotFoundError: pass + return None + + +def _find_resolved(candidates): + """Return the first resolvable TinyTeX path among candidates, else raise.""" + assert len(candidates) > 0 # sanity check, removed by -O + found = next(filter(None, map(_attempt_resolve_path, candidates)), None) + if found: + return found + tried = "\n".join(f" - {path}" for path in candidates) raise RuntimeError( - f"Unable to resolve TinyTeX path.\n" - f"Tried {path}.\n" - f"You can install TinyTeX using pytinytex.download_tinytex()" + "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." ) diff --git a/pytinytex/cli.py b/pytinytex/cli.py index 41bfbf8..d3e60bb 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=None, + help="Path to an existing TinyTeX install (equivalent to PYTINYTEX_TINYTEX environment variable)", + ) sub = parser.add_subparsers(dest="command") # compile @@ -114,6 +120,9 @@ def main(argv=None): args = parser.parse_args(argv) _setup_logging() + if args.tinytex: + os.environ["PYTINYTEX_TINYTEX"] = args.tinytex + if not args.command: parser.print_help() return 1 @@ -187,9 +196,12 @@ def main(argv=None): return 1 elif args.command == "download": + target = args.tinytex or os.getenv("PYTINYTEX_TINYTEX") + download_kwargs = {"target_folder": target} if target else {} pytinytex.download_tinytex( version=getattr(args, "version", "latest"), variation=args.variation, + **download_kwargs, ) print("Done.") diff --git a/readme.md b/readme.md index a7cb099..49dcab9 100644 --- a/readme.md +++ b/readme.md @@ -171,6 +171,7 @@ pytinytex download --variation 2 # 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 @@ -189,6 +190,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..895a4a4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,8 @@ """Tests for the CLI module.""" +import os +from unittest import mock + from pytinytex.cli import main @@ -22,3 +25,40 @@ 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 diff --git a/tests/test_tinytex_path_resolver.py b/tests/test_tinytex_path_resolver.py index 803205c..ceeb1a9 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._find_resolved(["failing"]) + with pytest.raises(RuntimeError, match="Unable to resolve TinyTeX path"): pytinytex.ensure_tinytex_installed("failing") @@ -49,3 +52,110 @@ 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").write_text("") + 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").write_text("") + 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_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.write_text("") + 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._find_resolved([d])