diff --git a/clang_tools/install.py b/clang_tools/install.py index 43f0bcd..e53dbc8 100644 --- a/clang_tools/install.py +++ b/clang_tools/install.py @@ -4,38 +4,35 @@ The module that performs the installation of clang-tools. """ + import os from pathlib import Path, PurePath import re import shutil import subprocess import sys -from typing import Optional -from . import release_tag +from typing import Optional, cast -from . import install_os, RESET_COLOR, suffix, YELLOW -from .util import download_file, verify_sha512, get_sha_checksum +from . import release_tag, install_os, RESET_COLOR, suffix, YELLOW +from .util import download_file, verify_sha512, get_sha_checksum, Version #: This pattern is designed to match only the major version number. RE_PARSE_VERSION = re.compile(rb"version\s([\d\.]+)", re.MULTILINE) -def is_installed(tool_name: str, version: str) -> Optional[Path]: +def is_installed(tool_name: str, version: Version) -> Optional[Path]: """Detect if the specified tool is installed. :param tool_name: The name of the specified tool. - :param version: The specific version to expect. + :param version: The specific major version to expect. :returns: The path to the detected tool (if found), otherwise `None`. """ - version_tuple = version.split(".") - ver_major = version_tuple[0] - if len(version_tuple) < 3: - # append minor and patch version numbers if not specified - version_tuple += ("0",) * (3 - len(version_tuple)) exe_name = ( - f"{tool_name}" + (f"-{ver_major}" if install_os != "windows" else "") + suffix + f"{tool_name}" + + (f"-{version.info[0]}" if install_os != "windows" else "") + + suffix ) try: result = subprocess.run( @@ -47,19 +44,21 @@ def is_installed(tool_name: str, version: str) -> Optional[Path]: except (FileNotFoundError, subprocess.CalledProcessError): return None # tool is not installed ver_num = RE_PARSE_VERSION.search(result.stdout) + assert ver_num is not None, "Failed to parse version from tool output" + ver_match = cast(bytes, ver_num.groups(0)[0]).decode(encoding="utf-8") print( f"Found a installed version of {tool_name}:", - ver_num.groups(0)[0].decode(encoding="utf-8"), + ver_match, end=" ", ) - path = shutil.which(exe_name) # find the installed binary - if path is None: + exe_path = shutil.which(exe_name) # find the installed binary + if exe_path is None: print() # print end-of-line return None # failed to locate the binary - path = Path(path).resolve() + path = Path(exe_path).resolve() print("at", str(path)) - ver_num = ver_num.groups(0)[0].decode(encoding="utf-8").split(".") - if ver_num is None or ver_num[0] != ver_major: + ver_tuple = ver_match.split(".") + if ver_tuple is None or ver_tuple[0] != str(version.info[0]): return None # version is unknown or not the desired major release return path @@ -160,7 +159,7 @@ def create_sym_link( version: str, install_dir: str, overwrite: bool = False, - target: Path = None, + target: Optional[Path] = None, ) -> bool: """Create a symlink to the installed binary that doesn't have the version number appended. @@ -249,7 +248,7 @@ def uninstall_clang_tools(version: str, directory: str): def install_clang_tools( - version: str, tools: str, directory: str, overwrite: bool, no_progress_bar: bool + version: Version, tools: str, directory: str, overwrite: bool, no_progress_bar: bool ) -> None: """Wraps functions used to individually install tools. @@ -261,7 +260,7 @@ def install_clang_tools( :param no_progress_bar: A flag used to disable the downloads' progress bar. """ install_dir = install_dir_name(directory) - if install_dir.rstrip(os.sep) not in os.environ.get("PATH"): + if install_dir.rstrip(os.sep) not in os.environ.get("PATH", ""): print( f"{YELLOW}{install_dir}", f"directory is not in your environment variable PATH.{RESET_COLOR}", @@ -270,7 +269,7 @@ def install_clang_tools( native_bin = is_installed(tool_name, version) if native_bin is None: # (not already installed) # `install_tool()` guarantees that the binary exists now - install_tool(tool_name, version, install_dir, no_progress_bar) + install_tool(tool_name, version.string, install_dir, no_progress_bar) create_sym_link( # pragma: no cover - tool_name, version, install_dir, overwrite, native_bin + tool_name, version.string, install_dir, overwrite, native_bin ) diff --git a/clang_tools/main.py b/clang_tools/main.py index b67b303..8b0e53e 100644 --- a/clang_tools/main.py +++ b/clang_tools/main.py @@ -4,10 +4,12 @@ The module containing main entrypoint function. """ + import argparse from .install import install_clang_tools, uninstall_clang_tools from . import RESET_COLOR, YELLOW +from .util import Version def get_parser() -> argparse.ArgumentParser: @@ -18,7 +20,9 @@ def get_parser() -> argparse.ArgumentParser: "-i", "--install", metavar="VERSION", - help="Install clang-tools about a specific version.", + help="Install clang-tools about a specific version. This can be in the form of" + " a semantic version specification (``x.y.z``, ``x.y``, ``x``). NOTE: A " + "malformed version specification will cause a silent failure.", ) parser.add_argument( "-t", @@ -66,13 +70,20 @@ def main(): if args.uninstall: uninstall_clang_tools(args.uninstall, args.directory) elif args.install: - install_clang_tools( - args.install, - args.tool, - args.directory, - args.overwrite, - args.no_progress_bar, - ) + version = Version(args.install) + if version.info != (0, 0, 0): + install_clang_tools( + version, + args.tool, + args.directory, + args.overwrite, + args.no_progress_bar, + ) + else: + print( + f"{YELLOW}The version specified is not a semantic", + f"specification{RESET_COLOR}", + ) else: print( f"{YELLOW}Nothing to do because `--install` and `--uninstall`", diff --git a/clang_tools/util.py b/clang_tools/util.py index f571716..710d7ea 100644 --- a/clang_tools/util.py +++ b/clang_tools/util.py @@ -4,11 +4,12 @@ A module containing utility functions. """ + import platform import hashlib from pathlib import Path import urllib.request -from typing import Optional +from typing import Optional, Tuple from urllib.error import HTTPError from http.client import HTTPResponse @@ -82,7 +83,6 @@ def get_sha_checksum(binary_url: str) -> str: with urllib.request.urlopen( binary_url.replace(".exe", "") + ".sha512sum" ) as response: - response: HTTPResponse return response.read(response.length).decode(encoding="utf-8") @@ -99,3 +99,27 @@ def verify_sha512(checksum: str, exe: bytes) -> bool: # released checksum's include the corresponding filename (which we don't need) checksum = checksum.split(" ", 1)[0] return checksum == hashlib.sha512(exe).hexdigest() + + +class Version: + """Parse the given version string into a semantic specification. + + :param user_input: The version specification as a string. + """ + + def __init__(self, user_input: str): + #: The version input in string form + self.string = user_input + version_tuple = user_input.split(".") + self.info: Tuple[int, int, int] + """ + A tuple of integers that describes the major, minor, and patch versions. + If the version `string` is a path, then this tuple is just 3 zeros. + """ + if len(version_tuple) < 3: + # append minor and patch version numbers if not specified + version_tuple += ["0"] * (3 - len(version_tuple)) + try: + self.info = tuple([int(x) for x in version_tuple]) # type: ignore[assignment] + except ValueError: + self.info = (0, 0, 0) diff --git a/docs/_static/extra_css.css b/docs/_static/extra_css.css index 0cb4401..8e15004 100644 --- a/docs/_static/extra_css.css +++ b/docs/_static/extra_css.css @@ -3,3 +3,46 @@ thead { background-color: var(--md-accent-bg-color--light); color: var(--md-default-bg-color); } + +.md-typeset .mdx-badge { + font-size: .85em +} + +.md-typeset .mdx-badge--right { + float: right; + margin-left: .35em +} + +.md-typeset .mdx-badge__icon { + background: var(--md-accent-fg-color--transparent); + padding: .2rem; +} + +.md-typeset .mdx-badge__icon:last-child { + border-radius: .1rem; +} + +[dir=ltr] .md-typeset .mdx-badge__icon { + border-top-left-radius: .1rem; + border-bottom-left-radius: .1rem; +} + +[dir=rtl] .md-typeset .mdx-badge__icon { + border-top-right-radius: .1rem; + border-bottom-right-radius: .1rem; +} + +.md-typeset .mdx-badge__text { + box-shadow: 0 0 0 1px inset var(--md-accent-fg-color--transparent); + padding: .2rem .3rem; +} + +[dir=ltr] .md-typeset .mdx-badge__text { + border-top-right-radius: .1rem; + border-bottom-right-radius: .1rem; +} + +[dir=rtl] .md-typeset .mdx-badge__text { + border-top-left-radius: .1rem; + border-bottom-left-radius: .1rem; +} diff --git a/docs/conf.py b/docs/conf.py index 0f19b43..f91fe21 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,8 +4,15 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +from argparse import _StoreTrueAction +from io import StringIO from pathlib import Path +import time +from typing import Optional +import docutils from sphinx.application import Sphinx +from sphinx.util.docutils import SphinxRole +from sphinx_immaterial.inline_icons import load_svg_into_builder_env from clang_tools.main import get_parser # -- Path setup -------------------------------------------------------------- @@ -21,8 +28,9 @@ # -- Project information ----------------------------------------------------- +year = time.strftime("%Y", time.gmtime()) project = "clang-tools" -copyright = "2022, cpp-linter team" +copyright = f"{year}, cpp-linter team" author = "cpp-linter team" @@ -111,19 +119,126 @@ # pylint: disable=protected-access +class CliBadge(SphinxRole): + badge_type: str + badge_icon: Optional[str] = None + href: Optional[str] = None + href_title: Optional[str] = None + + def run(self): + is_linked = "" + if self.href is not None and self.href_title is not None: + is_linked = ( + f'' + ) + head = '' + if not self.badge_icon: + head += self.badge_type.title() + else: + head += is_linked + head += ( + f'' + ) + head += "" + header = docutils.nodes.raw( + self.rawtext, + f'{head}' + + is_linked + + (self.text if self.badge_type in ["version", "switch"] else ""), + format="html", + ) + if self.badge_type not in ["version", "switch"]: + code, sys_msgs = docutils.parsers.rst.roles.code_role( + role="code", + rawtext=self.rawtext, + text=self.text, + lineno=self.lineno, + inliner=self.inliner, + options={"language": "text", "classes": ["highlight"]}, + content=self.content, + ) + else: + code, sys_msgs = ([], []) + tail = "" + if self.href is not None and self.href_title is not None: + tail = "" + tail + trailer = docutils.nodes.raw(self.rawtext, tail, format="html") + return ([header, *code, trailer], sys_msgs) + + +class CliBadgeVersion(CliBadge): + badge_type = "version" + href = "https://github.com/cpp-linter/clang-tools-pip/releases/v" + href_title = "Minimum Version" + + def run(self): + self.badge_icon = load_svg_into_builder_env( + self.env.app.builder, "material/tag-outline" + ) + return super().run() + + +class CliBadgeDefault(CliBadge): + badge_type = "Default" + + +class CliBadgeSwitch(CliBadge): + badge_type = "switch" + + def run(self): + self.badge_icon = load_svg_into_builder_env( + self.env.app.builder, "material/toggle-switch" + ) + return super().run() + + +REQUIRED_VERSIONS = { + "0.1.0": ["install"], + "0.2.0": ["directory"], + "0.3.0": ["overwrite"], + "0.5.0": ["no_progress_bar", "uninstall"], + "0.11.0": ["tool"], +} + + def setup(app: Sphinx): """Generate a doc from the executable script's ``--help`` output.""" - parser = get_parser() - # print(parser.format_help()) - formatter = parser._get_formatter() - doc = "Command Line Interface Options\n==============================\n\n" - for arg in parser._actions: - doc += f"\n.. option:: {formatter._format_action_invocation(arg)}\n\n" - if arg.default != "==SUPPRESS==": - doc += f" :Default: ``{repr(arg.default)}``\n\n" - description = ( - "" if arg.help is None else " %s\n" % (arg.help.replace("\n", "\n ")) - ) - doc += description + app.add_role("badge-version", CliBadgeVersion()) + app.add_role("badge-default", CliBadgeDefault()) + app.add_role("badge-switch", CliBadgeSwitch()) + cli_doc = Path(app.srcdir, "cli_args.rst") - cli_doc.write_text(doc, encoding="utf-8") + with open(cli_doc, mode="w") as doc: + doc.write("Command Line Interface Options\n==============================\n\n") + parser = get_parser() + doc.write(".. code-block:: text\n :caption: Usage\n :class: no-copy\n\n") + parser.prog = "clang-tools" + str_buf = StringIO() + parser.print_usage(str_buf) + usage = str_buf.getvalue() + start = usage.find(parser.prog) + for line in usage.splitlines(): + doc.write(f" {line[start:]}\n") + + args = parser._optionals._actions + for arg in args: + aliases = arg.option_strings + if not aliases or arg.default == "==SUPPRESS==": + continue + assert arg.help is not None + doc.write("\n.. std:option:: " + ", ".join(aliases) + "\n") + req_ver = "0.1.0" + for ver, names in REQUIRED_VERSIONS.items(): + if arg.dest in names: + req_ver = ver + break + doc.write(f"\n :badge-version:`{req_ver}` ") + if arg.default: + default = arg.default + if isinstance(arg.default, list): + default = " ".join(arg.default) + doc.write(f":badge-default:`{default}` ") + if isinstance(arg, _StoreTrueAction): + doc.write(":badge-switch:`Accepts no value` ") + doc.write("\n\n ") + doc.write("\n ".join(arg.help.splitlines()) + "\n") diff --git a/tests/test_install.py b/tests/test_install.py index d27fd58..3820907 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -12,6 +12,7 @@ is_installed, uninstall_clang_tools, ) +from clang_tools.util import Version @pytest.mark.parametrize("version", [str(v) for v in range(7, 17)] + ["12.0.1"]) @@ -48,7 +49,7 @@ def test_create_symlink(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): # intentionally overwrite symlink assert create_sym_link(tool_name, version, str(tmp_path), True) - # test safegaurd that doesn't overwrite a file that isn't a symlink + # test safeguard that doesn't overwrite a file that isn't a symlink os.remove(str(tmp_path / f"{tool_name}{suffix}")) Path(tmp_path / f"{tool_name}{suffix}").write_bytes(b"som data") assert not create_sym_link(tool_name, version, str(tmp_path), True) @@ -73,7 +74,7 @@ def test_install_tools(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, version: @pytest.mark.parametrize("version", ["0"]) def test_is_installed(version: str): """Test if installed version matches specified ``version``""" - tool_path = is_installed("clang-format", version=version) + tool_path = is_installed("clang-format", version=Version(version)) assert tool_path is None @@ -84,9 +85,9 @@ def test_path_warning(capsys: pytest.CaptureFixture): 2. indicates a failure to download a tool """ try: - install_clang_tools("x", "x", ".", False, False) + install_clang_tools(Version("0"), "x", ".", False, False) except OSError as exc: - if install_dir_name(".") not in os.environ.get("PATH"): # pragma: no cover + if install_dir_name(".") not in os.environ.get("PATH", ""): # pragma: no cover # this warning does not happen in an activated venv result = capsys.readouterr() assert "directory is not in your environment variable PATH" in result.out diff --git a/tests/test_util.py b/tests/test_util.py index e726df0..414f5cb 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,9 +1,10 @@ """Tests related to the utility functions.""" + from pathlib import Path, PurePath import pytest from clang_tools import install_os from clang_tools.install import clang_tools_binary_url -from clang_tools.util import check_install_os, download_file, get_sha_checksum +from clang_tools.util import check_install_os, download_file, get_sha_checksum, Version from clang_tools import release_tag @@ -33,3 +34,9 @@ def test_get_sha(monkeypatch: pytest.MonkeyPatch): ) url = clang_tools_binary_url("clang-format", "12", tag=release_tag) assert get_sha_checksum(url) == expected + + +def test_version_path(): + """Tests version parsing when given specification is a path.""" + version = str(Path(__file__).parent) + assert Version(version).info == (0, 0, 0)