Cat Feeder  1.0.0
The Cat feeder project
Loading...
Searching...
No Matches
downloader.py
Go to the documentation of this file.
1"""
2# +==== BEGIN CatFeeder =================+
3# LOGO:
4# ..............(..../\\
5# ...............)..(.')
6# ..............(../..)
7# ...............\\(__)|
8# Inspired by Joan Stark
9# source https://www.asciiart.eu/
10# animals/cats
11# /STOP
12# PROJECT: CatFeeder
13# FILE: downloader.py
14# CREATION DATE: 16-01-2026
15# LAST Modified: 23:55:24 16-01-2026
16# DESCRIPTION:
17# This is the backend server in charge of making the actual website work.
18# /STOP
19# COPYRIGHT: (c) Cat Feeder
20# PURPOSE: The code in charge of downloading the tyni tex binaries and exposing them to the PATH.
21# // AR
22# +==== END CatFeeder =================+
23"""
24import os
25import sys
26import tarfile
27import zipfile
28from pathlib import Path
29from typing import Dict, Tuple, Optional, Union
30
31import hashlib
32import requests
33from display_tty import Disp, initialise_logger
34
35from ..core import FinalClass
36from ..utils import CONST
37
38DOWNLOAD_PATH: Union[str, Path] = CONST.ASSETS_DIRECTORY / "tinytex"
39
40
41class TinyTeXInstaller(metaclass=FinalClass):
42 """
43 Downloads and installs TinyTeX locally in the current directory.
44 Handles cross-platform archive selection, SHA256 verification,
45 extraction, and exposes the bin folder to Python via PATH.
46
47 Usage:
48 installer = TinyTeXInstaller(flavour="TinyTeX-1")
49 bin_path = installer.install()
50 """
51
52 disp: Disp = initialise_logger(__qualname__, CONST.DEBUG)
53
54 _instance: Optional["TinyTeXInstaller"] = None
55
56 # Predefined URLs + SHA256 hashes for each OS and scheme
57 RELEASES: Dict[str, Dict[str, Tuple[str, str]]] = {
58 "windows": {
59 "TinyTeX-0": (
60 "https://github.com/rstudio/tinytex-releases/releases/download/v2026.01/TinyTeX-0-v2026.01.zip",
61 "2ecc48c2e25387b4736f63fdcbe7fddeb8027e8da3bcbf4f3149e3affe926722"
62 ),
63 "TinyTeX-1": (
64 "https://github.com/rstudio/tinytex-releases/releases/download/v2026.01/TinyTeX-1-v2026.01.zip",
65 "807d2600e9bf7171a3785416ad2e9d2cf18aeabc6cb56f533ad43d2819d37b8d"
66 )
67 },
68 "linux": {
69 "TinyTeX-0": (
70 "https://github.com/rstudio/tinytex-releases/releases/download/v2026.01/TinyTeX-0-v2026.01.tar.gz",
71 "5b1cc012e4c033ef7748023d67ae6709b1db65b738edd2c69e82bedf297ba4cd"
72 ),
73 "TinyTeX-1": (
74 "https://github.com/rstudio/tinytex-releases/releases/download/v2026.01/TinyTeX-1-v2026.01.tar.gz",
75 "91c7e636c900d70f3731149c1b54ede26a5c10212b0bd1b7694c1b0d898ec37a"
76 ),
77 },
78 "darwin": { # macOS
79 "TinyTeX-0": (
80 "https://github.com/rstudio/tinytex-releases/releases/download/v2026.01/TinyTeX-0-v2026.01.tgz",
81 "638a817a448f896b81de8219896a20c306fd484067136f3febdf12c03bb49605"
82 ),
83 "TinyTeX-1": (
84 "https://github.com/rstudio/tinytex-releases/releases/download/v2026.01/TinyTeX-1-v2026.01.tgz",
85 "1eec5125d45b73c3112399afa7c2c3595803b17c762bfd97180c86656d183706"
86 ),
87 },
88 }
89
90 def __new__(cls, *args, **kwargs) -> "TinyTeXInstaller":
91 if cls._instance is None:
92 cls._instance = super(TinyTeXInstaller, cls).__new__(cls)
93 return cls._instance
94
95 def __init__(self, flavour: str = "TinyTeX-1", install_dir_name: Union[str, Path] = DOWNLOAD_PATH, *, timeout_seconds: int = 300) -> None:
96 self.disp.log_debug("Initialising...")
97 self.flavour: str = flavour
98 self.install_dir_name: Union[str, Path] = install_dir_name
99 self.os_key: str = self.detect_os()
100 self.timeout: int = timeout_seconds
101
102 if self.flavour not in self.RELEASES[self.os_key]:
103 raise ValueError(
104 f"flavour '{flavour}' not available for OS '{self.os_key}'"
105 )
106
107 self.url, self.sha256 = self.RELEASES[self.os_key][self.flavour]
108 self.disp.log_debug("Initialised.")
109
110 def __call__(self) -> str:
111 return self.install()
112
113 @staticmethod
114 def detect_os() -> str:
115 """Detect the current operating system."""
116 if sys.platform.startswith("win"):
117 return "windows"
118 if sys.platform.startswith("linux"):
119 return "linux"
120 if sys.platform.startswith("darwin"):
121 return "darwin"
122 raise RuntimeError(f"Unsupported OS: {sys.platform}")
123
124 def _download_file(self, url: str, dest_path: str) -> None:
125 """Download a file from a URL to a local destination.
126
127 Args:
128 url (str): The URL to download from.
129 dest_path (str): The local file path to save the downloaded file.
130 """
131 self.disp.log_info(f"Downloading {url} ...")
132 with requests.get(url, stream=True, timeout=self.timeout) as r:
133 r.raise_for_status()
134 with open(dest_path, "wb") as f:
135 for chunk in r.iter_content(chunk_size=8192):
136 f.write(chunk)
137 self.disp.log_info(f"Downloaded to {dest_path}")
138
139 def _verify_sha256(self, file_path: str, expected_hash: str) -> None:
140 """Verify the SHA256 hash of a file.
141
142 Args:
143 file_path (str): Path to the file to verify.
144 expected_hash (str): Expected SHA256 hash string.
145
146 Raises:
147 ValueError: If the hash does not match.
148 """
149 self.disp.log_info(f"Verifying SHA256 for {file_path} ...")
150 h = hashlib.sha256()
151 with open(file_path, "rb") as f:
152 for chunk in iter(lambda: f.read(8192), b""):
153 h.update(chunk)
154 actual_hash = h.hexdigest()
155 if actual_hash != expected_hash:
156 self.disp.log_error(
157 f"SHA256 mismatch: {actual_hash} != {expected_hash}"
158 )
159 raise ValueError(
160 f"SHA256 mismatch: {actual_hash} != {expected_hash}"
161 )
162 self.disp.log_info("SHA256 verified")
163
164 def _extract_archive(self, archive_path: str, dest_dir: str) -> None:
165 self.disp.log_info(f"Extracting {archive_path} → {dest_dir}")
166 if archive_path.endswith(".zip"):
167 self.disp.log_debug("Detected ZIP archive.")
168 with zipfile.ZipFile(archive_path, "r") as zf:
169 zf.extractall(dest_dir)
170 elif archive_path.endswith((".tar.gz", ".tgz")):
171 self.disp.log_debug("Detected TAR.GZ archive.")
172 with tarfile.open(archive_path, "r:gz") as tf:
173 tf.extractall(dest_dir)
174 else:
175 self.disp.log_error("Unknown archive format.")
176 raise ValueError(f"Unknown archive format: {archive_path}")
177
178 def install(self) -> str:
179 """Install TinyTeX locally and expose its bin folder. Returns the bin path."""
180 self.disp.log_info("Starting TinyTeX installation...")
181 cwd = os.getcwd()
182 install_dir = os.path.join(cwd, self.install_dir_name)
183 self.disp.log_debug(f"Installation directory: {install_dir}")
184 os.makedirs(install_dir, exist_ok=True)
185
186 archive_path = os.path.join(install_dir, os.path.basename(self.url))
187 self.disp.log_debug(f"Archive path: {archive_path}")
188
189 if not os.path.exists(archive_path):
190 self.disp.log_info(
191 f"{archive_path} does not exist, downloading...")
192 self._download_file(self.url, archive_path)
193 self.disp.log_debug("Download complete, verifying...")
194 self._verify_sha256(archive_path, self.sha256)
195 else:
196 self.disp.log_info(
197 f"{archive_path} already exists, skipping download.")
198 self._verify_sha256(archive_path, self.sha256)
199
200 self._extract_archive(archive_path, install_dir)
201
202 # Find bin folder
203 bin_path: Optional[str] = None
204 for root, dirs, _ in os.walk(install_dir):
205 if "bin" in dirs:
206 bin_path = os.path.join(root, "bin")
207 break
208 self.disp.log_debug(f"Bin path: {bin_path}")
209
210 if bin_path is None:
211 raise RuntimeError(
212 "Could not find TinyTeX 'bin' directory after extraction")
213
214 # Expose path
215 os.environ["PATH"] = f"{bin_path}{os.pathsep}{os.environ.get('PATH', '')}"
216 sys.path.insert(0, bin_path)
217 self.disp.log_info(f"TinyTeX bin available at: {bin_path}")
218
219 return bin_path
220
221
222# === USAGE EXAMPLE ===
223if __name__ == "__main__":
224 installer = TinyTeXInstaller(flavour="TinyTeX-1")
225 bin_dir = installer.install()
226 print("You can now run pdflatex, xelatex, lualatex from Python or Pandoc.")
"TinyTeXInstaller" __new__(cls, *args, **kwargs)
Definition downloader.py:90
None _extract_archive(self, str archive_path, str dest_dir)
None _verify_sha256(self, str file_path, str expected_hash)
None __init__(self, str flavour="TinyTeX-1", Union[str, Path] install_dir_name=DOWNLOAD_PATH, *, int timeout_seconds=300)
Definition downloader.py:95
None _download_file(self, str url, str dest_path)