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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
*.zip
*.tar.gz
*.tgz
*.tar.xz
*.exe
tinytex/*


Expand Down
13 changes: 7 additions & 6 deletions pytinytex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,14 @@ def main(argv=None):
p_download = sub.add_parser("download", help="Download TinyTeX")
p_download.add_argument(
"--variation",
type=int,
default=1,
choices=[0, 1, 2],
help="TinyTeX variation (default: 1)",
default="1",
choices=["0", "1", "2", "none"],
help="TinyTeX variation: 0, 1, 2 or none (default: 1)",
)
p_download.add_argument(
"--version", default="latest", help="TinyTeX version (default: latest)"
"--version",
default="latest",
help="TinyTeX version: latest, daily or year.month (default: latest)",
)

# uninstall
Expand Down Expand Up @@ -189,7 +190,7 @@ def main(argv=None):
elif args.command == "download":
pytinytex.download_tinytex(
version=getattr(args, "version", "latest"),
variation=args.variation,
variation=None if args.variation == "none" else int(args.variation),
)
print("Done.")

Expand Down
126 changes: 87 additions & 39 deletions pytinytex/tinytex_download.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import glob
import logging
import platform
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
Expand All @@ -20,6 +22,11 @@ def _is_arm64():
return platform.machine().lower() in ("aarch64", "arm64")


def _is_musl():
"""Return True if running on a musl-based Linux (e.g. Alpine)."""
return bool(glob.glob("/lib/ld-musl-*.so.1"))


def _default_progress(downloaded, total):
"""Print download progress on a TTY."""
if total > 0:
Expand All @@ -41,24 +48,30 @@ def download_tinytex(
download_folder=None,
progress_callback=None,
):
if variation not in [0, 1, 2]:
if variation not in [None, 0, 1, 2]:
raise RuntimeError(
"Invalid TinyTeX variation {}. Valid variations are 0, 1, 2.".format(
"Invalid TinyTeX variation {}. Valid variations are None, 0, 1, 2.".format(
variation
)
)
if re.match(r"\d{4}\.\d{2}", version) or version == "latest":
if version != "latest":
version = "v" + version
else:
if version == "latest" and variation == 2:
logger.info("TinyTeX-2 is only published in the daily release, using it.")
version = "daily"
if re.match(r"\d{4}\.\d{2}", version):
if variation == 2:
raise RuntimeError(
"TinyTeX variation 2 is only available with version='daily'."
)
version = "v" + version
elif version not in ("latest", "daily"):
raise RuntimeError(
"Invalid TinyTeX version {}. TinyTeX version has to be in the format "
"'latest' for the latest available version, or year.month, for example: "
"'2024.12', '2024.09' for a specific version.".format(version)
"'latest' for the latest available version, 'daily' for the daily "
"build, or year.month, for example: '2024.12', '2024.09' for a "
"specific version.".format(version)
)
if progress_callback is None and sys.stdout.isatty():
progress_callback = _default_progress
variation = str(variation)
pf = sys.platform
if pf.startswith("linux"):
pf = "linux"
Expand Down Expand Up @@ -103,18 +116,25 @@ def download_tinytex(
logger.info("Extracting %s to a temporary folder...", filename)
with tempfile.TemporaryDirectory() as tmpdirname:
tmpdirname = Path(tmpdirname)
extracted_dir_name = "TinyTeX" # for Windows and macOS
if filename.suffix == ".zip":
with zipfile.ZipFile(filename) as zf:
zf.extractall(tmpdirname)
elif filename.suffix in (".tgz", ".gz"):
with tarfile.open(filename, "r:gz") as tf:
elif filename.suffix == ".exe":
# 7-Zip self-extracting archive (Windows)
subprocess.run(
[str(filename), "-y", "-o" + str(tmpdirname)],
check=True,
capture_output=True,
)
elif filename.suffix in (".tgz", ".gz", ".xz"):
with tarfile.open(filename, "r:*") as tf:
tf.extractall(tmpdirname)
if filename.suffix == ".gz":
extracted_dir_name = ".TinyTeX" # linux only
else:
raise RuntimeError("File {0} not supported".format(filename))
tinytex_extracted = tmpdirname / extracted_dir_name
# archives contain a single "TinyTeX" (or ".TinyTeX" on Linux) folder
tinytex_extracted = next(
p for p in tmpdirname.iterdir() if p.name.lstrip(".") == "TinyTeX"
)
logger.info("Copying TinyTeX to %s...", target_folder)
shutil.copytree(tinytex_extracted, target_folder, dirs_exist_ok=True)
# Resolve the path and add to PATH so everything is ready to use
Expand Down Expand Up @@ -142,30 +162,58 @@ def _get_tinytex_urls(version, variation):
)
content = response.read()
regex = re.compile(
r"/rstudio/tinytex-releases/releases/download/.*TinyTeX\-.*.(?:tar\.gz|tgz|zip)"
r"/rstudio/tinytex-releases/releases/download/[^\"]*TinyTeX[^\"]*"
r"\.(?:tar\.gz|tar\.xz|tgz|zip|exe)"
)
tinytex_urls_list = regex.findall(content.decode("utf-8"))
ext2platform = {"zip": "win32", ".gz": "linux", "tgz": "darwin"}
if variation in ("0", "1"):
variation_txt = "TinyTeX-{}-".format(variation)
else:
variation_txt = "TinyTeX-v"
tinytex_urls_list = {
url_frag for url_frag in tinytex_urls_list if variation_txt in url_frag
}
# Filter Linux tar.gz URLs by architecture: TinyTeX releases include
# separate arm64 tarballs (e.g. "TinyTeX-0-arm64-v2026.03.02.tar.gz")
# alongside x86_64 ones. Both end in .tar.gz and would map to the same
# "linux" dict key. Only apply this filter to .tar.gz (Linux) URLs —
# macOS .tgz and Windows .zip are unaffected.
arm64 = _is_arm64()
tinytex_urls_list = {
url_frag
for url_frag in tinytex_urls_list
if not url_frag.endswith(".tar.gz") or arm64 == ("arm64" in url_frag)
}
tinytex_urls = {
ext2platform[url_frag[-3:]]: ("https://github.com" + url_frag)
for url_frag in tinytex_urls_list
}
tinytex_urls = _select_tinytex_urls(
tinytex_urls_list, variation, _is_arm64(), _is_musl()
)
return tinytex_urls, version


_ASSET_RE = re.compile(
r"^TinyTeX(?:-([012]))?"
r"(?:-(darwin|linux-x86_64|linux-arm64|linuxmusl-x86_64|windows))?"
r"(-arm64)?(?:-v[\d.]+)?\.(tar\.xz|exe|tar\.gz|tgz|zip)$"
)


def _select_tinytex_urls(asset_paths, variation, arm64, musl=False):
"""Map release asset paths to {sys.platform: url} for the given variation.

Handles both the naming used up to v2026.03.02
(``TinyTeX-1[-arm64]-v2026.03.tar.gz`` / ``.tgz`` / ``.zip``) and the
naming used since (``TinyTeX-1-linux-arm64-v2026.04.tar.xz`` /
``TinyTeX-1-windows-v2026.04.exe``). Legacy archives win when both exist,
except on musl where the dedicated musl build (new naming only) is used.
"""
new_style, old_style = {}, {}
for path in asset_paths:
m = _ASSET_RE.match(path.split("/")[-1])
if not m:
continue
num, os_name, arm_suffix, ext = m.groups()
if (int(num) if num else None) != variation:
continue
url = "https://github.com" + path
if os_name == "darwin":
new_style["darwin"] = url
elif os_name == "windows":
new_style["win32"] = url
elif os_name == "linux-arm64" and arm64:
new_style["linux"] = url
elif os_name == "linux-x86_64" and not arm64 and not musl:
new_style["linux"] = url
elif os_name == "linuxmusl-x86_64" and not arm64 and musl:
new_style["linux"] = url
elif os_name is None:
if ext == "zip":
old_style["win32"] = url
elif ext == "tgz":
old_style["darwin"] = url
elif ext == "tar.gz" and arm64 == bool(arm_suffix):
old_style["linux"] = url
if musl and "linux" in new_style:
old_style.pop("linux", None) # legacy tarballs are glibc-only
return {**new_style, **old_style}
13 changes: 7 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ import pytinytex
# Download the default variation (variation 1: ~90 common LaTeX packages)
pytinytex.download_tinytex()

# Or pick a variation:
# 0 — infrastructure only, no packages
# 1 — common packages (default)
# 2 — extended package set
pytinytex.download_tinytex(variation=2)
# Or pick a variation (numbers match the TinyTeX release names):
# 0 — TinyTeX-0: infrastructure only, no packages
# 1 — TinyTeX-1: common packages (default)
# None — TinyTeX: community-requested packages
# 2 — TinyTeX-2: full TeX Live (~1.7 GB, daily release only)
pytinytex.download_tinytex(variation=None)

# Track download progress
pytinytex.download_tinytex(progress_callback=lambda downloaded, total: print(f"{downloaded}/{total} bytes"))
Expand Down Expand Up @@ -167,7 +168,7 @@ Every feature is also available from the terminal:
```bash
# Download TinyTeX
pytinytex download
pytinytex download --variation 2
pytinytex download --variation none

# Compile a document
pytinytex compile paper.tex
Expand Down
133 changes: 133 additions & 0 deletions tests/test_tinytex_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,136 @@ def test_failing_download_invalid_variation():
def test_failing_download_invalid_version():
with pytest.raises(RuntimeError, match="Invalid TinyTeX version invalid."):
pytinytex.download_tinytex(version="invalid")


# Asset names copied from real releases of rstudio/tinytex-releases.
_ASSETS_2026_08 = [
"/rstudio/tinytex-releases/releases/download/v2026.08/" + n
for n in (
"TinyTeX-0-darwin-v2026.08.tar.xz",
"TinyTeX-0-linux-arm64-v2026.08.tar.xz",
"TinyTeX-0-linux-x86_64-v2026.08.tar.xz",
"TinyTeX-0-linuxmusl-x86_64-v2026.08.tar.xz",
"TinyTeX-0-windows-v2026.08.exe",
"TinyTeX-1-darwin-v2026.08.tar.xz",
"TinyTeX-1-linux-arm64-v2026.08.tar.xz",
"TinyTeX-1-linux-x86_64-v2026.08.tar.xz",
"TinyTeX-1-linuxmusl-x86_64-v2026.08.tar.xz",
"TinyTeX-1-tar-v2026.08.gz",
"TinyTeX-1-windows-v2026.08.exe",
"TinyTeX-darwin-v2026.08.tar.xz",
"TinyTeX-linux-arm64-v2026.08.tar.xz",
"TinyTeX-linux-x86_64-v2026.08.tar.xz",
"TinyTeX-linuxmusl-x86_64-v2026.08.tar.xz",
"TinyTeX-v2026.08.tar.gz",
"TinyTeX-v2026.08.tgz",
"TinyTeX-v2026.08.zip",
"TinyTeX-windows-v2026.08.exe",
"installer-unix-v2026.08.tar.gz",
)
]
_ASSETS_DAILY = [
"/rstudio/tinytex-releases/releases/download/daily/" + n
for n in (
"TinyTeX-2-darwin.tar.xz",
"TinyTeX-2-linux-arm64.tar.xz",
"TinyTeX-2-linux-x86_64.tar.xz",
"TinyTeX-2-linuxmusl-x86_64.tar.xz",
"TinyTeX-2-windows.exe",
"TinyTeX-linux-x86_64.tar.xz",
"TinyTeX.tar.gz",
"TinyTeX.tgz",
"TinyTeX.zip",
)
]
_ASSETS_2026_03 = [
"/rstudio/tinytex-releases/releases/download/v2026.03.02/" + n
for n in (
"TinyTeX-0-arm64-v2026.03.02.tar.gz",
"TinyTeX-0-v2026.03.02.tar.gz",
"TinyTeX-0-v2026.03.02.tgz",
"TinyTeX-0-v2026.03.02.zip",
"TinyTeX-arm64-v2026.03.02.tar.gz",
"TinyTeX-v2026.03.02.tar.gz",
"tinitex.zip",
)
]


def _names(urls):
return {k: v.rsplit("/", 1)[1] for k, v in urls.items()}


def test_select_urls_new_naming():
sel = pytinytex.tinytex_download._select_tinytex_urls
assert _names(sel(_ASSETS_2026_08, 0, arm64=False)) == {
"darwin": "TinyTeX-0-darwin-v2026.08.tar.xz",
"linux": "TinyTeX-0-linux-x86_64-v2026.08.tar.xz",
"win32": "TinyTeX-0-windows-v2026.08.exe",
}
assert _names(sel(_ASSETS_2026_08, 1, arm64=True))["linux"] == (
"TinyTeX-1-linux-arm64-v2026.08.tar.xz"
)
# community bundle: legacy archives preferred, arm64 only has tar.xz
assert _names(sel(_ASSETS_2026_08, None, arm64=False)) == {
"darwin": "TinyTeX-v2026.08.tgz",
"linux": "TinyTeX-v2026.08.tar.gz",
"win32": "TinyTeX-v2026.08.zip",
}
assert _names(sel(_ASSETS_2026_08, None, arm64=True))["linux"] == (
"TinyTeX-linux-arm64-v2026.08.tar.xz"
)
assert sel(_ASSETS_2026_08, 2, arm64=False) == {}


def test_select_urls_musl():
sel = pytinytex.tinytex_download._select_tinytex_urls
assert _names(sel(_ASSETS_2026_08, 1, arm64=False, musl=True))["linux"] == (
"TinyTeX-1-linuxmusl-x86_64-v2026.08.tar.xz"
)
# legacy glibc tarball must not override the musl build
assert _names(sel(_ASSETS_2026_08, None, arm64=False, musl=True))["linux"] == (
"TinyTeX-linuxmusl-x86_64-v2026.08.tar.xz"
)
# no musl arm64 build upstream: fall back to glibc arm64
assert _names(sel(_ASSETS_2026_08, 1, arm64=True, musl=True))["linux"] == (
"TinyTeX-1-linux-arm64-v2026.08.tar.xz"
)
# old releases have no musl build: fall back to glibc
assert _names(sel(_ASSETS_2026_03, 0, arm64=False, musl=True))["linux"] == (
"TinyTeX-0-v2026.03.02.tar.gz"
)


def test_select_urls_daily_variation_2():
sel = pytinytex.tinytex_download._select_tinytex_urls
assert _names(sel(_ASSETS_DAILY, 2, arm64=True)) == {
"darwin": "TinyTeX-2-darwin.tar.xz",
"linux": "TinyTeX-2-linux-arm64.tar.xz",
"win32": "TinyTeX-2-windows.exe",
}
assert _names(sel(_ASSETS_DAILY, None, arm64=False)) == {
"darwin": "TinyTeX.tgz",
"linux": "TinyTeX.tar.gz",
"win32": "TinyTeX.zip",
}


def test_select_urls_old_naming():
sel = pytinytex.tinytex_download._select_tinytex_urls
assert _names(sel(_ASSETS_2026_03, 0, arm64=False)) == {
"darwin": "TinyTeX-0-v2026.03.02.tgz",
"linux": "TinyTeX-0-v2026.03.02.tar.gz",
"win32": "TinyTeX-0-v2026.03.02.zip",
}
assert _names(sel(_ASSETS_2026_03, 0, arm64=True))["linux"] == (
"TinyTeX-0-arm64-v2026.03.02.tar.gz"
)
assert _names(sel(_ASSETS_2026_03, None, arm64=True)) == {
"linux": "TinyTeX-arm64-v2026.03.02.tar.gz"
}


def test_failing_download_variation_2_with_pinned_version():
with pytest.raises(RuntimeError, match="only available with version='daily'"):
pytinytex.download_tinytex(variation=2, version="2024.12")
2 changes: 1 addition & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ def cleanup():
if os.path.isdir(TINYTEX_DISTRIBUTION):
shutil.rmtree(TINYTEX_DISTRIBUTION)
for item in os.listdir("tests"):
if item.endswith(".zip") or item.endswith(".tar.gz") or item.endswith(".tgz"):
if item.endswith((".zip", ".tar.gz", ".tgz", ".tar.xz", ".exe")):
os.remove(os.path.join("tests", item))
pytinytex.clear_path_cache()