From dda2147841272afa17f090c0e4cf1545f5c2d377 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Sun, 20 Sep 2026 17:33:01 +0100 Subject: [PATCH 1/7] Add TinyTeX discovery across well-known install locations - Resolve existing installs from ~/.pytinytex, the official installer/R dir, and $XDG_DATA_HOME/TinyTeX - Keep explicit base / PYTINYTEX_TINYTEX authoritative - Report every tried path on failure --- pytinytex/__init__.py | 80 ++++++++++++++----- tests/test_tinytex_path_resolver.py | 116 +++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 21 deletions(-) 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/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]) From 994ae9e5e3a89ac3d0ff32f3b3956426414da81d Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Sun, 20 Sep 2026 17:33:07 +0100 Subject: [PATCH 2/7] Add --tinytex flag and download target override - --tinytex PATH sets PYTINYTEX_TINYTEX for every command - download installs to --tinytex / PYTINYTEX_TINYTEX when given --- pytinytex/cli.py | 12 ++++++++++++ tests/test_cli.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/pytinytex/cli.py b/pytinytex/cli.py index 3ccfefc..d2c2025 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 @@ -115,6 +121,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 @@ -188,9 +197,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=None if args.variation == "none" else int(args.variation), + **download_kwargs, ) print("Done.") 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 From 0cf9c6e45b1c698fd3a76906e29c6fb1ed55f197 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Sun, 20 Sep 2026 17:33:12 +0100 Subject: [PATCH 3/7] Document install and discovery behaviour - Add Installing TinyTeX section - Add Finding an existing TinyTeX section - Show --tinytex CLI example --- readme.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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: From 1a47def43b0e4800441737e291c61fda85c4aad9 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 14 Oct 2026 16:32:37 +0100 Subject: [PATCH 4/7] Fix uninstall ignoring --tinytex target --- pytinytex/cli.py | 15 +++++++++------ tests/test_cli.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/pytinytex/cli.py b/pytinytex/cli.py index d2c2025..2b7aeef 100644 --- a/pytinytex/cli.py +++ b/pytinytex/cli.py @@ -35,8 +35,8 @@ def main(argv=None): ) parser.add_argument( "--tinytex", - default=None, - help="Path to an existing TinyTeX install (equivalent to PYTINYTEX_TINYTEX environment variable)", + 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") @@ -115,13 +115,17 @@ 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: @@ -197,8 +201,7 @@ 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 {} + 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), @@ -207,7 +210,7 @@ def main(argv=None): 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/tests/test_cli.py b/tests/test_cli.py index 895a4a4..a56a1b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +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 @@ -62,3 +66,30 @@ def test_cli_help_documents_tinytex(capsys): 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").write_text("") + 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) From 7c3143b33ab4478b792351c8b8a04f0c5ba51deb Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Wed, 14 Oct 2026 16:36:13 +0100 Subject: [PATCH 5/7] Fix formatting/linter warnings --- tests/test_cli.py | 6 ++++-- tests/test_tinytex_path_resolver.py | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index a56a1b7..ea1df45 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,7 +57,9 @@ 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") + dl.assert_called_once_with( + version="latest", variation=1, target_folder="/tmp/opt/tinytext" + ) def test_cli_help_documents_tinytex(capsys): @@ -80,7 +82,7 @@ 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").write_text("") + (tinytex_install / "bin/tlmgr").touch() path = os.fspath(tinytex_install) rmtree = Mock() diff --git a/tests/test_tinytex_path_resolver.py b/tests/test_tinytex_path_resolver.py index ceeb1a9..b1cc99d 100644 --- a/tests/test_tinytex_path_resolver.py +++ b/tests/test_tinytex_path_resolver.py @@ -56,7 +56,9 @@ def test_get_pdf_latex_engine_deprecated(download_tinytex): # noqa 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")) + monkeypatch.setattr( + pytinytex, "_upstream_tinytex_dir", lambda: Path("/tmp/upstream") + ) assert pytinytex.candidate_tinytex_dirs() == [ pytinytex.DEFAULT_TARGET_FOLDER, Path("/tmp/upstream"), @@ -94,11 +96,9 @@ def test_ensure_discovers_existing(monkeypatch, tmp_path): found = tmp_path / "tinytex" found.mkdir() (found / "bin").mkdir() - (found / "bin/tlmgr").write_text("") + (found / "bin/tlmgr").touch() missing = tmp_path / "missing" - monkeypatch.setattr( - pytinytex, "candidate_tinytex_dirs", lambda: [missing, found] - ) + monkeypatch.setattr(pytinytex, "candidate_tinytex_dirs", lambda: [missing, found]) assert pytinytex.ensure_tinytex_installed() is True assert pytinytex.__tinytex_path == os.fspath(found / "bin") @@ -106,7 +106,9 @@ def test_ensure_discovers_existing(monkeypatch, tmp_path): 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)] + 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() @@ -119,7 +121,7 @@ def test_get_tinytex_path_explicit_beats_env(monkeypatch, tmp_path): explicit = tmp_path / "explicit" explicit.mkdir() (explicit / "bin").mkdir() - (explicit / "bin/tlmgr").write_text("") + (explicit / "bin/tlmgr").touch() monkeypatch.setenv("PYTINYTEX_TINYTEX", os.fspath(tmp_path / "inexistent")) assert pytinytex.get_tinytex_path(base=explicit) == os.fspath(explicit / "bin") @@ -147,7 +149,7 @@ def test_get_tinytex_path_discovers_default(monkeypatch, tmp_path, tlmgr_path): monkeypatch.delenv("PYTINYTEX_TINYTEX", raising=False) tlmgr = found / tlmgr_path.format(arch="fakearch") tlmgr.parent.mkdir(parents=True) - tlmgr.write_text("") + tlmgr.touch() assert pytinytex.get_tinytex_path() == os.fspath(tlmgr.parent) From 0e35384d828ee89f2e6c322fe98e233b63bdcedd Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 15 Oct 2026 12:34:39 +0100 Subject: [PATCH 6/7] Defer architecture mismatch until all candidates are tried --- pytinytex/__init__.py | 94 ++++++++++++++++++----------- tests/test_tinytex_path_resolver.py | 18 +++++- 2 files changed, 76 insertions(+), 36 deletions(-) diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index 5c45c72..70a7d0b 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -3,6 +3,8 @@ 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 @@ -37,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 @@ -147,11 +159,11 @@ def ensure_tinytex_installed(path=None): """ global __tinytex_path if path: - __tinytex_path = _find_resolved([path]) + __tinytex_path = _resolve_tinytex([path]) elif __tinytex_path: return True else: - __tinytex_path = _find_resolved(candidate_tinytex_dirs()) + __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 @@ -260,56 +272,70 @@ def _get_platform_arch(): return arch_map.get(machine, machine + "-linux") -def _attempt_resolve_path(path): - if not os.path.isdir(path): - return None # early exit - 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 _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))] + 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 _attempt_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 " - "been downloaded.\nYou can point at another installation " - "with --tinytex / PYTINYTEX_TINYTEX." - ) - return _attempt_resolve_path(os.path.join(path, entry)) - except FileNotFoundError: - pass - return None + 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 _find_resolved(candidates): +def _resolve_tinytex(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 + found, issues = _scan_candidates(candidates) + message = _render_issues(issues) tried = "\n".join(f" - {path}" for path in candidates) - raise RuntimeError( + 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() + ) def _find_file(dir, prefix): diff --git a/tests/test_tinytex_path_resolver.py b/tests/test_tinytex_path_resolver.py index b1cc99d..ba7b239 100644 --- a/tests/test_tinytex_path_resolver.py +++ b/tests/test_tinytex_path_resolver.py @@ -12,7 +12,7 @@ def test_failing_resolver(download_tinytex): # noqa with pytest.raises(RuntimeError, match="Unable to resolve TinyTeX path"): - pytinytex._find_resolved(["failing"]) + pytinytex._resolve_tinytex(["failing"]) with pytest.raises(RuntimeError, match="Unable to resolve TinyTeX path"): pytinytex.ensure_tinytex_installed("failing") @@ -160,4 +160,18 @@ def test_architecture_mismatch_raises(monkeypatch, tmp_path): d.mkdir() (d / "x86_64-linux").mkdir() with pytest.raises(RuntimeError, match="architecture mismatch"): - pytinytex._find_resolved([d]) + 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") From 13eef79d7fc1649b05865da414415ea045995e73 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 15 Oct 2026 13:09:36 +0100 Subject: [PATCH 7/7] Honour explicit base over cached TinyTeX path --- pytinytex/__init__.py | 9 ++------- tests/test_tinytex_path_resolver.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index 70a7d0b..351eae0 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -126,14 +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 - if base: - ensure_tinytex_installed(base) - elif os.getenv("PYTINYTEX_TINYTEX"): - ensure_tinytex_installed(os.environ["PYTINYTEX_TINYTEX"]) - else: - ensure_tinytex_installed() + ensure_tinytex_installed(base or os.getenv("PYTINYTEX_TINYTEX")) return __tinytex_path diff --git a/tests/test_tinytex_path_resolver.py b/tests/test_tinytex_path_resolver.py index ba7b239..fe1e221 100644 --- a/tests/test_tinytex_path_resolver.py +++ b/tests/test_tinytex_path_resolver.py @@ -126,6 +126,22 @@ def test_get_tinytex_path_explicit_beats_env(monkeypatch, tmp_path): 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"):