Skip to content

Commit

Permalink
feat: create InitService
Browse files Browse the repository at this point in the history
Signed-off-by: Callahan Kovacs <[email protected]>
  • Loading branch information
mr-cal committed Oct 11, 2024
1 parent 8e484cc commit 8a303f9
Show file tree
Hide file tree
Showing 16 changed files with 540 additions and 395 deletions.
150 changes: 10 additions & 140 deletions craft_application/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,9 @@
from __future__ import annotations

import argparse
import os
import pathlib
import shutil
from textwrap import dedent
from typing import Any, cast

from craft_cli import emit
from jinja2 import (
Environment,
PackageLoader,
StrictUndefined,
)

from craft_application.util import make_executable
from typing import cast

from . import base

Expand Down Expand Up @@ -69,107 +58,19 @@ def run(self, parsed_args: argparse.Namespace) -> None:
"""Run the command."""
project_dir = self._get_project_dir(parsed_args)
name = self._get_name(parsed_args)
context = self._get_context(parsed_args)
template_dir = self._get_template_dir(parsed_args)
environment = self._get_templates_environment(template_dir)
executable_files = self._get_executable_files(parsed_args)

self._create_project_dir(project_dir=project_dir, name=name)
self._render_project(
environment,
project_dir,
template_dir,
context,
executable_files,
)

def _copy_template_file(
self,
template_name: str,
template_dir: pathlib.Path,
project_dir: pathlib.Path,
) -> None:
"""Copy the non-ninja template from template_dir to project_dir.
If the file already exists in the projects copying is skipped.
:param project_dir: The directory to render the files into.
:param template_dir: The directory where templates are stored.
:param template_name: Name of the template to copy.
"""
emit.debug(f"Copying file {template_name} to {project_dir}")
template_file = template_dir / template_name
destination_file = project_dir / template_name
if destination_file.exists():
emit.trace(f"Skipping file {template_name} as it is already present")
return
destination_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_file, destination_file, follow_symlinks=False)

def _render_project(
self,
environment: Environment,
project_dir: pathlib.Path,
template_dir: pathlib.Path,
context: dict[str, Any],
executable_files: list[str],
) -> None:
"""Render files for a project from a template.
self._services.init.run(project_dir=project_dir, name=name)

:param environment: The Jinja environment to use.
:param project_dir: The directory to render the files into.
:param template_dir: The directory where templates are stored.
:param context: The context to render the templates with.
:param executable_files: The list of files that should be executable.
"""
for template_name in environment.list_templates():
if not template_name.endswith(".j2"):
self._copy_template_file(template_name, template_dir, project_dir)
continue
template = environment.get_template(template_name)

# trim off `.j2`
rendered_template_name = pathlib.Path(template_name).stem
emit.debug(f"Rendering {template_name} to {rendered_template_name}")

path = project_dir / rendered_template_name
if path.exists():
emit.trace(f"Skipping file {template_name} as it is already present")
continue
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wt", encoding="utf8") as file:
out = template.render(context)
file.write(out)
if rendered_template_name in executable_files and os.name == "posix":
make_executable(file)
emit.debug(" made executable")
emit.message("Successfully initialised project.")

def _get_template_dir(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (unused-method-argument)
) -> pathlib.Path:
"""Return the path to the template directory."""
return pathlib.Path("templates")

def _get_executable_files(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (unused-method-argument)
) -> list[str]:
"""Return the list of files that should be executable."""
return []

def _get_context(self, parsed_args: argparse.Namespace) -> dict[str, Any]:
"""Get context to render templates with.
def _get_name(self, parsed_args: argparse.Namespace) -> str:
"""Get name of the package that is about to be initialized.
:returns: A dict of context variables.
Check if name is set explicitly or fallback to project_dir.
"""
name = self._get_name(parsed_args)
emit.debug(f"Set project name to '{name}'")

return {"name": name}
if parsed_args.name is not None:
return cast(str, parsed_args.name)
return self._get_project_dir(parsed_args).name

def _get_project_dir(self, parsed_args: argparse.Namespace) -> pathlib.Path:
@staticmethod
def _get_project_dir(parsed_args: argparse.Namespace) -> pathlib.Path:
"""Get project dir where project should be initialized.
It applies rules in the following order:
Expand All @@ -182,34 +83,3 @@ def _get_project_dir(self, parsed_args: argparse.Namespace) -> pathlib.Path:

# If both args are undefined, default to current dir
return pathlib.Path.cwd().resolve()

def _get_name(self, parsed_args: argparse.Namespace) -> str:
"""Get name of the package that is about to be initialized.
Check if name is set explicitly or fallback to project_dir.
"""
if parsed_args.name is not None:
return cast(str, parsed_args.name)
return self._get_project_dir(parsed_args).name

def _create_project_dir(
self,
project_dir: pathlib.Path,
*,
name: str,
) -> None:
"""Create the project path if it does not already exist."""
if not project_dir.exists():
project_dir.mkdir(parents=True)
emit.debug(f"Using project directory {str(project_dir)!r} for {name}")

def _get_templates_environment(self, template_dir: pathlib.Path) -> Environment:
"""Create and return a Jinja environment to deal with the templates."""
loader = PackageLoader(self._app.name, str(template_dir))
return Environment(
loader=loader,
autoescape=False, # noqa: S701 (jinja2-autoescape-false)
keep_trailing_newline=True, # they're not text files if they don't end in newline!
optimized=False, # optimization doesn't make sense for one-offs
undefined=StrictUndefined,
) # fail on undefined
3 changes: 2 additions & 1 deletion craft_application/commands/other.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from craft_cli import CommandGroup, emit

from craft_application.commands import base
from . import InitCommand, base

if TYPE_CHECKING: # pragma: no cover
import argparse
Expand All @@ -27,6 +27,7 @@
def get_other_command_group() -> CommandGroup:
"""Return the lifecycle related command group."""
commands: list[type[base.AppCommand]] = [
InitCommand,
VersionCommand,
]

Expand Down
2 changes: 2 additions & 0 deletions craft_application/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from craft_application.services.config import ConfigService
from craft_application.services.fetch import FetchService
from craft_application.services.lifecycle import LifecycleService
from craft_application.services.init import InitService
from craft_application.services.package import PackageService
from craft_application.services.provider import ProviderService
from craft_application.services.remotebuild import RemoteBuildService
Expand All @@ -31,6 +32,7 @@
"ProjectService",
"ConfigService",
"LifecycleService",
"InitService",
"PackageService",
"ProviderService",
"RemoteBuildService",
Expand Down
155 changes: 155 additions & 0 deletions craft_application/services/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# This file is part of craft-application.
#
# Copyright 2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Service for initializing a project."""

import os
import pathlib
import shutil
from typing import Any

import jinja2
from craft_cli import emit

from craft_application.util import make_executable

from . import base


class InitService(base.AppService):
"""Service class for initializing a project."""

def run(self, *, project_dir: pathlib.Path, name: str) -> None:
"""Initialize a new project."""
context = self._get_context(name=name)
template_dir = self._get_template_dir()
environment = self._get_templates_environment(template_dir)
executable_files = self._get_executable_files()

self._create_project_dir(project_dir=project_dir, name=name)
self._render_project(
environment,
project_dir,
template_dir,
context,
executable_files,
)

def _copy_template_file(
self,
template_name: str,
template_dir: pathlib.Path,
project_dir: pathlib.Path,
) -> None:
"""Copy the non-ninja template from template_dir to project_dir.
If the file already exists in the projects copying is skipped.
:param project_dir: The directory to render the files into.
:param template_dir: The directory where templates are stored.
:param template_name: Name of the template to copy.
"""
emit.debug(f"Copying file {template_name} to {project_dir}")
template_file = template_dir / template_name
destination_file = project_dir / template_name
if destination_file.exists():
emit.debug(f"Skipping file {template_name} because it is already present.")
return
destination_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_file, destination_file, follow_symlinks=False)

def _render_project(
self,
environment: jinja2.Environment,
project_dir: pathlib.Path,
template_dir: pathlib.Path,
context: dict[str, Any],
executable_files: list[str],
) -> None:
"""Render files for a project from a template.
:param environment: The Jinja environment to use.
:param project_dir: The directory to render the files into.
:param template_dir: The directory where templates are stored.
:param context: The context to render the templates with.
:param executable_files: The list of files that should be executable.
"""
for template_name in environment.list_templates():
if not template_name.endswith(".j2"):
self._copy_template_file(template_name, template_dir, project_dir)
continue
template = environment.get_template(template_name)

# trim off `.j2`
rendered_template_name = template_name[:-3]
emit.debug(f"Rendering {template_name} to {rendered_template_name}")

path = project_dir / rendered_template_name
if path.exists():
emit.trace(f"Skipping file {template_name} as it is already present")
continue
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wt", encoding="utf8") as file:
out = template.render(context)
file.write(out)
if rendered_template_name in executable_files and os.name == "posix":
make_executable(file)
emit.debug(" made executable")
emit.message("Successfully initialised project.")

def _get_template_dir(self) -> pathlib.Path:
"""Return the path to the template directory."""
return pathlib.Path("templates")

def _get_executable_files(self) -> list[str]:
"""Return the list of files that should be executable."""
return []

def _get_context(self, name: str) -> dict[str, Any]:
"""Get context to render templates with.
:returns: A dict of context variables.
"""
emit.debug(f"Set project name to '{name}'")

return {"name": name}

def _create_project_dir(
self,
project_dir: pathlib.Path,
*,
name: str,
) -> None:
"""Create the project path if it does not already exist."""
if not project_dir.exists():
project_dir.mkdir(parents=True)
emit.debug(f"Using project directory {str(project_dir)!r} for {name}")

def _get_loader(self, template_dir: pathlib.Path) -> jinja2.PackageLoader:
"""Return a Jinja template loader for the given template directory."""
return jinja2.PackageLoader(self._app.name, str(template_dir))

def _get_templates_environment(
self, template_dir: pathlib.Path
) -> jinja2.Environment:
"""Create and return a Jinja environment to deal with the templates."""
return jinja2.Environment(
loader=self._get_loader(template_dir),
autoescape=False, # noqa: S701 (jinja2-autoescape-false)
keep_trailing_newline=True, # they're not text files if they don't end in newline!
optimized=False, # optimization doesn't make sense for one-offs
undefined=jinja2.StrictUndefined,
) # fail on undefined
2 changes: 2 additions & 0 deletions craft_application/services/service_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class ServiceFactory:
RequestClass: type[services.RequestService] = services.RequestService
ConfigClass: type[services.ConfigService] = services.ConfigService
FetchClass: type[services.FetchService] = services.FetchService
InitClass: type[services.InitService] = services.InitService

project: models.Project | None = None

Expand All @@ -57,6 +58,7 @@ class ServiceFactory:
request: services.RequestService = None # type: ignore[assignment]
config: services.ConfigService = None # type: ignore[assignment]
fetch: services.FetchService = None # type: ignore[assignment]
init: services.InitService = None # type: ignore[assignment]

def __post_init__(self) -> None:
self._service_kwargs: dict[str, dict[str, Any]] = {}
Expand Down
1 change: 1 addition & 0 deletions craft_application/util/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

def make_executable(fh: io.IOBase) -> None:
"""Make open file fh executable.
:param fh: An open file object.
"""
fileno = fh.fileno()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"craft-parts>=2.1.1",
"craft-platforms>=0.3.1",
"craft-providers>=2.0.4",
"Jinja2~=3.1",
"snap-helpers>=0.4.2",
"platformdirs>=3.10",
"pydantic~=2.0",
Expand Down
Loading

0 comments on commit 8a303f9

Please sign in to comment.