From a58a78492be694e36e8713d4c6bcb0b226c2451c Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 23 Mar 2024 00:00:43 -0700 Subject: [PATCH 1/2] Switch from Copier to Typer Typer has better interactivity, ease of use, and out-of-the-box functionality. It offers a better foundation on which a complex CLI command structure can be erected. Addresses #399 --- CHANGELOG.rst | 12 ++ poetry.lock | 57 +++++-- pyproject.toml | 6 +- src/protean/cli.py | 142 ++++++++++++++--- src/protean/server/engine.py | 2 +- src/protean/template/copier.yml | 5 +- .../.gitignore | 0 .../MANIFEST.in | 0 .../README.rst.jinja | 0 .../config.py.jinja | 0 .../docker-compose.yml.jinja | 0 .../setup.cfg.jinja | 0 .../setup.py.jinja | 0 .../src/{{package_name}}/__init__.py | 0 .../src/{{package_name}}/domain.py.jinja | 0 .../tests/conftest.py.jinja | 0 .../{{_copier_conf.answers_file}}.jinja | 0 .../test_domain_loading.py} | 4 +- tests/cli/test_project_generator.py | 144 ++++++++++++++++++ tests/cli/test_version.py | 15 ++ tests/test_protean_cli.py | 15 -- 21 files changed, 343 insertions(+), 59 deletions(-) rename src/protean/template/{{{package_name}} => domain_template}/.gitignore (100%) rename src/protean/template/{{{package_name}} => domain_template}/MANIFEST.in (100%) rename src/protean/template/{{{package_name}} => domain_template}/README.rst.jinja (100%) rename src/protean/template/{{{package_name}} => domain_template}/config.py.jinja (100%) rename src/protean/template/{{{package_name}} => domain_template}/docker-compose.yml.jinja (100%) rename src/protean/template/{{{package_name}} => domain_template}/setup.cfg.jinja (100%) rename src/protean/template/{{{package_name}} => domain_template}/setup.py.jinja (100%) rename src/protean/template/{{{package_name}} => domain_template}/src/{{package_name}}/__init__.py (100%) rename src/protean/template/{{{package_name}} => domain_template}/src/{{package_name}}/domain.py.jinja (100%) rename src/protean/template/{{{package_name}} => domain_template}/tests/conftest.py.jinja (100%) rename src/protean/template/{{{package_name}} => domain_template}/{{_copier_conf.answers_file}}.jinja (100%) rename tests/{test_cli.py => cli/test_domain_loading.py} (95%) create mode 100644 tests/cli/test_project_generator.py create mode 100644 tests/cli/test_version.py delete mode 100644 tests/test_protean_cli.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 161febc1..e7d11c6b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,18 @@ Release History DEV --- +* Switch from Copier to Typer and add comprehensive tests for project generation + +0.11.0 +------ + +* Add support for Python 3.12.0 +* Move to poetry +* Control domain directory traversal explicitly in init() +* Domain Traversal Refactoring +* ReadTheDocs config enhancements + + 0.10.0 ------ diff --git a/poetry.lock b/poetry.lock index 7a986385..815cf407 100644 --- a/poetry.lock +++ b/poetry.lock @@ -984,13 +984,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.0.2" +version = "7.1.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.0.2-py3-none-any.whl", hash = "sha256:f4bc4c0c070c490abf4ce96d715f68e95923320370efb66143df00199bb6c100"}, - {file = "importlib_metadata-7.0.2.tar.gz", hash = "sha256:198f568f3230878cb1b44fbd7975f87906c22336dba2e4a7f05278c281fbd792"}, + {file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"}, + {file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"}, ] [package.dependencies] @@ -999,7 +999,7 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "inflection" @@ -1759,13 +1759,13 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no [[package]] name = "pytest-asyncio" -version = "0.23.5.post1" +version = "0.23.6" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"}, - {file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"}, + {file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"}, + {file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"}, ] [package.dependencies] @@ -2148,6 +2148,17 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.16.0" @@ -2440,13 +2451,13 @@ files = [ [[package]] name = "tox" -version = "4.14.1" +version = "4.14.2" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.14.1-py3-none-any.whl", hash = "sha256:b03754b6ee6dadc70f2611da82b4ed8f625fcafd247e15d1d0cb056f90a06d3b"}, - {file = "tox-4.14.1.tar.gz", hash = "sha256:f0ad758c3bbf7e237059c929d3595479363c3cdd5a06ac3e49d1dd020ffbee45"}, + {file = "tox-4.14.2-py3-none-any.whl", hash = "sha256:2900c4eb7b716af4a928a7fdc2ed248ad6575294ed7cfae2ea41203937422847"}, + {file = "tox-4.14.2.tar.gz", hash = "sha256:0defb44f6dafd911b61788325741cc6b2e12ea71f987ac025ad4d649f1f1a104"}, ] [package.dependencies] @@ -2486,6 +2497,30 @@ rfc3986 = ">=1.4.0" rich = ">=12.0.0" urllib3 = ">=1.26.0" +[[package]] +name = "typer" +version = "0.9.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.6" +files = [ + {file = "typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee"}, + {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"}, +] + +[package.dependencies] +click = ">=7.1.1,<9.0.0" +colorama = {version = ">=0.4.3,<0.5.0", optional = true, markers = "extra == \"all\""} +rich = {version = ">=10.11.0,<14.0.0", optional = true, markers = "extra == \"all\""} +shellingham = {version = ">=1.3.0,<2.0.0", optional = true, markers = "extra == \"all\""} +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + [[package]] name = "types-mock" version = "5.1.0.20240311" @@ -2673,4 +2708,4 @@ sqlite = ["sqlalchemy"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "07ec59d0bfca302f9ceca43f4dbddb0cd856b61a94c8654697ec27173c043aa0" +content-hash = "5dc8bea45dd2c2f666a0f0fa05869f4515bf673c52023a3ba78d69927a655ce6" diff --git a/pyproject.toml b/pyproject.toml index 40ff4e4c..a8c527dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,12 +34,12 @@ classifiers=[ [tool.poetry.dependencies] python = "^3.11" bleach = ">=4.1.0" -click = ">=7.0" cookiecutter = ">=1.7.0" -copier = ">=6.1.0" +copier = "^9.1.1" inflection = ">=0.5.1" marshmallow = ">=3.15.0" # FIXME Remove core dependency python-dateutil = ">=2.8.2" +typer = {extras = ["all"], version = "^0.9.0"} werkzeug = ">=2.0.0" ########## @@ -115,7 +115,7 @@ types-redis = ">=3.5.4" types-Werkzeug = ">=1.0.5" [tool.poetry.scripts] -protean = "protean.cli:main" +protean = "protean.cli:app" ################## # Configurations # diff --git a/src/protean/cli.py b/src/protean/cli.py index 9152ee3c..510c4a84 100644 --- a/src/protean/cli.py +++ b/src/protean/cli.py @@ -18,23 +18,30 @@ import ast import os import re +import shutil import sys import traceback -import click +from typing import Optional, Tuple +import typer -class NoDomainException(click.UsageError): +from copier import run_copy +from rich import print +from typing_extensions import Annotated + +import protean + +from protean.exceptions import ProteanException + + +class NoDomainException(ProteanException): """Raised if a domain cannot be found or loaded.""" -@click.group(invoke_without_command=True) -@click.version_option() -@click.pass_context -def main(ctx): - """CLI utilities for Protean""" - if ctx.invoked_subcommand is None: - click.echo(ctx.get_help()) +# 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) def find_best_domain(module): @@ -178,7 +185,7 @@ def derive_domain(domain_path): domain_import_path = os.environ.get("PROTEAN_DOMAIN") or domain_path if domain_import_path: - click.secho(f"Loading domain from {domain_import_path}...") + print(f"Loading domain from {domain_import_path}...") path, name = (re.split(r":(?![\\/])", domain_import_path, 1) + [None])[:2] import_name = prepare_import(path) domain = locate_domain(import_name, name) @@ -189,9 +196,28 @@ def derive_domain(domain_path): return domain -@main.command() -@click.option("-c", "--category") -def test(category): +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[str, typer.Option()] = ""): import subprocess commands = ["pytest", "--cache-clear", "--ignore=tests/support/"] @@ -250,18 +276,85 @@ def test(category): subprocess.call(commands + ["-m", "eventstore", f"--store={store}"]) -@main.command() -@click.option("-o", "--output-folder") -def new(output_folder): - from copier import run_auto +@app.command() +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) - import protean + # 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_auto(f"{protean.__path__[0]}/template", output_folder or ".") + 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, + ) -@main.command() +@app.command() def livereload_docs(): """Run in shell as `protean livereload-docs`""" from livereload import Server, shell @@ -272,10 +365,11 @@ def livereload_docs(): server.serve(root="build/html", debug=True) -@main.command() -@click.option("-d", "--domain-path") -@click.option("-t", "--test-mode", is_flag=True) -def server(domain_path, test_mode): +@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 diff --git a/src/protean/server/engine.py b/src/protean/server/engine.py index aa01e953..8458e046 100644 --- a/src/protean/server/engine.py +++ b/src/protean/server/engine.py @@ -25,7 +25,7 @@ class Engine: - def __init__(self, domain, test_mode: str = False) -> None: + def __init__(self, domain, test_mode: bool = False) -> None: self.domain = domain self.test_mode = test_mode diff --git a/src/protean/template/copier.yml b/src/protean/template/copier.yml index 82885543..db61a353 100644 --- a/src/protean/template/copier.yml +++ b/src/protean/template/copier.yml @@ -1,10 +1,12 @@ _templates_suffix: .jinja +_subdirectory: 'domain_template' project_name: type: str short_description: type: str + default: "{{ project_name }} - A Protean Application" package_name: type: str @@ -37,6 +39,3 @@ cache: Memory: memory Redis: redis default: memory - -_tasks: - - "cd {{ package_name }}; pip install -e .[all]" diff --git a/src/protean/template/{{package_name}}/.gitignore b/src/protean/template/domain_template/.gitignore similarity index 100% rename from src/protean/template/{{package_name}}/.gitignore rename to src/protean/template/domain_template/.gitignore diff --git a/src/protean/template/{{package_name}}/MANIFEST.in b/src/protean/template/domain_template/MANIFEST.in similarity index 100% rename from src/protean/template/{{package_name}}/MANIFEST.in rename to src/protean/template/domain_template/MANIFEST.in diff --git a/src/protean/template/{{package_name}}/README.rst.jinja b/src/protean/template/domain_template/README.rst.jinja similarity index 100% rename from src/protean/template/{{package_name}}/README.rst.jinja rename to src/protean/template/domain_template/README.rst.jinja diff --git a/src/protean/template/{{package_name}}/config.py.jinja b/src/protean/template/domain_template/config.py.jinja similarity index 100% rename from src/protean/template/{{package_name}}/config.py.jinja rename to src/protean/template/domain_template/config.py.jinja diff --git a/src/protean/template/{{package_name}}/docker-compose.yml.jinja b/src/protean/template/domain_template/docker-compose.yml.jinja similarity index 100% rename from src/protean/template/{{package_name}}/docker-compose.yml.jinja rename to src/protean/template/domain_template/docker-compose.yml.jinja diff --git a/src/protean/template/{{package_name}}/setup.cfg.jinja b/src/protean/template/domain_template/setup.cfg.jinja similarity index 100% rename from src/protean/template/{{package_name}}/setup.cfg.jinja rename to src/protean/template/domain_template/setup.cfg.jinja diff --git a/src/protean/template/{{package_name}}/setup.py.jinja b/src/protean/template/domain_template/setup.py.jinja similarity index 100% rename from src/protean/template/{{package_name}}/setup.py.jinja rename to src/protean/template/domain_template/setup.py.jinja diff --git a/src/protean/template/{{package_name}}/src/{{package_name}}/__init__.py b/src/protean/template/domain_template/src/{{package_name}}/__init__.py similarity index 100% rename from src/protean/template/{{package_name}}/src/{{package_name}}/__init__.py rename to src/protean/template/domain_template/src/{{package_name}}/__init__.py diff --git a/src/protean/template/{{package_name}}/src/{{package_name}}/domain.py.jinja b/src/protean/template/domain_template/src/{{package_name}}/domain.py.jinja similarity index 100% rename from src/protean/template/{{package_name}}/src/{{package_name}}/domain.py.jinja rename to src/protean/template/domain_template/src/{{package_name}}/domain.py.jinja diff --git a/src/protean/template/{{package_name}}/tests/conftest.py.jinja b/src/protean/template/domain_template/tests/conftest.py.jinja similarity index 100% rename from src/protean/template/{{package_name}}/tests/conftest.py.jinja rename to src/protean/template/domain_template/tests/conftest.py.jinja diff --git a/src/protean/template/{{package_name}}/{{_copier_conf.answers_file}}.jinja b/src/protean/template/domain_template/{{_copier_conf.answers_file}}.jinja similarity index 100% rename from src/protean/template/{{package_name}}/{{_copier_conf.answers_file}}.jinja rename to src/protean/template/domain_template/{{_copier_conf.answers_file}}.jinja diff --git a/tests/test_cli.py b/tests/cli/test_domain_loading.py similarity index 95% rename from tests/test_cli.py rename to tests/cli/test_domain_loading.py index 271ffc53..9ccf1543 100644 --- a/tests/test_cli.py +++ b/tests/cli/test_domain_loading.py @@ -5,7 +5,7 @@ import pytest -from click.testing import CliRunner +from typer.testing import CliRunner from protean import Domain from protean.cli import NoDomainException, derive_domain, find_best_domain @@ -63,7 +63,7 @@ def reset_path(self, request): def change_working_directory_to(self, path): test_path = ( - Path(__file__) / ".." / "support" / "test_domains" / path + Path(__file__) / ".." / ".." / "support" / "test_domains" / path ).resolve() os.chdir(test_path) diff --git a/tests/cli/test_project_generator.py b/tests/cli/test_project_generator.py new file mode 100644 index 00000000..ace31661 --- /dev/null +++ b/tests/cli/test_project_generator.py @@ -0,0 +1,144 @@ +import os + +from pathlib import Path + +import pytest + +from typer.testing import CliRunner + +from protean.cli import app + +runner = CliRunner() + +PROJECT_NAME = "foobar" + + +@pytest.mark.slow +class TestGenerator: + def test_successful_project_generation(self): + with runner.isolated_filesystem() as current_dir: + with runner.isolated_filesystem() as project_dir: + args = ["new", PROJECT_NAME, "-o", project_dir] + + # Switch to the target directory + os.chdir(current_dir) + + result = runner.invoke(app, args) + + assert result.exit_code == 0 + + # Output folder should be the specified target directory + assert len(os.listdir(project_dir)) > 0 + assert os.path.isfile(f"{project_dir}/{PROJECT_NAME}/README.rst") + with open(f"{project_dir}/{PROJECT_NAME}/README.rst") as f: + assert f.readline() == "========\n" + + def test_output_directory_is_current_directory_if_not_specified(self): + # Create a temporary directory + with runner.isolated_filesystem() as current_dir: + with runner.isolated_filesystem(): + args = ["new", "foobar"] + + # Switch to the target directory + os.chdir(current_dir) + + result = runner.invoke(app, args) + + assert result.exit_code == 0 + + # Output folder should be the current working directory + assert len(os.listdir(current_dir)) > 0 + assert os.path.isfile(f"{current_dir}/{PROJECT_NAME}/README.rst") + + def test_pretend_project_generation(self): + # Create a temporary directory + with runner.isolated_filesystem() as project_dir: + args = ["new", "foobar", "--pretend"] + result = runner.invoke(app, args) + + assert result.exit_code == 0 + + # Output folder should not exist + assert len(os.listdir(project_dir)) == 0 + + def test_invalid_output_folder_throws_error(self): + args = ["new", PROJECT_NAME, "-o", "baz"] + result = runner.invoke(app, args) + + assert result.exit_code == 1 + assert isinstance(result.exception, FileNotFoundError) + assert result.exception.args[0] == 'Output folder "baz" does not exist' + + def test_nonempty_output_folder_throws_error(self): + # Create a temporary directory + with runner.isolated_filesystem() as project_dir: + # Create a non-empty directory + os.makedirs(f"{project_dir}/{PROJECT_NAME}") + Path(f"{project_dir}/{PROJECT_NAME}/file.txt").touch() + + # Specify the non-empty directory as the output folder + args = ["new", PROJECT_NAME, "-o", project_dir] + result = runner.invoke(app, args) + + assert result.exit_code == 1 + assert isinstance(result.exception, FileExistsError) + assert result.exception.args[0] == ( + f'Folder "{PROJECT_NAME}" is not empty. Use --force to overwrite.' + ) + + def test_nonempty_output_folder_can_be_overwritten_explicitly(self): + # Create a temporary directory + with runner.isolated_filesystem() as project_dir: + # Create a non-empty directory + os.makedirs(f"{project_dir}/{PROJECT_NAME}") + + temp_file = f"{project_dir}/{PROJECT_NAME}/file.txt" + Path(temp_file).touch() + + # Pass --force to generate output into the non-empty directory + args = ["new", "foobar", "-o", project_dir, "--force"] + result = runner.invoke(app, args) + + assert result.exit_code == 0 + assert result.exception is None + assert os.path.isfile(f"{project_dir}/{PROJECT_NAME}/README.rst") + assert not os.path.exists(temp_file), "File should not exist" + + def test_project_generation_in_unwritable_directory(self): + # Create a temporary directory + with runner.isolated_filesystem() as project_dir: + # Make the directory unwritable + os.chmod(project_dir, 0o400) + + args = ["new", "unwritable_project", "-o", project_dir] + result = runner.invoke(app, args) + + # Restore the permissions for cleanup + os.chmod(project_dir, 0o700) + + assert result.exit_code != 0 + assert result.exception.args[1] == "Permission denied" + + @pytest.mark.parametrize( + "invalid_project_name", + [ + "projectname", + "project:name", + 'project"name', + "project/name", + "project\\name", + "project|name", + "project?name", + "project*name", + "project name", # Including spaces as per requirement + "project\nname", # Control character (newline) + "project\tname", # Control character (tab) + ], + ) + def test_project_generation_with_invalid_names(self, invalid_project_name): + with runner.isolated_filesystem(): + args = ["new", invalid_project_name] + result = runner.invoke(app, args) + assert result.exit_code != 0 + assert result.exception.args[0] == "Invalid project name" diff --git a/tests/cli/test_version.py b/tests/cli/test_version.py new file mode 100644 index 00000000..db42da8d --- /dev/null +++ b/tests/cli/test_version.py @@ -0,0 +1,15 @@ +from typer.testing import CliRunner + +from protean.cli import app + + +def test_main(): + runner = CliRunner() + result = runner.invoke(app, ["--version"]) + + from protean import __version__ + + assert result.output == "Protean {protean_version}\n".format( + protean_version=__version__ + ) + assert result.exit_code == 0 diff --git a/tests/test_protean_cli.py b/tests/test_protean_cli.py deleted file mode 100644 index 4ab277ea..00000000 --- a/tests/test_protean_cli.py +++ /dev/null @@ -1,15 +0,0 @@ -from click.testing import CliRunner - -from protean.cli import main - - -def test_main(): - runner = CliRunner() - result = runner.invoke(main, ["--version"]) - - from protean import __version__ - - assert result.output == "main, version {protean_version}\n".format( - protean_version=__version__ - ) - assert result.exit_code == 0 From 2d8388b5975ce25fc40f03e4be73b7a02c049cc8 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Sat, 23 Mar 2024 08:35:46 -0700 Subject: [PATCH 2/2] Run tests on PRs against main --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f387b83..fb38b4f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: test: