Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 104 additions & 39 deletions pytinytex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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


Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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()
)


Expand Down
19 changes: 17 additions & 2 deletions pytinytex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import logging
import os
import sys

logger = logging.getLogger("pytinytex")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
Loading
Loading