From d064701ea329e674dadf5785e3b058f1cef40471 Mon Sep 17 00:00:00 2001 From: Kyle Finley Date: Mon, 2 Dec 2024 14:41:47 -0500 Subject: [PATCH] add install script --- .github/workflows/ci.yml | 7 + .vscode/cspell.json | 4 +- Makefile | 27 +- install.py | 574 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 35 ++- package.json | 3 +- poetry.lock | 29 +- pyproject.toml | 72 +++++ 8 files changed, 746 insertions(+), 5 deletions(-) create mode 100755 install.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c2249c..e22d50d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,13 @@ on: - master jobs: + python-checks: + strategy: + matrix: + python-version: ['3.10', 3.11, 3.12] + uses: finleyfamily/workflows/.github/workflows/python.checks.yml@master + with: + python-version: ${{ matrix.python-version }} shellcheck: name: shellcheck runs-on: ubuntu-latest diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 8ec9b56..912eb7f 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -8,5 +8,7 @@ ], "maxNumberOfProblems": 100, "version": "0.2", - "words": [] + "words": [ + "gtar" + ] } diff --git a/Makefile b/Makefile index 41f0074..5f527f9 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,38 @@ help: ## show this message 'BEGIN {FS = ":.*##"; printf "\nUsage: make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-30s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' \ $(MAKEFILE_LIST) -fix: run-pre-commit ## run all automatic fixes +fix: fix-ruff run-pre-commit ## run all automatic fixes + +fix-imports: ## automatically fix all import sorting errors + @poetry run ruff check . --fix-only --fixable I001 + +fix-ruff: ## automatically fix everything ruff can fix (implies fix-imports) + @poetry run ruff check . --fix-only fix-md: ## automatically fix markdown format errors @poetry run pre-commit run mdformat --all-files lint: lint-shellcheck ## run all linters +lint: lint-ruff lint-pyright ## run all linters + @if [ $${CI} ]; then \ + echo ""; \ + echo "skipped linters that have dedicated jobs"; \ + else \ + echo ""; \ + $(MAKE) --no-print-directory lint-shellcheck; \ + fi + +lint-pyright: ## run pyright + @echo "Running pyright..." + @npm exec --no -- pyright --venvpath ./ + @echo "" + +lint-ruff: ## run ruff + @echo "Running ruff... If this fails, run 'make fix-ruff' to resolve some error automatically, other require manual action." + @poetry run ruff check . + @echo "" + lint-shellcheck: ## runs shellcheck using act @act --job shellcheck diff --git a/install.py b/install.py new file mode 100755 index 0000000..828002f --- /dev/null +++ b/install.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +"""This script will install `oi`.""" +# NOTE (kyle): pieces of installer have been borrowed from https://install.python-poetry.org/ + +from __future__ import annotations + +import sys + +# eager version check so we fail nicely before possible syntax errors +if sys.version_info < (3, 10): # noqa: UP036 + sys.stdout.write("oi installer requires Python 3.10 or newer to run!\n") + sys.exit(1) + +import argparse +import json +import os +import re +import shutil +import sysconfig +import tempfile +from contextlib import closing +from functools import cached_property, cmp_to_key +from io import UnsupportedOperation +from pathlib import Path +from typing import Any, Literal, TypedDict +from urllib.request import Request, urlopen, urlretrieve + +GITHUB_REPO = "finleyfamily/oi" +"""GitHub repository (``/``).""" + +SHELL = os.getenv("SHELL", "") +WINDOWS = sys.platform.startswith("win") or (sys.platform == "cli" and os.name == "nt") +MINGW = sysconfig.get_platform().startswith("mingw") +MACOS = sys.platform == "darwin" + +FOREGROUND_COLORS = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, +} +"""Terminal escape codes for foreground colors.""" + +BACKGROUND_COLORS = { + "black": 40, + "red": 41, + "green": 42, + "yellow": 43, + "blue": 44, + "magenta": 45, + "cyan": 46, + "white": 47, +} +"""Terminal escape codes for background colors.""" + +OPTIONS = {"bold": 1, "underscore": 4, "blink": 5, "reverse": 7, "conceal": 8} +"""Additional terminal escape code styles.""" + +PRE_MESSAGE = """# Welcome to {package}! + +This will download and install the latest version of {package}. + +It will add the `{package}` command to {package}'s bin directory, located at: + +{home_bin} + +You can uninstall at any time by executing this script with the --uninstall option, +and these changes will be reverted. +""" + +POST_MESSAGE = """{package} ({version}) is installed now. Great! + +You can test that everything is set up by executing: + +`{test_command}` +""" + +POST_MESSAGE_CONFIGURE_UNIX = """ +Add `export PATH="{home_bin}:$PATH"` to your shell configuration file. +""" + +POST_MESSAGE_NOT_IN_PATH = """{package} ({version}) is installed now. Great! + +To get started you need {package}'s bin directory ({home_bin}) in your `PATH` +environment variable. +{configure_message} +Alternatively, you can call {package} explicitly with `{package_executable}`. + +You can test that everything is set up by executing: + +`{test_command}` +""" + + +def style( + fg: str | None = None, + bg: str | None = None, + options: str | list[str] | tuple[str, ...] | None = None, +) -> str: + """Create terminal escape code style.""" + codes: list[int | str] = [] + + if fg: + codes.append(FOREGROUND_COLORS[fg]) + + if bg: + codes.append(BACKGROUND_COLORS[bg]) + + if options: + if not isinstance(options, list | tuple): + options = [options] + + codes.extend([OPTIONS[i] for i in options]) + + return "\033[{}m".format(";".join(map(str, codes))) + + +STYLES = { + "info": style("cyan", None, None), + "comment": style("yellow", None, None), + "success": style("green", None, None), + "error": style("red", None, None), + "warning": style("yellow", None, None), + "b": style(None, None, ("bold",)), +} +"""Predetermined message styles.""" + + +def is_decorated() -> bool: + """Determine if terminal output should be decorated.""" + if WINDOWS: + return ( + os.getenv("ANSICON") is not None + or os.getenv("ConEmuANSI") == "ON" # noqa: SIM112 + or os.getenv("Term") == "xterm" # noqa: SIM112 + ) + + if not hasattr(sys.stdout, "fileno"): + return False + + try: + return os.isatty(sys.stdout.fileno()) + except UnsupportedOperation: + return False + + +def colorize(style: str, text: Path | str) -> str: + """Conditionally colorize terminal output.""" + if not is_decorated(): + return str(text) + return f"{STYLES[style]}{text}\033[0m" + + +def string_to_bool(value: bool | str) -> bool: + """Convert string to bool.""" + if isinstance(value, bool): + return value + return value.lower() in {"true", "1", "y", "yes"} + + +class ArchiveExtractor: + """Abstract base class for archive extractors.""" + + archive: Path + """Resolved path to the archive file.""" + + def __init__(self, archive: Path | str) -> None: + """Instantiate class. + + Args: + archive: Path to the archive file. + + """ + self.archive = Path(archive).resolve() + + if not self.archive.is_file(): + raise FileNotFoundError(self.archive) + + def extract(self, destination: Path | None = None) -> Path: + """Extract the archive file. + + Args: + destination: Where the archive file will be extracted to. + + Returns: + Path to the extraction. + + """ + if not destination: + destination = self.archive.parent + else: + destination.mkdir(exist_ok=True, parents=True) + shutil.unpack_archive(self.archive, destination) + return destination + + def __bool__(self) -> Literal[True]: + """Boolean representation of this object.""" + return True + + def __str__(self) -> str: + """String representation of this object.""" + return str(self.archive) + + +class Installer: + """Logic to perform install.""" + + API_URL = f"https://api.github.com/repos/{GITHUB_REPO}" + """GitHub API URL.""" + + GIT_REPO = f"https://github.com/{GITHUB_REPO}" + """Repository where source is stored.""" + + VERSION_REGEX = re.compile( + r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?" + "(" + "[._-]?" + r"(?:(stable|beta|b|rc|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*)?)?" + "([.-]?dev)?" + ")?" + r"(?:\+[^\s]+)?" + ) + + def __init__( + self, + *, + allow_prereleases: bool = False, + force: bool = False, + version: str | None = None, + ) -> None: + """Instantiate class. + + Args: + allow_prereleases: Allows prereleases to be considered for install + when a version is not explicitly provided. + force: Always perform the install, even if the requested version is + detected as the currently installed version. + version: Version to install from GitHub releases. + + """ + self._version = version + self._allow_prereleases = allow_prereleases + self._force = force + + @property + def allows_prereleases(self) -> bool: + """Whether prereleases can be installed.""" + return self._allow_prereleases + + @cached_property + def bin_dir(self) -> Path: + """User's bin directory.""" + rv = Path.home() / ".local/bin" + rv.mkdir(exist_ok=True, parents=True) + return rv + + @cached_property + def current_version(self) -> str | None: + """Currently installed version.""" + if self.version_file.exists(): + version_match = self.VERSION_REGEX.findall(self.version_file.read_text()) + if version_match: + return version_match[0] + return None + + @cached_property + def lib_dir(self) -> Path: + """User's lib directory.""" + rv = Path.home() / ".local/lib" + rv.mkdir(exist_ok=True, parents=True) + return rv + + @cached_property + def releases(self) -> list[dict[str, Any]]: + """List of available releases.""" + metadata = self._get(f"{self.API_URL}/releases") + + def _compare_versions( + x: dict[str, Any], y: dict[str, Any] + ) -> Literal[-1, 0, 1]: + mx = self.VERSION_REGEX.match(x["tag_name"]) + my = self.VERSION_REGEX.match(y["tag_name"]) + + if not mx or not my: + raise NotImplementedError( + f"could not parse a version from {x} and/or {y}" # noqa: EM102 + ) + + vx = (*tuple(int(p) for p in mx.groups()[:3]), mx.group(5)) + vy = (*tuple(int(p) for p in my.groups()[:3]), my.group(5)) + + if vx < vy: + return -1 + if vx > vy: + return 1 + + return 0 + + releases = sorted(metadata, key=cmp_to_key(_compare_versions)) + releases.reverse() + return releases + + @cached_property + def version_file(self) -> Path: + """Path to ``version.sh`` file.""" + return self.lib_dir / "oi" / "version.sh" + + def _get(self, url: str, *, json_response: bool = True) -> Any: # noqa: ANN401 + """Make an HTTP GET request.""" + headers: dict[str, str] = {} + if json_response: + headers.update( + { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + request = Request(url, headers=headers) # noqa: S310 + + with closing(urlopen(request)) as r: # noqa: S310 + response = r.read() + if json_response: + return json.loads(response.decode()) + return response.decode() + + def _install_comment(self, version: str, message: str) -> None: + self.write_stdout( + "Installing {} ({}): {}".format( + colorize("info", "oi"), + colorize("b", version), + colorize("comment", message), + ) + ) + + def write_stdout(self, line: str) -> None: + """Log to stdout.""" + sys.stdout.write(line + "\n") + + def display_post_message(self, version: str) -> None: + """Display post-install message.""" + paths = os.getenv("PATH", "").split(":") + + message = POST_MESSAGE_NOT_IN_PATH + if paths and str(self.bin_dir) in paths: + message = POST_MESSAGE + + self.write_stdout( + message.format( + package=colorize("info", "oi"), + version=colorize("b", version), + home_bin=colorize("comment", self.bin_dir), + package_executable=colorize("b", self.bin_dir / "oi"), + configure_message=POST_MESSAGE_CONFIGURE_UNIX.format( + home_bin=colorize("comment", self.bin_dir) + ), + test_command=colorize("b", "oi --version"), + ) + ) + + def display_pre_message(self) -> None: + """Display pre-install message.""" + self.write_stdout( + PRE_MESSAGE.format( + package=colorize("info", "oi"), + home_bin=colorize("comment", self.bin_dir), + ) + ) + + def download_release_artifact( + self, artifact: ReleaseArtifact, tmp_dir: Path + ) -> Path: + """Download a release artifact.""" + self.write_stdout( + "Downloading from {}...".format( + colorize("info", artifact["browser_download_url"]) + ) + ) + out_file = tmp_dir / artifact["name"] + urlretrieve(artifact["browser_download_url"], out_file) # noqa: S310 + return out_file + + def find_oi_release_artifact( + self, *, artifact_type: Literal["zip", "gtar"] = "gtar", version: str + ) -> ReleaseArtifact: + """Find oi artifact to be downloaded.""" + release = self._get(f"{self.API_URL}/releases/tags/v{version.lstrip('v')}") + asset: ReleaseArtifact | None = None + mime_type = ( + "application/zip" + if artifact_type == "zip" + else f"application/x-{artifact_type}" + ) + for i in release["assets"]: + if i["content_type"] == mime_type: + asset = i + break + + if not asset: + msg = f"Version {version} doesn't have an asset of type '{mime_type}'" + self.write_stdout(colorize("error", msg)) + raise ValueError(msg) + + return asset + + def get_version(self) -> tuple[str | None, str | None]: + """Get version to install.""" + self.write_stdout(colorize("info", "retrieving releases...")) + + release = None + if self._version: + releases = [v for v in self.releases if v["tag_name"] == self._version] + if not releases: + msg = f"Version {self._version} doesn't exist" + self.write_stdout(colorize("error", msg)) + + raise ValueError(msg) + release = releases[0] + + if not release: + for i in self.releases: + if i["prerelease"] and not self.allows_prereleases: + continue + release = i + break + + if not release: + msg = "Unable to determine a release to use, try passing '--allow-prereleases'." + self.write_stdout(colorize("error", msg)) + raise ValueError(msg) + + if self.current_version and ( + ".".join(self.current_version[:3]) + self.current_version[4] + == release["tag_name"].lstrip("v") + and not self._force + ): + self.write_stdout( + f'The latest version ({colorize("b", release["tag_name"])}) is already installed' + ) + + return None, self.current_version + + return release["tag_name"].lstrip("v"), self.current_version + + def install(self, artifact_type: Literal["gtar", "zip"] = "gtar") -> int: + """Installs oi.""" + self.display_pre_message() + try: + version, _ = self.get_version() + except ValueError: + return 1 + + if version is None: + return 0 + + self.write_stdout( + "Installing {} ({})".format( + colorize("info", "oi"), colorize("info", version) + ) + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + extracted = ( + ArchiveExtractor( + self.download_release_artifact( + self.find_oi_release_artifact( + artifact_type=artifact_type, version=version + ), + Path(tmp_dir), + ) + ).extract() + / "oi" + ) + + shutil.rmtree(self.lib_dir / "oi", ignore_errors=True) + shutil.move(extracted, self.lib_dir) + + self._install_comment(version, f"Symlinking into {self.bin_dir}") + bin_file = self.bin_dir / "oi" + if bin_file.exists(): + bin_file.unlink() + bin_file.symlink_to(self.lib_dir / "oi") + self._install_comment(version, "Complete") + self.display_post_message(version) + return 0 + + +class ReleaseArtifact(TypedDict): + """TypedDict for ``asserts`` of ``/repos/{owner}/{repo}/releases/tags/{tag}``.""" + + browser_download_url: str + content_type: str + created_at: str + id: int + name: str + size: int + state: str + updated_at: str + uploader: dict[str, Any] + url: str + + +def main() -> int: + """Entrypoint of this script.""" + parser = argparse.ArgumentParser( + description="Installs the latest (or given) version of oi" + ) + parser.add_argument( + "-a", + "--artifact-type", + action="store", + choices=["gtar", "zip"], + default="gtar", + dest="artifact_type", + help="Artifact type to install.", + ) + parser.add_argument( + "-f", + "--force", + action="store_true", + default=False, + dest="force", + help="Install on top of existing version.", + ) + parser.add_argument( + "-p", + "--allow-prereleases", + action="store_true", + default=False, + dest="allow_prereleases", + help="Allows prereleases to be considered for install when a version is not explicitly provided.", + ) + parser.add_argument( + "--uninstall", + action="store_true", + default=False, + dest="uninstall", + help="Uninstall oi.", + ) + parser.add_argument( + "--version", help="Explicitly provide the version to install.", dest="version" + ) + args = parser.parse_args() + + installer = Installer( + version=args.version, allow_prereleases=args.allow_prereleases, force=args.force + ) + + if args.uninstall: + installer.write_stdout( + colorize( + "red", + "Uninstall is not currently supported. It will be added in the future.", + ) + ) + return 1 + + try: + return installer.install(args.artifact_type) + except Exception as err: # noqa: BLE001 + import traceback + + installer.write_stdout( + colorize("error", "".join(traceback.format_exception(err))) + ) + installer.write_stdout(colorize("error", "Installation failed!")) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/package-lock.json b/package-lock.json index 88bc9bc..b6b0b69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "devDependencies": { "@itprokyle/cspell-dict": "^1.2.7", - "cspell": "^8.16.1" + "cspell": "^8.16.1", + "pyright": "^1.1.389" } }, "node_modules/@cspell/cspell-bundled-dicts": { @@ -986,6 +987,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/gensequence": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-7.0.0.tgz", @@ -1163,6 +1179,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pyright": { + "version": "1.1.389", + "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.389.tgz", + "integrity": "sha512-EYt7yRtG6R6I3C3Wfa6O4tOPnbnN7e3ZG4BF9ZiyY6xs1hJGq2ymINyuWC+da0hPNebuMGkY7vvCnD+R7wwbdg==", + "dev": true, + "license": "MIT", + "bin": { + "pyright": "index.js", + "pyright-langserver": "langserver.index.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", diff --git a/package.json b/package.json index a335f9e..4205209 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "description": "Version management for npm tools.", "devDependencies": { "@itprokyle/cspell-dict": "^1.2.7", - "cspell": "^8.16.1" + "cspell": "^8.16.1", + "pyright": "^1.1.389" }, "name": "oi", "version": "0.0.0" diff --git a/poetry.lock b/poetry.lock index 8b09599..d0891e2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -159,6 +159,33 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "ruff" +version = "0.8.1" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5"}, + {file = "ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087"}, + {file = "ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209"}, + {file = "ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871"}, + {file = "ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1"}, + {file = "ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5"}, + {file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d"}, + {file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26"}, + {file = "ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1"}, + {file = "ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c"}, + {file = "ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa"}, + {file = "ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540"}, + {file = "ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9"}, + {file = "ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5"}, + {file = "ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790"}, + {file = "ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6"}, + {file = "ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737"}, + {file = "ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f"}, +] + [[package]] name = "virtualenv" version = "20.28.0" @@ -182,4 +209,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "8bce006b1ab2a79ca6f8bcd8898814adf80b0fbd44ea5e6db01ac81ac623929b" +content-hash = "94f084115e3a5c08c66533edc4247ad952ac8723c3cdcaf2fd16414404c79753" diff --git a/pyproject.toml b/pyproject.toml index e56716e..bbcecdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,78 @@ python = "^3.12" [tool.poetry.group.dev.dependencies] pre-commit = "^4.0.1" +[tool.poetry.group.lint.dependencies] +ruff = "^0.8.1" + +[tool.pyright] +exclude = [ + "**/.eggs", + "**/.git", + "**/.venv", + "**/__pycache__", + "**/docs", + "**/node_modules", + "**/typings", +] +pythonPlatform = "All" +pythonVersion = "3.10" +reportDuplicateImport = "none" +reportImportCycles = "none" +reportIncompatibleMethodOverride = "warning" +reportMissingTypeStubs = "none" +reportPrivateUsage = "none" +reportUnknownMemberType = "none" +reportUnnecessaryIsInstance = "warning" +reportUnnecessaryTypeIgnoreComment = "warning" +reportUnusedImport = "none" +reportUnusedVariable = "none" +strictParameterNoneValue = false +typeCheckingMode = "strict" +useLibraryCodeForTypes = true +venv = ".venv" + +[tool.ruff] # https://docs.astral.sh/ruff/settings/#top-level +force-exclude = true +line-length = 120 +show-fixes = true +target-version = "py310" + +[tool.ruff.lint] # https://docs.astral.sh/ruff/settings/#lint +ignore = [ + "COM812", # Trailing comma missing + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D215", # Section underline is over-indented + "D406", # Section name should end with a newline + "D407", # Missing dashed underline after section + "D408", # Section underline should be in the line following the section's name + "D409", # Section underline should match the length of its name + "ERA001", # Found commented-out code # NOTE (kyle): incorrectly detects cspell + "FIX002", # Line contains TODO + "TD003", # Missing issue link on the line following this TODO + "TID252", # Relative imports from parent modules are banned +] +select = ["ALL"] + +[tool.ruff.lint.extend-per-file-ignores] # https://docs.astral.sh/ruff/settings/#lintextend-per-file-ignores +"*.py" = [ + "PYI024", # Use `typing.NamedTuple` instead of `collections.namedtuple` - should only apply to pyi +] + +[tool.ruff.lint.flake8-type-checking] # https://docs.astral.sh/ruff/settings/#lint_flake8-type-checking_runtime-evaluated-base-classes +runtime-evaluated-base-classes = [ + "pydantic.BaseModel", + "pydantic.BeforeValidator", +] + +[tool.ruff.lint.pydocstyle] # https://docs.astral.sh/ruff/settings/#lintpydocstyle +convention = "google" + +[tool.ruff.lint.pylint] # https://docs.astral.sh/ruff/settings/#lintpylint +allow-magic-value-types = ["bytes", "int", "str"] +max-args = 15 +max-returns = 10 + [tool.tomlsort] all = true in_place = true