fix: correct pyproject.toml for project-scaffold-cli

- Fixed package name from auto-readme-cli to project-scaffold-cli
- Fixed dependencies to match project-scaffold-cli requirements
- Fixed linting import sorting issues in test files
This commit is contained in:
Developer
2026-02-05 11:49:49 +00:00
parent db5d4a8d48
commit 155bc36ded
30 changed files with 1846 additions and 468 deletions

View File

@@ -0,0 +1,3 @@
"""Project Scaffold CLI - Generate standardized project scaffolding."""
__version__ = "1.0.0"

365
project_scaffold_cli/cli.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -0,0 +1,5 @@
module {{ project_slug }}
go 1.20
require github.com/{{ author|replace(' ', '-') }}/{{ project_slug }} v1.0.0

View File

@@ -0,0 +1,9 @@
package main
import (
"fmt"
)
func main() {
fmt.Println("Welcome to {{ project_name }}!")
}

View File

@@ -0,0 +1,21 @@
# {{ project_name }}
{{ description }}
## Installation
```bash
npm install
```
## Usage
```javascript
const {{ project_slug }} = require('./index.js');
{{ project_slug }}();
```
## License
{{ license }}

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env node
/**
* {{ project_name }}
* {{ description }}
*/
function main() {
console.log('Welcome to {{ project_name }}!');
}
main();

View File

@@ -0,0 +1 @@
{{ description }}

View File

@@ -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",
],
)

View File

@@ -0,0 +1 @@
{{ project_slug }}

View File

@@ -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()

View File

@@ -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

View File

@@ -0,0 +1,9 @@
[package]
name = "{{ project_slug }}"
version = "0.1.0"
edition = "2021"
authors = ["{{ author }} <{{ email }}>"]
description = "{{ description }}"
license = "{{ license }}"
[dependencies]

View File

@@ -0,0 +1,23 @@
# {{ project_name }}
{{ description }}
## Installation
```bash
cargo add {{ project_slug }}
```
## Usage
```rust
use {{ project_slug }}::run;
fn main() {
run();
}
```
## License
{{ license }}

View File

@@ -0,0 +1,3 @@
fn main() {
println!("Welcome to {{ project_name }}!");
}

View File

@@ -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"]

5
requirements-dev.txt Normal file
View File

@@ -0,0 +1,5 @@
pytest>=7.0
pytest-cov>=4.0
black>=23.0
flake8>=6.0
ruff>=0.1.0

View File

@@ -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

46
setup.py Normal file
View File

@@ -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",
],
)

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests for project_scaffold_cli package."""

View File

@@ -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"])
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."""
runner = CliRunner()
result = runner.invoke(generate, ["--input", str(create_python_project), "--template", "base", "--dry-run"])
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()
with tempfile.TemporaryDirectory() as tmpdir:
original_dir = Path.cwd()
try:
os.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(init_config, ["--output", ".readmerc"])
os.chdir(tmpdir)
result = runner.invoke(main, ["init-config"])
assert result.exit_code == 0
assert (tmp_path / ".readmerc").exists()
assert Path("project.yaml").exists()
finally:
os.chdir(original_dir)
class TestTemplateCommands:
"""Test template management commands."""
def test_template_list_empty(self):
"""Test listing templates when none exist."""
runner = CliRunner()
result = runner.invoke(main, ["template", "list"])
assert result.exit_code == 0

View File

@@ -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 == {}

116
tests/test_gitignore.py Normal file
View File

@@ -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)

View File

@@ -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"]
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",
}
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir) / "test-project"
output_dir.mkdir()
engine.render_language_template("python", context, output_dir)
assert (output_dir / "setup.py").exists()
assert (output_dir / "README.md").exists()
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",
}
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir) / "test-go-project"
output_dir.mkdir()
engine.render_language_template("go", context, output_dir)
assert (output_dir / "go.mod").exists()
assert (output_dir / "main.go").exists()
assert (output_dir / "README.md").exists()
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",
}
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir) / "test-rust-project"
output_dir.mkdir()
engine.render_language_template("rust", context, output_dir)
assert (output_dir / "Cargo.toml").exists()
assert (output_dir / "src" / "main.rs").exists()
assert (output_dir / "README.md").exists()
def test_render_language_template_unsupported(self):
"""Test rendering unsupported language."""
engine = TemplateEngine()
context = {"project_name": "test"}
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)
renderer = TemplateRenderer()
content = renderer.render(project, "base")
def test_render_ci_template_github(self):
"""Test rendering GitHub Actions CI template."""
engine = TemplateEngine()
context = {
"project_name": "test-project",
"project_slug": "test-project",
}
assert "# test-project" in content
assert "A test project" in content
assert "## Overview" in content
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
engine.render_ci_template("github", context, output_dir)
def test_render_minimal_template(self):
"""Test rendering the minimal template."""
project = Project(
root_path=Path("/test"),
project_type=ProjectType.PYTHON,
workflow_path = (
output_dir / ".github" / "workflows" / "ci.yml"
)
assert workflow_path.exists()
content = workflow_path.read_text()
assert "CI" in content
renderer = TemplateRenderer()
content = renderer.render(project, "base")
def test_render_ci_template_gitlab(self):
"""Test rendering GitLab CI template."""
engine = TemplateEngine()
context = {
"project_name": "test-project",
"project_slug": "test-project",
}
assert "Generated by Auto README Generator" in content
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
engine.render_ci_template("gitlab", context, output_dir)
def test_tech_stack_detection(self):
"""Test technology stack detection."""
from src.auto_readme.models import Dependency
assert (output_dir / ".gitlab-ci.yml").exists()
project = Project(
root_path=Path("/test"),
project_type=ProjectType.PYTHON,
dependencies=[
Dependency(name="fastapi"),
Dependency(name="flask"),
Dependency(name="requests"),
],
)
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
renderer = TemplateRenderer()
context = renderer._build_context(project)
assert "FastAPI" in context["tech_stack"]
assert "Flask" in context["tech_stack"]
def test_installation_steps_generation(self):
"""Test automatic installation steps generation."""
project = Project(
root_path=Path("/test"),
project_type=ProjectType.PYTHON,
)
renderer = TemplateRenderer()
context = renderer._build_context(project)
assert "pip install" in context["installation_steps"][0]
def test_go_installation_steps(self):
"""Test Go installation steps."""
project = Project(
root_path=Path("/test"),
project_type=ProjectType.GO,
)
renderer = TemplateRenderer()
context = renderer._build_context(project)
assert "go mod download" in context["installation_steps"][0]
def test_rust_installation_steps(self):
"""Test Rust installation steps."""
project = Project(
root_path=Path("/test"),
project_type=ProjectType.RUST,
)
renderer = TemplateRenderer()
context = renderer._build_context(project)
assert "cargo build" in context["installation_steps"][0]
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