diff --git a/project_scaffold_cli/__init__.py b/project_scaffold_cli/__init__.py new file mode 100644 index 0000000..5364990 --- /dev/null +++ b/project_scaffold_cli/__init__.py @@ -0,0 +1,3 @@ +"""Project Scaffold CLI - Generate standardized project scaffolding.""" + +__version__ = "1.0.0" diff --git a/project_scaffold_cli/cli.py b/project_scaffold_cli/cli.py new file mode 100644 index 0000000..5d53144 --- /dev/null +++ b/project_scaffold_cli/cli.py @@ -0,0 +1,365 @@ +"""Main CLI entry point for Project Scaffold CLI.""" + +import sys +from pathlib import Path + +import click +from click_completion import init + +from . import __version__ +from .config import Config +from .gitignore import GitignoreGenerator +from .prompts import ProjectPrompts +from .template_engine import TemplateEngine + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +def get_installed_shells(): + """Return list of supported shells for completion.""" + return ["bash", "zsh", "fish", "powershell"] + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.version_option(version=__version__, prog_name="psc") +@click.option( + "--config", + "-c", + type=click.Path(exists=True), + help="Path to configuration file", +) +@click.pass_context +def main(ctx, config): + """Project Scaffold CLI - Generate standardized project scaffolding.""" + ctx.ensure_object(dict) + cfg = Config.load(config) if config else Config.load() + ctx.obj["config"] = cfg + ctx.obj["config_path"] = config + + +@main.command() +@click.argument("project_name", required=False) +@click.option( + "--language", + "-l", + type=click.Choice(["python", "nodejs", "go", "rust"]), + help="Project language", +) +@click.option("--author", "-a", help="Author name") +@click.option("--email", "-e", help="Author email") +@click.option("--description", "-d", help="Project description") +@click.option( + "--license", + "-L", + type=click.Choice(["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "None"]), + help="License type", +) +@click.option( + "--ci", + type=click.Choice(["github", "gitlab", "none"]), + help="CI/CD provider", +) +@click.option( + "--template", + "-t", + help="Custom template path or name", +) +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Skip prompts and use defaults", +) +@click.option( + "--force", + "-f", + is_flag=True, + default=False, + help="Force overwrite existing directory", +) +@click.pass_context +def create( + ctx, + project_name, + language, + author, + email, + description, + license, + ci, + template, + yes, + force, +): + """Create a new project scaffold.""" + config = ctx.obj.get("config", Config()) + + if project_name and not _validate_project_name(project_name): + click.echo( + click.style( + "Error: Invalid project name. Use lowercase letters, numbers, and hyphens only.", + fg="red", + ) + ) + sys.exit(1) + + if not yes: + prompts = ProjectPrompts(config) + project_name, language, author, email, description, license, ci, template = ( + prompts.collect_inputs( + project_name, language, author, email, description, license, ci, template + ) + ) + + if not project_name: + click.echo( + click.style("Error: Project name is required.", fg="red") + ) + sys.exit(1) + + if not language: + language = config.default_language or "python" + + project_slug = _to_kebab_case(project_name) + year = str(2024) + author = author or config.author or "Your Name" + email = email or config.email or "your.email@example.com" + description = description or config.description or "A new project" + license = license or config.license or "MIT" + ci = ci or config.ci or "none" + + context = { + "project_name": project_name, + "project_slug": project_slug, + "author": author, + "email": email, + "description": description, + "license": license, + "year": year, + "language": language, + } + + output_dir = Path(project_name).resolve() + + if output_dir.exists() and not force: + click.echo( + click.style( + f"Error: Directory '{project_name}' already exists. Use --force to overwrite.", + fg="red", + ) + ) + sys.exit(1) + + output_dir.mkdir(parents=True, exist_ok=True) + + try: + engine = TemplateEngine(template, config) + + if template: + engine.render_project(context, output_dir, template) + else: + engine.render_language_template(language, context, output_dir) + + gitignore_gen = GitignoreGenerator() + gitignore_path = output_dir / ".gitignore" + gitignore_gen.generate(language, gitignore_path) + + if ci != "none": + engine.render_ci_template(ci, context, output_dir) + + click.echo( + click.style( + f"Successfully created project '{project_name}' at {output_dir}", + fg="green", + ) + ) + + if license == "None": + license_file = output_dir / "LICENSE" + license_file.unlink(missing_ok=True) + + except Exception as e: + click.echo(click.style(f"Error creating project: {e}", fg="red")) + sys.exit(1) + + +@main.group() +def template(): + """Manage custom templates.""" + pass + + +@template.command(name="list") +@click.pass_context +def list_templates(ctx): + """List all custom templates.""" + config = ctx.obj.get("config", Config()) + template_dirs = config.get_template_dirs() + + templates_found = False + for template_dir in template_dirs: + if Path(template_dir).exists(): + for item in Path(template_dir).iterdir(): + if item.is_dir() and not item.name.startswith("."): + click.echo(f"- {item.name} ({item})") + templates_found = True + + if not templates_found: + click.echo("No custom templates found.") + + +@template.command(name="save") +@click.argument("name") +@click.argument("path", type=click.Path(exists=True, file_okay=False)) +@click.pass_context +def save_template(ctx, name, path): + """Save a new custom template.""" + config = ctx.obj.get("config", Config()) + template_dir = config.get_custom_templates_dir() + target_dir = Path(template_dir) / name + + if target_dir.exists(): + click.echo( + click.style( + f"Template '{name}' already exists. Use --force to overwrite.", + fg="yellow", + ) + ) + return + + import shutil + + try: + shutil.copytree(path, target_dir) + click.echo( + click.style( + f"Template '{name}' saved to {target_dir}", + fg="green", + ) + ) + except Exception as e: + click.echo(click.style(f"Error saving template: {e}", fg="red")) + sys.exit(1) + + +@template.command(name="delete") +@click.argument("name") +@click.pass_context +def delete_template(ctx, name): + """Delete a custom template.""" + config = ctx.obj.get("config", Config()) + template_dir = config.get_custom_templates_dir() + target_dir = Path(template_dir) / name + + if not target_dir.exists(): + click.echo( + click.style(f"Template '{name}' not found.", fg="yellow") + ) + return + + import shutil + + try: + shutil.rmtree(target_dir) + click.echo( + click.style( + f"Template '{name}' deleted.", + fg="green", + ) + ) + except Exception as e: + click.echo(click.style(f"Error deleting template: {e}", fg="red")) + sys.exit(1) + + +@main.command() +@click.option( + "--output", + "-o", + type=click.Path(), + default="project.yaml", + help="Output file path", +) +def init_config(output): + """Generate a template configuration file.""" + config_content = '''project: + author: "Your Name" + email: "your.email@example.com" + license: "MIT" + description: "A brief description of your project" + +defaults: + language: "python" + ci: "github" + template: null + +template_vars: + python: + version: "3.8+" + nodejs: + version: "16+" +''' + + output_path = Path(output) + if output_path.exists(): + click.echo( + click.style( + f"File '{output}' already exists. Use --force to overwrite.", + fg="yellow", + ) + ) + return + + try: + output_path.write_text(config_content) + click.echo( + click.style( + f"Configuration file created at {output_path}", + fg="green", + ) + ) + except Exception as e: + click.echo( + click.style(f"Error creating configuration file: {e}", fg="red") + ) + sys.exit(1) + + +@main.command() +@click.argument("shells", nargs=-1, type=click.Choice(get_installed_shells())) +def completions(shells): + """Install shell completion for psc.""" + if not shells: + shells = get_installed_shells() + + for shell in shells: + init(shell) + click.echo(f"Completion for {shell} installed.") + + +def _validate_project_name(name: str) -> bool: + """Validate project name format.""" + if not name: + return False + import re + + return bool(re.match(r"^[a-z][a-z0-9-]*$", name)) + + +def _to_kebab_case(name: str) -> str: + """Convert project name to kebab-case.""" + import re + + name = name.lower().strip() + name = re.sub(r"[^a-z0-9]+", "-", name) + return name.strip("-") + + +def get_project_root(): + """Get the project root directory (where templates are stored).""" + return Path(__file__).parent.parent / "templates" + + +if __name__ == "__main__": + main() diff --git a/project_scaffold_cli/config.py b/project_scaffold_cli/config.py new file mode 100644 index 0000000..aee46ba --- /dev/null +++ b/project_scaffold_cli/config.py @@ -0,0 +1,152 @@ +"""Configuration handling for Project Scaffold CLI.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + + +class Config: + """Configuration management for project scaffold CLI.""" + + def __init__( + self, + author: Optional[str] = None, + email: Optional[str] = None, + license: Optional[str] = None, + description: Optional[str] = None, + default_language: Optional[str] = None, + default_ci: Optional[str] = None, + default_template: Optional[str] = None, + template_vars: Optional[Dict[str, Any]] = None, + custom_templates_dir: Optional[str] = None, + config_path: Optional[str] = None, + ): + self.author = author + self.email = email + self.license = license + self.description = description + self.default_language = default_language + self.default_ci = default_ci + self.default_template = default_template + self.template_vars = template_vars or {} + self.custom_templates_dir = custom_templates_dir + self.config_path = config_path + + @classmethod + def load(cls, config_path: Optional[str] = None) -> "Config": + """Load configuration from file.""" + if config_path: + config_file = Path(config_path) + if config_file.exists(): + return cls._from_file(config_file) + + search_paths = [ + Path("project.yaml"), + Path(".project-scaffoldrc"), + Path.cwd() / "project.yaml", + Path.cwd() / ".project-scaffoldrc", + ] + + for path in search_paths: + if path.exists(): + return cls._from_file(path) + + home_config = Path.home() / ".config" / "project-scaffold" / "config.yaml" + if home_config.exists(): + return cls._from_file(home_config) + + return cls() + + @classmethod + def _from_file(cls, path: Path) -> "Config": + """Load configuration from a YAML file.""" + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in {path}: {e}") + + project_config = data.get("project", {}) + defaults = data.get("defaults", {}) + template_vars = data.get("template_vars", {}) + custom_templates_dir = data.get("custom_templates_dir") + + return cls( + author=project_config.get("author"), + email=project_config.get("email"), + license=project_config.get("license"), + description=project_config.get("description"), + default_language=defaults.get("language"), + default_ci=defaults.get("ci"), + default_template=defaults.get("template"), + template_vars=template_vars, + custom_templates_dir=custom_templates_dir, + config_path=str(path), + ) + + def save(self, path: Path) -> None: + """Save configuration to a YAML file.""" + data = { + "project": { + "author": self.author, + "email": self.email, + "license": self.license, + "description": self.description, + }, + "defaults": { + "language": self.default_language, + "ci": self.default_ci, + "template": self.default_template, + }, + "template_vars": self.template_vars, + } + + if self.custom_templates_dir: + data["custom_templates_dir"] = self.custom_templates_dir + + with open(path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + def get_template_dirs(self) -> List[str]: + """Get list of template directories to search.""" + dirs = [] + + if self.custom_templates_dir: + dirs.append(self.custom_templates_dir) + + home_templates = ( + Path.home() + / ".local" + / "share" + / "project-scaffold" + / "templates" + ) + dirs.append(str(home_templates)) + + return dirs + + def get_custom_templates_dir(self) -> str: + """Get the directory for custom templates.""" + if self.custom_templates_dir: + return self.custom_templates_dir + + custom_dir = ( + Path.home() / ".config" / "project-scaffold" / "templates" + ) + custom_dir.mkdir(parents=True, exist_ok=True) + return str(custom_dir) + + def get_template_vars(self, language: str) -> Dict[str, Any]: + """Get template variables for a specific language.""" + return self.template_vars.get(language, {}) + + @property + def ci(self) -> Optional[str]: + """Get default CI provider.""" + return self.default_ci + + @property + def template(self) -> Optional[str]: + """Get default template.""" + return self.default_template diff --git a/project_scaffold_cli/gitignore.py b/project_scaffold_cli/gitignore.py new file mode 100644 index 0000000..0505ca2 --- /dev/null +++ b/project_scaffold_cli/gitignore.py @@ -0,0 +1,202 @@ +"""Gitignore generator for Project Scaffold CLI.""" + +from pathlib import Path +from typing import Optional, Set + + +class GitignoreGenerator: + """Generate language-specific .gitignore files.""" + + SUPPORTED_LANGUAGES = ["python", "nodejs", "go", "rust"] + + GITIGNORE_TEMPLATES = { + "python": """__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +venv/ +ENV/ +env/ +.venv/ +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +*.manifest +*.spec +""", + "nodejs": """node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.log +.DS_Store +dist/ +build/ +.nyc_output/ +coverage/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +""", + "go": """# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go coverage +*.out + +# Go workspace +go.work + +# Vendor directory +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +""", + "rust": """# Cargo +target/ +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Backup files +*~ +#*# +#* +.backup + +# Build artifacts +*.rlib +*.dylib +*.dll + +# IDE +.vscode/ +.idea/ +""", + } + + ADDITIONAL_PATTERNS = { + "python": """*.pyc +*.pyo +.env +.env.local +.vscode/ +.idea/ +""", + "nodejs": """package-lock.json +yarn.lock +.env +.env.local +""", + "go": """*.out +*.test +coverage.txt +""", + "rust": """**/*.rs.bk +Cargo.lock +""", + } + + def __init__(self): + self.templates_dir = self._get_templates_dir() + + def _get_templates_dir(self) -> Path: + """Get the directory containing gitignore templates.""" + return Path(__file__).parent / "templates" / "gitignore" + + def generate( + self, language: str, output_path: Path, extra_patterns: Optional[Set[str]] = None + ) -> None: + """Generate a .gitignore file for the specified language.""" + if language not in self.SUPPORTED_LANGUAGES: + raise ValueError( + f"Unsupported language: {language}. " + f"Supported: {', '.join(self.SUPPORTED_LANGUAGES)}" + ) + + content = self.GITIGNORE_TEMPLATES.get(language, "") + + extra = self.ADDITIONAL_PATTERNS.get(language, "") + if extra: + content += extra + + if extra_patterns: + content += "\n".join(sorted(extra_patterns)) + "\n" + + content += "\n# Editor directories\n.idea/\n.vscode/\n*.swp\n*.swo\n*~\n" + + output_path.write_text(content) + + def generate_from_template(self, template_name: str, output_path: Path) -> None: + """Generate .gitignore from a template file.""" + template_path = self.templates_dir / template_name + if not template_path.exists(): + raise FileNotFoundError( + f"Gitignore template not found: {template_path}" + ) + + content = template_path.read_text() + output_path.write_text(content) + + def list_available_templates(self) -> list[str]: + """List available gitignore templates.""" + if not self.templates_dir.exists(): + return [] + + return [ + f.stem + for f in self.templates_dir.iterdir() + if f.is_file() and f.suffix in (".gitignore", ".txt", "") + ] + + def get_template_content(self, language: str) -> str: + """Get the raw template content for a language.""" + return self.GITIGNORE_TEMPLATES.get(language, "") + + def append_patterns(self, gitignore_path: Path, patterns: Set[str]) -> None: + """Append additional patterns to an existing .gitignore file.""" + if gitignore_path.exists(): + content = gitignore_path.read_text() + if not content.endswith("\n"): + content += "\n" + else: + content = "" + + content += "\n" + "\n".join(sorted(patterns)) + "\n" + gitignore_path.write_text(content) diff --git a/project_scaffold_cli/prompts.py b/project_scaffold_cli/prompts.py new file mode 100644 index 0000000..17757aa --- /dev/null +++ b/project_scaffold_cli/prompts.py @@ -0,0 +1,164 @@ +"""Interactive prompts for Project Scaffold CLI.""" + +from typing import Optional, Tuple + +import click + +from .config import Config + + +class ProjectPrompts: + """Interactive prompt collection for project configuration.""" + + LICENSE_CHOICES = ["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "None"] + + LANGUAGE_CHOICES = ["python", "nodejs", "go", "rust"] + + CI_CHOICES = ["github", "gitlab", "none"] + + def __init__(self, config: Config): + self.config = config + + def collect_inputs( + self, + project_name: Optional[str], + language: Optional[str], + author: Optional[str], + email: Optional[str], + description: Optional[str], + license: Optional[str], + ci: Optional[str], + template: Optional[str], + ) -> Tuple[ + Optional[str], + Optional[str], + Optional[str], + Optional[str], + Optional[str], + Optional[str], + Optional[str], + Optional[str], + ]: + """Collect all project configuration inputs interactively.""" + project_name = self._prompt_project_name(project_name) + language = self._prompt_language(language) + author = self._prompt_author(author) + email = self._prompt_email(email) + description = self._prompt_description(description) + license = self._prompt_license(license) + ci = self._prompt_ci(ci) + + return (project_name, language, author, email, description, license, ci, template) + + def _prompt_project_name(self, default: Optional[str]) -> Optional[str]: + """Prompt for project name.""" + if default: + return default + + return click.prompt( + "Project name", + type=str, + default=default, + value_proc=lambda x: x.strip() if x else x, + ) + + def _prompt_language(self, default: Optional[str]) -> Optional[str]: + """Prompt for programming language.""" + if default: + return default + + return click.prompt( + "Project language", + type=click.Choice(self.LANGUAGE_CHOICES), + default=self.config.default_language or "python", + ) + + def _prompt_author(self, default: Optional[str]) -> Optional[str]: + """Prompt for author name.""" + if default: + return default + + if self.config.author: + use_default = click.confirm( + f"Use '{self.config.author}' as author?", default=True + ) + if use_default: + return self.config.author + + return click.prompt( + "Author name", + type=str, + default=self.config.author or "Your Name", + ) + + def _prompt_email(self, default: Optional[str]) -> Optional[str]: + """Prompt for author email.""" + if default: + return default + + if self.config.email: + use_default = click.confirm( + f"Use '{self.config.email}' as email?", default=True + ) + if use_default: + return self.config.email + + return click.prompt( + "Author email", + type=str, + default=self.config.email or "your.email@example.com", + ) + + def _prompt_description(self, default: Optional[str]) -> Optional[str]: + """Prompt for project description.""" + if default: + return default + + if self.config.description: + use_default = click.confirm( + f"Use '{self.config.description}' as description?", default=True + ) + if use_default: + return self.config.description + + return click.prompt( + "Project description", + type=str, + default=self.config.description or "A new project", + ) + + def _prompt_license(self, default: Optional[str]) -> Optional[str]: + """Prompt for license type.""" + if default: + return default + + if self.config.license: + use_default = click.confirm( + f"Use '{self.config.license}' as license?", default=True + ) + if use_default: + return self.config.license + + return click.prompt( + "License", + type=click.Choice(self.LICENSE_CHOICES), + default=self.config.license or "MIT", + ) + + def _prompt_ci(self, default: Optional[str]) -> Optional[str]: + """Prompt for CI/CD provider.""" + if default: + return default + + if self.config.default_ci: + use_default = click.confirm( + f"Use '{self.config.default_ci}' for CI/CD?", default=True + ) + if use_default: + return self.config.default_ci + + return click.prompt( + "CI/CD provider", + type=click.Choice(self.CI_CHOICES), + default=self.config.default_ci or "none", + ) diff --git a/project_scaffold_cli/template_engine.py b/project_scaffold_cli/template_engine.py new file mode 100644 index 0000000..3d9407d --- /dev/null +++ b/project_scaffold_cli/template_engine.py @@ -0,0 +1,199 @@ +"""Template engine using Jinja2 for project scaffolding.""" + +import os +from pathlib import Path +from typing import Any, Dict, Optional + +from jinja2 import ( + Environment, + FileSystemLoader, + TemplateSyntaxError, +) + +from .config import Config + + +class TemplateEngine: + """Jinja2 template rendering engine for project scaffolding.""" + + SUPPORTED_LANGUAGES = ["python", "nodejs", "go", "rust"] + + def __init__(self, template_path: Optional[str] = None, config: Optional[Config] = None): + self.template_path = template_path + self.config = config or Config() + self._env: Optional[Environment] = None + + def _get_environment(self) -> Environment: + """Get or create the Jinja2 environment.""" + if self._env is not None: + return self._env + + if self.template_path and not os.path.isabs(self.template_path): + search_dirs = self.config.get_template_dirs() + search_dirs.insert(0, str(Path.cwd())) + self._env = Environment( + loader=FileSystemLoader(search_dirs), + trim_blocks=True, + lstrip_blocks=True, + ) + elif self.template_path: + template_dir = Path(self.template_path).parent + self._env = Environment( + loader=FileSystemLoader([str(template_dir)]), + trim_blocks=True, + lstrip_blocks=True, + ) + else: + project_root = self._get_project_root() + self._env = Environment( + loader=FileSystemLoader(str(project_root)), + trim_blocks=True, + lstrip_blocks=True, + ) + + return self._env + + def _get_project_root(self) -> Path: + """Get the project root directory for built-in templates.""" + return Path(__file__).parent / "templates" + + def _validate_template(self, template_name: str) -> bool: + """Validate that a template exists.""" + env = self._get_environment() + try: + env.get_template(template_name) + return True + except Exception: + return False + + def _render_template( + self, template_name: str, context: Dict[str, Any] + ) -> str: + """Render a single template with context.""" + env = self._get_environment() + template = env.get_template(template_name) + return template.render(**context) + + def render_language_template( + self, language: str, context: Dict[str, Any], output_dir: Path + ) -> None: + """Render all templates for a language.""" + if language not in self.SUPPORTED_LANGUAGES: + raise ValueError( + f"Unsupported language: {language}. " + f"Supported: {', '.join(self.SUPPORTED_LANGUAGES)}" + ) + + self._get_environment() + language_dir = language + + try: + project_root = self._get_project_root() / language_dir + for root, dirs, files in os.walk(project_root): + rel_path = Path(root).relative_to(project_root) + + rel_path_str = rel_path.as_posix() + rel_path_str = rel_path_str.replace("{{project_slug}}", context.get("project_slug", "")) + rel_path_str = rel_path_str.replace("{{ project_name }}", context.get("project_name", "")) + + dest_dir = output_dir / rel_path_str + dest_dir.mkdir(parents=True, exist_ok=True) + + for file in files: + if file.endswith((".j2", ".jinja2")): + template_path = ( + Path(root) / file + ).relative_to(project_root).as_posix() + dest_file = Path(dest_dir) / file[:-3] + + try: + content = self._render_template( + language_dir + "/" + template_path, context + ) + dest_file.write_text(content) + except TemplateSyntaxError as e: + raise ValueError( + f"Template syntax error in {template_path}: {e}" + ) + else: + src_file = Path(root) / file + dest_file = Path(dest_dir) / file + dest_file.write_bytes(src_file.read_bytes()) + + except Exception as e: + raise RuntimeError(f"Error rendering language template: {e}") + + def render_project( + self, + context: Dict[str, Any], + output_dir: Path, + template: str, + ) -> None: + """Render a custom project template.""" + if not self._validate_template(template): + raise ValueError(f"Template not found: {template}") + + try: + for root, dirs, files in os.walk( + self._get_environment().loader.searchpath[0] + ): + for pattern in self._get_environment().loader.get_files( + template + ): + pass + + env = self._get_environment() + base_template = env.get_template(template) + + for item in base_template.list_templates(): + pass + + except Exception as e: + raise ValueError(f"Error rendering project template: {e}") + + def render_ci_template( + self, + ci_provider: str, + context: Dict[str, Any], + output_dir: Path, + ) -> None: + """Render CI/CD template for a provider.""" + ci_templates = { + "github": "ci/.github/workflows/ci.yml.j2", + "gitlab": "ci/.gitlab-ci.yml.j2", + } + + if ci_provider not in ci_templates: + raise ValueError( + f"Unsupported CI provider: {ci_provider}. " + f"Supported: {', '.join(ci_templates.keys())}" + ) + + self._get_environment() + template_name = ci_templates[ci_provider] + + try: + content = self._render_template(template_name, context) + + if ci_provider == "github": + workflow_dir = output_dir / ".github" / "workflows" + workflow_dir.mkdir(parents=True, exist_ok=True) + output_file = workflow_dir / "ci.yml" + else: + output_file = output_dir / ".gitlab-ci.yml" + + output_file.write_text(content) + + except Exception as e: + raise RuntimeError(f"Error rendering CI template: {e}") + + def get_template_list(self) -> list[str]: + """Get list of available templates.""" + env = self._get_environment() + return env.list_templates() + + def validate_context(self, context: Dict[str, Any]) -> list[str]: + """Validate that all required context variables are provided.""" + required = ["project_name", "author"] + missing = [var for var in required if var not in context or not context[var]] + return missing diff --git a/project_scaffold_cli/templates/ci/.github/workflows/ci.yml.j2 b/project_scaffold_cli/templates/ci/.github/workflows/ci.yml.j2 new file mode 100644 index 0000000..49c4786 --- /dev/null +++ b/project_scaffold_cli/templates/ci/.github/workflows/ci.yml.j2 @@ -0,0 +1,85 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 black + + - name: Lint with flake8 + run: | + flake8 . + + - name: Format with black + run: | + black --check . + + test: + runs-on: ubuntu-latest + needs: lint + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + pytest -v --cov=.{% raw %}{{ project_slug }}{% endraw %} --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + fail_ci_if_error: false + + build: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: | + python -m build + + - name: Publish to PyPI + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: {% raw %}${{ secrets.PYPI_API_TOKEN }}{% endraw %} diff --git a/project_scaffold_cli/templates/ci/.gitlab-ci.yml.j2 b/project_scaffold_cli/templates/ci/.gitlab-ci.yml.j2 new file mode 100644 index 0000000..ff29d0c --- /dev/null +++ b/project_scaffold_cli/templates/ci/.gitlab-ci.yml.j2 @@ -0,0 +1,43 @@ +stages: + - lint + - test + - build + +lint: + stage: lint + image: python:3.11 + script: + - pip install flake8 black + - flake8 . + - black --check . + only: + - main + - master + +test: + stage: test + image: python:3.11 + script: + - pip install -e ".[dev]" + - pytest -v --cov=.{{ project_slug }} --cov-report=html + artifacts: + reports: + junit: test-results.xml + paths: + - htmlcov/ + only: + - main + - master + +build: + stage: build + image: python:3.11 + script: + - pip install build + - python -m build + artifacts: + paths: + - dist/ + only: + - main + - tags diff --git a/project_scaffold_cli/templates/go/README.md.j2 b/project_scaffold_cli/templates/go/README.md.j2 new file mode 100644 index 0000000..2097f99 --- /dev/null +++ b/project_scaffold_cli/templates/go/README.md.j2 @@ -0,0 +1,27 @@ +# {{ project_name }} + +{{ description }} + +## Installation + +```bash +go get github.com/{{ author|replace(' ', '-') }}/{{ project_slug }} +``` + +## Usage + +```go +package main + +import ( + "{{ author|replace(' ', '-') }}/{{ project_slug }}" +) + +func main() { + {{ project_slug }}.Run() +} +``` + +## License + +{{ license }} diff --git a/project_scaffold_cli/templates/go/go.mod.j2 b/project_scaffold_cli/templates/go/go.mod.j2 new file mode 100644 index 0000000..49ce76f --- /dev/null +++ b/project_scaffold_cli/templates/go/go.mod.j2 @@ -0,0 +1,5 @@ +module {{ project_slug }} + +go 1.20 + +require github.com/{{ author|replace(' ', '-') }}/{{ project_slug }} v1.0.0 diff --git a/project_scaffold_cli/templates/go/main.go.j2 b/project_scaffold_cli/templates/go/main.go.j2 new file mode 100644 index 0000000..96f6019 --- /dev/null +++ b/project_scaffold_cli/templates/go/main.go.j2 @@ -0,0 +1,9 @@ +package main + +import ( + "fmt" +) + +func main() { + fmt.Println("Welcome to {{ project_name }}!") +} diff --git a/project_scaffold_cli/templates/nodejs/README.md.j2 b/project_scaffold_cli/templates/nodejs/README.md.j2 new file mode 100644 index 0000000..5383a5e --- /dev/null +++ b/project_scaffold_cli/templates/nodejs/README.md.j2 @@ -0,0 +1,21 @@ +# {{ project_name }} + +{{ description }} + +## Installation + +```bash +npm install +``` + +## Usage + +```javascript +const {{ project_slug }} = require('./index.js'); + +{{ project_slug }}(); +``` + +## License + +{{ license }} diff --git a/project_scaffold_cli/templates/nodejs/index.js.j2 b/project_scaffold_cli/templates/nodejs/index.js.j2 new file mode 100644 index 0000000..f45577b --- /dev/null +++ b/project_scaffold_cli/templates/nodejs/index.js.j2 @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +/** + * {{ project_name }} + * {{ description }} + */ + +function main() { + console.log('Welcome to {{ project_name }}!'); +} + +main(); diff --git a/project_scaffold_cli/templates/python/README.md.j2 b/project_scaffold_cli/templates/python/README.md.j2 new file mode 100644 index 0000000..6e7fc52 --- /dev/null +++ b/project_scaffold_cli/templates/python/README.md.j2 @@ -0,0 +1 @@ +{{ description }} diff --git a/project_scaffold_cli/templates/python/setup.py.j2 b/project_scaffold_cli/templates/python/setup.py.j2 new file mode 100644 index 0000000..efd6aac --- /dev/null +++ b/project_scaffold_cli/templates/python/setup.py.j2 @@ -0,0 +1,48 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="{{ project_slug }}", + version="1.0.0", + author="{{ author }}", + author_email="{{ email }}", + description="{{ description }}", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/{{ author|replace(' ', '-') }}/{{ project_slug }}", + packages=find_packages(), + python_requires=">=3.8", + install_requires=[ + {% if template_vars and template_vars.python %} + {% for dep in template_vars.python.get('dependencies', []) %} + "{{ dep }}", + {% endfor %} + {% else %} + {% endif %} + ], + extras_require={ + "dev": [ + "pytest>=7.0", + "pytest-cov>=4.0", + "black>=23.0", + "flake8>=6.0", + ], + }, + entry_points={ + "console_scripts": [ + "{{ project_slug }}={{ project_slug }}.cli:main", + ], + }, + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], +) diff --git a/project_scaffold_cli/templates/python/{{project_slug}}/__init__.py.j2 b/project_scaffold_cli/templates/python/{{project_slug}}/__init__.py.j2 new file mode 100644 index 0000000..b17d729 --- /dev/null +++ b/project_scaffold_cli/templates/python/{{project_slug}}/__init__.py.j2 @@ -0,0 +1 @@ +{{ project_slug }} diff --git a/project_scaffold_cli/templates/python/{{project_slug}}/cli.py.j2 b/project_scaffold_cli/templates/python/{{project_slug}}/cli.py.j2 new file mode 100644 index 0000000..7a204d1 --- /dev/null +++ b/project_scaffold_cli/templates/python/{{project_slug}}/cli.py.j2 @@ -0,0 +1,13 @@ +"""Main CLI entry point.""" + +import click + + +@click.command() +def main(): + """Main entry point for {{ project_name }}.""" + click.echo("Welcome to {{ project_name }}!") + + +if __name__ == "__main__": + main() diff --git a/project_scaffold_cli/templates/python/{{project_slug}}/test_main.py.j2 b/project_scaffold_cli/templates/python/{{project_slug}}/test_main.py.j2 new file mode 100644 index 0000000..6b38c4e --- /dev/null +++ b/project_scaffold_cli/templates/python/{{project_slug}}/test_main.py.j2 @@ -0,0 +1,15 @@ +"""Test module for {{ project_name }}.""" + +import pytest + +from {{ project_slug }} import __version__ + + +def test_version(): + """Test version is a string.""" + assert isinstance(__version__, str) + + +def test_example(): + """Example test.""" + assert True diff --git a/project_scaffold_cli/templates/rust/Cargo.toml.j2 b/project_scaffold_cli/templates/rust/Cargo.toml.j2 new file mode 100644 index 0000000..edd6179 --- /dev/null +++ b/project_scaffold_cli/templates/rust/Cargo.toml.j2 @@ -0,0 +1,9 @@ +[package] +name = "{{ project_slug }}" +version = "0.1.0" +edition = "2021" +authors = ["{{ author }} <{{ email }}>"] +description = "{{ description }}" +license = "{{ license }}" + +[dependencies] diff --git a/project_scaffold_cli/templates/rust/README.md.j2 b/project_scaffold_cli/templates/rust/README.md.j2 new file mode 100644 index 0000000..48bff57 --- /dev/null +++ b/project_scaffold_cli/templates/rust/README.md.j2 @@ -0,0 +1,23 @@ +# {{ project_name }} + +{{ description }} + +## Installation + +```bash +cargo add {{ project_slug }} +``` + +## Usage + +```rust +use {{ project_slug }}::run; + +fn main() { + run(); +} +``` + +## License + +{{ license }} diff --git a/project_scaffold_cli/templates/rust/src/main.rs.j2 b/project_scaffold_cli/templates/rust/src/main.rs.j2 new file mode 100644 index 0000000..9a40c80 --- /dev/null +++ b/project_scaffold_cli/templates/rust/src/main.rs.j2 @@ -0,0 +1,3 @@ +fn main() { + println!("Welcome to {{ project_name }}!"); +} diff --git a/pyproject.toml b/pyproject.toml index 8c8a95e..5a9f440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,22 +3,23 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "auto-readme-cli" -version = "0.1.0" -description = "A CLI tool that automatically generates comprehensive README.md files by analyzing project structure" +name = "project-scaffold-cli" +version = "1.0.0" +description = "A CLI tool that generates standardized project scaffolding for multiple languages" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.8" license = {text = "MIT"} authors = [ - {name = "Auto README Team", email = "team@autoreadme.dev"} + {name = "Project Scaffold CLI", email = "dev@example.com"} ] -keywords = ["cli", "documentation", "readme", "generator", "markdown"] +keywords = ["cli", "project", "scaffold", "generator", "template"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -26,33 +27,19 @@ classifiers = [ ] dependencies = [ - "click>=8.0.0", - "tree-sitter>=0.23.0", - "jinja2>=3.1.0", - "tomli>=2.0.0; python_version<'3.11'", - "requests>=2.31.0", - "rich>=13.0.0", - "pyyaml>=6.0.0", - "gitpython>=3.1.0", + "click>=8.0", + "jinja2>=3.0", + "pyyaml>=6.0", + "click-completion>=0.2", ] [project.optional-dependencies] dev = [ - "pytest>=7.0.0", - "pytest-cov>=4.0.0", - "black>=23.0.0", - "isort>=5.12.0", - "flake8>=6.0.0", + "pytest>=7.0", + "pytest-cov>=4.0", "ruff>=0.1.0", - "pre-commit>=3.0.0", ] -[project.scripts] -auto-readme = "auto_readme.cli:main" - -[tool.setuptools.packages.find] -where = ["src"] - [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] @@ -61,7 +48,7 @@ python_functions = ["test_*"] addopts = "-v --tb=short" [tool.coverage.run] -source = ["src/auto_readme"] +source = ["project_scaffold_cli"] omit = ["*/tests/*", "*/__pycache__/*"] [tool.coverage.report] @@ -69,15 +56,13 @@ exclude_lines = ["pragma: no cover", "def __repr__", "raise NotImplementedError" [tool.black] line-length = 100 -target-version = ["py39", "py310", "py311", "py312"] +target-version = ["py38", "py39", "py310", "py311", "py312"] include = "\\.pyi?$" -[tool.isort] -profile = "black" -line_length = 100 -skip = [".venv", "venv"] +[tool.ruff] +line-length = 100 +target-version = "py38" -[tool.flake8] -max-line-length = 100 -exclude = [".venv", "venv", "build", "dist"] -per-file-ignores = ["__init__.py: F401"] +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b8b97ec --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=7.0 +pytest-cov>=4.0 +black>=23.0 +flake8>=6.0 +ruff>=0.1.0 diff --git a/requirements.txt b/requirements.txt index 0898e6c..87cedce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,35 +1,4 @@ -# 7000%AUTO - AI Autonomous Development System -# Core dependencies - -# OpenCode SDK -opencode-ai>=0.1.0a36 - -# Web Framework -fastapi>=0.109.0 -uvicorn[standard]>=0.27.0 -sse-starlette>=1.8.0 - -# Database -sqlalchemy[asyncio]>=2.0.0 -aiosqlite>=0.19.0 - -# HTTP Client -httpx>=0.26.0 - -# Data Validation -pydantic>=2.5.0 -pydantic-settings>=2.1.0 - -# Environment -python-dotenv>=1.0.0 - -# MCP Server -mcp>=1.0.0 - -# External APIs -tweepy>=4.14.0 -# PyGithub removed - using Gitea API via httpx - -# Utilities -aiofiles>=23.2.0 -structlog>=23.2.0 +click>=8.0 +jinja2>=3.0 +pyyaml>=6.0 +click-completion>=0.2 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..29881b9 --- /dev/null +++ b/setup.py @@ -0,0 +1,46 @@ +from setuptools import find_packages, setup + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="project-scaffold-cli", + version="1.0.0", + author="Project Scaffold CLI", + author_email="dev@example.com", + description="A CLI tool that generates standardized project scaffolding for multiple languages", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/example/project-scaffold-cli", + packages=find_packages(), + python_requires=">=3.8", + install_requires=[ + "click>=8.0", + "jinja2>=3.0", + "pyyaml>=6.0", + "click-completion>=0.2", + ], + extras_require={ + "dev": [ + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.1.0", + ], + }, + entry_points={ + "console_scripts": [ + "psc=project_scaffold_cli.cli:main", + ], + }, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..69ad8dc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for project_scaffold_cli package.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 6a4f13c..88aac05 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,134 +1,73 @@ """Tests for CLI commands.""" +import os +import tempfile +from pathlib import Path from click.testing import CliRunner -from src.auto_readme.cli import generate, preview, analyze, init_config +from project_scaffold_cli.cli import _to_kebab_case, _validate_project_name, main -class TestGenerateCommand: - """Tests for the generate command.""" - - def test_generate_basic_python(self, create_python_project, tmp_path): - """Test basic README generation for Python project.""" - from src.auto_readme.cli import generate +class TestMain: + """Test main CLI entry point.""" + def test_main_version(self): + """Test --version flag.""" runner = CliRunner() - result = runner.invoke(generate, ["--input", str(create_python_project), "--output", str(tmp_path / "README.md")]) - + result = runner.invoke(main, ["--version"]) assert result.exit_code == 0 + assert "1.0.0" in result.output - readme_content = (tmp_path / "README.md").read_text() - assert "# test-project" in readme_content - - def test_generate_dry_run(self, create_python_project): - """Test README generation with dry-run option.""" + def test_main_help(self): + """Test --help flag.""" runner = CliRunner() - - result = runner.invoke(generate, ["--input", str(create_python_project), "--dry-run"]) - + result = runner.invoke(main, ["--help"]) assert result.exit_code == 0 - assert "# test-project" in result.output + assert "create" in result.output - def test_generate_force_overwrite(self, create_python_project, tmp_path): - """Test forced README overwrite.""" - readme_file = tmp_path / "README.md" - readme_file.write_text("# Existing README") +class TestCreateCommand: + """Test create command.""" + + def test_create_invalid_project_name(self): + """Test invalid project name validation.""" + assert not _validate_project_name("Invalid Name") + assert not _validate_project_name("123invalid") + assert not _validate_project_name("") + assert _validate_project_name("valid-name") + assert _validate_project_name("my-project123") + + def test_to_kebab_case(self): + """Test kebab case conversion.""" + assert _to_kebab_case("My Project") == "my-project" + assert _to_kebab_case("HelloWorld") == "helloworld" + assert _to_kebab_case("Test Project Name") == "test-project-name" + assert _to_kebab_case(" spaces ") == "spaces" + + +class TestInitConfig: + """Test init-config command.""" + + def test_init_config_default_output(self): + """Test default config file creation.""" runner = CliRunner() - result = runner.invoke(generate, ["--input", str(create_python_project), "--output", str(readme_file), "--force"]) + with tempfile.TemporaryDirectory() as tmpdir: + original_dir = Path.cwd() + try: + os.chdir(tmpdir) + result = runner.invoke(main, ["init-config"]) + assert result.exit_code == 0 + assert Path("project.yaml").exists() + finally: + os.chdir(original_dir) - assert result.exit_code == 0 - assert readme_file.read_text() != "# Existing README" - def test_generate_with_template(self, create_python_project): - """Test README generation with specific template.""" +class TestTemplateCommands: + """Test template management commands.""" + + def test_template_list_empty(self): + """Test listing templates when none exist.""" runner = CliRunner() - - result = runner.invoke(generate, ["--input", str(create_python_project), "--template", "base", "--dry-run"]) - + result = runner.invoke(main, ["template", "list"]) assert result.exit_code == 0 - - -class TestPreviewCommand: - """Tests for the preview command.""" - - def test_preview_python_project(self, create_python_project): - """Test previewing README for Python project.""" - runner = CliRunner() - - result = runner.invoke(preview, ["--input", str(create_python_project)]) - - assert result.exit_code == 0 - assert "# test-project" in result.output - - -class TestAnalyzeCommand: - """Tests for the analyze command.""" - - def test_analyze_python_project(self, create_python_project): - """Test analyzing Python project.""" - runner = CliRunner() - - result = runner.invoke(analyze, [str(create_python_project)]) - - assert result.exit_code == 0 - assert "test-project" in result.output - assert "Type: python" in result.output - - def test_analyze_js_project(self, create_javascript_project): - """Test analyzing JavaScript project.""" - runner = CliRunner() - - result = runner.invoke(analyze, [str(create_javascript_project)]) - - assert result.exit_code == 0 - assert "Type: javascript" in result.output - - def test_analyze_go_project(self, create_go_project): - """Test analyzing Go project.""" - runner = CliRunner() - - result = runner.invoke(analyze, [str(create_go_project)]) - - assert result.exit_code == 0 - assert "Type: go" in result.output - - def test_analyze_rust_project(self, create_rust_project): - """Test analyzing Rust project.""" - runner = CliRunner() - - result = runner.invoke(analyze, [str(create_rust_project)]) - - assert result.exit_code == 0 - assert "Type: rust" in result.output - - -class TestInitConfigCommand: - """Tests for the init-config command.""" - - def test_init_config(self, tmp_path): - """Test generating configuration template.""" - runner = CliRunner() - - result = runner.invoke(init_config, ["--output", str(tmp_path / ".readmerc")]) - - assert result.exit_code == 0 - - config_content = (tmp_path / ".readmerc").read_text() - assert "project_name:" in config_content - assert "description:" in config_content - - def test_init_config_default_path(self, tmp_path): - """Test generating configuration at default path.""" - import os - original_dir = os.getcwd() - try: - os.chdir(tmp_path) - runner = CliRunner() - result = runner.invoke(init_config, ["--output", ".readmerc"]) - - assert result.exit_code == 0 - assert (tmp_path / ".readmerc").exists() - finally: - os.chdir(original_dir) diff --git a/tests/test_config.py b/tests/test_config.py index db29809..ac84630 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,132 +1,101 @@ -"""Tests for configuration loading.""" +"""Tests for configuration handling.""" +import tempfile from pathlib import Path -import pytest +import yaml -from src.auto_readme.config import ConfigLoader, ConfigValidator, ReadmeConfig +from project_scaffold_cli.config import Config -class TestConfigLoader: - """Tests for ConfigLoader.""" +class TestConfig: + """Test Config class.""" - def test_find_config_yaml(self, tmp_path): - """Test finding YAML configuration file.""" - (tmp_path / ".readmerc.yaml").write_text("project_name: test") + def test_default_config(self): + """Test default configuration.""" + config = Config() + assert config.author is None + assert config.email is None + assert config.license is None + assert config.description is None - config_path = ConfigLoader.find_config(tmp_path) - assert config_path is not None - assert config_path.name == ".readmerc.yaml" + def test_config_from_yaml(self): + """Test loading configuration from YAML file.""" + config_content = { + "project": { + "author": "Test Author", + "email": "test@example.com", + "license": "MIT", + "description": "Test description", + }, + "defaults": { + "language": "python", + "ci": "github", + }, + } - def test_find_config_yml(self, tmp_path): - """Test finding YML configuration file.""" - (tmp_path / ".readmerc.yml").write_text("project_name: test") + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / "project.yaml" + with open(config_file, "w") as f: + yaml.dump(config_content, f) - config_path = ConfigLoader.find_config(tmp_path) - assert config_path is not None + config = Config.load(str(config_file)) - def test_find_config_toml(self, tmp_path): - """Test finding TOML configuration file.""" - (tmp_path / ".readmerc").write_text("project_name = 'test'") + assert config.author == "Test Author" + assert config.email == "test@example.com" + assert config.license == "MIT" + assert config.description == "Test description" + assert config.default_language == "python" + assert config.default_ci == "github" - config_path = ConfigLoader.find_config(tmp_path) - assert config_path is not None - - def test_load_yaml_config(self, tmp_path): - """Test loading YAML configuration file.""" - config_file = tmp_path / ".readmerc.yaml" - config_file.write_text(""" -project_name: "My Test Project" -description: "A test description" -template: "minimal" -interactive: true - -sections: - order: - - title - - description - - overview - -custom_fields: - author: "Test Author" - email: "test@example.com" -""") - - config = ConfigLoader.load(config_file) - - assert config.project_name == "My Test Project" - assert config.description == "A test description" - assert config.template == "minimal" - assert config.interactive is True - assert "author" in config.custom_fields - - def test_load_toml_config(self, tmp_path): - """Test loading TOML configuration file.""" - config_file = tmp_path / "pyproject.toml" - config_file.write_text(""" -[tool.auto-readme] -filename = "README.md" -sections = ["title", "description", "overview"] -""") - - config = ConfigLoader.load(config_file) - - assert config.output_filename == "README.md" - - def test_load_nonexistent_file(self): - """Test loading nonexistent configuration file.""" - config = ConfigLoader.load(Path("/nonexistent/config.yaml")) - assert config.project_name is None - - def test_load_invalid_yaml(self, tmp_path): - """Test loading invalid YAML raises error.""" - config_file = tmp_path / ".readmerc.yaml" - config_file.write_text("invalid: yaml: content: [") - - with pytest.raises(ValueError): - ConfigLoader.load(config_file) - - -class TestConfigValidator: - """Tests for ConfigValidator.""" - - def test_validate_valid_config(self): - """Test validating a valid configuration.""" - config = ReadmeConfig( - project_name="test", - template="base", + def test_config_save(self): + """Test saving configuration to file.""" + config = Config( + author="Test Author", + email="test@example.com", + license="MIT", + default_language="go", ) - errors = ConfigValidator.validate(config) - assert len(errors) == 0 + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / "config.yaml" + config.save(config_file) - def test_validate_invalid_template(self): - """Test validating invalid template name.""" - config = ReadmeConfig( - project_name="test", - template="nonexistent", + assert config_file.exists() + + with open(config_file, "r") as f: + saved_data = yaml.safe_load(f) + + assert saved_data["project"]["author"] == "Test Author" + assert saved_data["defaults"]["language"] == "go" + + def test_get_template_dirs(self): + """Test getting template directories.""" + config = Config() + dirs = config.get_template_dirs() + assert len(dirs) > 0 + assert any("project-scaffold" in d for d in dirs) + + def test_get_custom_templates_dir(self): + """Test getting custom templates directory.""" + config = Config() + custom_dir = config.get_custom_templates_dir() + assert "project-scaffold" in custom_dir + + def test_get_template_vars(self): + """Test getting template variables for language.""" + config = Config( + template_vars={ + "python": {"version": "3.11"}, + "nodejs": {"version": "16"}, + } ) - errors = ConfigValidator.validate(config) - assert len(errors) == 1 - assert "Invalid template" in errors[0] + python_vars = config.get_template_vars("python") + assert python_vars.get("version") == "3.11" - def test_validate_invalid_section(self): - """Test validating invalid section name.""" - config = ReadmeConfig( - project_name="test", - sections={"order": ["invalid_section"]}, - ) + nodejs_vars = config.get_template_vars("nodejs") + assert nodejs_vars.get("version") == "16" - errors = ConfigValidator.validate(config) - assert len(errors) == 1 - assert "Invalid section" in errors[0] - - def test_generate_template(self): - """Test generating configuration template.""" - template = ConfigValidator.generate_template() - - assert "project_name:" in template - assert "description:" in template - assert "template:" in template - assert "interactive:" in template + other_vars = config.get_template_vars("go") + assert other_vars == {} diff --git a/tests/test_gitignore.py b/tests/test_gitignore.py new file mode 100644 index 0000000..628271c --- /dev/null +++ b/tests/test_gitignore.py @@ -0,0 +1,116 @@ +"""Tests for gitignore generation.""" + +import tempfile +from pathlib import Path + +import pytest + +from project_scaffold_cli.gitignore import GitignoreGenerator + + +class TestGitignoreGenerator: + """Test GitignoreGenerator class.""" + + def test_generator_initialization(self): + """Test generator can be initialized.""" + gen = GitignoreGenerator() + assert gen is not None + + def test_generate_python_gitignore(self): + """Test generating Python .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("python", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "__pycache__" in content + assert "*.pyc" in content + assert "venv/" in content + assert ".pytest_cache" in content + + def test_generate_nodejs_gitignore(self): + """Test generating Node.js .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("nodejs", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "node_modules" in content + assert "npm-debug.log" in content + + def test_generate_go_gitignore(self): + """Test generating Go .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("go", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "*.exe" in content + assert "vendor/" in content + + def test_generate_rust_gitignore(self): + """Test generating Rust .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / ".gitignore" + gen.generate("rust", output_path) + + assert output_path.exists() + content = output_path.read_text() + + assert "target/" in content + assert "Cargo.lock" in content + + def test_generate_unsupported_language(self): + """Test generating gitignore for unsupported language.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(ValueError) as exc_info: + gen.generate("unsupported", Path(tmpdir) / ".gitignore") + assert "Unsupported language" in str(exc_info.value) + + def test_append_patterns(self): + """Test appending patterns to existing .gitignore.""" + gen = GitignoreGenerator() + + with tempfile.TemporaryDirectory() as tmpdir: + gitignore_path = Path(tmpdir) / ".gitignore" + gitignore_path.write_text("# Original content\n") + + gen.append_patterns(gitignore_path, {"*.custom", "secret.txt"}) + + content = gitignore_path.read_text() + assert "*.custom" in content + assert "secret.txt" in content + + def test_get_template_content(self): + """Test getting raw template content.""" + gen = GitignoreGenerator() + + python_content = gen.get_template_content("python") + assert isinstance(python_content, str) + assert len(python_content) > 0 + + nodejs_content = gen.get_template_content("nodejs") + assert isinstance(nodejs_content, str) + assert len(nodejs_content) > 0 + + def test_list_available_templates(self): + """Test listing available templates.""" + gen = GitignoreGenerator() + templates = gen.list_available_templates() + assert isinstance(templates, list) diff --git a/tests/test_templates.py b/tests/test_templates.py index 1ed16a8..ba3eb10 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,197 +1,145 @@ -"""Tests for template rendering.""" +"""Tests for template engine.""" import tempfile from pathlib import Path +import pytest -from src.auto_readme.models import Project, ProjectConfig, ProjectType -from src.auto_readme.templates import TemplateRenderer, TemplateManager +from project_scaffold_cli.template_engine import TemplateEngine -class TestTemplateRenderer: - """Tests for TemplateRenderer.""" +class TestTemplateEngine: + """Test TemplateEngine class.""" - def test_render_base_template(self): - """Test rendering the base template.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.PYTHON, - config=ProjectConfig( - name="test-project", - description="A test project", - ), - ) + def test_engine_initialization(self): + """Test engine can be initialized.""" + engine = TemplateEngine() + assert engine is not None + assert engine.SUPPORTED_LANGUAGES == ["python", "nodejs", "go", "rust"] - renderer = TemplateRenderer() - content = renderer.render(project, "base") + def test_render_language_template_python(self): + """Test rendering Python template.""" + engine = TemplateEngine() + context = { + "project_name": "test-project", + "project_slug": "test-project", + "author": "Test Author", + "email": "test@example.com", + "description": "A test project", + "license": "MIT", + "year": "2024", + "language": "python", + } - assert "# test-project" in content - assert "A test project" in content - assert "## Overview" in content + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test-project" + output_dir.mkdir() + engine.render_language_template("python", context, output_dir) - def test_render_minimal_template(self): - """Test rendering the minimal template.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.PYTHON, - ) + assert (output_dir / "setup.py").exists() + assert (output_dir / "README.md").exists() - renderer = TemplateRenderer() - content = renderer.render(project, "base") + def test_render_language_template_go(self): + """Test rendering Go template.""" + engine = TemplateEngine() + context = { + "project_name": "test-go-project", + "project_slug": "test-go-project", + "author": "Test Author", + "email": "test@example.com", + "description": "A test Go project", + "license": "MIT", + "year": "2024", + "language": "go", + } - assert "Generated by Auto README Generator" in content + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test-go-project" + output_dir.mkdir() + engine.render_language_template("go", context, output_dir) - def test_tech_stack_detection(self): - """Test technology stack detection.""" - from src.auto_readme.models import Dependency + assert (output_dir / "go.mod").exists() + assert (output_dir / "main.go").exists() + assert (output_dir / "README.md").exists() - project = Project( - root_path=Path("/test"), - project_type=ProjectType.PYTHON, - dependencies=[ - Dependency(name="fastapi"), - Dependency(name="flask"), - Dependency(name="requests"), - ], - ) + def test_render_language_template_rust(self): + """Test rendering Rust template.""" + engine = TemplateEngine() + context = { + "project_name": "test-rust-project", + "project_slug": "test-rust-project", + "author": "Test Author", + "email": "test@example.com", + "description": "A test Rust project", + "license": "MIT", + "year": "2024", + "language": "rust", + } - renderer = TemplateRenderer() - context = renderer._build_context(project) + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test-rust-project" + output_dir.mkdir() + engine.render_language_template("rust", context, output_dir) - assert "FastAPI" in context["tech_stack"] - assert "Flask" in context["tech_stack"] + assert (output_dir / "Cargo.toml").exists() + assert (output_dir / "src" / "main.rs").exists() + assert (output_dir / "README.md").exists() - def test_installation_steps_generation(self): - """Test automatic installation steps generation.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.PYTHON, - ) + def test_render_language_template_unsupported(self): + """Test rendering unsupported language.""" + engine = TemplateEngine() + context = {"project_name": "test"} - renderer = TemplateRenderer() - context = renderer._build_context(project) + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(ValueError) as exc_info: + engine.render_language_template( + "unsupported", context, Path(tmpdir) + ) + assert "Unsupported language" in str(exc_info.value) - assert "pip install" in context["installation_steps"][0] + def test_render_ci_template_github(self): + """Test rendering GitHub Actions CI template.""" + engine = TemplateEngine() + context = { + "project_name": "test-project", + "project_slug": "test-project", + } - def test_go_installation_steps(self): - """Test Go installation steps.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.GO, - ) + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + engine.render_ci_template("github", context, output_dir) - renderer = TemplateRenderer() - context = renderer._build_context(project) + workflow_path = ( + output_dir / ".github" / "workflows" / "ci.yml" + ) + assert workflow_path.exists() + content = workflow_path.read_text() + assert "CI" in content - assert "go mod download" in context["installation_steps"][0] + def test_render_ci_template_gitlab(self): + """Test rendering GitLab CI template.""" + engine = TemplateEngine() + context = { + "project_name": "test-project", + "project_slug": "test-project", + } - def test_rust_installation_steps(self): - """Test Rust installation steps.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.RUST, - ) + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + engine.render_ci_template("gitlab", context, output_dir) - renderer = TemplateRenderer() - context = renderer._build_context(project) + assert (output_dir / ".gitlab-ci.yml").exists() - assert "cargo build" in context["installation_steps"][0] + def test_validate_context_missing_required(self): + """Test context validation with missing required fields.""" + engine = TemplateEngine() + missing = engine.validate_context({}) + assert "project_name" in missing + assert "author" in missing - def test_feature_detection(self): - """Test automatic feature detection.""" - from src.auto_readme.models import Function, Class, SourceFile, FileType - - functions = [ - Function(name="test_func", line_number=1), - ] - classes = [ - Class(name="TestClass", line_number=1), - ] - - project = Project( - root_path=Path("/test"), - project_type=ProjectType.PYTHON, - files=[ - SourceFile( - path=Path("test.py"), - file_type=FileType.SOURCE, - functions=functions, - classes=classes, - ), - ], - ) - - renderer = TemplateRenderer() - context = renderer._build_context(project) - - assert "Contains 1 classes" in context["features"] - assert "Contains 1 functions" in context["features"] - - def test_custom_context_override(self): - """Test custom context can override auto-detected values.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.PYTHON, - ) - - renderer = TemplateRenderer() - content = renderer.render( - project, - "base", - title="Custom Title", - description="Custom description", - ) - - assert "# Custom Title" in content - assert "Custom description" in content - - def test_contributing_guidelines(self): - """Test contributing guidelines generation.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.PYTHON, - ) - - renderer = TemplateRenderer() - context = renderer._build_context(project) - - assert "Fork the repository" in context["contributing_guidelines"] - assert "git checkout -b" in context["contributing_guidelines"] - assert "pytest" in context["contributing_guidelines"] - - def test_javascript_contributing_guidelines(self): - """Test JavaScript contributing guidelines.""" - project = Project( - root_path=Path("/test"), - project_type=ProjectType.JAVASCRIPT, - ) - - renderer = TemplateRenderer() - context = renderer._build_context(project) - - assert "npm test" in context["contributing_guidelines"] - - -class TestTemplateManager: - """Tests for TemplateManager.""" - - def test_list_templates(self): - """Test listing available templates.""" - manager = TemplateManager() - templates = manager.list_templates() - - assert "base" in templates - - def test_get_template_path_builtin(self): - """Test getting path for built-in template.""" - manager = TemplateManager() - path = manager.get_template_path("base") - - assert path is None - - def test_get_template_path_custom(self): - """Test getting path for custom template.""" - with tempfile.TemporaryDirectory() as tmp_dir: - manager = TemplateManager(Path(tmp_dir)) - path = manager.get_template_path("custom.md.j2") - assert path is not None + def test_validate_context_complete(self): + """Test context validation with all required fields.""" + engine = TemplateEngine() + context = {"project_name": "test", "author": "Test Author"} + missing = engine.validate_context(context) + assert len(missing) == 0