dotlocalslashbin 0.0.6__tar.gz → 0.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotlocalslashbin-0.0.8/PKG-INFO +77 -0
- dotlocalslashbin-0.0.8/README.md +58 -0
- {dotlocalslashbin-0.0.6 → dotlocalslashbin-0.0.8}/pyproject.toml +23 -1
- dotlocalslashbin-0.0.8/src/dotlocalslashbin.py +184 -0
- dotlocalslashbin-0.0.6/PKG-INFO +0 -26
- dotlocalslashbin-0.0.6/README.md +0 -8
- dotlocalslashbin-0.0.6/src/dotlocalslashbin.py +0 -192
- {dotlocalslashbin-0.0.6 → dotlocalslashbin-0.0.8}/LICENSES/MPL-2.0.txt +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: dotlocalslashbin
|
|
3
|
+
Version: 0.0.8
|
|
4
|
+
Summary: Download and extract files to `~/.local/bin/`.
|
|
5
|
+
Author-email: Keith Maxwell <keith.maxwell@gmail.com>
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
|
9
|
+
Requires-Dist: black ; extra == "test"
|
|
10
|
+
Requires-Dist: codespell ; extra == "test"
|
|
11
|
+
Requires-Dist: flit ; extra == "test"
|
|
12
|
+
Requires-Dist: mypy ; extra == "test"
|
|
13
|
+
Requires-Dist: nox ; extra == "test"
|
|
14
|
+
Requires-Dist: ruff ; extra == "test"
|
|
15
|
+
Requires-Dist: usort ; extra == "test"
|
|
16
|
+
Project-URL: Home, https://github.com/maxwell-k/dotlocalslashbin/
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
|
|
19
|
+
`dotlocalslashbin` → Download to `~/.local/bin/`
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
Uses a [TOML] configuration file, by default `bin.toml` and has no dependencies
|
|
24
|
+
beyond the Python standard library. Supports the following actions after
|
|
25
|
+
downloading the URL\* to a cache:
|
|
26
|
+
|
|
27
|
+
- extract to the output directory — from zip or tar files — or
|
|
28
|
+
- create a symbolic link in the output directory or
|
|
29
|
+
- run a command for example to correct the shebang line in a zipapp or
|
|
30
|
+
- copy the downloaded file
|
|
31
|
+
|
|
32
|
+
Guesses the correct action if none is specified. By default caches downloads to
|
|
33
|
+
`~/.cache/dotlocalslashbin/`.
|
|
34
|
+
|
|
35
|
+
Optionally can:
|
|
36
|
+
|
|
37
|
+
- run a command after download for example to correct a shebang line
|
|
38
|
+
- confirm a SHA256 or SHA512 hex-digest of the downloaded file
|
|
39
|
+
- invoke the target with an argument, for example `--version`
|
|
40
|
+
- strip a prefix while extracting
|
|
41
|
+
- ignore certain files while extracting
|
|
42
|
+
|
|
43
|
+
\* if the URL is an absolute path on the local file system; it is not downloaded
|
|
44
|
+
to the cache.
|
|
45
|
+
|
|
46
|
+
[TOML]: https://en.wikipedia.org/wiki/TOML
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
For example to download `yq` to the current working directory, first save the
|
|
51
|
+
following as `yq.toml`, then run the command below:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
[yq]
|
|
55
|
+
expected = "cfbbb9ba72c9402ef4ab9d8f843439693dfb380927921740e51706d90869c7e1"
|
|
56
|
+
url = "https://github.com/mikefarah/yq/releases/download/v4.43.1/yq_linux_amd64"
|
|
57
|
+
version = "--version"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Command:
|
|
61
|
+
|
|
62
|
+
dotlocalslashbin --input=yq.toml --output=.
|
|
63
|
+
|
|
64
|
+
Further examples are available in
|
|
65
|
+
[`bin.toml` in maxwell-k/dotfiles](https://github.com/maxwell-k/dotfiles/blob/main/bin.toml).
|
|
66
|
+
|
|
67
|
+
## See also
|
|
68
|
+
|
|
69
|
+
<https://github.com/buildinspace/peru>
|
|
70
|
+
|
|
71
|
+
<!--
|
|
72
|
+
README.md
|
|
73
|
+
SPDX-FileCopyrightText: 2024 Keith Maxwell <keith.maxwell@gmail.com>
|
|
74
|
+
SPDX-License-Identifier: CC0-1.0
|
|
75
|
+
-->
|
|
76
|
+
<!-- vim: set filetype=markdown.htmlCommentNoSpell : -->
|
|
77
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
`dotlocalslashbin` → Download to `~/.local/bin/`
|
|
2
|
+
|
|
3
|
+
## Features
|
|
4
|
+
|
|
5
|
+
Uses a [TOML] configuration file, by default `bin.toml` and has no dependencies
|
|
6
|
+
beyond the Python standard library. Supports the following actions after
|
|
7
|
+
downloading the URL\* to a cache:
|
|
8
|
+
|
|
9
|
+
- extract to the output directory — from zip or tar files — or
|
|
10
|
+
- create a symbolic link in the output directory or
|
|
11
|
+
- run a command for example to correct the shebang line in a zipapp or
|
|
12
|
+
- copy the downloaded file
|
|
13
|
+
|
|
14
|
+
Guesses the correct action if none is specified. By default caches downloads to
|
|
15
|
+
`~/.cache/dotlocalslashbin/`.
|
|
16
|
+
|
|
17
|
+
Optionally can:
|
|
18
|
+
|
|
19
|
+
- run a command after download for example to correct a shebang line
|
|
20
|
+
- confirm a SHA256 or SHA512 hex-digest of the downloaded file
|
|
21
|
+
- invoke the target with an argument, for example `--version`
|
|
22
|
+
- strip a prefix while extracting
|
|
23
|
+
- ignore certain files while extracting
|
|
24
|
+
|
|
25
|
+
\* if the URL is an absolute path on the local file system; it is not downloaded
|
|
26
|
+
to the cache.
|
|
27
|
+
|
|
28
|
+
[TOML]: https://en.wikipedia.org/wiki/TOML
|
|
29
|
+
|
|
30
|
+
## Examples
|
|
31
|
+
|
|
32
|
+
For example to download `yq` to the current working directory, first save the
|
|
33
|
+
following as `yq.toml`, then run the command below:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
[yq]
|
|
37
|
+
expected = "cfbbb9ba72c9402ef4ab9d8f843439693dfb380927921740e51706d90869c7e1"
|
|
38
|
+
url = "https://github.com/mikefarah/yq/releases/download/v4.43.1/yq_linux_amd64"
|
|
39
|
+
version = "--version"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Command:
|
|
43
|
+
|
|
44
|
+
dotlocalslashbin --input=yq.toml --output=.
|
|
45
|
+
|
|
46
|
+
Further examples are available in
|
|
47
|
+
[`bin.toml` in maxwell-k/dotfiles](https://github.com/maxwell-k/dotfiles/blob/main/bin.toml).
|
|
48
|
+
|
|
49
|
+
## See also
|
|
50
|
+
|
|
51
|
+
<https://github.com/buildinspace/peru>
|
|
52
|
+
|
|
53
|
+
<!--
|
|
54
|
+
README.md
|
|
55
|
+
SPDX-FileCopyrightText: 2024 Keith Maxwell <keith.maxwell@gmail.com>
|
|
56
|
+
SPDX-License-Identifier: CC0-1.0
|
|
57
|
+
-->
|
|
58
|
+
<!-- vim: set filetype=markdown.htmlCommentNoSpell : -->
|
|
@@ -23,12 +23,34 @@ dotlocalslashbin = "dotlocalslashbin:main"
|
|
|
23
23
|
test = [
|
|
24
24
|
"black",
|
|
25
25
|
"codespell",
|
|
26
|
-
"flake8",
|
|
27
26
|
"flit",
|
|
27
|
+
"mypy",
|
|
28
28
|
"nox",
|
|
29
|
+
"ruff",
|
|
29
30
|
"usort",
|
|
30
31
|
]
|
|
31
32
|
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 88
|
|
35
|
+
indent-width = 4
|
|
36
|
+
target-version = "py312"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["ALL"]
|
|
40
|
+
ignore = [
|
|
41
|
+
# "C901", # TODO
|
|
42
|
+
"D203", # incompatible with D211
|
|
43
|
+
"D213", # incompatible with D212
|
|
44
|
+
"I", # prefer usort to ruff isort implementation
|
|
45
|
+
# "PLR0912", # TODO
|
|
46
|
+
# "PLR0913", # TODO
|
|
47
|
+
# "PLR0915", # TODO
|
|
48
|
+
"S310", # the rule errors on the "use instead" code from `ruff rule S310`
|
|
49
|
+
"S602", # assume arguments to subprocess.run are validated
|
|
50
|
+
"S603", # assume trusted input to subprocess.run
|
|
51
|
+
"T201", # print is used for output in command line scripts
|
|
52
|
+
]
|
|
53
|
+
|
|
32
54
|
# pyproject.toml
|
|
33
55
|
# SPDX-FileCopyrightText: 2024 Keith Maxwell <keith.maxwell@gmail.com>
|
|
34
56
|
# SPDX-License-Identifier: CC0-1.0
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# src/dotlocalslashbin.py
|
|
3
|
+
# Copyright 2022 Keith Maxwell
|
|
4
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
"""Download and extract files to `~/.local/bin/`."""
|
|
6
|
+
import tarfile
|
|
7
|
+
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from hashlib import file_digest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from shlex import split
|
|
13
|
+
from shutil import copy
|
|
14
|
+
from stat import S_IEXEC
|
|
15
|
+
from subprocess import run
|
|
16
|
+
from tomllib import load
|
|
17
|
+
from urllib.error import HTTPError
|
|
18
|
+
from urllib.request import urlopen
|
|
19
|
+
from zipfile import ZipFile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__version__ = "0.0.8"
|
|
23
|
+
|
|
24
|
+
DEFAULT_OUTPUT = Path("~/.local/bin/")
|
|
25
|
+
_SHA512_LENGTH = 128
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _CustomNamespace(Namespace):
|
|
29
|
+
output: Path
|
|
30
|
+
input: Path
|
|
31
|
+
downloaded: Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Action = Enum("Action", ["command", "copy", "symlink", "untar", "unzip"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(init=False)
|
|
38
|
+
class Item:
|
|
39
|
+
"""Class for an application."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
url: str
|
|
43
|
+
target: Path
|
|
44
|
+
action: Action
|
|
45
|
+
downloaded: Path
|
|
46
|
+
expected: str | None
|
|
47
|
+
version: str | None
|
|
48
|
+
prefix: str
|
|
49
|
+
command: str | None
|
|
50
|
+
ignore: set
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> int:
|
|
54
|
+
"""Parse command line arguments and download each file."""
|
|
55
|
+
args = _parse_args()
|
|
56
|
+
|
|
57
|
+
with args.input.expanduser().open("rb") as file:
|
|
58
|
+
data = load(file)
|
|
59
|
+
|
|
60
|
+
for name, record in data.items():
|
|
61
|
+
item = Item()
|
|
62
|
+
item.name = name
|
|
63
|
+
item.url = record["url"]
|
|
64
|
+
default = args.output.joinpath(name)
|
|
65
|
+
item.target = Path(record.get("target", default)).expanduser()
|
|
66
|
+
item.ignore = record.get("ignore", set())
|
|
67
|
+
item.expected = record.get("expected", None)
|
|
68
|
+
item.version = record.get("version", None)
|
|
69
|
+
item.prefix = record.get("prefix", "")
|
|
70
|
+
item.command = record.get("command", None)
|
|
71
|
+
|
|
72
|
+
if "action" in record:
|
|
73
|
+
item.action = getattr(Action, record["action"])
|
|
74
|
+
else:
|
|
75
|
+
item.action = _guess_action(item)
|
|
76
|
+
|
|
77
|
+
if item.url.startswith("https://"):
|
|
78
|
+
item.downloaded = args.downloaded.expanduser() / item.url.rsplit("/", 1)[1]
|
|
79
|
+
else:
|
|
80
|
+
item.downloaded = Path(item.url)
|
|
81
|
+
try:
|
|
82
|
+
_process(item)
|
|
83
|
+
except HTTPError as e:
|
|
84
|
+
print(f"Error {e.code} downloading {e.url}")
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
arg0 = item.name if args.output == DEFAULT_OUTPUT else str(item.target)
|
|
88
|
+
print(" ".join(("#" if item.version else "$", arg0, item.version or "")))
|
|
89
|
+
if item.version:
|
|
90
|
+
run([arg0, item.version], check=True)
|
|
91
|
+
print()
|
|
92
|
+
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _process(item: Item) -> None:
|
|
97
|
+
"""Context manager to download and install a program."""
|
|
98
|
+
if not item.downloaded.is_file() and item.url.startswith("https://"):
|
|
99
|
+
_download(item)
|
|
100
|
+
|
|
101
|
+
if item.expected:
|
|
102
|
+
with item.downloaded.open("rb") as f:
|
|
103
|
+
_digest = "sha512" if len(item.expected) == _SHA512_LENGTH else "sha256"
|
|
104
|
+
digest = file_digest(f, _digest)
|
|
105
|
+
|
|
106
|
+
if (actual := digest.hexdigest()) != item.expected:
|
|
107
|
+
msg = f"Unexpected digest for {item.downloaded}: {actual=} {item.expected=}"
|
|
108
|
+
raise RuntimeError(msg)
|
|
109
|
+
|
|
110
|
+
item.target.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
item.target.unlink(missing_ok=True)
|
|
112
|
+
if item.action == Action.copy:
|
|
113
|
+
copy(item.downloaded, item.target)
|
|
114
|
+
elif item.action == Action.symlink:
|
|
115
|
+
item.target.symlink_to(item.downloaded)
|
|
116
|
+
elif item.action == Action.unzip:
|
|
117
|
+
with ZipFile(item.downloaded, "r") as file:
|
|
118
|
+
file.extract(item.target.name, path=item.target.parent)
|
|
119
|
+
elif item.action == Action.untar:
|
|
120
|
+
_untar(item)
|
|
121
|
+
elif item.action == Action.command and item.command is not None:
|
|
122
|
+
kwargs = {"target": item.target, "downloaded": item.downloaded}
|
|
123
|
+
run(split(item.command.format(**kwargs)), check=True)
|
|
124
|
+
|
|
125
|
+
if not item.target.is_symlink():
|
|
126
|
+
item.target.chmod(item.target.stat().st_mode | S_IEXEC)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_args() -> _CustomNamespace:
|
|
130
|
+
parser = ArgumentParser(
|
|
131
|
+
prog=Path(__file__).name,
|
|
132
|
+
formatter_class=ArgumentDefaultsHelpFormatter,
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
135
|
+
help_ = "TOML specification"
|
|
136
|
+
parser.add_argument("--input", default="bin.toml", help=help_, type=Path)
|
|
137
|
+
help_ = "Target directory"
|
|
138
|
+
parser.add_argument("--output", default=DEFAULT_OUTPUT, help=help_, type=Path)
|
|
139
|
+
help_ = "Output directory"
|
|
140
|
+
default = "~/.cache/dotlocalslashbin/"
|
|
141
|
+
parser.add_argument("--downloaded", default=default, help=help_, type=Path)
|
|
142
|
+
return parser.parse_args(namespace=_CustomNamespace())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _download(item: Item) -> None:
|
|
146
|
+
item.downloaded.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
with urlopen(item.url) as fp, item.downloaded.open("wb") as dp:
|
|
148
|
+
size = int(fp.headers.get("Content-Length", -1))
|
|
149
|
+
print(f"Downloading {item.name}…")
|
|
150
|
+
written = dp.write(fp.read())
|
|
151
|
+
|
|
152
|
+
if size >= 0 and written != size:
|
|
153
|
+
msg = "Wrong content length"
|
|
154
|
+
raise RuntimeError(msg)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _untar(item: Item) -> None:
|
|
158
|
+
with tarfile.open(item.downloaded, "r") as file:
|
|
159
|
+
for member in file.getmembers():
|
|
160
|
+
if member.path in item.ignore:
|
|
161
|
+
continue
|
|
162
|
+
member.path = member.path.removeprefix(item.prefix)
|
|
163
|
+
try:
|
|
164
|
+
file.extract(member, path=item.target.parent, filter="tar")
|
|
165
|
+
except TypeError: # before 3.11.4 e.g. Debian 12
|
|
166
|
+
file.extract(member, path=item.target.parent)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _guess_action(item: Item) -> Action:
|
|
170
|
+
if item.url.endswith((".tar.gz", ".tar")):
|
|
171
|
+
guess = Action.untar
|
|
172
|
+
elif item.url.endswith(".zip"):
|
|
173
|
+
guess = Action.unzip
|
|
174
|
+
elif item.url.startswith("/"):
|
|
175
|
+
guess = Action.symlink
|
|
176
|
+
elif item.command:
|
|
177
|
+
guess = Action.command
|
|
178
|
+
else:
|
|
179
|
+
guess = Action.copy
|
|
180
|
+
return guess
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
raise SystemExit(main())
|
dotlocalslashbin-0.0.6/PKG-INFO
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: dotlocalslashbin
|
|
3
|
-
Version: 0.0.6
|
|
4
|
-
Summary: Download and extract files to ~/.local/bin/
|
|
5
|
-
Author-email: Keith Maxwell <keith.maxwell@gmail.com>
|
|
6
|
-
Description-Content-Type: text/markdown
|
|
7
|
-
Classifier: Programming Language :: Python :: 3
|
|
8
|
-
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
|
9
|
-
Requires-Dist: black ; extra == "test"
|
|
10
|
-
Requires-Dist: codespell ; extra == "test"
|
|
11
|
-
Requires-Dist: flake8 ; extra == "test"
|
|
12
|
-
Requires-Dist: flit ; extra == "test"
|
|
13
|
-
Requires-Dist: nox ; extra == "test"
|
|
14
|
-
Requires-Dist: usort ; extra == "test"
|
|
15
|
-
Project-URL: Home, https://github.com/maxwell-k/dotlocalslashbin/
|
|
16
|
-
Provides-Extra: test
|
|
17
|
-
|
|
18
|
-
Download and extract files to ~/.local/bin/
|
|
19
|
-
|
|
20
|
-
<!--
|
|
21
|
-
README.md
|
|
22
|
-
SPDX-FileCopyrightText: 2024 Keith Maxwell <keith.maxwell@gmail.com>
|
|
23
|
-
SPDX-License-Identifier: CC0-1.0
|
|
24
|
-
-->
|
|
25
|
-
<!-- vim: set filetype=markdown.htmlCommentNoSpell : -->
|
|
26
|
-
|
dotlocalslashbin-0.0.6/README.md
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# src/dotlocalslashbin.py
|
|
3
|
-
# Copyright 2022 Keith Maxwell
|
|
4
|
-
# SPDX-License-Identifier: MPL-2.0
|
|
5
|
-
"""Download and extract files to ~/.local/bin/"""
|
|
6
|
-
import tarfile
|
|
7
|
-
from argparse import (
|
|
8
|
-
ArgumentDefaultsHelpFormatter as formatter_class,
|
|
9
|
-
ArgumentParser,
|
|
10
|
-
Namespace,
|
|
11
|
-
)
|
|
12
|
-
from collections.abc import Generator
|
|
13
|
-
from contextlib import contextmanager
|
|
14
|
-
from hashlib import file_digest
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from shlex import split
|
|
17
|
-
from shutil import copy
|
|
18
|
-
from stat import S_IEXEC
|
|
19
|
-
from subprocess import run
|
|
20
|
-
from tomllib import load
|
|
21
|
-
from urllib.error import HTTPError
|
|
22
|
-
from urllib.request import urlopen
|
|
23
|
-
from zipfile import ZipFile
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
__version__ = "0.0.6"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class CustomNamespace(Namespace):
|
|
30
|
-
output: Path
|
|
31
|
-
input: Path
|
|
32
|
-
downloaded: Path
|
|
33
|
-
completions: Path
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def parse_args():
|
|
37
|
-
parser = ArgumentParser(prog=Path(__file__).name, formatter_class=formatter_class)
|
|
38
|
-
parser.add_argument("--version", action="version", version=__version__)
|
|
39
|
-
help_ = "TOML specification"
|
|
40
|
-
parser.add_argument("--input", default="bin.toml", help=help_, type=Path)
|
|
41
|
-
help_ = "Target directory"
|
|
42
|
-
parser.add_argument("--output", default="~/.local/bin/", help=help_, type=Path)
|
|
43
|
-
help_ = "Download directory"
|
|
44
|
-
default = "~/.cache/dotlocalslashbin/"
|
|
45
|
-
parser.add_argument("--downloaded", default=default, help=help_, type=Path)
|
|
46
|
-
help_ = "Directory for ZSH completions"
|
|
47
|
-
default = "~/.local/share/zsh/site-functions/"
|
|
48
|
-
parser.add_argument("--completions", default=default, help=help_, type=Path)
|
|
49
|
-
return parser.parse_args(namespace=CustomNamespace)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@contextmanager
|
|
53
|
-
def _download(
|
|
54
|
-
args: type[CustomNamespace],
|
|
55
|
-
*,
|
|
56
|
-
name: str,
|
|
57
|
-
url: str,
|
|
58
|
-
target: Path | None = None,
|
|
59
|
-
action: str | None = None,
|
|
60
|
-
expected: str | None = None,
|
|
61
|
-
version: str | None = None,
|
|
62
|
-
prefix: str | None = None,
|
|
63
|
-
completions: str | None = None,
|
|
64
|
-
command: str | None = None,
|
|
65
|
-
ignore: set = set(),
|
|
66
|
-
) -> Generator[tuple[Path, Path], None, None]:
|
|
67
|
-
"""Context manager to download and install a program
|
|
68
|
-
|
|
69
|
-
Arguments:
|
|
70
|
-
url: the URL to download
|
|
71
|
-
action: action to take to install for example copy
|
|
72
|
-
target: the destination
|
|
73
|
-
expected: the SHA256 or SHA512 hex-digest of the file at URL
|
|
74
|
-
version: an argument to display the version for example --version
|
|
75
|
-
prefix: to remove when untarring
|
|
76
|
-
completions: whether to generate ZSH completions
|
|
77
|
-
command: command to run to install after download
|
|
78
|
-
"""
|
|
79
|
-
if target is None:
|
|
80
|
-
target = args.output.joinpath(name)
|
|
81
|
-
assert target is not None
|
|
82
|
-
|
|
83
|
-
if url.startswith("https://"):
|
|
84
|
-
downloaded = args.downloaded.expanduser() / url.rsplit("/", 1)[1]
|
|
85
|
-
downloaded.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
-
if not downloaded.is_file():
|
|
87
|
-
with urlopen(url) as fp, downloaded.open("wb") as dp:
|
|
88
|
-
if "content-length" in fp.headers:
|
|
89
|
-
size = int(fp.headers["Content-Length"])
|
|
90
|
-
else:
|
|
91
|
-
size = -1
|
|
92
|
-
|
|
93
|
-
print(f"Downloading {name}…")
|
|
94
|
-
written = dp.write(fp.read())
|
|
95
|
-
|
|
96
|
-
if size >= 0 and written != size:
|
|
97
|
-
raise RuntimeError("Wrong content length")
|
|
98
|
-
|
|
99
|
-
if expected:
|
|
100
|
-
digest = "sha256"
|
|
101
|
-
if len(expected) == 128:
|
|
102
|
-
digest = "sha512"
|
|
103
|
-
with downloaded.open("rb") as f:
|
|
104
|
-
digest = file_digest(f, digest)
|
|
105
|
-
|
|
106
|
-
if (actual := digest.hexdigest()) != expected:
|
|
107
|
-
raise RuntimeError(
|
|
108
|
-
f"Unexpected digest for {downloaded}: {actual=} {expected=}"
|
|
109
|
-
)
|
|
110
|
-
else:
|
|
111
|
-
downloaded = Path(url)
|
|
112
|
-
|
|
113
|
-
if action is None:
|
|
114
|
-
if url.endswith(".tar.gz"):
|
|
115
|
-
action = "untar"
|
|
116
|
-
elif url.endswith(".zip"):
|
|
117
|
-
action = "unzip"
|
|
118
|
-
elif url.startswith("/"):
|
|
119
|
-
action = "symlink"
|
|
120
|
-
elif command:
|
|
121
|
-
action = "command"
|
|
122
|
-
else:
|
|
123
|
-
action = "copy"
|
|
124
|
-
|
|
125
|
-
message = ("#" if version else "$") + f" {target} " + (version or "")
|
|
126
|
-
target = target.expanduser()
|
|
127
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
-
target.unlink(missing_ok=True)
|
|
129
|
-
if action == "copy":
|
|
130
|
-
copy(downloaded, target)
|
|
131
|
-
elif action == "symlink":
|
|
132
|
-
target.symlink_to(downloaded)
|
|
133
|
-
elif action == "unzip":
|
|
134
|
-
with ZipFile(downloaded, "r") as file:
|
|
135
|
-
file.extract(target.name, path=target.parent)
|
|
136
|
-
elif action == "untar":
|
|
137
|
-
with tarfile.open(downloaded, "r") as file:
|
|
138
|
-
for member in file.getmembers():
|
|
139
|
-
if prefix:
|
|
140
|
-
member.path = member.path.removeprefix(prefix)
|
|
141
|
-
if member.path in ignore:
|
|
142
|
-
continue
|
|
143
|
-
try:
|
|
144
|
-
file.extract(member, path=target.parent, filter="tar")
|
|
145
|
-
except TypeError: # before 3.11.4 e.g. Debian 12
|
|
146
|
-
file.extract(member, path=target.parent)
|
|
147
|
-
|
|
148
|
-
elif action == "command" and command is not None:
|
|
149
|
-
kwargs = dict(target=target, downloaded=downloaded)
|
|
150
|
-
run(split(command.format(**kwargs)), check=True)
|
|
151
|
-
|
|
152
|
-
yield downloaded, target
|
|
153
|
-
|
|
154
|
-
if not target.is_symlink():
|
|
155
|
-
target.chmod(target.stat().st_mode | S_IEXEC)
|
|
156
|
-
|
|
157
|
-
if completions:
|
|
158
|
-
output = args.completions.expanduser() / f"_{target.name}"
|
|
159
|
-
output.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
-
kwargs = dict(target=target) # target may not be on PATH
|
|
161
|
-
with output.open("w") as file:
|
|
162
|
-
run(split(completions.format(**kwargs)), check=True, stdout=file)
|
|
163
|
-
|
|
164
|
-
print(message)
|
|
165
|
-
if version:
|
|
166
|
-
run([target, version], check=True)
|
|
167
|
-
|
|
168
|
-
print()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def main() -> int:
|
|
172
|
-
args = parse_args()
|
|
173
|
-
|
|
174
|
-
with args.input.expanduser().open("rb") as file:
|
|
175
|
-
data = load(file)
|
|
176
|
-
|
|
177
|
-
for name, kwargs in data.items():
|
|
178
|
-
kwargs["name"] = name
|
|
179
|
-
if "target" in kwargs:
|
|
180
|
-
kwargs["target"] = Path(kwargs["target"])
|
|
181
|
-
try:
|
|
182
|
-
with _download(args, **kwargs) as (downloaded, target):
|
|
183
|
-
pass
|
|
184
|
-
except HTTPError as e:
|
|
185
|
-
print(f"Error {e.code} downloading {e.url}")
|
|
186
|
-
return 1
|
|
187
|
-
|
|
188
|
-
return 0
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if __name__ == "__main__":
|
|
192
|
-
raise SystemExit(main())
|
|
File without changes
|