From 8d073d5f60650981ec313e7f611e4a63ef16b272 Mon Sep 17 00:00:00 2001 From: Artem <17594656+lykhvar@users.noreply.github.com> Date: Sat, 28 Sep 2024 11:36:37 +0300 Subject: [PATCH] Initial commit --- .github/dependabot.yml | 7 ++ .github/workflows/auto-merge.yml | 30 +++++ .github/workflows/test.yml | 50 ++++++++ .gitignore | 20 ++++ LICENSE | 21 ++++ README.md | 79 +++++++++++++ pyproject.toml | 194 +++++++++++++++++++++++++++++++ src/ppcli/__about__.py | 4 + src/ppcli/__init__.py | 3 + src/ppcli/app.py | 147 +++++++++++++++++++++++ tests/__init__.py | 3 + tests/conftest.py | 17 +++ tests/pyproject.toml | 17 +++ tests/test_cli.py | 84 +++++++++++++ 14 files changed, 676 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/auto-merge.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/ppcli/__about__.py create mode 100644 src/ppcli/__init__.py create mode 100644 src/ppcli/app.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/pyproject.toml create mode 100644 tests/test_cli.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5f04d37 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +--- +version: 2 +updates: +- package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..d325317 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,30 @@ +--- +name: auto-merge + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + branches: + - main + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + + steps: + - name: Wait for tests to succeed + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ github.ref }} + check-name: check + wait-interval: 10 + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge --auto --squash ${{ github.event.pull_request.html_url }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2387836 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +--- +name: test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + STABLE_PYTHON_VERSION: '3.12' + PYTHONUNBUFFERED: "1" + FORCE_COLOR: "1" + +jobs: + run: + name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Ensure latest pip + run: python -m pip install --upgrade pip + + - name: Install hatch + run: | + pip install hatch + + - name: Run static analysis + run: hatch run lint:style + + - name: Run tests + run: hatch run test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf7f21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*.dll +*.so +*.log +*.swp +/.benchmarks/ +/.cache/ +/.env/ +/.idea/ +/.mypy_cache/ +/.pytest_cache/ +/.ruff_cache/ +/.vscode/ +/backend/dist/ +/dist/ +/site/ +/.coverage* +/coverage.* + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..994c368 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-present Artem Lykhvar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f88162 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# ppcli + +[![PyPI - Version](https://img.shields.io/pypi/v/ppcli.svg)](https://pypi.org/project/ppcli) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ppcli.svg)](https://pypi.org/project/ppcli) + +----- +`ppcli` stands for **pyproject CLI**. It is a Python package designed to provide an easy way to specify and manage auxiliary commands within a `pyproject.toml` file for any Python project. + +## Purpose + +The primary purpose of `ppcli` is to allow developers to define and manage common project tasks, such as test, lint, and migration commands, directly within the `pyproject.toml` file. This ensures that all project-specific commands are centralized and easily accessible. + +## Installation + +Install `ppcli` easily using pip: +```console +pip install ppcli +``` + +## Usage +After installing ppcli, you can define your project-specific commands within your pyproject.toml file under the `[tool.ppcli]` section. + +### Example pyproject.toml Configuration +```toml +[tool.ppcli] +lint="black --check --diff ." +fmt="black ." +clean = [ + "find . -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir", + "coverage erase", +] +test = [ + "clean", + "pytest --cov --blockage -x -s --no-header -ra", +] +``` +### Defining and Combining Commands +* **Single Command**: Each key under [tool.ppcli] represents a command that can be executed. The value can be a single command string or a list of commands. +* **Combined Commands**: Use the keys of other commands to create combined tasks. In the example above, the test command executes the clean command followed by pytest. +* **Environment Variable Substitution**: Include environment variables in your commands using the $VARIABLE_NAME syntax. These will be replaced at runtime with their respective values. Ensure that relevant environment variables are set when running these commands. If a variable is not set, ppcli will raise an error indicating which variable is missing. + +### Running Commands + +To execute the defined commands, simply run the ppcli tool followed by the command name: + +```bash +ppcli +``` +For example: + +```bash +ppcli lint +ppcli fmt +ppcli test +``` + +### Example with Environment Variables + +```toml +[tool.ppcli] +deploy = "scp build/* $DEPLOY_USER@$DEPLOY_HOST:/var/www/app/" +``` + +In this example, ensure that `DEPLOY_USER` and `DEPLOY_HOST` are set in your environment: + +```bash +export DEPLOY_USER=username +export DEPLOY_HOST=example.com +ppcli deploy +``` + +## Contributing + +Contributions are welcome! Please open an issue or a pull request to contribute. + +## License +This project is licensed under the [MIT](https://spdx.org/licenses/MIT.html) License. See the [LICENSE](/LICENSE) file for more details. + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7646310 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,194 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ppcli" +dynamic = ["version"] +description = "Dynamic CLI tool to manage project-specific commands using pyproject.toml configuration" +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = [ + "cli", + "pyproject", + "task-runner", + "automation", + "command-line", + "project-management", + "dev-tools", + "configuration", + "utility", + "scripting", + "tooling", + "developer-tools", +] +authors = [ + { name = "Artem Lykhvar", email = "me@a10r.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "click>=8.0.6", +] + +[project.urls] +Documentation = "https://github.com/lykhvar/ppcli#readme" +Issues = "https://github.com/lykhvar/ppcli/issues" +Source = "https://github.com/lykhvar/ppcli" + +[project.scripts] +ppcli = "ppcli.app:cli" + +[tool.hatch.version] +path = "src/ppcli/__about__.py" + +[tool.hatch.build.targets.sdist] +exclude = [ + "/.github", + "/scripts", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/ppcli"] + +[tool.hatch.envs.default] +dependencies = [ + "coverage[toml]>=6.5", + "pytest", + "pytest-cov", + "pytest-rerunfailures", + "pytest-xdist", +] +[tool.hatch.envs.default.scripts] +test = "pytest {args:tests}" +test-cov = "pytest --cov --cov-report={env:COVERAGE_REPORT:term-missing} --cov-config=pyproject.toml" +full = "test-cov -n auto --reruns 5 --reruns-delay 3 -r aR {args:tests}" + +[[tool.hatch.envs.all.matrix]] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.lint] +detached = true +dependencies = [ + "black>=23.1.0", + "mypy>=1.0.0", + "ruff>=0.0.243", +] + +[tool.hatch.envs.lint.scripts] +typing = "mypy --install-types --non-interactive {args:src/ppcli tests}" +style = [ + "ruff check {args:.}", + "black --check --diff {args:.}", +] +fmt = [ + "black {args:.}", + "ruff check --fix {args:.}", + "style", +] +all = [ + "style", + "typing", +] + +[tool.hatch.envs.coverage] +detached = true +dependencies = [ + "coverage[toml]>=6.2", + "lxml", +] + +[tool.hatch.envs.coverage.scripts] +combine = "coverage combine {args}" +report-xml = "coverage xml" +report-uncovered-html = "coverage html --skip-covered --skip-empty" +generate-summary = "python scripts/generate_coverage_summary.py" +write-summary-report = "python scripts/write_coverage_summary_report.py" + +[tool.black] +target-version = ["py38"] +line-length = 79 +skip-string-normalization = true + +[tool.ruff] +target-version = "py38" +line-length = 79 + +[tool.ruff.lint] +select = [ + "A", + "ARG", + "B", + "C", + "DTZ", + "E", + "EM", + "F", + "FBT", + "I", + "ICN", + "N", + "PLC", + "PLE", + "PLR", + "PLW", + "Q", + "RUF", + "S", + "T", + "TID", + "UP", + "W", + "YTT", +] +ignore = [ + # Allow non-abstract empty methods in abstract base classes + "B027", + # Allow boolean positional values in function calls, like `dict.get(... True)` + "FBT003", + # Ignore checks for possible passwords + "S105", "S106", "S107", + # Ignore complexity + "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", + "EM101" +] +unfixable = [ + # Don't touch unused imports + "F401", +] + +[tool.ruff.lint.isort] +known-first-party = ["ppcli"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.per-file-ignores] +# Tests can use magic values, assertions, and relative imports +"tests/**/*" = ["PLR2004", "S101", "TID252"] + +[tool.coverage.run] +source_pkgs = ["ppcli", "tests"] +branch = true +parallel = true + +[tool.coverage.paths] +ppcli = ["src/ppcli", "*/ppcli/src/ppcli"] +tests = ["tests", "*/ppcli/tests"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/src/ppcli/__about__.py b/src/ppcli/__about__.py new file mode 100644 index 0000000..ba2560b --- /dev/null +++ b/src/ppcli/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2023-present Artem Lykhvar +# +# SPDX-License-Identifier: MIT +__version__ = "0.0.2" diff --git a/src/ppcli/__init__.py b/src/ppcli/__init__.py new file mode 100644 index 0000000..6008773 --- /dev/null +++ b/src/ppcli/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2023-present Artem Lykhvar +# +# SPDX-License-Identifier: MIT diff --git a/src/ppcli/app.py b/src/ppcli/app.py new file mode 100644 index 0000000..faf9f47 --- /dev/null +++ b/src/ppcli/app.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: 2023-present Artem Lykhvar +# +# SPDX-License-Identifier: MIT +import os +import re +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, Generator, List, Union + +from click import Context, Group, pass_context, secho, version_option + +try: + import tomllib +except ImportError: + try: + import tomli as tomllib + except ImportError: + secho( + "You need to install 'tomllib' or 'tomli' to run this application", + err=True, + fg="red", + ) + sys.exit(1) + + +context_settings: Dict[str, Any] = { + "help_option_names": ["--help"], + "ignore_unknown_options": True, +} + + +class App(Group): + def __init__(self, config: Union[Path, None] = None) -> None: + help_text = """ + \b + _ _ + _ __ _ __ __| (_) + | '_ \\ '_ \\/ _| | | + | .__/ .__/\\__|_|_| + |_| |_| + """ + super().__init__( + help=help_text, + context_settings=context_settings, + ) + self.config = config or Path(os.getcwd(), "pyproject.toml") + self.commands = self._commands() + self.invoke_without_command = True + self.no_args_is_help = (True,) + self.chain = True + + @staticmethod + def _execute( + cmd: Generator[str, None, None], ctx: Context # noqa: ARG004 + ) -> None: + for entry in cmd: + secho(f"\n => {entry}") + args = shlex.split(entry) + + if not args: + raise ValueError("Empty command not allowed.") + + subprocess.call(entry, shell=True) # noqa: S602 + + @classmethod + def _unfold( + cls, + cmd: Union[str, List[str]], + entries: Dict[str, Union[str, List[str]]], + ) -> Generator[str, None, None]: + for sub in cmd: + if sub in entries: + sub_cmd = entries.get(sub) + + if isinstance(sub_cmd, list): + yield from cls._unfold(sub_cmd, entries) + else: + yield cls._substitute_env_vars(sub_cmd) + else: + yield cls._substitute_env_vars(sub) + + @staticmethod + def _abort(message: str) -> None: + secho(message, err=True, fg="red") + sys.exit(0) + + def _read_config( + self, entry: str = "ppcli" + ) -> Dict[str, Union[str, List[str]]]: + if not self.config.exists(): + self._abort("No pyproject.toml file found") + try: + with open(self.config, encoding="utf-8") as f: + config = tomllib.loads(f.read()) + return config["tool"].get(entry, {}) + except KeyError: + self._abort("No configuration found at pyproject.toml") + except OSError as err: + self._abort(f"Error loading configuration: {err}") + + @staticmethod + def _substitute_env_vars(command: str) -> str: + def replace_var(match): + var_name = match.group(1) + if var_name in os.environ: + return os.environ[var_name] + else: + message = f"Missing environment variable: {var_name}" + secho(message, err=True, fg="red") + raise ValueError(message) + + try: + return re.sub(r"\$(\w+)", replace_var, command) + except ValueError: + sys.exit(1) + + def _commands(self) -> Dict[str, Any]: + commands: Dict[str, Any] = {} + entries: Dict[str, Union[str, List[str]]] = self._read_config() + + for name, entry in entries.items(): + cmd = [entry] if isinstance(entry, str) else entry + if name in cmd: + self._abort("Command error. Command can't rely on itself.") + + help_text = " && ".join(cmd) if isinstance(cmd, list) else str(cmd) + + def make_command(unfolded): + @pass_context + def command(ctx): + self._execute(unfolded, ctx) + + return command + + commands[name] = self.command( + name=name, + short_help=help_text, + help=help_text, + context_settings=context_settings, + )(make_command(self._unfold(cmd, entries))) + + return commands + + +cli = version_option()(App()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6008773 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2023-present Artem Lykhvar +# +# SPDX-License-Identifier: MIT diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b1bc0b4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest + +pytest_plugins = ["pytester"] +EXEC = "ppcli" + + +@pytest.fixture +def sample(pytester): + pytester.copy_example("pyproject.toml") + + +@pytest.fixture +def run(testdir, sample): # noqa: ARG001 + def do_run(*args): + return testdir.run(EXEC, *list(args)) + + return do_run diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..d04b48e --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,17 @@ +[tool.ppcli] +a = [ + "a1", + "a2", +] +b = "b1" +c = [ + "c1", + "c2", + "a", +] +all = [ + "a", + "b", + "c", +] + diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..fb2d1b6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,84 @@ +import tempfile +from pathlib import Path + +import pytest +from click.core import Command, Context + +from ppcli.__about__ import __version__ +from ppcli.app import App + + +@pytest.fixture +def test_entries(): + return { + "a": ["a1", "a2"], + "b": "b1", + "c": ["c1", "c2", "a"], + "all": ["a", "b", "c"], + } + + +@pytest.fixture +def app(): + config = Path(__file__).parent / "pyproject.toml" + return App(config=config) + + +def test_noentry(app): + assert not app._read_config(entry="keyerror") + + +def test_exit(app): + with pytest.raises(SystemExit): + app._abort("test") + + +def test_nofile(): + with pytest.raises(SystemExit): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir, "pyproject.toml") + App(config=path)._read_config() + + +def test_version_print_lines(run): + output = run("--version") + output_text = "".join(output.outlines) + assert f"version {__version__}" in output_text + + +def test_command_parsing(app, test_entries): + entries = app._read_config() + for key in test_entries: + assert entries.get(key) == test_entries[key] + + +def test_command_substitution(app): + entries = app._read_config() + commands = app._unfold(["all"], entries) + assert list(commands) == ["a1", "a2", "b1", "c1", "c2", "a1", "a2"] + + +def test_command_wraps(app): + commands = app.commands + for key in app._read_config().keys(): + assert isinstance(commands[key], Command) + assert commands[key].name == key + + +def test_execute(app): + cmd = "echo 'test_execution'" + with Context(Command(cmd)) as ctx: + app._execute([cmd], ctx) + + +def test_env_var_substitution(app, monkeypatch): + monkeypatch.setenv("TEST_ENV_VAR", "TEST_VAR") + entries = {"with_env": "echo $TEST_ENV_VAR"} + commands = list(app._unfold(["with_env"], entries)) + assert commands == ["echo TEST_VAR"] + + +def test_invalid_env_var_substitution(app): + entries = {"missing_env_var": "echo $MISSING_VAR"} + with pytest.raises(SystemExit): + list(app._unfold(["missing_env_var"], entries))