Skip to content

Commit

Permalink
feat: Allow create initial releases and pre-releases
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Previously `badabump` only allows to create next
version if project already have a proper git tag. Now it allows to
create an initial release (and pre-release) even project does not
yet have any git tags available.

Fixes: #15
  • Loading branch information
playpauseandstop committed Nov 5, 2020
1 parent 90ab997 commit 9bb49d1
Show file tree
Hide file tree
Showing 13 changed files with 249 additions and 28 deletions.
63 changes: 46 additions & 17 deletions src/badabump/cli/app.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import argparse
import os
import sys
from typing import Optional

from .arguments import add_path_argument
from .commands import (
run_post_bump_hook,
update_changelog_file,
update_version_files,
)
from .output import echo_value, github_actions_output
from .output import echo_value, EMPTY, github_actions_output
from .. import __app__, __version__
from ..annotations import Argv
from ..changelog import ChangeLog
from ..configs import ProjectConfig, UpdateConfig
from ..constants import INITIAL_PRE_RELEASE_COMMIT, INITIAL_RELEASE_COMMIT
from ..enums import ChangeLogTypeEnum
from ..git import Git
from ..versions import Version
Expand Down Expand Up @@ -86,33 +88,54 @@ def main(argv: Argv = None) -> int:
# Read latest git tag and parse current version
git = Git(path=project_config.path)

current_tag = git.retrieve_last_tag()
current_tag = git.retrieve_last_tag_or_none()
echo_value(
"Current tag: ", current_tag, is_ci=args.is_ci, ci_name="current_tag"
"Current tag: ",
current_tag or EMPTY,
is_ci=args.is_ci,
ci_name="current_tag",
)

current_version = Version.from_tag(current_tag, config=project_config)
current_version: Optional[Version] = None
if current_tag is not None:
current_version = Version.from_tag(current_tag, config=project_config)

echo_value(
"Current version: ",
current_version.format(config=project_config),
(
current_version.format(config=project_config)
if current_version
else EMPTY
),
is_ci=args.is_ci,
ci_name="current_version",
)

# Read commits from last tag
try:
git_commits = git.list_commits(current_tag)
if not git_commits and current_version.pre_release is None:
raise ValueError("No commits found after latest tag")
except ValueError:
print(
f"ERROR: No commits found after: {current_tag!r}. Exit...",
file=sys.stderr,
if current_tag is not None and current_version is not None:
try:
git_commits = git.list_commits(current_tag)
if not git_commits and current_version.pre_release is None:
raise ValueError("No commits found after latest tag")
except ValueError:
print(
f"ERROR: No commits found after: {current_tag!r}. Exit...",
file=sys.stderr,
)
return 1

# Create changelog using commits from last tag
changelog = ChangeLog.from_git_commits(git_commits)
# Create initial changelog
else:
changelog = ChangeLog.from_git_commits(
(
INITIAL_PRE_RELEASE_COMMIT
if args.is_pre_release
else INITIAL_RELEASE_COMMIT,
),
)
return 1

# Create changelog using commits from last tag
changelog = ChangeLog.from_git_commits(git_commits)
git_changelog = changelog.format(
ChangeLogTypeEnum.git_commit,
project_config.changelog_format_type_git,
Expand All @@ -127,7 +150,13 @@ def main(argv: Argv = None) -> int:
# Supply update config and guess next version
update_config = create_update_config(changelog, args.is_pre_release)

next_version = current_version.update(update_config)
if current_version is not None:
next_version = current_version.update(update_config)
else:
next_version = Version.guess_initial_version(
config=project_config, is_pre_release=args.is_pre_release
)

next_version_str = next_version.format(config=project_config)
echo_value(
"\nNext version: ",
Expand Down
10 changes: 6 additions & 4 deletions src/badabump/cli/commands.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import subprocess
from pathlib import Path
from typing import Set, Tuple
from typing import Optional, Set, Tuple

import toml

from .output import diff, echo_message
from .output import diff, echo_message, EMPTY
from ..changelog import ChangeLog, in_development_header, version_header
from ..configs import find_changelog_file, ProjectConfig
from ..constants import (
Expand Down Expand Up @@ -202,7 +202,7 @@ def update_file(

def update_version_files(
config: ProjectConfig,
current_version: Version,
current_version: Optional[Version],
next_version: Version,
*,
is_dry_run: bool = False,
Expand All @@ -216,7 +216,9 @@ def update_version_files(
version_files = guess_version_files(config)

path = config.path
current_version_str = current_version.format(config=config)
current_version_str = (
current_version.format(config=config) if current_version else EMPTY
)
next_version_str = next_version.format(config=config)

updated: Set[bool] = set()
Expand Down
3 changes: 3 additions & 0 deletions src/badabump/cli/output.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from difflib import ndiff


EMPTY = "-"


def diff(current_content: str, next_content: str) -> str:
items = ndiff(
current_content.splitlines(keepends=True),
Expand Down
3 changes: 3 additions & 0 deletions src/badabump/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
DEFAULT_CALVER_SCHEMA = DEFAULT_VERSION_SCHEMA = "YY.MINOR.MICRO"
DEFAULT_SEMVER_SCHEMA = "MAJOR.MINOR.PATCH"

INITIAL_RELEASE_COMMIT = "feat: Initial release"
INITIAL_PRE_RELEASE_COMMIT = "feat: Initial pre-release"

FILE_CONFIG_TOML = f".{__app__}.toml"
FILE_PACKAGE_JSON = "package.json"
FILE_PACKAGE_LOCK_JSON = "package-lock.json"
Expand Down
8 changes: 7 additions & 1 deletion src/badabump/git.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import subprocess
from contextlib import suppress
from pathlib import Path
from typing import Iterator, List, Tuple
from typing import Iterator, List, Optional, Tuple

import attr

Expand All @@ -27,6 +28,11 @@ def retrieve_last_commit(self) -> str:
def retrieve_last_tag(self) -> str:
return self._check_output(["git", "describe", "--abbrev=0", "--tags"])

def retrieve_last_tag_or_none(self) -> Optional[str]:
with suppress(subprocess.CalledProcessError, ValueError):
return self.retrieve_last_tag()
return None

def retrieve_tag_body(self, tag: str) -> str:
return self._check_output(
["git", "tag", "-l", "--format=%(body)", tag]
Expand Down
13 changes: 13 additions & 0 deletions src/badabump/versions/calver.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ def from_parsed_dict(cls, parsed: DictStrStr, *, schema: str) -> "CalVer":
def get_format_context(self) -> DictStrAny:
return {**attr.asdict(self), "short_year": self.short_year}

@classmethod
def initial(cls, *, schema: str) -> "CalVer":
utcnow = datetime.datetime.utcnow()
return cls(
year=utcnow.year,
month=utcnow.month,
week=get_week(utcnow),
day=utcnow.day,
minor=1,
micro=0,
schema=schema,
)

@classmethod
def parse(cls, value: str, *, schema: str) -> "CalVer":
maybe_parsed = parse_version(schema, SCHEMA_PARTS_PARSING, value)
Expand Down
8 changes: 5 additions & 3 deletions src/badabump/versions/pre_release.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import suppress
from enum import Enum, unique
from typing import DefaultDict, Optional

Expand Down Expand Up @@ -83,9 +84,10 @@ def parse(

maybe_parsed = parse_version(schema, SCHEMA_PARTS_PARSING, value)
if maybe_parsed:
return cls.from_parsed_dict(
maybe_parsed, project_type=project_type
)
with suppress(KeyError):
return cls.from_parsed_dict(
maybe_parsed, project_type=project_type
)

raise ValueError(
"Invalid pre-release value, which do not match any supported "
Expand Down
4 changes: 4 additions & 0 deletions src/badabump/versions/semver.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def from_parsed_dict(
schema=schema or SCHEMA,
)

@classmethod
def initial(cls, *, schema: str = None) -> "SemVer":
return cls(major=1, minor=0, patch=0, schema=schema or SCHEMA)

@classmethod
def parse(cls, value: str, *, schema: str = None) -> "SemVer":
maybe_parsed = parse_version(SCHEMA, SCHEMA_PARTS_PARSING, value)
Expand Down
51 changes: 49 additions & 2 deletions src/badabump/versions/version.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
from contextlib import suppress
from typing import Optional, Type, Union
from typing import cast, Optional, Type, Union

import attr
import toml

from . import calver, pre_release, semver
from .calver import CalVer
Expand All @@ -11,7 +13,7 @@
from .semver import SemVer
from ..annotations import DictStrStr
from ..configs import ProjectConfig, UpdateConfig
from ..enums import VersionTypeEnum
from ..enums import ProjectTypeEnum, VersionTypeEnum
from ..regexps import to_regexp


Expand All @@ -38,6 +40,15 @@ def format(self, *, config: ProjectConfig) -> str: # noqa: A003
)
return self.version.format()

@classmethod
def guess_initial_version(
cls, *, config: ProjectConfig, is_pre_release: bool
) -> "Version":
version = guess_initial_version(config)
if version.pre_release is None and is_pre_release:
return attr.evolve(version, pre_release=PreRelease())
return version

@classmethod
def parse(cls, value: str, *, config: ProjectConfig) -> "Version":
schema = config.version_schema
Expand Down Expand Up @@ -90,6 +101,42 @@ def update(self, config: UpdateConfig) -> "Version":
return Version(version=self.version.update(config))


def find_project_version(config: ProjectConfig) -> Optional[str]:
if config.project_type == ProjectTypeEnum.javascript:
package_json_path = config.path / "package.json"
if package_json_path.exists():
try:
return cast(
str, json.loads(package_json_path.read_text())["version"]
)
except (KeyError, ValueError):
...
else:
pyproject_toml_path = config.path / "pyproject.toml"
if pyproject_toml_path.exists():
try:
return cast(
str,
toml.loads(pyproject_toml_path.read_text())["tool"][
"poetry"
]["version"],
)
except (KeyError, ValueError):
...

return None


def guess_initial_version(config: ProjectConfig) -> Version:
maybe_version_str = find_project_version(config)
if maybe_version_str:
return Version.parse(maybe_version_str, config=config)

if config.version_type == VersionTypeEnum.semver:
return Version(version=SemVer.initial())
return Version(version=CalVer.initial(schema=config.version_schema))


def guess_version_from_tag(value: str, *, tag_format: str) -> str:
matched = to_regexp(tag_format).match(value)
if matched:
Expand Down
24 changes: 24 additions & 0 deletions tests/test_versions_calver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import datetime

import pytest

from badabump.versions.calver import CalVer, SHORT_YEAR_START

UTCNOW = datetime.datetime.utcnow()

YEAR = UTCNOW.year
SHORT_YEAR = YEAR - SHORT_YEAR_START
MONTH = UTCNOW.month
DAY = UTCNOW.day


@pytest.mark.parametrize(
"schema, expected",
(
("YY.MINOR.MICRO", f"{SHORT_YEAR}.1.0"),
("YYYY.MM.DD", f"{YEAR}.{MONTH}.{DAY}"),
("YYYY_MICRO", f"{YEAR}_0"),
),
)
def test_calver_initial(schema, expected):
assert CalVer.initial(schema=schema).format() == expected
11 changes: 11 additions & 0 deletions tests/test_versions_pre_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ def test_pre_release_parse(
assert PreRelease.parse(pre_release, project_type=project_type) == expected


@pytest.mark.parametrize(
"project_type, invalid_value",
((ProjectTypeEnum.javascript, "a0"), (ProjectTypeEnum.python, "-beta.0")),
)
def test_pre_release_parse_value_error(
project_type: ProjectTypeEnum, invalid_value: str
):
with pytest.raises(ValueError):
PreRelease.parse(invalid_value, project_type=project_type)


@pytest.mark.parametrize(
"current, update_config, expected",
(
Expand Down
4 changes: 4 additions & 0 deletions tests/test_versions_semver.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ def test_semver_format(semver: SemVer, expected: str):
assert semver.format() == expected


def test_semver_initial():
assert SemVer.initial().format() == "1.0.0"


@pytest.mark.parametrize("expected, semver", VERSIONS)
def test_semver_parse(expected: SemVer, semver: str):
assert SemVer.parse(semver) == expected
Expand Down
Loading

0 comments on commit 9bb49d1

Please sign in to comment.