Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: generate version for templates usage #548

Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
dariuszd21 marked this conversation as resolved.
Show resolved Hide resolved
"""
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
17 changes: 17 additions & 0 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
Changelog
*********

4.5.0 (2024-XX-XX)
-------------------

Application
===========

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 @@ -366,3 +368,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
Loading