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.
@@ -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())
@@ -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
-
@@ -1,8 +0,0 @@
1
- Download and extract files to ~/.local/bin/
2
-
3
- <!--
4
- README.md
5
- SPDX-FileCopyrightText: 2024 Keith Maxwell <keith.maxwell@gmail.com>
6
- SPDX-License-Identifier: CC0-1.0
7
- -->
8
- <!-- vim: set filetype=markdown.htmlCommentNoSpell : -->
@@ -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())