Skip to content

Commit

Permalink
Refactor CLI structure for future growth
Browse files Browse the repository at this point in the history
Place each major command/subcommand in its own file. This will help
future growth of `generate` subcommand. Also create separate test files.
  • Loading branch information
subhashb committed Apr 4, 2024
1 parent 4f9dd11 commit 155aa2c
Show file tree
Hide file tree
Showing 10 changed files with 500 additions and 445 deletions.
438 changes: 0 additions & 438 deletions src/protean/cli.py

This file was deleted.

151 changes: 151 additions & 0 deletions src/protean/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
Module that contains the command line app.
Why does this file exist, and why not put this in __main__?
You might be tempted to import things from __main__ later, but that will cause
problems: the code will get executed twice:
- When you run `python -mprotean` python will execute
``__main__.py`` as a script. That means there won't be any
``protean.__main__`` in ``sys.modules``.
- When you import __main__ it will get executed again (as a module) because
there's no ``protean.__main__`` in ``sys.modules``.
Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration
"""

import subprocess

from enum import Enum
from typing import Optional

import typer

from rich import print
from typing_extensions import Annotated

from protean.cli.generate import app as generate_app
from protean.cli.new import new
from protean.exceptions import NoDomainException
from protean.utils.domain import derive_domain

# Create the Typer app
# `no_args_is_help=True` will show the help message when no arguments are passed
app = typer.Typer(no_args_is_help=True)

app.command()(new)
app.add_typer(generate_app, name="generate")


class Category(str, Enum):
CORE = "CORE"
EVENTSTORE = "EVENTSTORE"
DATABASE = "DATABASE"
FULL = "FULL"


def version_callback(value: bool):
if value:
from protean import __version__

typer.echo(f"Protean {__version__}")
raise typer.Exit()


@app.callback()
def main(
ctx: typer.Context,
version: Annotated[
bool, typer.Option(help="Show version information", callback=version_callback)
] = False,
):
"""
Protean CLI
"""


@app.command()
def test(
category: Annotated[
Category, typer.Option("-c", "--category", case_sensitive=False)
] = Category.CORE
):
commands = ["pytest", "--cache-clear", "--ignore=tests/support/"]

match category.value:
case "EVENTSTORE":
# Run tests for EventStore adapters
# FIXME: Add support for auto-fetching supported event stores
for store in ["MEMORY", "MESSAGE_DB"]:
print(f"Running tests for EVENTSTORE: {store}...")
subprocess.call(commands + ["-m", "eventstore", f"--store={store}"])
case "DATABASE":
# Run tests for database adapters
# FIXME: Add support for auto-fetching supported databases
for db in ["POSTGRESQL", "SQLITE"]:
print(f"Running tests for DATABASE: {db}...")
subprocess.call(commands + ["-m", "database", f"--db={db}"])
case "FULL":
# Run full suite of tests with coverage
# FIXME: Add support for auto-fetching supported adapters
subprocess.call(
commands
+ [
"--slow",
"--sqlite",
"--postgresql",
"--elasticsearch",
"--redis",
"--message_db",
"--cov=protean",
"--cov-config",
".coveragerc",
"tests",
]
)

# Test against each supported database
for db in ["POSTGRESQL", "SQLITE"]:
print(f"Running tests for DB: {db}...")

subprocess.call(commands + ["-m", "database", f"--db={db}"])

for store in ["MESSAGE_DB"]:
print(f"Running tests for EVENTSTORE: {store}...")
subprocess.call(commands + ["-m", "eventstore", f"--store={store}"])
case _:
print("Running core tests...")
subprocess.call(commands)


@app.command()
def livereload_docs():
"""Run in shell as `protean livereload-docs`"""
from livereload import Server, shell

server = Server()
server.watch("docs-sphinx/**/*.rst", shell("make html"))
server.watch("./*.rst", shell("make html"))
server.serve(root="build/html", debug=True)


@app.command()
def server(
domain_path: Annotated[str, typer.Argument()] = "",
test_mode: Annotated[Optional[bool], typer.Option()] = False,
):
"""Run Async Background Server"""
# FIXME Accept MAX_WORKERS as command-line input as well
from protean.server import Engine

domain = derive_domain(domain_path)
if not domain:
raise NoDomainException(
"Could not locate a Protean domain. You should provide a domain in"
'"PROTEAN_DOMAIN" environment variable or pass a domain file in options '
'and a "domain.py" module was not found in the current directory.'
)

engine = Engine(domain, test_mode=test_mode)
engine.run()
33 changes: 33 additions & 0 deletions src/protean/cli/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import typer

from typing_extensions import Annotated

from protean.utils.domain import derive_domain

app = typer.Typer(no_args_is_help=True)


@app.callback()
def callback():
"""
If we want to create a CLI app with one single command but
still want it to be a command/subcommand, we need to add a callback.
This can be removed when we have more than one command/subcommand.
https://typer.tiangolo.com/tutorial/commands/one-or-multiple/#one-command-and-one-callback
"""


@app.command()
def docker_compose(
domain_path: Annotated[str, typer.Argument()],
):
"""Generate a `docker-compose.yml` from Domain config"""
print(f"Generating docker-compose.yml for domain at {domain_path}")
domain = derive_domain(domain_path)

with domain.domain_context():
domain.init()

# FIXME Generate docker-compose.yml from domain config
89 changes: 89 additions & 0 deletions src/protean/cli/new.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import os
import re
import shutil

from typing import Optional, Tuple

import typer

from copier import run_copy
from typing_extensions import Annotated

import protean


def new(
project_name: Annotated[str, typer.Argument()],
output_folder: Annotated[
str, typer.Option("--output-dir", "-o", show_default=False)
] = ".",
data: Annotated[
Tuple[str, str], typer.Option("--data", "-d", show_default=False)
] = (None, None),
pretend: Annotated[Optional[bool], typer.Option("--pretend", "-p")] = False,
force: Annotated[Optional[bool], typer.Option("--force", "-f")] = False,
):
def is_valid_project_name(project_name):
"""
Validates the project name against criteria that ensure compatibility across
Mac, Linux, and Windows systems, and also disallows spaces.
"""
# Define a regex pattern that disallows the specified special characters
# and spaces. This pattern also disallows leading and trailing spaces.
forbidden_characters = re.compile(r'[<>:"/\\|?*\s]')

if forbidden_characters.search(project_name) or not project_name:
return False

return True

def clear_directory_contents(dir_path):
"""
Removes all contents of a specified directory without deleting the directory itself.
Parameters:
dir_path (str): The path to the directory whose contents are to be cleared.
"""
for item in os.listdir(dir_path):
item_path = os.path.join(dir_path, item)
if os.path.isfile(item_path) or os.path.islink(item_path):
os.unlink(item_path) # Remove files and links
elif os.path.isdir(item_path):
shutil.rmtree(item_path) # Remove subdirectories and their contents

if not is_valid_project_name(project_name):
raise ValueError("Invalid project name")

# Ensure the output folder exists
if not os.path.isdir(output_folder):
raise FileNotFoundError(f'Output folder "{output_folder}" does not exist')

# The output folder is named after the project, and placed in the target folder
project_directory = os.path.join(output_folder, project_name)

# If the project folder already exists, and --force is not set, raise an error
if os.path.isdir(project_directory) and os.listdir(project_directory):
if not force:
raise FileExistsError(
f'Folder "{project_name}" is not empty. Use --force to overwrite.'
)
# Clear the directory contents if --force is set
clear_directory_contents(project_directory)

# Convert data tuples to a dictionary, if provided
data = (
{value[0]: value[1] for value in data} if len(data) != data.count(None) else {}
)

# Add the project name to answers
data["project_name"] = project_name

# Create project from the cookiecutter-protean.git repo template
run_copy(
f"{protean.__path__[0]}/template",
project_directory or ".",
data=data,
unsafe=True, # Trust our own template implicitly
defaults=True, # Use default values for all prompts
pretend=pretend,
)
Loading

0 comments on commit 155aa2c

Please sign in to comment.