Source code for pypreset.generator

"""Project generator - creates projects from configurations."""

import logging
import stat
import subprocess
from pathlib import Path

from pypreset.models import (
    CreationPackageManager,
    FileTemplate,
    LayoutStyle,
    ProjectConfig,
)
from pypreset.template_engine import (
    create_jinja_environment,
    get_template_context,
    render_content,
    render_path,
    render_template,
)

logger = logging.getLogger(__name__)


[docs] class ProjectGenerator: """Generates a project from a configuration."""
[docs] def __init__(self, config: ProjectConfig, output_dir: Path) -> None: self.config = config self.output_dir = output_dir self.project_dir = output_dir / config.metadata.name self.env = create_jinja_environment() self.context = get_template_context(config) self._is_src = config.layout == LayoutStyle.SRC self._is_uv = config.package_manager == CreationPackageManager.UV self._is_setuptools = config.package_manager == CreationPackageManager.SETUPTOOLS self._is_podman = config.docker.container_runtime.value == "podman"
@property def _package_dir(self) -> Path: """Return the package directory based on layout style.""" package_name = self.context["project"]["package_name"] if self._is_src: return self.project_dir / "src" / package_name return self.project_dir / package_name
[docs] def generate(self) -> Path: """Generate the complete project structure.""" logger.info(f"Generating project '{self.config.metadata.name}' at {self.project_dir}") # Create project directory self.project_dir.mkdir(parents=True, exist_ok=True) # Create directory structure self._create_directories() # Create files from templates self._create_files() # Create pyproject.toml self._create_pyproject_toml() # Create README.md self._create_readme() # Create additional standard files self._create_gitignore() # Create GitHub workflows self._create_github_workflows() # Create pre-commit config if enabled if self.config.formatting.pre_commit: self._create_pre_commit_config() # Create Docker files if enabled if self.config.docker.enabled: self._create_docker_files() # Create devcontainer if enabled if self.config.docker.devcontainer: self._create_devcontainer() # Create codecov config if enabled if ( self.config.testing.coverage_config.enabled and self.config.testing.coverage_config.tool.value == "codecov" ): self._create_codecov_config() # Create documentation scaffolding if enabled if self.config.documentation.enabled: self._create_documentation() # Create tox config if enabled if self.config.tox.enabled: self._create_tox_config() # Create version sync guard if enabled if self.config.formatting.version_sync_guard: self._create_version_sync_guard() # Create .python-version if pyenv enabled if self.config.pyenv: self._create_python_version_file() logger.info(f"Project '{self.config.metadata.name}' generated successfully") return self.project_dir
def _create_directories(self) -> None: """Create all directories in the project structure.""" # Create the package directory (src-layout: src/pkg, flat-layout: pkg/) self._package_dir.mkdir(parents=True, exist_ok=True) # Create directories from structure config for dir_path in self.config.structure.directories: rendered_path = render_path(dir_path, self.context) full_path = self.project_dir / rendered_path full_path.mkdir(parents=True, exist_ok=True) logger.debug(f"Created directory: {full_path}") # Create tests directory if testing is enabled if self.config.testing.enabled: tests_dir = self.project_dir / "tests" tests_dir.mkdir(parents=True, exist_ok=True) def _create_files(self) -> None: """Create all files from templates or inline content.""" for file_def in self.config.structure.files: self._create_file(file_def) # Always create __init__.py for the package init_path = self._package_dir / "__init__.py" if not init_path.exists(): name = self.config.metadata.name version = self.config.metadata.version init_content = f'"""{name} package."""\n\n__version__ = "{version}"\n' init_path.write_text(init_content) # Create tests/__init__.py and test file if testing is enabled if self.config.testing.enabled: tests_init = self.project_dir / "tests" / "__init__.py" if not tests_init.exists(): tests_init.write_text('"""Tests package."""\n') test_file = self.project_dir / "tests" / "test_basic.py" if not test_file.exists(): test_content = self._render_test_file() test_file.write_text(test_content) def _create_file(self, file_def: FileTemplate) -> None: """Create a single file from a template or inline content.""" rendered_path = render_path(file_def.path, self.context) full_path = self.project_dir / rendered_path # Ensure parent directory exists full_path.parent.mkdir(parents=True, exist_ok=True) # Get content from template or inline if file_def.template: content = render_template(self.env, file_def.template, self.context) elif file_def.content: content = render_content(file_def.content, self.context) else: content = "" full_path.write_text(content) logger.debug(f"Created file: {full_path}") # Make executable if needed if file_def.executable: current_mode = full_path.stat().st_mode full_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) def _create_pyproject_toml(self) -> None: """Create the pyproject.toml file.""" if self._is_uv: template = "pyproject_uv.toml.j2" elif self._is_setuptools: template = "pyproject_setuptools.toml.j2" else: template = "pyproject.toml.j2" content = render_template(self.env, template, self.context) pyproject_path = self.project_dir / "pyproject.toml" pyproject_path.write_text(content) logger.debug(f"Created pyproject.toml: {pyproject_path}") def _create_readme(self) -> None: """Create the README.md file.""" template = self.config.metadata.readme_template or "README.md.j2" content = render_template(self.env, template, self.context) readme_path = self.project_dir / "README.md" readme_path.write_text(content) logger.debug(f"Created README.md: {readme_path}") def _create_gitignore(self) -> None: """Create the .gitignore file.""" content = render_template(self.env, "gitignore.j2", self.context) gitignore_path = self.project_dir / ".gitignore" gitignore_path.write_text(content) logger.debug(f"Created .gitignore: {gitignore_path}") def _create_github_workflows(self) -> None: """Create GitHub Actions workflow files.""" # Only create workflows if testing or formatting is enabled if not self.config.testing.enabled and not self.config.formatting.enabled: logger.debug("Skipping GitHub workflows - no testing or formatting enabled") return workflows_dir = self.project_dir / ".github" / "workflows" workflows_dir.mkdir(parents=True, exist_ok=True) if self._is_uv: ci_template = "github_ci_uv.yaml.j2" elif self._is_setuptools: ci_template = "github_ci_setuptools.yaml.j2" else: ci_template = "github_ci.yaml.j2" content = render_template(self.env, ci_template, self.context) ci_path = workflows_dir / "ci.yaml" ci_path.write_text(content) logger.debug(f"Created GitHub CI workflow: {ci_path}") # Create dependabot.yml if enabled if self.config.dependabot.enabled: self._create_dependabot() def _create_dependabot(self) -> None: """Create the dependabot.yml configuration file.""" github_dir = self.project_dir / ".github" github_dir.mkdir(parents=True, exist_ok=True) content = render_template(self.env, "dependabot.yml.j2", self.context) dependabot_path = github_dir / "dependabot.yml" dependabot_path.write_text(content) logger.debug(f"Created dependabot.yml: {dependabot_path}") def _create_pre_commit_config(self) -> None: """Create .pre-commit-config.yaml for git hooks.""" content = render_template(self.env, "pre-commit-config.yaml.j2", self.context) config_path = self.project_dir / ".pre-commit-config.yaml" config_path.write_text(content) logger.debug(f"Created .pre-commit-config.yaml: {config_path}") def _create_docker_files(self) -> None: """Create Dockerfile/Containerfile and ignore file.""" if self._is_uv: template = "Dockerfile_uv.j2" elif self._is_setuptools: template = "Dockerfile_setuptools.j2" else: template = "Dockerfile.j2" content = render_template(self.env, template, self.context) if self._is_podman: dockerfile_name = "Containerfile" ignore_name = ".containerignore" else: dockerfile_name = "Dockerfile" ignore_name = ".dockerignore" dockerfile_path = self.project_dir / dockerfile_name dockerfile_path.write_text(content) logger.debug(f"Created {dockerfile_name}: {dockerfile_path}") ignore_content = render_template(self.env, "dockerignore.j2", self.context) ignore_path = self.project_dir / ignore_name ignore_path.write_text(ignore_content) logger.debug(f"Created {ignore_name}: {ignore_path}") def _create_devcontainer(self) -> None: """Create .devcontainer/devcontainer.json configuration.""" devcontainer_dir = self.project_dir / ".devcontainer" devcontainer_dir.mkdir(parents=True, exist_ok=True) content = render_template(self.env, "devcontainer.json.j2", self.context) devcontainer_path = devcontainer_dir / "devcontainer.json" devcontainer_path.write_text(content) logger.debug(f"Created devcontainer.json: {devcontainer_path}") def _create_codecov_config(self) -> None: """Create codecov.yml configuration.""" content = render_template(self.env, "codecov.yml.j2", self.context) codecov_path = self.project_dir / "codecov.yml" codecov_path.write_text(content) logger.debug(f"Created codecov.yml: {codecov_path}") def _create_documentation(self) -> None: """Create documentation scaffolding based on the chosen tool.""" docs_dir = self.project_dir / "docs" docs_dir.mkdir(parents=True, exist_ok=True) doc_tool = self.config.documentation.tool.value if doc_tool == "mkdocs": # mkdocs.yml at project root config_content = render_template(self.env, "mkdocs.yml.j2", self.context) (self.project_dir / "mkdocs.yml").write_text(config_content) # docs/index.md index_content = render_template(self.env, "docs_index.md.j2", self.context) (docs_dir / "index.md").write_text(index_content) logger.debug("Created MkDocs documentation scaffolding") elif doc_tool == "sphinx": # docs/conf.py conf_content = render_template(self.env, "sphinx_conf.py.j2", self.context) (docs_dir / "conf.py").write_text(conf_content) # docs/index.rst index_content = render_template(self.env, "docs_index.rst.j2", self.context) (docs_dir / "index.rst").write_text(index_content) logger.debug("Created Sphinx documentation scaffolding") # GitHub Pages deploy workflow if self.config.documentation.deploy_gh_pages: workflows_dir = self.project_dir / ".github" / "workflows" workflows_dir.mkdir(parents=True, exist_ok=True) workflow_content = render_template(self.env, "docs_workflow.yaml.j2", self.context) (workflows_dir / "docs.yaml").write_text(workflow_content) logger.debug("Created docs deployment workflow") def _create_tox_config(self) -> None: """Create tox.ini configuration.""" content = render_template(self.env, "tox.ini.j2", self.context) tox_path = self.project_dir / "tox.ini" tox_path.write_text(content) logger.debug(f"Created tox.ini: {tox_path}") def _create_python_version_file(self) -> None: """Create .python-version file for pyenv/uv version pinning.""" python_version = self.context["project"]["python_version"] version_path = self.project_dir / ".python-version" version_path.write_text(f"{python_version}\n") logger.debug(f"Created .python-version: {version_path}") def _create_version_sync_guard(self) -> None: """Create scripts/check_tool_versions.py for version sync checking.""" scripts_dir = self.project_dir / "scripts" scripts_dir.mkdir(parents=True, exist_ok=True) content = render_template(self.env, "check_tool_versions.py.j2", self.context) script_path = scripts_dir / "check_tool_versions.py" script_path.write_text(content) # Make executable current_mode = script_path.stat().st_mode script_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) logger.debug(f"Created version sync guard: {script_path}") def _render_test_file(self) -> str: """Render a basic test file.""" package_name = self.context["project"]["package_name"] return f'''"""Basic tests for {self.config.metadata.name}.""" import {package_name} def test_version() -> None: """Test that version is defined.""" assert hasattr({package_name}, "__version__") assert isinstance({package_name}.__version__, str) def test_import() -> None: """Test that package can be imported.""" import {package_name} assert {package_name} is not None '''
[docs] def generate_project( config: ProjectConfig, output_dir: Path, initialize_git: bool = True, install_dependencies: bool = False, ) -> Path: """Generate a project from a configuration. Args: config: The project configuration output_dir: Directory to create the project in initialize_git: Whether to initialize a git repository install_dependencies: Whether to run poetry install Returns: Path to the generated project directory """ generator = ProjectGenerator(config, output_dir) project_dir = generator.generate() if initialize_git: _init_git(project_dir) if install_dependencies: _install_dependencies(project_dir, package_manager=config.package_manager) return project_dir
def _init_git(project_dir: Path) -> None: """Initialize a git repository in the project directory.""" try: subprocess.run( ["git", "init"], cwd=project_dir, capture_output=True, check=True, ) logger.info("Initialized git repository") except subprocess.CalledProcessError as e: logger.warning(f"Failed to initialize git repository: {e}") except FileNotFoundError: logger.warning("git not found, skipping repository initialization") def _install_dependencies( project_dir: Path, package_manager: CreationPackageManager = CreationPackageManager.POETRY ) -> None: """Run dependency installation in the project directory.""" match package_manager: case CreationPackageManager.UV: cmd = ["uv", "sync"] tool_name = "uv" case CreationPackageManager.SETUPTOOLS: cmd = ["pip", "install", "-e", ".[dev]"] tool_name = "pip" case _: cmd = ["poetry", "install"] tool_name = "poetry" try: subprocess.run( cmd, cwd=project_dir, capture_output=True, check=True, ) logger.info(f"Installed dependencies with {tool_name}") except subprocess.CalledProcessError as e: logger.warning(f"Failed to install dependencies: {e}") except FileNotFoundError: logger.warning(f"{tool_name} not found, skipping dependency installation")