Skip to content

Commit ec9a482

Browse files
authored
add no-progress-bar and uninstall CLI options (#29)
* resolve #28 switch to '\b' char & update tests * add pytest config to toml * compensate for error creating symlinks on windows - add a note to README - show error message from exception that was caught - urge windows users to enable dev mode from a prompt after showing the exception's message. * add note to README about what files we touch * Use ANSII escape sequence instead of '\r`, use the ANSII escape sequence to move the cursor backward. * use multiple ANSII escape code to move cursor back * use ANSII move to previous line * update docs - update logo & favicon - generate doc about CLI args - replace CLI info in readme with link to new doc * add uninstall option add uninstall functionality and CLI option - use new functionality when SHA512 is invalid - uninstall before installing (accordingly) update CLI args' defaults, description, and unit test - print a prompt about non-operation (before displaying --help msg) change parse_args() into get_parser() for direct access to parser obj in test. * test uninstall_tool() coverage ignores a function that we already test in units and an exception's handling that cannot be manually induced * ignore some other exception handling also move coverage config into toml * throw exception when failed to download bin This meant to provide an explanatory message that users can understand.` * add coverage to test a user prompt about PATH * cover OSError about failure to download * [no ci] raise setuptools vers req as we use toml * better doc for CLI args * add color to non-op prompt fix line too long * concatenate f-str w/ f-str
1 parent 9fd87df commit ec9a482

16 files changed

+250
-85
lines changed

.coveragerc

-24
This file was deleted.

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ tests/__pycache__/
1313
.eggs
1414
.mypy_cache/
1515
*env
16+
docs/cli_args.rst

README.rst

+14-10
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ clang-tools Introduction
2020

2121
Install clang-tools binaries (clang-format, clang-tidy) with pip.
2222

23+
.. important::
24+
This package only manages binary executables (& corresponding symbolic links) that
25+
are installed using this package's executable script. It does not intend to change or
26+
modify any binary executable installed from other sources (like LLVM releases).
27+
2328
Features
2429
--------
2530

@@ -31,6 +36,11 @@ Features
3136
- Installed binaries are symbolically linked for better cross-platform usage.
3237
For example (on Windows), the ``clang-tidy-13.exe`` binary executable can
3338
also be invoked with the symbolic link titled ``clang-tidy.exe``
39+
40+
.. note::
41+
To create symbolic links on Windows, you must enable developer mode
42+
from the Windows settings under "Privacy & security" > "For developers"
43+
category.
3444
- Customizable install path.
3545

3646
Install
@@ -71,17 +81,11 @@ Install `clang-tools` from git repo
7181
Usage
7282
-----
7383

74-
.. code-block:: shell
75-
76-
usage: clang-tools [-h] [-i INSTALL] [-d DIRECTORY] [-f]
84+
For a list of supported Command Line Interface options, see
85+
`the CLI documentation <https://cpp-linter.github.io/clang-tools-pip/cli_args.html>`_
7786

78-
optional arguments:
79-
-h, --help show this help message and exit
80-
-i INSTALL, --install INSTALL
81-
Install clang-tools with specific version. default is 13.
82-
-d DIRECTORY, --directory DIRECTORY
83-
The directory where is the clang-tools install.
84-
-f Force overwriting the symlink to the installed binary. This will only overwrite an existing symlink.
87+
Examples
88+
********
8589

8690
Use ``clang-tools`` command to install version 13 binaries.
8791

clang_tools/install.py

+64-11
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,15 @@ def clang_tools_binary_url(
3232
return download_url.replace(" ", "")
3333

3434

35-
def install_tool(tool_name: str, version: str, directory: str) -> bool:
35+
def install_tool(
36+
tool_name: str, version: str, directory: str, no_progress_bar: bool
37+
) -> bool:
3638
"""An abstract function that can install either clang-tidy or clang-format.
3739
3840
:param tool_name: The name of the clang-tool to install.
3941
:param version: The version of the tools to install.
4042
:param directory: The installation directory.
43+
:param no_progress_bar: A flag used to disable the downloads' progress bar.
4144
4245
:returns: `True` if the binary had to be downloaded and installed.
4346
`False` if the binary was not downloaded but is installed in ``directory``.
@@ -51,12 +54,16 @@ def install_tool(tool_name: str, version: str, directory: str) -> bool:
5154
print("valid")
5255
return False
5356
print("invalid")
57+
uninstall_tool(tool_name, version, directory)
5458
print("downloading", tool_name, f"(version {version})")
5559
bin_name = str(PurePath(bin_url).stem)
56-
download_file(bin_url, bin_name)
60+
if download_file(bin_url, bin_name, no_progress_bar) is None:
61+
raise OSError(f"Failed to download {bin_name} from {bin_url}")
5762
move_and_chmod_bin(bin_name, f"{tool_name}-{version}{suffix}", directory)
5863
if not verify_sha512(get_sha_checksum(bin_url), destination.read_bytes()):
59-
raise ValueError(f"file was corrupted during download from {bin_url}")
64+
raise ValueError(
65+
f"file was corrupted during download from {bin_url}"
66+
) # pragma: no cover
6067
return True
6168

6269

@@ -92,7 +99,7 @@ def move_and_chmod_bin(old_bin_name: str, new_bin_name: str, install_dir: str) -
9299
os.makedirs(install_dir)
93100
shutil.move(old_bin_name, f"{install_dir}/{new_bin_name}")
94101
os.chmod(os.path.join(install_dir, new_bin_name), 0o755)
95-
except PermissionError as exc:
102+
except PermissionError as exc: # pragma: no cover
96103
raise SystemExit(
97104
f"Don't have permission to install {new_bin_name} to {install_dir}."
98105
+ " Try to run with the appropriate permissions."
@@ -129,21 +136,67 @@ def create_sym_link(
129136
"already exists. Use '-f' to overwrite. Leaving it as is.",
130137
)
131138
return False
132-
os.remove(str(link))
139+
link.unlink()
133140
print("overwriting symbolic link", str(link))
134141
assert target.exists()
135-
link.symlink_to(target)
136-
print("symbolic link created", str(link))
137-
return True
142+
try:
143+
link.symlink_to(target)
144+
print("symbolic link created", str(link))
145+
return True
146+
except OSError as exc: # pragma: no cover
147+
print(
148+
"Encountered an error when trying to create the symbolic link:",
149+
exc.strerror,
150+
sep="\n ",
151+
)
152+
if install_os == "windows":
153+
print("Enable developer mode to create symbolic links")
154+
return False
155+
156+
157+
def uninstall_tool(tool_name: str, version: str, directory: str):
158+
"""Remove a specified tool of a given version.
159+
160+
:param tool_name: The name of the clang tool to uninstall.
161+
:param version: The version of the clang-tools to remove.
162+
:param directory: the directory from which to remove the
163+
installed clang-tools.
164+
"""
165+
tool_path = Path(directory, f"{tool_name}-{version}{suffix}")
166+
if tool_path.exists():
167+
print("Removing", tool_path.name, "from", str(tool_path.parent))
168+
tool_path.unlink()
169+
170+
# check for a dead symlink
171+
symlink = Path(directory, f"{tool_name}{suffix}")
172+
if symlink.is_symlink() and not symlink.exists():
173+
print("Removing dead symbolic link", str(symlink))
174+
symlink.unlink()
175+
176+
177+
def uninstall_clang_tools(version: str, directory: str):
178+
"""Uninstall a clang tool of a given version.
179+
180+
:param version: The version of the clang-tools to remove.
181+
:param directory: the directory from which to remove the
182+
installed clang-tools.
183+
"""
184+
install_dir = install_dir_name(directory)
185+
print(f"Uninstalling version {version} from {str(install_dir)}")
186+
for tool in ("clang-format", "clang-tidy"):
187+
uninstall_tool(tool, version, install_dir)
138188

139189

140-
def install_clang_tools(version: str, directory: str, overwrite: bool) -> None:
190+
def install_clang_tools(
191+
version: str, directory: str, overwrite: bool, no_progress_bar: bool
192+
) -> None:
141193
"""Wraps functions used to individually install tools.
142194
143195
:param version: The version of the tools to install.
144196
:param directory: The installation directory.
145197
:param overwrite: A flag to indicate if the creation of a symlink has
146198
permission to overwrite an existing symlink.
199+
:param no_progress_bar: A flag used to disable the downloads' progress bar.
147200
"""
148201
install_dir = install_dir_name(directory)
149202
if install_dir.rstrip(os.sep) not in os.environ.get("PATH"):
@@ -152,6 +205,6 @@ def install_clang_tools(version: str, directory: str, overwrite: bool) -> None:
152205
f"directory is not in your environment variable PATH.{RESET_COLOR}",
153206
)
154207
for tool_name in ("clang-format", "clang-tidy"):
155-
install_tool(tool_name, version, install_dir)
208+
install_tool(tool_name, version, install_dir, no_progress_bar)
156209
# `install_tool()` guarantees that the binary exists now
157-
create_sym_link(tool_name, version, install_dir, overwrite)
210+
create_sym_link(tool_name, version, install_dir, overwrite) # pragma: no cover

clang_tools/main.py

+39-18
Original file line numberDiff line numberDiff line change
@@ -5,46 +5,67 @@
55
The module containing main entrypoint function.
66
"""
77
import argparse
8-
from typing import List
98

10-
from .install import install_clang_tools
9+
from .install import install_clang_tools, uninstall_clang_tools
10+
from . import RESET_COLOR, YELLOW
1111

12-
13-
def parse_args(args: List[str] = None) -> argparse.Namespace:
14-
"""Get and parse args given on the CLI.
15-
16-
:param args: The arguments given on the command line. If specified, this does not
17-
need to include the name of the program (ie "clang_tools").
18-
"""
19-
parser = argparse.ArgumentParser(prog="clang-tools")
12+
def get_parser() -> argparse.ArgumentParser:
13+
"""Get and parser to interpret CLI args."""
14+
parser = argparse.ArgumentParser()
2015

2116
parser.add_argument(
2217
"-i",
2318
"--install",
24-
default="13",
25-
help="Install clang-tools with specific version. default is 13.",
19+
metavar="VERSION",
20+
help="Install clang-tools about a specific version.",
2621
)
27-
2822
parser.add_argument(
2923
"-d",
3024
"--directory",
3125
default="",
32-
help="The directory where is the clang-tools install.",
26+
metavar="DIR",
27+
help="The directory where the clang-tools are installed.",
3328
)
3429
parser.add_argument(
3530
"-f",
31+
"--overwrite",
3632
action="store_true",
37-
dest="overwrite",
3833
help="Force overwriting the symlink to the installed binary. This will only "
3934
"overwrite an existing symlink.",
4035
)
41-
return parser.parse_args(args)
36+
parser.add_argument(
37+
"-b",
38+
"--no-progress-bar",
39+
action="store_true",
40+
help="Do not display a progress bar for downloads.",
41+
)
42+
parser.add_argument(
43+
"-u",
44+
"--uninstall",
45+
metavar="VERSION",
46+
help="Uninstall clang-tools with specific version. "
47+
"This is done before any install.",
48+
)
49+
return parser
4250

4351

4452
def main():
4553
"""The main entrypoint to the CLI program."""
46-
args = parse_args()
47-
install_clang_tools(args.install, args.directory, args.overwrite)
54+
parser = get_parser()
55+
args = parser.parse_args()
56+
if not args.install and not args.uninstall:
57+
print(
58+
f"{YELLOW}Nothing to do because `--install` and `--uninstall`",
59+
f"was not specified.{RESET_COLOR}"
60+
)
61+
parser.print_help()
62+
else:
63+
if args.uninstall:
64+
uninstall_clang_tools(args.uninstall, args.directory)
65+
if args.install:
66+
install_clang_tools(
67+
args.install, args.directory, args.overwrite, args.no_progress_bar
68+
)
4869

4970

5071
if __name__ == "__main__":

clang_tools/util.py

+14-8
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
A module containing utility functions.
66
"""
77
import platform
8-
import math
98
import hashlib
109
from pathlib import Path
1110
import urllib.request
@@ -14,6 +13,7 @@
1413
from http.client import HTTPResponse
1514

1615

16+
1717
def check_install_os() -> str:
1818
"""Identify this Operating System.
1919
@@ -32,7 +32,7 @@ def check_install_os() -> str:
3232
return this_os
3333

3434

35-
def download_file(url: str, file_name: str) -> Optional[str]:
35+
def download_file(url: str, file_name: str, no_progress_bar: bool) -> Optional[str]:
3636
"""Download the given file_name from the given url.
3737
3838
:param url: The URL to download from.
@@ -47,20 +47,26 @@ def download_file(url: str, file_name: str) -> Optional[str]:
4747

4848
if response.status != 200:
4949
return None
50+
assert response.length is not None
5051
length = response.length
5152
buffer = bytes()
5253
progress_bar = "=" if check_install_os() == "windows" else "█"
5354
while len(buffer) < length:
5455
block_size = int(length / 20)
55-
# show completed
56-
completed = len(buffer) / length
57-
print(" |" + progress_bar * int(completed * 20), end="")
58-
print(" " * math.ceil((1 - completed) * 20), end="|")
59-
print(f"{int(completed * 100)}% (of {length} bytes)", end="\r")
56+
if not no_progress_bar: # show completed
57+
percent = len(buffer) / length
58+
completed = int(percent * 20)
59+
display = " |" + (progress_bar * completed)
60+
display += " " * (20 - completed) + "| "
61+
display += f"{int(percent * 100)}% (of {length} bytes)"
62+
reset_pos = "" if not buffer else "\033[F"
63+
print(reset_pos + display)
6064
remaining = length - len(buffer)
6165
buffer += response.read(block_size if remaining > block_size else remaining)
6266
response.close()
63-
print(" |" + (progress_bar * 20) + f"| 100% (of {length} bytes)")
67+
if not no_progress_bar:
68+
display = f" |{(progress_bar * 20)}| 100% (of {length} bytes)"
69+
print("\033[F" + display)
6470
file = Path(file_name)
6571
file.write_bytes(buffer)
6672
return file.as_posix()

docs/_static/favicon.ico

-16.6 KB
Binary file not shown.

docs/_static/logo.png

35.1 KB
Loading

docs/_static/new_favicon.ico

162 KB
Binary file not shown.

0 commit comments

Comments
 (0)