Skip to content

Commit

Permalink
feat: generate version for templates usage (canonical#548)
Browse files Browse the repository at this point in the history
Signed-off-by: Dariusz Duda <[email protected]>
  • Loading branch information
dariuszd21 authored and linostar committed Dec 4, 2024
1 parent 9a7637a commit 2bc1acc
Show file tree
Hide file tree
Showing 10 changed files with 522 additions and 45 deletions.
8 changes: 6 additions & 2 deletions craft_application/git/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@

"""Git repository utilities."""

from ._consts import COMMIT_SHORT_SHA_LEN
from ._errors import GitError
from ._models import GitType
from ._git_repo import GitRepo, get_git_repo_type, is_repo
from ._models import GitType, short_commit_sha
from ._git_repo import GitRepo, get_git_repo_type, is_repo, parse_describe

__all__ = [
"GitError",
"GitRepo",
"GitType",
"get_git_repo_type",
"is_repo",
"parse_describe",
"short_commit_sha",
"COMMIT_SHORT_SHA_LEN",
]
19 changes: 19 additions & 0 deletions craft_application/git/_consts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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 warranty of
# MERCHANTABILITY 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/>.

"""Git repository consts."""

from typing import Final

COMMIT_SHORT_SHA_LEN: Final[int] = 7
62 changes: 62 additions & 0 deletions craft_application/git/_git_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,35 @@ def get_git_repo_type(path: Path) -> GitType:
return GitType.INVALID


def parse_describe(describe_str: str) -> str:
"""Parse git describe string to get a human-readable version.
Examples (git describe -> parse_describe):
4.1.1-0-gad012482d -> 4.1.1
4.1.1-16-g2d8943dbc -> 4.1.1.post16+git2d8943dbc
curl-8_11_0-0-gb1ef0e1 -> curl-8_11_0
For shallow clones or repositories missing tags:
0ae7c04 -> 0ae7c04
"""
if "-" not in describe_str:
return describe_str
number_of_expected_elements = 3
splitted_describe = describe_str.rsplit(
"-",
maxsplit=number_of_expected_elements - 1,
)
if len(splitted_describe) != number_of_expected_elements:
logger.warning("Cannot determine version basing on describe result.")
return describe_str

version, distance, commit = splitted_describe

if distance == "0":
return version
return f"{version}.post{distance}+git{commit[1:]}"


class GitRepo:
"""Git repository class."""

Expand Down Expand Up @@ -353,6 +382,39 @@ def push_url( # noqa: PLR0912 (too-many-branches)
f"for the git repository in {str(self.path)!r}."
)

def describe(
self,
*,
committish: str | None = None,
abbreviated_size: int | None = None,
always_use_long_format: bool | None = None,
show_commit_oid_as_fallback: bool | None = None,
) -> str:
"""Return a human readable name base on an available ref.
:param committish: Commit-ish object name to describe. If None, HEAD will be
described
:param abbreviated_size: The same as --abbrev of ``git describe`` command
:param always_use_long_format: Always use the long format
:param show_commit_oid_as_fallback: Show uniquely abbrevaited commit as fallback
:returns: String that describes given object.
raises GitError: if object cannot be described
"""
logger.debug(f"Trying to describe {committish or 'HEAD'!r}.")
try:
described: str = self._repo.describe(
committish=committish,
abbreviated_size=abbreviated_size,
always_use_long_format=always_use_long_format,
show_commit_oid_as_fallback=show_commit_oid_as_fallback,
)
except (pygit2.GitError, KeyError) as err:
raise GitError("Could not describe given object") from err
else:
return described

def _resolve_ref(self, ref: str) -> str:
"""Get a full reference name for a shorthand ref.
Expand Down
7 changes: 7 additions & 0 deletions craft_application/git/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@

from enum import Enum

from ._consts import COMMIT_SHORT_SHA_LEN


def short_commit_sha(commit_sha: str) -> str:
"""Return shortened version of the commit."""
return commit_sha[:COMMIT_SHORT_SHA_LEN]


class GitType(Enum):
"""Type of git repository."""
Expand Down
31 changes: 28 additions & 3 deletions craft_application/services/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from craft_cli import emit

from craft_application.errors import InitError
from craft_application.git import GitError, GitRepo, is_repo, parse_describe

from . import base

Expand Down Expand Up @@ -53,9 +54,9 @@ def initialise_project(
f"Initialising project {project_name!r} in {str(project_dir)!r} from "
f"template in {str(template_dir)!r}."
)
context = self._get_context(name=project_name)
environment = self._get_templates_environment(template_dir)
self._create_project_dir(project_dir=project_dir)
context = self._get_context(name=project_name, project_dir=project_dir)
self._render_project(environment, project_dir, template_dir, context)

def check_for_existing_files(
Expand Down Expand Up @@ -92,6 +93,11 @@ def check_for_existing_files(
retcode=os.EX_CANTCREAT,
)

@property
def default_version(self) -> str:
"""Return default version that should be used for the InitService context."""
return "0.1"

def _copy_template_file(
self,
template_name: str,
Expand Down Expand Up @@ -150,21 +156,40 @@ def _render_project(
shutil.copystat((template_dir / template_name), path)
emit.progress("Rendered project.")

def _get_context(self, name: str) -> dict[str, Any]:
def _get_context(self, name: str, *, project_dir: pathlib.Path) -> 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}
version = self._get_version(project_dir=project_dir)
if version is not None:
emit.debug(f"Discovered project version: {version!r}")

return {"name": name, "version": version or self.default_version}

@staticmethod
def _create_project_dir(project_dir: pathlib.Path) -> None:
"""Create the project directory if it does not already exist."""
emit.debug(f"Creating project directory {str(project_dir)!r}.")
project_dir.mkdir(parents=True, exist_ok=True)

def _get_version(self, *, project_dir: pathlib.Path) -> str | None:
"""Try to determine version if project is the git repository."""
try:
if is_repo(project_dir):
git_repo = GitRepo(project_dir)
described = git_repo.describe(
always_use_long_format=True,
show_commit_oid_as_fallback=True,
)
return parse_describe(described)
except GitError as error:
emit.debug(f"cannot determine project version: {error.details}")

return None

def _get_loader(self, template_dir: pathlib.Path) -> jinja2.BaseLoader:
"""Return a Jinja loader for the given template directory.
Expand Down
11 changes: 11 additions & 0 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ Application
- The fetch-service integration now assumes that the fetch-service snap is
tracking the ``latest/candidate`` channel.

Commands
========

Services
========

- Add version to the template generation context of ``InitService``.

..
For a complete list of commits, check out the `4.5.0`_ release on GitHub.
4.4.0 (2024-Nov-08)
-------------------

Expand Down
77 changes: 76 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import os
import pathlib
import shutil
import subprocess
from dataclasses import dataclass
from importlib import metadata
from typing import TYPE_CHECKING, Any

Expand All @@ -27,7 +29,7 @@
import jinja2
import pydantic
import pytest
from craft_application import application, launchpad, models, services, util
from craft_application import application, git, launchpad, models, services, util
from craft_cli import EmitterMode, emit
from craft_providers import bases
from jinja2 import FileSystemLoader
Expand Down Expand Up @@ -407,3 +409,76 @@ def new_dir(tmp_path):
yield tmp_path

os.chdir(cwd)


@pytest.fixture
def empty_working_directory(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> pathlib.Path:
repo_dir = pathlib.Path(tmp_path, "test-repo")
repo_dir.mkdir()
monkeypatch.chdir(repo_dir)
return repo_dir


@pytest.fixture
def empty_repository(empty_working_directory: pathlib.Path) -> pathlib.Path:
subprocess.run(["git", "init"], check=True)
return empty_working_directory


@dataclass
class RepositoryDefinition:
repository_path: pathlib.Path
commit: str
tag: str | None = None

@property
def short_commit(self) -> str:
"""Return abbreviated commit."""
return git.short_commit_sha(self.commit)


@pytest.fixture
def repository_with_commit(empty_repository: pathlib.Path) -> RepositoryDefinition:
repo = git.GitRepo(empty_repository)
(empty_repository / "Some file").touch()
repo.add_all()
commit_sha = repo.commit("1")
return RepositoryDefinition(
repository_path=empty_repository,
commit=commit_sha,
)


@pytest.fixture
def repository_with_annotated_tag(
repository_with_commit: RepositoryDefinition,
) -> RepositoryDefinition:
test_tag = "v3.2.1"
subprocess.run(
["git", "config", "--local", "user.name", "Testcraft", test_tag], check=True
)
subprocess.run(
["git", "config", "--local", "user.email", "[email protected]", test_tag],
check=True,
)
subprocess.run(["git", "tag", "-a", "-m", "testcraft tag", test_tag], check=True)
repository_with_commit.tag = test_tag
return repository_with_commit


@pytest.fixture
def repository_with_unannotated_tag(
repository_with_commit: RepositoryDefinition,
) -> RepositoryDefinition:
subprocess.run(["git", "config", "--local", "user.name", "Testcraft"], check=True)
subprocess.run(
["git", "config", "--local", "user.email", "[email protected]"],
check=True,
)
test_tag = "non-annotated"
subprocess.run(["git", "tag", test_tag], check=True)
repository_with_commit.tag = test_tag
return repository_with_commit
Loading

0 comments on commit 2bc1acc

Please sign in to comment.