Skip to content
Open
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
80 changes: 62 additions & 18 deletions pytinytex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import platform
import sys
import warnings
from pathlib import Path
from importlib.metadata import version as _metadata_version, PackageNotFoundError

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


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


Expand Down
12 changes: 12 additions & 0 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=None,
help="Path to an existing TinyTeX install (equivalent to PYTINYTEX_TINYTEX environment variable)",
)
sub = parser.add_subparsers(dest="command")

# compile
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")

Expand Down
22 changes: 22 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Tests for the CLI module."""

import os
from unittest import mock

from pytinytex.cli import main


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