From e728963e12600c20deb84e06b71e3b365e034ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Greinhofer?= Date: Sat, 10 Oct 2020 23:03:31 -0500 Subject: [PATCH] Create a CLI Creates a CLI for OpenAlchemy. The first command available allows the users to build a package for the models and a distributable archive. Other workitems: * Adds `cli` marker and unit tests. * Updates the documentation accordingly. --- .github/workflows/code-quality.yaml | 2 +- .python-version | 3 - cspell.json | 3 + docs/source/cli.rst | 56 ++++++++++ docs/source/index.rst | 8 ++ open_alchemy/build/__init__.py | 37 +------ open_alchemy/cli.py | 105 +++++++++++++++++++ open_alchemy/exceptions.py | 4 + open_alchemy/helpers/__init__.py | 1 + open_alchemy/helpers/command.py | 37 +++++++ setup.cfg | 46 ++++----- setup.py | 3 + tests/conftest.py | 13 +++ tests/open_alchemy/test_build.py | 5 +- tests/test_cli.py | 155 ++++++++++++++++++++++++++++ 15 files changed, 415 insertions(+), 63 deletions(-) delete mode 100644 .python-version create mode 100644 docs/source/cli.rst create mode 100644 open_alchemy/cli.py create mode 100644 open_alchemy/helpers/command.py create mode 100644 tests/test_cli.py diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index ac161972..85e6117d 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -37,7 +37,7 @@ jobs: python -m pip install -e .[dev,test] - name: Test with pytest run: | - pytest + pytest -v staticPython: runs-on: ubuntu-latest strategy: diff --git a/.python-version b/.python-version deleted file mode 100644 index 680a41c6..00000000 --- a/.python-version +++ /dev/null @@ -1,3 +0,0 @@ -3.9.0 -3.8.6 -3.7.9 diff --git a/cspell.json b/cspell.json index 9ecd5922..892b636e 100644 --- a/cspell.json +++ b/cspell.json @@ -15,6 +15,8 @@ "openapi", "ondelete", "Autogen", + "openalchemy", + "SPECFILE", // Python "myapp", "mymodel", @@ -43,6 +45,7 @@ "isclass", "noqa", "datetime", + "chdir", // RST "seealso", "literalinclude", diff --git a/docs/source/cli.rst b/docs/source/cli.rst new file mode 100644 index 00000000..d2f1920e --- /dev/null +++ b/docs/source/cli.rst @@ -0,0 +1,56 @@ +OpenAlchemy CLI +=============== + +openalchemy build +----------------- + +Description +^^^^^^^^^^^ + +Build a Python package containing the models described in the OpenAPI +specification file. + +Usage +^^^^^ + +.. program:: openalchemy + +.. option:: openalchemy build [OPTIONS] SPECFILE PKG_NAME OUTPUT_DIR + + +Extended Description +^^^^^^^^^^^^^^^^^^^^ + +The :samp:`openalchemy build` command builds a reusable Python package based off of +the specification file. + +For instance, running the following command:: + + openalchemy build openapi.yml simple dist + +Produces the following artifacts:: + + dist + └── simple + ├── MANIFEST.in + ├── build + ├── dist + │   ├── simple-0.1-py3-none-any.whl + │   └── simple-0.1.tar.gz + ├── setup.py + ├── simple + │   ├── __init__.py + │   └── spec.json + └── simple.egg-info + +By default, a source and a wheel package are built, but this behavior can be +adjusted by using the :samp:`--format` option. + +Options +^^^^^^^ + ++-----------------+--------------+-------------------------------------------+ +| Name, shorthand | Default | Description | ++-----------------+--------------+-------------------------------------------+ +| --format, -f | sdist, wheel | limit the format to either sdist or wheel | ++-----------------+--------------+-------------------------------------------+ diff --git a/docs/source/index.rst b/docs/source/index.rst index e391854f..5820ec68 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -462,3 +462,11 @@ Examples :maxdepth: 3 examples/index + +CLI +--- + +.. toctree:: + :maxdepth: 3 + + cli diff --git a/open_alchemy/build/__init__.py b/open_alchemy/build/__init__.py index 6b72bb91..2e7c1736 100644 --- a/open_alchemy/build/__init__.py +++ b/open_alchemy/build/__init__.py @@ -4,13 +4,13 @@ import hashlib import json import pathlib -import subprocess # nosec: we are aware of the implications. import sys import typing import jinja2 from .. import exceptions +from .. import helpers from .. import models_file as models_file_module from .. import schemas as schemas_module from .. import types @@ -283,7 +283,7 @@ def build_sdist(name: str, path: str) -> None: path: The package directory. """ pkg_dir = pathlib.Path(path) / name - run([sys.executable, "setup.py", "sdist"], str(pkg_dir)) + helpers.command.run([sys.executable, "setup.py", "sdist"], str(pkg_dir)) def build_wheel(name: str, path: str) -> None: @@ -297,7 +297,7 @@ def build_wheel(name: str, path: str) -> None: """ pkg_dir = pathlib.Path(path) / name try: - run([sys.executable, "setup.py", "bdist_wheel"], str(pkg_dir)) + helpers.command.run([sys.executable, "setup.py", "bdist_wheel"], str(pkg_dir)) except exceptions.BuildError as exc: raise RuntimeError( "Building a wheel package requires the wheel package. " @@ -305,37 +305,6 @@ def build_wheel(name: str, path: str) -> None: ) from exc -def run(cmd: typing.List[str], cwd: str) -> typing.Tuple[str, str]: - """ - Run a shell command. - - Args: - cmd: The command to execute. - cwd: The path where the command must be executed from. - - Returns: - A tuple containing (stdout, stderr). - - """ - output = None - try: - # "nosec" is used here as we believe we followed the guidelines to use - # subprocess securely: - # https://security.openstack.org/guidelines/dg_use-subprocess-securely.html - output = subprocess.run( # nosec - cmd, - cwd=cwd, - check=True, - shell=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except subprocess.CalledProcessError as exc: - raise exceptions.BuildError(str(exc)) from exc - - return output.stdout.decode("utf-8"), output.stderr.decode("utf-8") - - def execute( *, spec: typing.Any, diff --git a/open_alchemy/cli.py b/open_alchemy/cli.py new file mode 100644 index 00000000..a1d01b35 --- /dev/null +++ b/open_alchemy/cli.py @@ -0,0 +1,105 @@ +"""Define the CLI module.""" +import argparse +import logging +import pathlib + +from open_alchemy import PackageFormat +from open_alchemy import build_json +from open_alchemy import build_yaml +from open_alchemy import exceptions + +# Configure the logger. +logging.basicConfig(format="%(message)s", level=logging.INFO) + +# Define constants. +VALID_EXTENSIONS = [".json", ".yml", ".yaml"] + + +def main() -> None: + """Define the CLI entrypoint.""" + args = build_application_parser() + try: + args.func(args) + except exceptions.CLIError as exc: + logging.error("Cannot perform the operation: %s.", exc) + + +def validate_specfile(specfile: pathlib.Path) -> None: + """ + Ensure the specfile is valid. + + The specification file must: + * exist + * have a valid extension (.json, .yml, or .yaml) + + Args: + specfile: Path object representing the specification file. + """ + # Ensure the file exists. + if not specfile.exists(): + raise exceptions.CLIError(f"the specfile '{specfile}' cannot be found") + + # Ensure the extension is valid. + ext = specfile.suffix.lower() + if ext not in VALID_EXTENSIONS: + raise exceptions.CLIError( + f"specification format not supported: '{specfile}' " + f"(valid extensions are {', '.join(VALID_EXTENSIONS)}, " + "case insensitive)" + ) + + +def build_application_parser() -> argparse.Namespace: + """Build the main parser and subparsers.""" + # Define the top level parser. + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(title="subcommands", help="subcommand help") + + # Define the parser for the "build" subcommand. + build_parser = subparsers.add_parser( + "build", + description="Build a package with the SQLAlchemy models.", + help="build a package", + ) + build_parser.add_argument( + "specfile", type=str, help="specify the specification file" + ) + build_parser.add_argument("name", type=str, help="specify the package name") + build_parser.add_argument("output", type=str, help="specify the output directory") + build_parser.add_argument( + "-f", + "--format", + type=str.lower, + choices=["sdist", "wheel"], + help="limit the format to either sdist or wheel, defaults to both", + ) + build_parser.set_defaults(func=build) + + # Return the parsed arguments for a particular command. + return parser.parse_args() + + +def build(args: argparse.Namespace) -> None: + """ + Define the build subcommand. + + Args: + args: CLI arguments from the parser. + """ + # Check the specfile. + specfile = pathlib.Path(args.specfile) + validate_specfile(specfile) + + # Select the builder method. + builders = dict(zip(VALID_EXTENSIONS, [build_json, build_yaml, build_yaml])) + + # Choose the distributable format. + fmt = { + None: PackageFormat.SDIST | PackageFormat.WHEEL, + "sdist": PackageFormat.SDIST, + "wheel": PackageFormat.WHEEL, + } + + # Build the package. + builder = builders.get(specfile.suffix.lower()) + builder(args.specfile, args.name, args.output, fmt.get(args.format)) # type: ignore diff --git a/open_alchemy/exceptions.py b/open_alchemy/exceptions.py index 475847f9..8b47a65e 100644 --- a/open_alchemy/exceptions.py +++ b/open_alchemy/exceptions.py @@ -70,3 +70,7 @@ class InheritanceError(BaseError): class BuildError(BaseError): """Raised when an error related to build occurs.""" + + +class CLIError(BaseError): + """Raised when an error occurs when the CLI is used.""" diff --git a/open_alchemy/helpers/__init__.py b/open_alchemy/helpers/__init__.py index c909b9a7..086d4d2e 100644 --- a/open_alchemy/helpers/__init__.py +++ b/open_alchemy/helpers/__init__.py @@ -2,6 +2,7 @@ # pylint: disable=useless-import-alias from . import all_of as all_of +from . import command from . import ext_prop as ext_prop from . import foreign_key as foreign_key from . import inheritance as inheritance diff --git a/open_alchemy/helpers/command.py b/open_alchemy/helpers/command.py new file mode 100644 index 00000000..0c694183 --- /dev/null +++ b/open_alchemy/helpers/command.py @@ -0,0 +1,37 @@ +"""Execute external commands.""" + +import subprocess # nosec: we are aware of the implications. +import typing + +from .. import exceptions + + +def run(cmd: typing.List[str], cwd: str) -> typing.Tuple[str, str]: + """ + Run a shell command. + + Args: + cmd: The command to execute. + cwd: The path where the command must be executed from. + + Returns: + A tuple containing (stdout, stderr). + + """ + output = None + try: + # "nosec" is used here as we believe we followed the guidelines to use + # subprocess securely: + # https://security.openstack.org/guidelines/dg_use-subprocess-securely.html + output = subprocess.run( # nosec + cmd, + cwd=cwd, + check=True, + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as exc: + raise exceptions.BuildError(str(exc)) from exc + + return output.stdout.decode("utf-8"), output.stderr.decode("utf-8") diff --git a/setup.cfg b/setup.cfg index 49b45a57..b36ca72e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,38 +2,38 @@ universal = 1 [tool:pytest] -addopts = -v --cov=open_alchemy --cov=examples/app --cov-config=tests/.coveragerc --flake8 --strict --cov-report xml --cov-report term +addopts = --cov=open_alchemy --cov=examples/app --cov-config=tests/.coveragerc --flake8 --strict --cov-report xml --cov-report term markers = - model - column - integration - helper app - utility_base - table_args + artifacts + association + build + cli + code_formatter + column + example facade - sqlalchemy + helper + init + integration + mixins + model models_file only_this - example - init - slow schemas - association - code_formatter - mixins - artifacts + slow + sqlalchemy + table_args + utility_base + validation validate - build python_functions = test_* mocked-sessions = examples.app.database.db.session - -[flake8] -per-file-ignores = - */__init__.py:F401 - */models_autogenerated.py:E501 - *models_auto.py:E501 -max-line-length=88 +flake8-max-line-length = 88 +flake8-ignore = + */__init__.py F401 + */models_autogenerated.py E501 + *models_auto.py E501 [rstcheck] ignore_messages=(No role entry for "samp")|(Duplicate implicit target name: )|(Hyperlink target .* is not referenced.) diff --git a/setup.py b/setup.py index e1a21ceb..1e146bf2 100644 --- a/setup.py +++ b/setup.py @@ -72,4 +72,7 @@ ], ":python_version<'3.8'": ["typing_extensions>=3.7.4"], }, + entry_points={ + "console_scripts": ["openalchemy=open_alchemy.cli:main"], + }, ) diff --git a/tests/conftest.py b/tests/conftest.py index 5d2dbe6a..9be46aa8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ """Fixtures for all tests.""" # pylint: disable=redefined-outer-name +import os +import pathlib + import pytest import sqlalchemy from sqlalchemy import orm @@ -30,3 +33,13 @@ def _clean_remote_schemas_store(): yield helpers.ref._remote_schema_store.reset() + + +@pytest.fixture(scope="function") +def _remember_current_directory(): + """Remember the current directory and make sure to switch back to it.""" + current_dir = pathlib.Path.cwd() + + yield + + os.chdir(current_dir) diff --git a/tests/open_alchemy/test_build.py b/tests/open_alchemy/test_build.py index 71f5c8d6..7807c75a 100644 --- a/tests/open_alchemy/test_build.py +++ b/tests/open_alchemy/test_build.py @@ -4,6 +4,7 @@ from open_alchemy import build from open_alchemy import exceptions +from open_alchemy import helpers @pytest.mark.parametrize( @@ -647,13 +648,13 @@ def test_build_dist_wheel_import_error(tmp_path): } try: - build.run(["pip", "uninstall", "-y", "wheel"], ".") + helpers.command.run(["pip", "uninstall", "-y", "wheel"], ".") with pytest.raises(RuntimeError): build.execute( spec=spec, name=name, path=str(dist), format_=build.PackageFormat.WHEEL ) finally: - build.run(["pip", "install", "wheel"], ".") + helpers.command.run(["pip", "install", "wheel"], ".") @pytest.mark.parametrize( diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..32648e48 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,155 @@ +"""Tests for the CLI.""" + +import os +import pathlib +import sys +from unittest import mock + +import pytest + +from open_alchemy import cli +from open_alchemy import exceptions +from open_alchemy import helpers + + +@pytest.mark.parametrize( + "specfile", + [ + pytest.param("spec.json", id="valid json specfile"), + pytest.param("spec.yml", id="valid yml specfile"), + pytest.param("spec.yaml", id="valid yaml specfile"), + ], +) +@pytest.mark.cli +def test_validate_specfile_valid(tmp_path, specfile): + """ + GIVEN a valid specification file + WHEN validate_specfile is called + THEN nothing happens + """ + spec = tmp_path / specfile + spec.write_text("") + + cli.validate_specfile(spec) + + +@pytest.mark.cli +def test_validate_specfile_invalid_extension(tmp_path): + """ + GIVEN a invalid specification file extension + WHEN validate_specfile is called + THEN a ValueError exception is raised + """ + spec = tmp_path / "specfile.txt" + spec.write_text("") + + with pytest.raises(exceptions.CLIError): + cli.validate_specfile(spec) + + +@pytest.mark.cli +def test_validate_specfile_does_not_exist(tmp_path): + """ + GIVEN a specification file which does not exist + WHEN validate_specfile is called + THEN a ValueError exception is raised + """ + spec = tmp_path / "specfile.txt" + + with pytest.raises(exceptions.CLIError): + cli.validate_specfile(spec) + + +@pytest.mark.parametrize( + "command, expected_arguments", + [ + pytest.param( + ["openalchemy", "build", "my_specfile", "my_package", "my_output_dir"], + ["specfile='my_specfile'", "name='my_package'", "output='my_output_dir'"], + id="cli help command", + ), + ], +) +@pytest.mark.cli +def test_build_application_parser(command, expected_arguments): + """ + GIVEN an argument on the command line + WHEN the application parser is built + THEN arguments are returned + """ + sys.argv = command + + args = cli.build_application_parser() + + str_args = str(args) + for expected_argument in expected_arguments: + assert expected_argument in str_args + + +@pytest.mark.cli +def test_build(mocker): + """ + GIVEN arguments from the parser + WHEN they are passed to the build() function + THEN the function execute + """ + m_validate = mocker.patch("open_alchemy.cli.validate_specfile") + m_build_json = mocker.patch("open_alchemy.cli.build_json") + args = mock.MagicMock() + args.specfile = pathlib.Path("spec.json") + + cli.build(args) + + m_validate.assert_called_once() + m_build_json.assert_called_once() + + +@pytest.mark.parametrize( + "command, expected_file", + [ + pytest.param( + f"build {pathlib.Path.cwd() / 'examples' / 'simple' / 'example-spec.yml'}" + " simple .", + "simple/setup.py", + id="cli build all", + ), + ], +) +@pytest.mark.cli +def test_main(tmp_path, _remember_current_directory, command, expected_file): + """ + GIVEN CLI options are set + WHEN the CLI is called + THEN the program runs + """ + os.chdir(tmp_path) + sys.argv = ["openalchemy"] + command.split() + + cli.main() + + assert pathlib.Path(expected_file).exists() + + +@pytest.mark.cli +def test_invalid_main(tmp_path, _remember_current_directory): + """ + GIVEN an invalid CLI command is used + WHEN the CLI is called + THEN the program fails and the error is logged + """ + os.chdir(tmp_path) + sys.argv = ["openalchemy", "build", "my_specfile", "my_package", "my_output_dir"] + + cli.main() + + +@pytest.mark.cli +def test_external_cli(): + """ + GIVEN + WHEN we run the CLI as an external command + THEN nothing fails + """ + out, _ = helpers.command.run(["openalchemy", "--help"], str(pathlib.Path.cwd())) + + assert "usage: openalchemy" in out