-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactor CLI structure for future growth
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
Showing
10 changed files
with
500 additions
and
445 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
Oops, something went wrong.