diff --git a/pyproject.toml b/pyproject.toml index 85b4498..96cfbe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,11 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +dependencies = [ + "pyclack-cli[prompts]==0.4.0", + "asyncclick==8.3.0.5", + "anyio==4.11.0" +] [build-system] requires = ["setuptools>=61.0"] @@ -26,6 +31,9 @@ build-backend = "setuptools.build_meta" Repository = "https://github.com/serv-c/servc-python.git" Documentation = "https://docs.servc.io/" +[project.scripts] +servc = "servc.cli:cli" + [tool.semantic_release] assets = [] commit_message = "{version}\n\nAutomatically generated by python-semantic-release" diff --git a/servc/__main__.py b/servc/__main__.py new file mode 100644 index 0000000..3e7a3f8 --- /dev/null +++ b/servc/__main__.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +""" +Main entry point for the servc CLI when run as a module. +This allows the package to be executed with: python -m servc +""" + +import asyncio +from servc.cli import cli, click + + +def main(): + """Main entry point for the CLI.""" + try: + asyncio.run(cli()) + except KeyboardInterrupt: + click.secho("Operation cancelled.", fg="red") + except Exception as e: + click.secho(f"{e}", fg="red") + raise + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/servc/cli/__init__.py b/servc/cli/__init__.py new file mode 100644 index 0000000..c887b75 --- /dev/null +++ b/servc/cli/__init__.py @@ -0,0 +1,21 @@ + +import asyncclick as click + +from servc.cli.config import config +from servc.cli.commands import init + + +@click.group() +@config +async def cli(ctx): + """servc CLI - A tool for servc services. + + Create and manage servc projects. + + Common commands: + + init Initialize a new servc project + """ + pass + +cli.add_command(init) diff --git a/servc/cli/commands/__init__.py b/servc/cli/commands/__init__.py new file mode 100644 index 0000000..6e252a3 --- /dev/null +++ b/servc/cli/commands/__init__.py @@ -0,0 +1 @@ +from servc.cli.commands.init import cli as init \ No newline at end of file diff --git a/servc/cli/commands/init/__init__.py b/servc/cli/commands/init/__init__.py new file mode 100644 index 0000000..fb2f7a3 --- /dev/null +++ b/servc/cli/commands/init/__init__.py @@ -0,0 +1 @@ +from servc.cli.commands.init.src import cli \ No newline at end of file diff --git a/servc/cli/commands/init/src/__init__.py b/servc/cli/commands/init/src/__init__.py new file mode 100644 index 0000000..0741bcf --- /dev/null +++ b/servc/cli/commands/init/src/__init__.py @@ -0,0 +1,336 @@ +import sys +import shutil +import subprocess +from pathlib import Path +import asyncclick as click +from pyclack.prompts import confirm, select, text, Option, spinner, note, outro, link +from pyclack.utils.styling import Color +from pyclack.core import Spinner, is_cancel + +from servc.cli.config import config + + +def get_python(): + """Get available Python command.""" + for cmd in ["python", "python3", "py"]: + if shutil.which(cmd): + return cmd + raise click.ClickException( + "No Python interpreter found. Please install Python to proceed." + ) + + +PY = get_python() + + +PACKAGE_MANAGERS = [ + { + "name": "uv", + "description": "UV (Fast and Modern)", + "note": "Recommended", + "setup": [PY, "-m", "pip", "install", "uv"], + "deps": [PY, "-m", "uv", "sync"], + "run": [PY, "-m", "uv", "run", "--isolated", "worker.py"], + }, + { + "name": "pip", + "description": "Pip (Standard Python Package Manager)", + "note": "", + "setup": None, + "deps": [PY, "-m", "pip", "install", "-r", "requirements.txt"], + "run": [PY, "worker.py"], + }, + { + "name": "poetry", + "description": "Poetry (Dependency Management and Packaging)", + "note": "", + "setup": [PY, "-m", "pip", "install", "poetry"], + "deps": [PY, "-m", "poetry", "install", "--no-root"], + "run": [PY, "-m", "poetry", "run", "worker.py"], + }, +] + + +def project_name_validator(val): + # check if the project name is empty + if not val: + return "Project name cannot be empty." + # check if the project name contains spaces or special characters + if not val.replace("-", "").replace("_", "").isalnum(): + return "Project name cannot contain spaces or special characters." + # check if the project name is minimum 3 characters long + if len(val) < 3: + return "Project name must be at least 3 characters long." + + return None + + +async def get_project_name(): + # Prompt the user for the project name with validation + return await text( + message="Enter the project name", + default_value="servc-service", + placeholder="servc-service", + validate=project_name_validator, + ) + + +async def get_pkg_manager(): + # Prompt the user for the package manager + options = [ + Option( + pm["name"], f"{pm['description']} {'- ' + pm['note'] if pm['note'] else ''}" + ) + for pm in PACKAGE_MANAGERS + ] + + return await select( + message="Select a package manager", + options=options, + ) + + +def run(cmd: list, cwd: Path, interactive: bool = False) -> None: + """Run command in directory""" + try: + subprocess.run( + cmd, + cwd=cwd, + check=True, + # Show output only if interactive is True + stdout=None if interactive else subprocess.DEVNULL, + stderr=None if interactive else subprocess.DEVNULL, + ) + except KeyboardInterrupt: + if interactive: + raise + else: + click.secho("\nOperation cancelled", fg="red") + sys.exit(1) + except subprocess.CalledProcessError as e: + # Check if the process was likely terminated by user (Ctrl+C) + # On Windows, Ctrl+C typically results in exit code 1 or 2 + if e.returncode in [-2, 1, 2] and interactive: + # This is likely a user interruption, treat it as KeyboardInterrupt + raise KeyboardInterrupt("Process terminated") + raise click.ClickException(f"Command '{' '.join(cmd)}' failed with exit code {e.returncode}") + except Exception as e: + raise click.ClickException(f"Error running command '{' '.join(cmd)}': {e}") + + +def setup(name: str, interactive: bool = False) -> None: + """Setup package manager in directory""" + pm = next((pm for pm in PACKAGE_MANAGERS if pm["name"] == name), None) + if pm is None: + raise click.ClickException(f"Unknown Package manager '{name}'.") + if pm["setup"]: + run(pm["setup"], Path.cwd(), interactive) + + +def install(path: Path, name: str, interactive: bool = False) -> None: + """Install project dependencies""" + mgr = next((m for m in PACKAGE_MANAGERS if m["name"] == name), None) + if not mgr: + raise click.ClickException(f"Unknown package manager: {name}") + run(mgr["deps"], path, interactive) + + +def start(path: Path, name: str, interactive: bool = False) -> None: + """Start development server""" + mgr = next((m for m in PACKAGE_MANAGERS if m["name"] == name), None) + if not mgr: + raise click.ClickException(f"Unknown package manager: {name}") + try: + run(mgr["run"], path, interactive) + except KeyboardInterrupt: + click.echo("\nDevelopment server stopped.") + return + + +def copy_template(name: str, target: Path, project_name: str) -> None: + """Copy template files to target""" + template = Path(__file__).parent.parent / f"template_{name}" + if not template.exists(): + raise click.ClickException(f"Template not found: {name}") + + # Create target directory + target.mkdir(parents=True, exist_ok=True) + + # Copy template files + for item in template.glob("**/*"): + if item.is_file(): + rel_path = item.relative_to(template) + dest = target / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + + # Read file content and replace placeholders + try: + content = item.read_text() + content = content.replace("{{PROJECT_NAME}}", project_name) + dest.write_text(content) + except UnicodeDecodeError: + # If the file is not a text file, copy it as is + shutil.copy2(item, dest) + + +def get_next_steps(target_path: Path, pkg_manager: str) -> str: + """Generate next steps instructions""" + mgr = next((m for m in PACKAGE_MANAGERS if m["name"] == pkg_manager), None) + if not mgr: + return "" + + rel_path = target_path.relative_to(Path.cwd()) + steps = [] + + # Add cd command if not in current directory + if target_path != Path.cwd(): + steps.append(f"cd {rel_path}") + + # Add setup command if needed + if mgr["setup"]: + steps.append(" ".join(mgr["setup"])) + + # Add install command + steps.append(" ".join(mgr["deps"])) + + # Add run command + steps.append(" ".join(mgr["run"])) + + return "\n".join(f" $ {step}" for step in steps) + + +@click.command("init") +@click.argument("name", required=False) +@click.option("-t", "--template", help="Project template to use (default: pip).") +@click.option( + "-i", + "--immediate", + is_flag=True, + help="Install dependencies immediately after project creation.", +) +@click.option( + "--interactive", + is_flag=True, + default=False, + help="Show detailed setup and installation logs.", +) +@config +async def cli(ctx, name, template, immediate, interactive): + """Initialize a new servc service project. + + Creates a new servc service with the package manager: + + \b + Available package managers: + uv Fast and Modern (Recommended) + pip Standard Python Package Manager + poetry Dependency Management and Packaging + + \b + Examples: + # Create new service + $ servc init my-service + + # Create service in current directory + $ servc init . + + # Create using UV package manager + $ servc init my-service -t uv + + # Create and start development server + $ servc init my-service -t uv -i + """ + ctx.log("Initializing a new servc service project...") + try: + # get project name + if "." == name: + project_name = Path.cwd().name + target_path = Path.cwd() + else: + # If name is provided as argument, use it; otherwise, prompt for it + if name: + error_msg = project_name_validator(name) + if error_msg: + name = None + raise click.secho(f"{error_msg}", fg="yellow") + else: + project_name = name + if not name: + # prompt for project name + project_name = await get_project_name() + if is_cancel(project_name): + raise KeyboardInterrupt("Operation cancelled.") + + target_path = Path.cwd() / project_name + # Handle package manager selection + if template: + if template not in [pm["name"] for pm in PACKAGE_MANAGERS]: + ctx.log(f"Invalid template '{template}'. Using 'pip' instead.") + pkg_manager = "pip" + else: + pkg_manager = template + else: + pkg_manager = await get_pkg_manager() + if is_cancel(pkg_manager): + raise KeyboardInterrupt("Operation cancelled.") + + # check if target path already exists and is not empty + if target_path.exists() and any(target_path.iterdir()): + # prompt user to choose how to proceed + res = await select( + message=f"Target directory '{target_path}' already exists and is not empty. How do you want to proceed?", + options=[ + Option("continue", "Continue and merge with existing files"), + Option("overwrite", "Overwrite the existing directory"), + Option("cancel", "Cancel the operation"), + ], + ) + if res == "cancel" or is_cancel(res): + raise KeyboardInterrupt("Operation cancelled.") + elif res == "overwrite": + shutil.rmtree(target_path) + ctx.log(f"Overwritten existing directory.") + else: + ctx.log(f"Merging with existing files.") + # Create project structure based on inputs + copy_template(pkg_manager, target_path, project_name) + ctx.log(f"Project '{project_name}' created.") + # Install dependencies if immediate flag is set + if not immediate: + # prompt user to install dependencies now by yes/no + immediate = await confirm("Do you want to install dependencies now?") + if is_cancel(immediate): + raise KeyboardInterrupt("Operation cancelled.") + + if immediate: + async with spinner("Setting up project...") as spin: + try: + # 0. Setup package manager if needed + spin.update(f"Setting up {pkg_manager}...") + setup(pkg_manager, interactive) + spin.update("Installing dependencies...") + # 1. Install dependencies + install(target_path, pkg_manager, interactive) + spin.stop("Dependencies installed successfully.", code=0) + except KeyboardInterrupt: + spin.stop("\nSetup cancelled", code=1) + sys.exit(1) + + # Start development server + try: + click.secho("Starting development server...", fg="green") + start(target_path, pkg_manager, True) + except KeyboardInterrupt: + click.secho("Development server stopped.", fg="yellow") + return + else: + steps = get_next_steps(target_path, pkg_manager) + note(title="Next steps", message="\nRun the following commands:\n" + steps) + outro( + f"{Color.dim(f'Problems? {link(url='https://github.com/serv-c/servc-python')}')}" + ) + except KeyboardInterrupt: + sys.exit(0) + except Exception as e: + click.secho(f"{e}", fg="red") + sys.exit(1) diff --git a/servc/cli/commands/init/template_pip/.gitignore b/servc/cli/commands/init/template_pip/.gitignore new file mode 100644 index 0000000..52bf3ac --- /dev/null +++ b/servc/cli/commands/init/template_pip/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.coverage* +*.swp +.venv \ No newline at end of file diff --git a/servc/cli/commands/init/template_pip/README.md b/servc/cli/commands/init/template_pip/README.md new file mode 100644 index 0000000..24dedb1 --- /dev/null +++ b/servc/cli/commands/init/template_pip/README.md @@ -0,0 +1 @@ +## {{PROJECT_NAME}} \ No newline at end of file diff --git a/servc/cli/commands/init/template_pip/requirements.txt b/servc/cli/commands/init/template_pip/requirements.txt new file mode 100644 index 0000000..83ce86a --- /dev/null +++ b/servc/cli/commands/init/template_pip/requirements.txt @@ -0,0 +1,7 @@ +-i https://pypi.org/simple +flask>=3.1.2 +pika>=1.3.2 +pyyaml>=6.0.3 +redis>=6.4.0 +servc>=1.11.11 +simplejson>=3.20.2 \ No newline at end of file diff --git a/servc/cli/commands/init/template_pip/src/__init__.py b/servc/cli/commands/init/template_pip/src/__init__.py new file mode 100644 index 0000000..cfdd9ec --- /dev/null +++ b/servc/cli/commands/init/template_pip/src/__init__.py @@ -0,0 +1,6 @@ +from src.domains.health import health + + +resolvers = { + "healthCheck": health, +} diff --git a/servc/cli/commands/init/template_pip/src/config.py b/servc/cli/commands/init/template_pip/src/config.py new file mode 100644 index 0000000..e090456 --- /dev/null +++ b/servc/cli/commands/init/template_pip/src/config.py @@ -0,0 +1,5 @@ +import os + +COMPONENT_NAME = "test-service" +PREFIX = os.environ.get("PREFIX", "servc") +QUEUE_NAME = os.environ.get("QUEUE_NAME", f"{PREFIX}-{COMPONENT_NAME}") \ No newline at end of file diff --git a/servc/cli/commands/init/template_pip/src/domains/__init__.py b/servc/cli/commands/init/template_pip/src/domains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/servc/cli/commands/init/template_pip/src/domains/health.py b/servc/cli/commands/init/template_pip/src/domains/health.py new file mode 100644 index 0000000..ef40634 --- /dev/null +++ b/servc/cli/commands/init/template_pip/src/domains/health.py @@ -0,0 +1,2 @@ +def health(*args, **kwargs): + return {"status": "ok"} \ No newline at end of file diff --git a/servc/cli/commands/init/template_pip/worker.py b/servc/cli/commands/init/template_pip/worker.py new file mode 100644 index 0000000..d261183 --- /dev/null +++ b/servc/cli/commands/init/template_pip/worker.py @@ -0,0 +1,13 @@ +from servc.server import start_server + +from src.config import QUEUE_NAME +from src import resolvers + +def main(): + start_server( + resolver=resolvers, + route=QUEUE_NAME, + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/servc/cli/commands/init/template_poetry/.gitignore b/servc/cli/commands/init/template_poetry/.gitignore new file mode 100644 index 0000000..52bf3ac --- /dev/null +++ b/servc/cli/commands/init/template_poetry/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.coverage* +*.swp +.venv \ No newline at end of file diff --git a/servc/cli/commands/init/template_poetry/README.md b/servc/cli/commands/init/template_poetry/README.md new file mode 100644 index 0000000..24dedb1 --- /dev/null +++ b/servc/cli/commands/init/template_poetry/README.md @@ -0,0 +1 @@ +## {{PROJECT_NAME}} \ No newline at end of file diff --git a/servc/cli/commands/init/template_poetry/pyproject.toml b/servc/cli/commands/init/template_poetry/pyproject.toml new file mode 100644 index 0000000..30da1f5 --- /dev/null +++ b/servc/cli/commands/init/template_poetry/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "{{PROJECT_NAME}}" +version = "0.1.0" +description = "Add your description here" +authors = ["Your Name "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +pyyaml = "6.0.2" +servc = "^1.11.11" +flask = "3.1.2" +redis = "6.4.0" +pika = "1.3.2" +simplejson = "3.20.1" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/servc/cli/commands/init/template_poetry/src/__init__.py b/servc/cli/commands/init/template_poetry/src/__init__.py new file mode 100644 index 0000000..cfdd9ec --- /dev/null +++ b/servc/cli/commands/init/template_poetry/src/__init__.py @@ -0,0 +1,6 @@ +from src.domains.health import health + + +resolvers = { + "healthCheck": health, +} diff --git a/servc/cli/commands/init/template_poetry/src/config.py b/servc/cli/commands/init/template_poetry/src/config.py new file mode 100644 index 0000000..e090456 --- /dev/null +++ b/servc/cli/commands/init/template_poetry/src/config.py @@ -0,0 +1,5 @@ +import os + +COMPONENT_NAME = "test-service" +PREFIX = os.environ.get("PREFIX", "servc") +QUEUE_NAME = os.environ.get("QUEUE_NAME", f"{PREFIX}-{COMPONENT_NAME}") \ No newline at end of file diff --git a/servc/cli/commands/init/template_poetry/src/domains/__init__.py b/servc/cli/commands/init/template_poetry/src/domains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/servc/cli/commands/init/template_poetry/src/domains/health.py b/servc/cli/commands/init/template_poetry/src/domains/health.py new file mode 100644 index 0000000..ef40634 --- /dev/null +++ b/servc/cli/commands/init/template_poetry/src/domains/health.py @@ -0,0 +1,2 @@ +def health(*args, **kwargs): + return {"status": "ok"} \ No newline at end of file diff --git a/servc/cli/commands/init/template_poetry/worker.py b/servc/cli/commands/init/template_poetry/worker.py new file mode 100644 index 0000000..d261183 --- /dev/null +++ b/servc/cli/commands/init/template_poetry/worker.py @@ -0,0 +1,13 @@ +from servc.server import start_server + +from src.config import QUEUE_NAME +from src import resolvers + +def main(): + start_server( + resolver=resolvers, + route=QUEUE_NAME, + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/servc/cli/commands/init/template_uv/.gitignore b/servc/cli/commands/init/template_uv/.gitignore new file mode 100644 index 0000000..f68c538 --- /dev/null +++ b/servc/cli/commands/init/template_uv/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.coverage* +*.swp +.venv/ \ No newline at end of file diff --git a/servc/cli/commands/init/template_uv/.python-version b/servc/cli/commands/init/template_uv/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/servc/cli/commands/init/template_uv/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/servc/cli/commands/init/template_uv/README.md b/servc/cli/commands/init/template_uv/README.md new file mode 100644 index 0000000..24dedb1 --- /dev/null +++ b/servc/cli/commands/init/template_uv/README.md @@ -0,0 +1 @@ +## {{PROJECT_NAME}} \ No newline at end of file diff --git a/servc/cli/commands/init/template_uv/pyproject.toml b/servc/cli/commands/init/template_uv/pyproject.toml new file mode 100644 index 0000000..164d6a8 --- /dev/null +++ b/servc/cli/commands/init/template_uv/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "{{PROJECT_NAME}}" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "flask>=3.1.2", + "pika>=1.3.2", + "pyyaml>=6.0.3", + "redis>=6.4.0", + "servc>=1.11.11", + "simplejson>=3.20.2", +] diff --git a/servc/cli/commands/init/template_uv/src/__init__.py b/servc/cli/commands/init/template_uv/src/__init__.py new file mode 100644 index 0000000..084f83d --- /dev/null +++ b/servc/cli/commands/init/template_uv/src/__init__.py @@ -0,0 +1,5 @@ +from src.domains.health import health + +resolvers = { + "healthCheck": health, +} diff --git a/servc/cli/commands/init/template_uv/src/config.py b/servc/cli/commands/init/template_uv/src/config.py new file mode 100644 index 0000000..e090456 --- /dev/null +++ b/servc/cli/commands/init/template_uv/src/config.py @@ -0,0 +1,5 @@ +import os + +COMPONENT_NAME = "test-service" +PREFIX = os.environ.get("PREFIX", "servc") +QUEUE_NAME = os.environ.get("QUEUE_NAME", f"{PREFIX}-{COMPONENT_NAME}") \ No newline at end of file diff --git a/servc/cli/commands/init/template_uv/src/domains/__init__.py b/servc/cli/commands/init/template_uv/src/domains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/servc/cli/commands/init/template_uv/src/domains/health.py b/servc/cli/commands/init/template_uv/src/domains/health.py new file mode 100644 index 0000000..ef40634 --- /dev/null +++ b/servc/cli/commands/init/template_uv/src/domains/health.py @@ -0,0 +1,2 @@ +def health(*args, **kwargs): + return {"status": "ok"} \ No newline at end of file diff --git a/servc/cli/commands/init/template_uv/worker.py b/servc/cli/commands/init/template_uv/worker.py new file mode 100644 index 0000000..d261183 --- /dev/null +++ b/servc/cli/commands/init/template_uv/worker.py @@ -0,0 +1,13 @@ +from servc.server import start_server + +from src.config import QUEUE_NAME +from src import resolvers + +def main(): + start_server( + resolver=resolvers, + route=QUEUE_NAME, + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/servc/cli/config.py b/servc/cli/config.py new file mode 100644 index 0000000..a8f7161 --- /dev/null +++ b/servc/cli/config.py @@ -0,0 +1,17 @@ +import os +import sys +import asyncclick as click + + +class Config: + def __init__(self): + self.home = os.getcwd() + + def log(self, msg, *args): + """Logs message to strerr.""" + if args: + msg = msg % args + click.echo(msg, file=sys.stderr) + + +config = click.make_pass_decorator(Config, ensure=True)