From ddb91be16852b6aa36817e92144c0b72eb4b5368 Mon Sep 17 00:00:00 2001 From: Kyle Finley Date: Mon, 2 Dec 2024 16:02:18 -0500 Subject: [PATCH] add install script (#14) --- .github/workflows/ci.yml | 8 + .vscode/cspell.json | 4 +- Makefile | 27 +- README.md | 53 ++++ install.py | 597 +++++++++++++++++++++++++++++++++++++++ lib/oi/LICENSE | 201 +++++++++++++ lib/oi/cache.sh | 108 +++++++ lib/oi/color.sh | 241 ++++++++++++++++ lib/oi/const.sh | 113 ++++++++ lib/oi/debug.sh | 14 + lib/oi/exit.sh | 88 ++++++ lib/oi/fs.sh | 79 ++++++ lib/oi/help.sh | 45 +++ lib/oi/jq.sh | 177 ++++++++++++ lib/oi/log.sh | 279 ++++++++++++++++++ lib/oi/oi | 40 +++ lib/oi/oi.sh | 54 ++++ lib/oi/string.sh | 96 +++++++ lib/oi/var.sh | 181 ++++++++++++ lib/oi/version.sh | 4 + package-lock.json | 35 ++- package.json | 3 +- poetry.lock | 31 +- pyproject.toml | 74 ++++- 24 files changed, 2544 insertions(+), 8 deletions(-) create mode 100755 install.py create mode 100644 lib/oi/LICENSE create mode 100644 lib/oi/cache.sh create mode 100644 lib/oi/color.sh create mode 100644 lib/oi/const.sh create mode 100644 lib/oi/debug.sh create mode 100644 lib/oi/exit.sh create mode 100644 lib/oi/fs.sh create mode 100644 lib/oi/help.sh create mode 100644 lib/oi/jq.sh create mode 100644 lib/oi/log.sh create mode 100755 lib/oi/oi create mode 100644 lib/oi/oi.sh create mode 100644 lib/oi/string.sh create mode 100644 lib/oi/var.sh create mode 100644 lib/oi/version.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c2249c..b0f02da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,14 @@ 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: + disable-tests: true + 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..b6bc639 100644 --- a/Makefile +++ b/Makefile @@ -11,12 +11,35 @@ 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}" == "yes" ]]; 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/README.md b/README.md index 5a4f566..01d24d8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,59 @@ OI! A zsh function library based on [bashio](https://github.com/hassio-addons/bashio) but for general use. While primarily intended for use with zsh, OI should be mostly compatible with Bash. +## Installation + +OI provides a [Python install script](https://github.com/finleyfamily/oi/blob/master/install.py) to make installation easy. +The install script requires Python ^3.10. + +```console +curl -sSL https://raw.githubusercontent.com/finleyfamily/oi/refs/heads/master/install.py | python3 - +``` + +> ℹ️ **NOTE:** On some systems, `python` may still refer to Python 2 instead of Python 3. +> It is suggested to use the `python3` binary to avoid ambiguity. + +To install a specific version of OI, the `--version` option can be passed to the script. + +```console +curl -sSL https://raw.githubusercontent.com/finleyfamily/oi/refs/heads/master/install.py | python3 - --version 1.0.0 +``` + +To install a pre-release version of OI, the `--allow-prereleases` flag can be provided. + +```console +curl -sSL https://raw.githubusercontent.com/finleyfamily/oi/refs/heads/master/install.py | python3 - --allow-prereleases +``` + +By default the `.tar.gz` artifact of OI is installed. +If perferred, the `.zip` artifact can be used by passing `--artifact-type zip` to the script. + +```console +curl -sSL https://raw.githubusercontent.com/finleyfamily/oi/refs/heads/master/install.py | python3 - --artifact-type zip +``` + +To uninstall OI, pass the `--uninstall` flag to the script. + +```console +curl -sSL https://raw.githubusercontent.com/finleyfamily/oi/refs/heads/master/install.py | python3 - --uninstall +``` + +### Adding OI to your PATH + +The install script creates an `oi` symlink in a well-known, platform-specific directory: + +- `$HOME/.local/bin` on Linux/Unix/macOS + +If this directory is not present in your `PATH`, it should be added. + +Alternatively, the full path to the OI script can always be used: + +- `~/.local/lib/oi/oi` on Linux/Unix/macOS + +### Updating + +To update OI, simply follow the steps in [Installation](#installation) section again. + ## Usage Configuring a zsh script to use the OI library is fairly easy. Simply replace the shebang of your script with from `zsh` to `oi`. diff --git a/install.py b/install.py new file mode 100755 index 0000000..57f641b --- /dev/null +++ b/install.py @@ -0,0 +1,597 @@ +#!/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) -> tuple[str, str, str, str, str, str, str, 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 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, tuple[str, str, str, str, str, str, str, 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" / "oi") + self._install_comment(version, "Complete") + self.display_post_message(version) + return 0 + + def uninstall(self) -> int: + """Uninstall oi.""" + lib_dir = self.lib_dir / "oi" + if not lib_dir.exists(): + self.write_stdout( + "{} is not currently installed.".format(colorize("info", "oi")) + ) + return 1 + + if self.current_version: + self.write_stdout( + "Removing {} ({})".format( + colorize("info", "oi"), + colorize( + "b", + ".".join(self.current_version[:3]) + self.current_version[4], + ), + ) + ) + else: + self.write_stdout("Removing {}".format(colorize("info", "oi"))) + + (self.bin_dir / "oi").unlink(missing_ok=True) + if lib_dir.exists(): + shutil.rmtree(lib_dir) + return 0 + + def write_stdout(self, line: str) -> None: + """Log to stdout.""" + sys.stdout.write(line + "\n") + + +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( + add_help=False, + 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 + ) + + try: + if args.uninstall: + return installer.uninstall() + 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/lib/oi/LICENSE b/lib/oi/LICENSE new file mode 100644 index 0000000..359d68f --- /dev/null +++ b/lib/oi/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Kyle Finley + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/lib/oi/cache.sh b/lib/oi/cache.sh new file mode 100644 index 0000000..869f4ef --- /dev/null +++ b/lib/oi/cache.sh @@ -0,0 +1,108 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::cache.exists() { + # + # Check if a cache key exists in the cache + # + # Arguments: + # $1 Cache key + # + local key=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if oi::fs.file_exists "${__OI_CACHE_DIR}/${key}.cache"; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::cache.get() { + # + # Returns the cached value based on a key + # + # Arguments: + # $1 Cache key + # + local key=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if ! oi::cache.exists "${key}"; then + return "${__OI_EXIT_NOK}"; + fi + + printf "%s" "$(<"${__OI_CACHE_DIR}/${key}.cache")"; + return "${__OI_EXIT_OK}"; +} + +function oi::cache.set() { + # + # Cache a value identified by a given key + # + # Arguments: + # $1 Cache key + # $2 Cache value + # + local key=${1}; + local value=${2}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if ! oi::fs.directory_exists "${__OI_CACHE_DIR}"; then + mkdir -p "${__OI_CACHE_DIR}" || + oi::exit.error "Could not create cache folder"; + fi + + if ! printf "%s" "$value" > "${__OI_CACHE_DIR}/${key}.cache"; then + oi::log.warning "An error occurred while storing ${key} to cache"; + return "${__OI_EXIT_NOK}"; + fi + + return "${__OI_EXIT_OK}"; +} + +function oi::cache.flush() { + # + # Remove a specific item from the cache based on the caching key + # + # Arguments: + # $1 Cache key + # + local key=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if ! rm -f "${__OI_CACHE_DIR}/${key}.cache"; then + oi::exit.error "An error while flushing ${key} from cache"; + return "${__OI_EXIT_NOK}"; + fi + + return "${__OI_EXIT_OK}"; +} + +function oi::cache.flush_all() { + # + # Flush all cached data + # + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}"; + + if ! oi::fs.directory_exists "${__OI_CACHE_DIR}"; then + return "${__OI_EXIT_OK}"; + fi + + if ! rm -f -r "${__OI_CACHE_DIR}"; then + oi::exit.error "Could not flush cache"; + return "${__OI_EXIT_NOK}"; + fi + + return "${__OI_EXIT_OK}"; +} diff --git a/lib/oi/color.sh b/lib/oi/color.sh new file mode 100644 index 0000000..b923d4c --- /dev/null +++ b/lib/oi/color.sh @@ -0,0 +1,241 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::color.reset() { + # + # Reset color output (background and foreground colors). + # + echo -n -e "${__OI_COLORS_RESET}"; +} + +function oi::color.default() { + # + # Set default output color. + # + echo -n -e "${__OI_COLORS_DEFAULT}"; +} + +function oi::color.black() { + # + # Set font output color to black. + # + echo -n -e "${__OI_COLORS_BLACK}"; +} + +function oi::color.red() { + # + # Set font output color to red. + # + echo -n -e "${__OI_COLORS_RED}"; +} + +function oi::color.green() { + # + # Set font output color to green. + # + echo -n -e "${__OI_COLORS_GREEN}"; +} + +function oi::color.yellow() { + # + # Set font output color to yellow. + # + echo -n -e "${__OI_COLORS_YELLOW}"; +} + +function oi::color.blue() { + # + # Set font output color to blue. + # + echo -n -e "${__OI_COLORS_BLUE}"; +} + +function oi::color.magenta() { + # + # Set font output color to magenta. + # + echo -n -e "${__OI_COLORS_MAGENTA}"; +} + +function oi::color.cyan() { + # + # Set font output color to cyan. + # + echo -n -e "${__OI_COLORS_CYAN}"; +} + +function oi::color.bg.default() { + # + # Set font output color background to default. + # + echo -n -e "${__OI_COLORS_BG_DEFAULT}"; +} + +function oi::color.bg.black() { + # + # Set font output color background to black. + # + echo -n -e "${__OI_COLORS_BG_BLACK}"; +} + +function oi::color.bg.red() { + # + # Set font output color background to red. + # + echo -n -e "${__OI_COLORS_BG_RED}"; +} + +function oi::color.bg.green() { + # + # Set font output color background to green. + # + echo -n -e "${__OI_COLORS_BG_GREEN}"; +} + +function oi::color.bg.yellow() { + # + # Set font output color background to yellow. + # + echo -n -e "${__OI_COLORS_BG_YELLOW}"; +} + +function oi::color.bg.blue() { + # + # Set font output color background to blue. + # + echo -n -e "${__OI_COLORS_BG_BLUE}"; +} + +function oi::color.bg.magenta() { + # + # Set font output color background to magenta. + # + echo -n -e "${__OI_COLORS_BG_MAGENTA}"; +} + +function oi::color.bg.cyan() { + # + # Set font output color background to cyan. + # + echo -n -e "${__OI_COLORS_BG_CYAN}"; +} + +function oi::color.bg.white() { + # + # Set font output color background to white. + # + echo -n -e "${__OI_COLORS_BG_WHITE}"; +} + +function oi::color.bold.default() { + # + # Set default output color. + # + echo -n -e "${__OI_COLORS_BOLD_DEFAULT}"; +} + +function oi::color.bold.black() { + # + # Set font output color to black. + # + echo -n -e "${__OI_COLORS_BOLD_BLACK}"; +} + +function oi::color.bold.red() { + # + # Set font output color to red. + # + echo -n -e "${__OI_COLORS_BOLD_RED}"; +} + +function oi::color.bold.green() { + # + # Set font output color to green. + # + echo -n -e "${__OI_COLORS_BOLD_GREEN}"; +} + +function oi::color.bold.yellow() { + # + # Set font output color to yellow. + # + echo -n -e "${__OI_COLORS_BOLD_YELLOW}"; +} + +function oi::color.bold.blue() { + # + # Set font output color to blue. + # + echo -n -e "${__OI_COLORS_BOLD_BLUE}"; +} + +function oi::color.bold.magenta() { + # + # Set font output color to magenta. + # + echo -n -e "${__OI_COLORS_BOLD_MAGENTA}"; +} + +function oi::color.bold.cyan() { + # + # Set font output color to cyan. + # + echo -n -e "${__OI_COLORS_BOLD_CYAN}"; +} + +function oi::color.dim.default() { + # + # Set default output color. + # + echo -n -e "${__OI_COLORS_DIM_DEFAULT}"; +} + +function oi::color.dim.black() { + # + # Set font output color to black. + # + echo -n -e "${__OI_COLORS_DIM_BLACK}"; +} + +function oi::color.dim.red() { + # + # Set font output color to red. + # + echo -n -e "${__OI_COLORS_DIM_RED}"; +} + +function oi::color.dim.green() { + # + # Set font output color to green. + # + echo -n -e "${__OI_COLORS_DIM_GREEN}"; +} + +function oi::color.dim.yellow() { + # + # Set font output color to yellow. + # + echo -n -e "${__OI_COLORS_DIM_YELLOW}"; +} + +function oi::color.dim.blue() { + # + # Set font output color to blue. + # + echo -n -e "${__OI_COLORS_DIM_BLUE}"; +} + +function oi::color.dim.magenta() { + # + # Set font output color to magenta. + # + echo -n -e "${__OI_COLORS_DIM_MAGENTA}"; +} + +function oi::color.dim.cyan() { + # + # Set font output color to cyan. + # + echo -n -e "${__OI_COLORS_DIM_CYAN}"; +} diff --git a/lib/oi/const.sh b/lib/oi/const.sh new file mode 100644 index 0000000..7a1b85b --- /dev/null +++ b/lib/oi/const.sh @@ -0,0 +1,113 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== +set -o errexit; # Exit script when a command exits with non-zero status +# set -o errtrace; # Exit on error inside any functions or sub-shells +set -o nounset; # Exit script on use of an undefined variable +set -o pipefail; # Return exit status of the last command in the pipe that failed +# ============================================================================== + +# Defaults +readonly __OI_DEFAULT_CACHE_DIR="/tmp/.oi"; +readonly __OI_DEFAULT_LOG_LEVEL=5; # Defaults to INFO +readonly __OI_DEFAULT_LOG_TIMESTAMP="%T"; + +# Exit codes +readonly __OI_EXIT_OK=0; # Successful termination +readonly __OI_EXIT_NOK=1; # Termination with errors + +# Colors +readonly __OI_COLORS_ESCAPE="\033["; +readonly __OI_COLORS_RESET="${__OI_COLORS_ESCAPE}0m"; + +readonly __OI_COLORS_DEFAULT="${__OI_COLORS_ESCAPE}39m"; +readonly __OI_COLORS_BLACK="${__OI_COLORS_ESCAPE}30m"; +readonly __OI_COLORS_RED="${__OI_COLORS_ESCAPE}31m"; +readonly __OI_COLORS_GREEN="${__OI_COLORS_ESCAPE}32m"; +readonly __OI_COLORS_YELLOW="${__OI_COLORS_ESCAPE}33m"; +readonly __OI_COLORS_BLUE="${__OI_COLORS_ESCAPE}34m"; +readonly __OI_COLORS_MAGENTA="${__OI_COLORS_ESCAPE}35m"; +readonly __OI_COLORS_CYAN="${__OI_COLORS_ESCAPE}36m"; +readonly __OI_COLORS_LIGHT_GRAY="${__OI_COLORS_ESCAPE}37m"; + +readonly __OI_COLORS_BG_DEFAULT="${__OI_COLORS_ESCAPE}49m"; +readonly __OI_COLORS_BG_BLACK="${__OI_COLORS_ESCAPE}40m"; +readonly __OI_COLORS_BG_RED="${__OI_COLORS_ESCAPE}41m"; +readonly __OI_COLORS_BG_GREEN="${__OI_COLORS_ESCAPE}42m"; +readonly __OI_COLORS_BG_YELLOW="${__OI_COLORS_ESCAPE}43m"; +readonly __OI_COLORS_BG_BLUE="${__OI_COLORS_ESCAPE}44m"; +readonly __OI_COLORS_BG_MAGENTA="${__OI_COLORS_ESCAPE}45m"; +readonly __OI_COLORS_BG_CYAN="${__OI_COLORS_ESCAPE}46m"; +readonly __OI_COLORS_BG_WHITE="${__OI_COLORS_ESCAPE}47m"; + +readonly __OI_COLORS_BOLD_DEFAULT="${__OI_COLORS_ESCAPE}39;1m"; +readonly __OI_COLORS_BOLD_BLACK="${__OI_COLORS_ESCAPE}30;1m"; +readonly __OI_COLORS_BOLD_RED="${__OI_COLORS_ESCAPE}31;1m"; +readonly __OI_COLORS_BOLD_GREEN="${__OI_COLORS_ESCAPE}32;1m"; +readonly __OI_COLORS_BOLD_YELLOW="${__OI_COLORS_ESCAPE}33;1m"; +readonly __OI_COLORS_BOLD_BLUE="${__OI_COLORS_ESCAPE}34;1m"; +readonly __OI_COLORS_BOLD_MAGENTA="${__OI_COLORS_ESCAPE}35;1m"; +readonly __OI_COLORS_BOLD_CYAN="${__OI_COLORS_ESCAPE}36;1m"; +readonly __OI_COLORS_BOLD_LIGHT_GRAY="${__OI_COLORS_ESCAPE}37;1m"; + +readonly __OI_COLORS_DIM_DEFAULT="${__OI_COLORS_ESCAPE}39;2m"; +readonly __OI_COLORS_DIM_BLACK="${__OI_COLORS_ESCAPE}30;2m"; +readonly __OI_COLORS_DIM_RED="${__OI_COLORS_ESCAPE}31;2m"; +readonly __OI_COLORS_DIM_GREEN="${__OI_COLORS_ESCAPE}32;2m"; +readonly __OI_COLORS_DIM_YELLOW="${__OI_COLORS_ESCAPE}33;2m"; +readonly __OI_COLORS_DIM_BLUE="${__OI_COLORS_ESCAPE}34;2m"; +readonly __OI_COLORS_DIM_MAGENTA="${__OI_COLORS_ESCAPE}35;2m"; +readonly __OI_COLORS_DIM_CYAN="${__OI_COLORS_ESCAPE}36;2m"; +readonly __OI_COLORS_DIM_LIGHT_GRAY="${__OI_COLORS_ESCAPE}37;2m"; + +readonly __OI_COLORS_ITALIC_DEFAULT="${__OI_COLORS_ESCAPE}39;3m"; +readonly __OI_COLORS_ITALIC_BLACK="${__OI_COLORS_ESCAPE}30;3m"; +readonly __OI_COLORS_ITALIC_RED="${__OI_COLORS_ESCAPE}31;3m"; +readonly __OI_COLORS_ITALIC_GREEN="${__OI_COLORS_ESCAPE}32;3m"; +readonly __OI_COLORS_ITALIC_YELLOW="${__OI_COLORS_ESCAPE}33;3m"; +readonly __OI_COLORS_ITALIC_BLUE="${__OI_COLORS_ESCAPE}34;3m"; +readonly __OI_COLORS_ITALIC_MAGENTA="${__OI_COLORS_ESCAPE}35;3m"; +readonly __OI_COLORS_ITALIC_CYAN="${__OI_COLORS_ESCAPE}36;3m"; +readonly __OI_COLORS_ITALIC_LIGHT_GRAY="${__OI_COLORS_ESCAPE}37;3m"; + +# Log levels +readonly __OI_LOG_LEVEL_ALL=8; +readonly __OI_LOG_LEVEL_DEBUG=6; +readonly __OI_LOG_LEVEL_ERROR=2; +readonly __OI_LOG_LEVEL_FATAL=1; +readonly __OI_LOG_LEVEL_INFO=5; +readonly __OI_LOG_LEVEL_NOTICE=4; +readonly __OI_LOG_LEVEL_OFF=0; +readonly __OI_LOG_LEVEL_TRACE=7; +readonly __OI_LOG_LEVEL_WARNING=3; + +if [[ "${BASH:-}" ]]; then + # shellcheck disable=SC2004 + readonly -a __OI_LOG_LEVELS=( + [${__OI_LOG_LEVEL_OFF}]="OFF" + [${__OI_LOG_LEVEL_FATAL}]="FATAL" + [${__OI_LOG_LEVEL_ERROR}]="ERROR" + [${__OI_LOG_LEVEL_WARNING}]="WARNING" + [${__OI_LOG_LEVEL_NOTICE}]="NOTICE" + [${__OI_LOG_LEVEL_INFO}]="INFO" + [${__OI_LOG_LEVEL_DEBUG}]="DEBUG" + [${__OI_LOG_LEVEL_TRACE}]="TRACE" + [${__OI_LOG_LEVEL_ALL}]="ALL" + ) +else + # shellcheck disable=SC2004 + readonly -A __OI_LOG_LEVELS=( + [${__OI_LOG_LEVEL_OFF}]="OFF" + [${__OI_LOG_LEVEL_FATAL}]="FATAL" + [${__OI_LOG_LEVEL_ERROR}]="ERROR" + [${__OI_LOG_LEVEL_WARNING}]="WARNING" + [${__OI_LOG_LEVEL_NOTICE}]="NOTICE" + [${__OI_LOG_LEVEL_INFO}]="INFO" + [${__OI_LOG_LEVEL_DEBUG}]="DEBUG" + [${__OI_LOG_LEVEL_TRACE}]="TRACE" + [${__OI_LOG_LEVEL_ALL}]="ALL" + ) +fi + +# Log format +readonly __OI_DEFAULT_LOG_FORMAT="${__OI_COLORS_DIM_CYAN}[{TIMESTAMP}]${__OI_COLORS_RESET} ${__OI_COLORS_ESCAPE}1m{LEVEL}:${__OI_COLORS_RESET} {MESSAGE}"; diff --git a/lib/oi/debug.sh b/lib/oi/debug.sh new file mode 100644 index 0000000..49aa58a --- /dev/null +++ b/lib/oi/debug.sh @@ -0,0 +1,14 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::debug { + # + # Checks if we are currently running in debug mode, based on the log module. + # + if [[ "${__OI_LOG_LEVEL}" -lt "${__OI_LOG_LEVEL_DEBUG}" ]]; then + return "${__OI_EXIT_NOK}" + fi + + return "${__OI_EXIT_OK}" +} diff --git a/lib/oi/exit.sh b/lib/oi/exit.sh new file mode 100644 index 0000000..35d80ad --- /dev/null +++ b/lib/oi/exit.sh @@ -0,0 +1,88 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::exit.error() { + # + # Exit the script as failed with an optional error message. + # + # Arguments: + # $1 Error message (optional) + # + local message=${1:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if oi::var.has_value "${message}"; then + oi::log.fatal "${message}"; + fi + + exit "${__OI_EXIT_NOK}"; +} + +function oi::exit.die_if_false() { + # + # Exit the script when given value is false, with an optional error message. + # + # Arguments: + # $1 Value to check if false + # $2 Error message (optional) + # + local value=${1:-}; + local message=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if oi::var.false "${value}"; then + oi::exit.error "${message}"; + fi +} + +function oi::die_if_true() { + # + # Exit the script when given value is true, with an optional error message. + # + # Arguments: + # $1 Value to check if true + # $2 Error message (optional) + # + local value=${1:-}; + local message=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if oi::var.true "${value}"; then + oi::exit.error "${message}"; + fi +} + +function oi::die_if_empty() { + # + # Exit the script when given value is empty, with an optional error message. + # + # Arguments: + # $1 Value to check if true + # $2 Error message (optional) + # + local value=${1:-}; + local message=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if oi::var.is_empty "${value}"; then + oi::exit.error "${message}"; + fi +} + +function oi::exit.ok() { + # + # Exit the script nicely. + # + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + exit "${__OI_EXIT_OK}"; +} diff --git a/lib/oi/fs.sh b/lib/oi/fs.sh new file mode 100644 index 0000000..a493d6c --- /dev/null +++ b/lib/oi/fs.sh @@ -0,0 +1,79 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::fs.directory_exists() { + # + # Check whether or not a directory exists. + # + # Arguments: + # $1 Path to directory + # + local directory=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ -d "${directory}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::fs.file_exists() { + # + # Check whether or not a file exists. + # + # Arguments: + # $1 Path to file + # + local file=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ -f "${file}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::fs.device_exists() { + # + # Check whether or not a device exists. + # + # Arguments: + # $1 Path to device + # + local device=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ -d "${device}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::fs.socket_exists() { + # + # Check whether or not a socket exists. + # + # Arguments: + # $1 Path to socket + # + local socket=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ -S "${socket}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} diff --git a/lib/oi/help.sh b/lib/oi/help.sh new file mode 100644 index 0000000..557ba77 --- /dev/null +++ b/lib/oi/help.sh @@ -0,0 +1,45 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::_help.list_functions { + # + # Helper to generate a list of OI functions to provide in help output. + # + local rv; + + rv=""; + + # shellcheck disable=SC2296 + for func in ${(ok)functions}; do + if [[ "${func}" = oi::* ]] && [[ "${func}" != "oi::_"* ]]; then + rv+=" ${func}\n" + fi + done + + echo "${rv}"; +} + +function oi::_help { + # + # Outputs help info for OI. + # + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + oi::log "\ + + OI library. + + Provides functions to simplify writing scripts. + + Usage: oi [OPTIONS] + + Library Functions: +$(oi::_help.list_functions) + + Arguments: + -h, --help show this help message and exit + --version show the version of this library and exit + + "; +} diff --git a/lib/oi/jq.sh b/lib/oi/jq.sh new file mode 100644 index 0000000..4ee2bc0 --- /dev/null +++ b/lib/oi/jq.sh @@ -0,0 +1,177 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::jq() { + # + # Execute a JSON query. + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ -f "${data}" ]]; then + jq --raw-output -c -M "$filter" "${data}"; + else + jq --raw-output -c -M "$filter" <<< "${data}"; + fi +} + +function oi::jq.exists() { + # + # Checks if variable exists (optionally after filtering). + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ $(oi::jq "${data}" "${filter}") = "null" ]]; then + return "${__OI_EXIT_NOK}"; + fi + + return "${__OI_EXIT_OK}"; +} + +function oi::jq.has_value() { + # + # Checks if data exists (optionally after filtering). + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + local value; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + value=$(oi::jq "${data}" \ + "${filter} | if (. == {} or . == []) then empty else . end // empty"); + + if ! oi::var.has_value "${value}"; then + return "${__OI_EXIT_NOK}"; + fi + + return "${__OI_EXIT_OK}"; +} + +function oi::jq.is() { + # + # Checks if resulting data is of a specific type. + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter + # $3 type (boolean, string, number, array, object, null) + # + local data=${1}; + local filter=${2}; + local type=${3}; + local value; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + value=$(oi::jq "${data}" \ + "${filter} | if type==\"${type}\" then true else false end"); + + if [[ "${value}" = "false" ]]; then + return "${__OI_EXIT_NOK}"; + fi + + return "${__OI_EXIT_OK}"; +} + +function oi::jq.is_boolean() { + # + # Checks if resulting data is a boolean. + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + oi::jq.is "${data}" "${filter}" "boolean"; +} + +function oi::jq.is_string() { + # + # Checks if resulting data is a string. + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + oi::jq.is "${data}" "${filter}" "string"; +} + +function oi::jq.is_object() { + # + # Checks if resulting data is an object. + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + oi::jq.is "${data}" "${filter}" "object"; +} + +function oi::jq.is_number() { + # + # Checks if resulting data is a number. + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + oi::jq.is "${data}" "${filter}" "number"; +} + +function oi::jq.is_array() { + # + # Checks if resulting data is an array. + # + # Arguments: + # $1 JSON string or path to a JSON file + # $2 jq filter (optional) + # + local data=${1}; + local filter=${2:-}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + oi::jq.is "${data}" "${filter}" "array"; +} diff --git a/lib/oi/log.sh b/lib/oi/log.sh new file mode 100644 index 0000000..b7d0797 --- /dev/null +++ b/lib/oi/log.sh @@ -0,0 +1,279 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + + +function oi::log() { + # + # Log a message to output. + # + # Arguments: + # $1 Message to display + # + local message=$*; + echo -e "${message}" >&2; + return "${__OI_EXIT_OK}"; +} + +function oi::log.red() { + # + # Log a message to output (in red). + # + # Arguments: + # $1 Message to display + # + local message=$*; + echo -e "${__OI_COLORS_RED}${message}${__OI_COLORS_RESET}" >&2; + return "${__OI_EXIT_OK}"; +} + +function oi::log.green() { + # + # Log a message to output (in green). + # + # Arguments: + # $1 Message to display + # + local message=$*; + echo -e "${__OI_COLORS_GREEN}${message}${__OI_COLORS_RESET}" >&2; + return "${__OI_EXIT_OK}"; +} + +function oi::log.yellow() { + # + # Log a message to output (in yellow). + # + # Arguments: + # $1 Message to display + # + local message=$*; + echo -e "${__OI_COLORS_YELLOW}${message}${__OI_COLORS_RESET}" >&2; + return "${__OI_EXIT_OK}"; +} + +function oi::log.blue() { + # + # Log a message to output (in blue). + # + # Arguments: + # $1 Message to display + # + local message=$*; + echo -e "${__OI_COLORS_BLUE}${message}${__OI_COLORS_RESET}" >&2; + return "${__OI_EXIT_OK}"; +} + +function oi::log.magenta() { + # + # Log a message to output (in magenta). + # + # Arguments: + # $1 Message to display + # + local message=$*; + echo -e "${__OI_COLORS_MAGENTA}${message}${__OI_COLORS_RESET}" >&2; + return "${__OI_EXIT_OK}"; +} + +function oi::log.cyan() { + # + # Log a message to output (in cyan). + # + # Arguments: + # $1 Message to display + # + local message=$*; + echo -e "${__OI_COLORS_CYAN}${message}${__OI_COLORS_RESET}" >&2; + return "${__OI_EXIT_OK}"; +} + +function oi::log.log() { + # + # Log a message using a log level. + # + # Arguments: + # $1 Log level + # $2 Message to display + # + local level=${1}; + local message=${2}; + local timestamp; + local output; + + if [[ "${level}" -gt "${__OI_LOG_LEVEL}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + timestamp=$(date +"${__OI_LOG_TIMESTAMP}"); + + output="${__OI_LOG_FORMAT}"; + output="${output//\{TIMESTAMP\}/"${timestamp}"}"; + output="${output//\{MESSAGE\}/"${message}"}"; + output="${output//\{LEVEL\}/"${__OI_LOG_LEVELS[$level]}"}"; + + echo -e "${output}" >&2; + + return "${__OI_EXIT_OK}"; +} + +function oi::log.trace() { + # + # Log a message @ trace level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_TRACE}" \ + "${__OI_COLORS_DIM_YELLOW}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.debug() { + # + # Log a message @ debug level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_DEBUG}" \ + "${__OI_COLORS_DIM_GREEN}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.deprecated() { + # + # Log a deprecation message @ warning level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_WARNING}" \ + "${__OI_COLORS_BOLD_YELLOW}[DEPRECATED] ${message}${__OI_COLORS_RESET}"; +} + +function oi::log.info() { + # + # Log a message @ info level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_INFO}" \ + "${__OI_COLORS_BLUE}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.notice() { + # + # Log a message @ notice level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_NOTICE}" \ + "${__OI_COLORS_BOLD_MAGENTA}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.success() { + # + # Log a message @ info level that is bold and green. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_INFO}" \ + "${__OI_COLORS_BOLD_GREEN}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.warning() { + # + # Log a message @ warning level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_WARNING}" \ + "${__OI_COLORS_BOLD_YELLOW}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.error() { + # + # Log a message @ error level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_ERROR}" \ + "${__OI_COLORS_RED}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.fatal() { + # + # Log a message @ fatal level. + # + # Arguments: + # $* Message to display + # + local message=$*; + oi::log.log \ + "${__OI_LOG_LEVEL_FATAL}" \ + "${__OI_COLORS_BOLD_RED}${message}${__OI_COLORS_RESET}"; +} + +function oi::log.level() { + # + # Changes the log level of OI on the fly. + # + # Arguments: + # $1 Log level + # + local log_level=${1}; + + # Find the matching log level + case "$(oi::string.lower "${log_level}")" in + all) + log_level="${__OI_LOG_LEVEL_ALL}"; + ;; + trace) + log_level="${__OI_LOG_LEVEL_TRACE}"; + ;; + debug) + log_level="${__OI_LOG_LEVEL_DEBUG}"; + ;; + info) + log_level="${__OI_LOG_LEVEL_INFO}"; + ;; + notice) + log_level="${__OI_LOG_LEVEL_NOTICE}"; + ;; + warning) + log_level="${__OI_LOG_LEVEL_WARNING}"; + ;; + error) + log_level="${__OI_LOG_LEVEL_ERROR}"; + ;; + fatal|critical) + log_level="${__OI_LOG_LEVEL_FATAL}"; + ;; + off) + log_level="${__OI_LOG_LEVEL_OFF}"; + ;; + *) + oi::exit.error "Unknown log_level: ${log_level}" + esac + + export __OI_LOG_LEVEL="${log_level}"; +} diff --git a/lib/oi/oi b/lib/oi/oi new file mode 100755 index 0000000..cb5aa9d --- /dev/null +++ b/lib/oi/oi @@ -0,0 +1,40 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== +# OI is a zsh function library inspired by https://github.com/hassio-addons/bashio. +# ============================================================================== +set -o errexit; # Exit script when a command exits with non-zero status +# set -o errtrace; # Exit on error inside any functions or sub-shells +set -o nounset; # Exit script on use of an undefined variable +set -o pipefail; # Return exit status of the last command in the pipe that failed +# ============================================================================== + +export __OI_BIN; +export __OI_LIB_DIR; + +# shellcheck disable=SC2296 +__OI_BIN="$(readlink -f "${(%):-%x}")"; +__OI_LIB_DIR=$(dirname "${__OI_BIN}"); + +# Include OI library +# shellcheck source=oi.sh +source "${__OI_LIB_DIR}/oi.sh"; + +case $1 in + --help | -h) + oi::_help "$1 option was provided"; + oi::exit.ok "exited early after printing help message"; + ;; + --version) + oi::log.info "OI library version ${OI_VERSION}"; + oi::exit.ok "exited early after printing version"; + ;; + *) ;; +esac + +# Execute source +# shellcheck disable=SC2086,SC2277 +0=${1:?script to source must be provided}; +shift; +# shellcheck source=/dev/null +source "$0" "$@"; diff --git a/lib/oi/oi.sh b/lib/oi/oi.sh new file mode 100644 index 0000000..729abf1 --- /dev/null +++ b/lib/oi/oi.sh @@ -0,0 +1,54 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== +set -o errexit; # Exit script when a command exits with non-zero status +# set -o errtrace; # Exit on error inside any functions or sub-shells +set -o nounset; # Exit script on use of an undefined variable +set -o pipefail; # Return exit status of the last command in the pipe that failed +# ============================================================================== + +# ============================================================================== +# GLOBALS +# ============================================================================== + +# Stores the location of this library +__OI_BIN="${BASH_SOURCE[0]:-}" +if [[ "$__OI_BIN" == "" ]]; then + # shellcheck disable=SC2296 + __OI_BIN="$(readlink -f "${(%):-%x}")"; +else + __OI_BIN="$(readlink -f "${__OI_BIN}")" +fi +__OI_LIB_DIR=$(dirname "${__OI_BIN}"); + +# shellcheck source=version.sh +source "${__OI_LIB_DIR}/version.sh"; + +# shellcheck source=const.sh +source "${__OI_LIB_DIR}/const.sh"; + +# Defaults +declare __OI_LOG_LEVEL=${LOG_LEVEL:-${__OI_DEFAULT_LOG_LEVEL}}; +declare __OI_LOG_FORMAT=${LOG_FORMAT:-${__OI_DEFAULT_LOG_FORMAT}}; +declare __OI_LOG_TIMESTAMP=${LOG_TIMESTAMP:-${__OI_DEFAULT_LOG_TIMESTAMP}}; +declare __OI_CACHE_DIR=${CACHE_DIR:-${__OI_DEFAULT_CACHE_DIR}}; + +# ============================================================================== +# MODULES +# ============================================================================== +source "${__OI_LIB_DIR}/color.sh"; +source "${__OI_LIB_DIR}/log.sh"; + +source "${__OI_LIB_DIR}/fs.sh"; +source "${__OI_LIB_DIR}/cache.sh"; + +# source "${__OI_LIB_DIR}/config.sh"; # TODO (kyle): create similar interface - https://github.com/hassio-addons/bashio/blob/main/lib/config.sh +source "${__OI_LIB_DIR}/debug.sh"; +source "${__OI_LIB_DIR}/exit.sh"; +# source "${__OI_LIB_DIR}/info.sh"; # TODO (kyle): create similar interface without using HASS API - https://github.com/hassio-addons/bashio/blob/main/lib/info.sh +source "${__OI_LIB_DIR}/jq.sh"; +# source "${__OI_LIB_DIR}/os.sh"; # TODO (kyle): create similar interface without using HASS API - https://github.com/hassio-addons/bashio/blob/main/lib/os.sh +source "${__OI_LIB_DIR}/string.sh"; +source "${__OI_LIB_DIR}/var.sh"; + +source "${__OI_LIB_DIR}/help.sh"; diff --git a/lib/oi/string.sh b/lib/oi/string.sh new file mode 100644 index 0000000..44894b1 --- /dev/null +++ b/lib/oi/string.sh @@ -0,0 +1,96 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::string.lower() { + # + # Converts a string to lower case. + # + # Arguments: + # $1 String to convert + # + local string="${1}"; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + printf "%s" "${string,,}"; +} + +function oi::string.upper() { + # + # Converts a string to upper case. + # + # Arguments: + # $1 String to convert + # + local string="${1}"; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + printf "%s" "${string^^}"; +} + +function oi::string.replace() { + # + # Replaces parts of the string with an other string. + # + # Arguments: + # $1 String to make replacements in + # $2 String part to replace + # $3 String replacement + # + local string="${1}"; + local needle="${2}"; + local replacement="${3}"; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + printf "%s" "${string//${needle}/${replacement}}"; +} + +oi::string.length() { + # + # Returns the length of a string. + # + # Arguments: + # $1 String to determine the length of + # + local string="${1}"; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + printf "%s" "${#string}"; +} + +function oi::string.substring() { + # + # Returns a substring of a string. + # + # stringZ=abcABC123ABCabc + # oi::string.substring "${stringZ}" 0 # abcABC123ABCabc + # oi::string.substring "${stringZ}" 1 # bcABC123ABCabc + # oi::string.substring "${stringZ}" 7 # 23ABCabc + # oi::string.substring "${stringZ}" 7 3 # 23AB + # + # Arguments: + # $1 String to return a substring off + # $2 Position to start + # $3 Length of the substring (optional) + # + local string="${1}"; + local position="${2}"; + local length="${3:-}"; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if oi::var.has_value "${length}"; then + printf "%s" "${string:${position}:${length}}"; + else + printf "%s" "${string:${position}}"; + fi +} diff --git a/lib/oi/var.sh b/lib/oi/var.sh new file mode 100644 index 0000000..2627967 --- /dev/null +++ b/lib/oi/var.sh @@ -0,0 +1,181 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +# ============================================================================== + +function oi::var.true() { + # + # Checks if a given value is true. + # + # Arguments: + # $1 value + # + local value=${1:-null}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ "${value}" = "true" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::var.false() { + # + # Checks if a given value is false. + # + # Arguments: + # $1 value + # + local value=${1:-null}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ "${value}" = "false" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::var.defined() { + # + # Checks if a global variable is defined. + # + # Arguments: + # $1 Name of the variable + # + local variable=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + [[ "${!variable-X}" = "${!variable-Y}" ]]; +} + +function oi::var.has_value() { + # + # Checks if a value has actual value. + # + # Arguments: + # $1 Value + # + local value=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ -n "${value}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::var.is_empty() { + # + # Checks if a value is empty. + # + # Arguments: + # $1 Value + # + local value=${1}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ -z "${value}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::var.equals() { + # + # Checks if a value equals. + # + # Arguments: + # $1 Value + # $2 Equals value + # + local value=${1}; + local equals=${2}; + + # shellcheck disable=SC2145,SC2154 + oi::log.trace "${funcstack[@]:0:1}:" "$@"; + + if [[ "${value}" = "${equals}" ]]; then + return "${__OI_EXIT_OK}"; + fi + + return "${__OI_EXIT_NOK}"; +} + +function oi::var.json() { + # + # Creates JSON based on function arguments. + # + # Arguments: + # $@ Bash array of key/value pairs, prefix integer or boolean values with ^ + # + local data=("$@"); + local number_of_items=${#data[@]}; + local json=''; + local separator; + local counter; + local item; + + if [[ ${number_of_items} -eq 0 ]]; then + oi::log.error "Length of input array needs to be at least 2"; + return "${__OI_EXIT_NOK}"; + fi + + if [[ $((number_of_items%2)) -eq 1 ]]; then + oi::log.error "Length of input array needs to be even (key/value pairs)"; + return "${__OI_EXIT_NOK}"; + fi + + counter=0; + for i in "${data[@]}"; do + item="\"$i\""; + + separator="," + if [ $((++counter%2)) -eq 0 ]; then + separator=":"; + + if [[ "${i:0:1}" == "^" ]]; then + item="${i:1}"; + else + item=$(oi::var.json_string "${i}"); + fi + fi + + json="$json$separator$item"; + done + + echo "{${json:1}}"; + return "${__OI_EXIT_OK}"; +} + +function oi::var.json_string() { + # + # Escapes a string for use in a JSON object. + # + # Arguments: + # $1 String to escape + # + local string="${1}"; + local json_string; + + # https://stackoverflow.com/a/50380697/12156188 + if json_string=$(echo -n "${string}" | jq -Rs .); then + echo "${json_string}"; + return "${__OI_EXIT_OK}"; + fi + + oi::log.error "Failed to escape string"; + return "${__OI_EXIT_NOK}"; +} diff --git a/lib/oi/version.sh b/lib/oi/version.sh new file mode 100644 index 0000000..d8da11f --- /dev/null +++ b/lib/oi/version.sh @@ -0,0 +1,4 @@ +#! /usr/bin/env zsh +# shellcheck shell=bash +readonly OI_VERSION="v1.0.0-beta.0"; +export OI_VERSION; 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..2a6d21a 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" @@ -181,5 +208,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" -python-versions = "^3.12" -content-hash = "8bce006b1ab2a79ca6f8bcd8898814adf80b0fbd44ea5e6db01ac81ac623929b" +python-versions = "^3.10" +content-hash = "9df06411fcc1d76e3736efa9f38f3c42e4535f86f96454a86c5a6748077fe822" diff --git a/pyproject.toml b/pyproject.toml index e56716e..96a329e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,83 @@ package-mode = false [tool.poetry.dependencies] -python = "^3.12" +python = "^3.10" [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