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)