Skip to content

Commit 8b3ef91

Browse files
committed
Add clear_cache methods and post extraction calls.
1 parent 11e751a commit 8b3ef91

3 files changed

Lines changed: 120 additions & 33 deletions

File tree

cachebin/cachebin.py

Lines changed: 105 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import zipfile
33
from pathlib import Path
44
from platform import machine, system
5+
from shutil import rmtree
56
from stat import S_IXGRP, S_IXOTH, S_IXUSR
67
from subprocess import PIPE, Popen
78
from typing import Callable
@@ -28,15 +29,14 @@ def download_file(url: str, directory_path: Path | str, force: bool = False) ->
2829
Path of the downloaded file.
2930
"""
3031
directory_path = Path(directory_path) # Ensure directory_path is a Path object
31-
response = requests.get(url, stream=True)
32-
response.raise_for_status() # Raise an error for bad responses
33-
3432
directory_path.mkdir(parents=True, exist_ok=True)
35-
3633
filename = url.split("/")[-1] # Extract the filename from the URL
3734
file_path = directory_path / filename
3835

3936
if not file_path.exists() or force:
37+
response = requests.get(url, stream=True)
38+
response.raise_for_status() # Raise an error for bad responses
39+
4040
print(f"Downloading {url} to {file_path}...")
4141
with open(file_path, "wb") as file:
4242
for chunk in response.iter_content(chunk_size=8192):
@@ -45,7 +45,7 @@ def download_file(url: str, directory_path: Path | str, force: bool = False) ->
4545
return file_path
4646

4747

48-
def extract_archive(archive_path: Path | str, extract_path: Path | str) -> Path: # noqa: PLR0912
48+
def extract_archive(archive_path: Path | str, extract_path: Path | str) -> tuple[Path, bool]: # noqa: PLR0912
4949
"""
5050
Extracts a compressed archive to the specified directory.
5151
@@ -94,10 +94,15 @@ def extract_archive(archive_path: Path | str, extract_path: Path | str) -> Path:
9494
if top_item.is_directory:
9595
extracted_parent_directory = extract_path / top_item.filename
9696

97+
extracted = False
9798
if not extracted_parent_directory.exists() or not any(extracted_parent_directory.iterdir()):
9899
print(f"Extracting {archive_path} to {extract_path}...")
99-
archive.extractall(path=extract_path)
100-
return extracted_parent_directory
100+
if isinstance(archive, tarfile.TarFile):
101+
archive.extractall(path=extract_path, filter="data")
102+
else:
103+
archive.extractall(path=extract_path)
104+
extracted = True
105+
return extracted_parent_directory, extracted
101106

102107

103108
def make_executable(file_path: str | Path) -> None:
@@ -107,6 +112,12 @@ def make_executable(file_path: str | Path) -> None:
107112
file_path.chmod(current_permissions | S_IXUSR | S_IXGRP | S_IXOTH)
108113

109114

115+
def remove_directory(directory_path: str | Path) -> None:
116+
directory_path = Path(directory_path)
117+
if directory_path.exists():
118+
rmtree(directory_path)
119+
120+
110121
class BinaryVersion:
111122
def __init__(self, version: str, parent: "BinaryManager"):
112123
self.parent = parent
@@ -118,23 +129,27 @@ def __init__(self, version: str, parent: "BinaryManager"):
118129
package_name=self.parent.package_name,
119130
)
120131
self.archive_name = self.url.split("/")[-1]
121-
self.archive_path = download_file(self.url, self.parent._downloads_directory)
122-
self.binary_directory_path = (
123-
extract_archive(self.archive_path, self.parent._package_directory / self.version)
124-
/ self.parent._extracted_bin_path
125-
)
126-
127-
def call(self, command: str, *args: str) -> str:
132+
self.archive_path = download_file(self.url, self.parent._archive_directory)
133+
self.extraction_directory_path = self.parent._package_directory / self.version
134+
extracted_path, extracted = extract_archive(self.archive_path, self.extraction_directory_path)
135+
self.binary_directory_path = extracted_path / self.parent._extracted_bin_path
136+
if not self.binary_directory_path.exists():
137+
raise FileNotFoundError(f"Binary directory path '{self.binary_directory_path}' does not exist.")
138+
if extracted:
139+
for call in self.parent._post_extraction_calls:
140+
command, args = call
141+
print(self.call(command, args, self.binary_directory_path))
142+
143+
def get_binary_path(self, command: str | None = None) -> Path:
128144
"""
129-
Calls the binary with the specified command and arguments.
145+
Returns the path to the binary for the specified command.
130146
131147
Args:
132-
command (str): The command to execute.
133-
*args (str): Additional arguments for the command.
148+
command (str): The command to get the binary path for.
134149
135-
Returns:
136-
str: The output of the command.
137150
"""
151+
if command is None:
152+
command = self.parent.package_name
138153
if self.parent._system == "windows":
139154
binary_path_exe = self.binary_directory_path / f"{command}.exe"
140155
binary_path_bat = self.binary_directory_path / f"{command}.bat"
@@ -144,33 +159,74 @@ def call(self, command: str, *args: str) -> str:
144159
command = f"{command}.bat"
145160
binary_path = self.binary_directory_path / command
146161
if not binary_path.exists():
147-
raise FileNotFoundError(f"Binary {binary_path} does not exist.")
162+
raise FileNotFoundError(f"Binary '{binary_path}' does not exist.")
163+
return binary_path
164+
165+
def call(
166+
self, command: str | None = None, arguments: list[str] | None = None, working_directory: Path | str = Path(".")
167+
) -> str:
168+
"""
169+
Calls the binary with the specified command and arguments.
170+
171+
Args:
172+
command (str): The command to execute.
173+
*args (str): Additional arguments for the command.
174+
175+
Returns:
176+
str: The output of the command.
177+
"""
178+
working_directory = Path(working_directory)
179+
if command is None:
180+
command = self.parent.package_name
181+
binary_path = self.get_binary_path(command)
148182

149183
make_executable(binary_path)
150184

151185
creation_flag = (
152186
0x08000000 if self.parent._system == "windows" else 0
153187
) # set creation flag to not open in new console on windows
154-
process = Popen([str(binary_path), *args], stdout=PIPE, stderr=PIPE, creationflags=creation_flag)
188+
189+
if arguments is None:
190+
arguments = []
191+
process = Popen(
192+
[str(binary_path)] + arguments, stdout=PIPE, stderr=PIPE, creationflags=creation_flag, cwd=working_directory
193+
)
155194
stdout, stderr = process.communicate()
156195
if process.returncode != 0:
157-
raise RuntimeError(f"Command failed with error: {stderr.decode('utf-8')}")
196+
raise RuntimeError(
197+
f"Command '{binary_path} {' '.join(arguments)}' failed with error:\n"
198+
f"{stderr.decode('utf-8')}\n{stdout.decode('utf-8')}"
199+
)
158200
return stdout.decode("utf-8")
159201

160-
# TODO: def clear_cache(self) -> None:
202+
def clear_cache(self) -> None:
203+
"""
204+
Clears the cache for the version.
205+
"""
206+
remove_directory(self.extraction_directory_path)
207+
self.archive_path.unlink(missing_ok=True)
208+
209+
210+
def default_platform_string(system: str, architecture: str) -> str:
211+
return f"{system}-{architecture}"
212+
213+
214+
def default_extracted_bin_path(system: str, architecture: str) -> str:
215+
return "bin"
161216

162217

163218
class BinaryManager:
164-
def __init__(
219+
def __init__( # noqa: PLR0913
165220
self,
166221
package_name: str,
167222
url_pattern: str,
168223
get_archive_extension: Callable[[str], str], # returns archive extension based on system
169-
get_platform_string: Callable[[str, str], str] = lambda system,
170-
architecture: f"{system}-{architecture}", # returns platform string used in url_pattern
171-
get_extracted_bin_path: Callable[[str, str], str] = lambda system,
172-
architecture: "bin", # returns extracted bin path
224+
get_platform_string: Callable[
225+
[str, str], str
226+
] = default_platform_string, # returns platform string used in url_pattern
227+
get_extracted_bin_path: Callable[[str, str], str] = default_extracted_bin_path, # returns extracted bin path
173228
cache_directory: Path | str | None = None,
229+
post_extraction_calls: list[tuple[str, list[str]]] | None = None,
174230
):
175231
self.package_name = package_name
176232
self.url_pattern = url_pattern
@@ -179,6 +235,10 @@ def __init__(
179235
self._platform_string = get_platform_string(self._system, self._architecture)
180236
self._extension = get_archive_extension(self._system)
181237
self._extracted_bin_path = get_extracted_bin_path(self._system, self._architecture)
238+
if post_extraction_calls is None:
239+
self._post_extraction_calls = []
240+
else:
241+
self._post_extraction_calls = post_extraction_calls
182242

183243
self._cache_directory: Path
184244
if cache_directory is None:
@@ -189,7 +249,7 @@ def __init__(
189249
else:
190250
self._cache_directory = Path(cache_directory)
191251
self._downloads_directory = self._cache_directory / "downloads"
192-
self._archive_directory = self._cache_directory / self.package_name
252+
self._archive_directory = self._downloads_directory / self.package_name
193253
self._packages_directory = self._cache_directory / "packages"
194254
self._package_directory = self._packages_directory / self.package_name
195255
self._versions: dict[str, BinaryVersion] = {}
@@ -208,4 +268,19 @@ def get_version(self, version: str) -> BinaryVersion:
208268
self._versions[version] = BinaryVersion(version, self)
209269
return self._versions[version]
210270

211-
# TODO: def clear_cache(self) -> None:
271+
def add_post_extraction_call(self, command: str, args: list[str]) -> None:
272+
"""
273+
Adds a post-extraction call to the manager.
274+
275+
Args:
276+
command (str): The command to execute.
277+
args (list[str]): Additional arguments for the command.
278+
"""
279+
self._post_extraction_calls.append((command, args))
280+
281+
def clear_cache(self) -> None:
282+
"""
283+
Clears the cache for the package manager.
284+
"""
285+
remove_directory(self._archive_directory)
286+
remove_directory(self._package_directory)

cachebin/recipies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def process_map(map: dict[str, str], key_description: str, key: str) -> str:
3737
else f"bin/{system}"
3838
if system == "windows"
3939
else f"bin/{architecture}-{system}",
40+
post_extraction_calls=[("tlmgr", ["update", "--self"])],
4041
)
4142

4243
pandoc_crossref_manager = BinaryManager(

test/test_cachebin.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
def test_pandoc():
22
from cachebin.recipies import pandoc_manager
33

4+
pandoc_manager.clear_cache()
5+
46
version = "3.6.4"
57
pandoc = pandoc_manager.get_version(version)
6-
assert version in pandoc.call("pandoc", "--version")
8+
assert version in pandoc.call("pandoc", ["--version"])
9+
pandoc.clear_cache()
710

811

912
def test_tinytex():
1013
from cachebin.recipies import tinytex_manager
1114

15+
tinytex_manager.clear_cache()
16+
1217
version = "v2025.05"
1318
tinytex = tinytex_manager.get_version(version)
14-
assert version in tinytex.call("tlmgr", "--version")
19+
assert version in tinytex.call("tlmgr", ["--version"])
20+
tlmgr_list = tinytex.call("tlmgr", ["info", "--only-installed"]).splitlines()
21+
tlmgr_list = [line[2 : line.find(": ")] for line in tlmgr_list]
22+
tinytex.clear_cache()
1523

1624

1725
def test_pandoc_crossref():
1826
from cachebin.recipies import pandoc_crossref_manager
1927

28+
pandoc_crossref_manager.clear_cache()
29+
2030
version = "v0.3.18.2"
2131
pandoc_crossref = pandoc_crossref_manager.get_version(version)
22-
assert version in pandoc_crossref.call("pandoc-crossref", "--version")
32+
assert version in pandoc_crossref.call("pandoc-crossref", ["--version"])
33+
pandoc_crossref.clear_cache()

0 commit comments

Comments
 (0)