Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/code-quality.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 0 additions & 3 deletions .python-version

This file was deleted.

3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"openapi",
"ondelete",
"Autogen",
"openalchemy",
"SPECFILE",
// Python
"myapp",
"mymodel",
Expand Down Expand Up @@ -43,6 +45,7 @@
"isclass",
"noqa",
"datetime",
"chdir",
// RST
"seealso",
"literalinclude",
Expand Down
56 changes: 56 additions & 0 deletions docs/source/cli.rst
Original file line number Diff line number Diff line change
@@ -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 |
+-----------------+--------------+-------------------------------------------+
8 changes: 8 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,11 @@ Examples
:maxdepth: 3

examples/index

CLI
---

.. toctree::
:maxdepth: 3

cli
37 changes: 3 additions & 34 deletions open_alchemy/build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -297,45 +297,14 @@ 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. "
"Try `pip install wheel`."
) 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,
Expand Down
105 changes: 105 additions & 0 deletions open_alchemy/cli.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions open_alchemy/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
1 change: 1 addition & 0 deletions open_alchemy/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions open_alchemy/helpers/command.py
Original file line number Diff line number Diff line change
@@ -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")
46 changes: 23 additions & 23 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,7 @@
],
":python_version<'3.8'": ["typing_extensions>=3.7.4"],
},
entry_points={
"console_scripts": ["openalchemy=open_alchemy.cli:main"],
},
)
Loading