Repository: relekang/python-semantic-release
Pull request #309 (2021-01-06 15:25:06)

feat(print-version): add print-version command to output version
This new command can be integrated in the build process before the effective release, ie. to rename some files with the version number.

Users may invoke `VERSION=$(semantic-release print-version)` to retrieve the version that will be generated during the release before it really occurs.

Toilal: In fact, most users won't need this. 

But in my use case (related to PR #308), the build is not performed by semantic-release but by some github actions running in another job. So I need to retrieve the version number that semantic-release will generate to rename the artifacts generated by github actions, before placing them in the dist directory and running the `semantic-release publish` command.
relekang: Nice, just added one suggestion since it seems to be a copy-paste error?
relekang: Just realised that changing the location of the main will break the github action. Can you please update https://github.com/relekang/python-semantic-release/blob/master/action.sh#L17 to work with the new setup?

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2421f2a..4c740d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
 
 <!--next-version-placeholder-->
 
+## v7.10.0 (2021-01-08)
+### Feature
+* **build:** Allow falsy values for build_command to disable build step ([`c07a440`](https://github.com/relekang/python-semantic-release/commit/c07a440f2dfc45a2ad8f7c454aaac180c4651f70))
+
+### Documentation
+* Fix incorrect reference syntax ([`42027f0`](https://github.com/relekang/python-semantic-release/commit/42027f0d2bb64f4c9eaec65112bf7b6f67568e60))
+* Rewrite getting started page ([`97a9046`](https://github.com/relekang/python-semantic-release/commit/97a90463872502d1207890ae1d9dd008b1834385))
+
 ## v7.9.0 (2020-12-21)
 ### Feature
 * **hvcs:** Add hvcs_domain config option ([`ab3061a`](https://github.com/relekang/python-semantic-release/commit/ab3061ae93c49d71afca043b67b361e2eb2919e6))
diff --git a/action.sh b/action.sh
index ed1d1d5..faeb50a 100755
--- a/action.sh
+++ b/action.sh
@@ -14,5 +14,5 @@ git config --global user.name "github-actions"
 git config --global user.email "action@github.com"
 
 # Run Semantic Release
-python -m semantic_release.cli publish -v DEBUG \
+python -m semantic_release publish -v DEBUG \
   -D commit_author="github-actions <action@github.com>"
diff --git a/docs/commands.rst b/docs/commands.rst
index 0d7e3e2..da6f020 100644
--- a/docs/commands.rst
+++ b/docs/commands.rst
@@ -24,6 +24,19 @@ Figure out the new version number, update and commit it, and create a tag.
 
 This will not push anything to any remote. All changes are local.
 
+.. _cmd-print-version:
+
+``semantic-release print-version``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Print to standard output the new version number.
+
+If the option ``--current`` is used, it will display the current version number.
+
+It can be used to retrieve the next version number in a shell script during the build, before running the effective
+release, ie. to rename a distribution binary with the effective version::
+
+    VERSION=$(semantic-release print-version)
 
 .. _cmd-publish:
 
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 4a51ebe..ccda4a1 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -377,7 +377,9 @@ Default: `true`
 -----------------
 Command to build dists. Build output should be stored in the directory configured in
 ``dist_path``.  If necessary, multiple commands can be specified using ``&&``, e.g.
-``pip install -m flit && flit build``.
+``pip install -m flit && flit build``. If set to false, build command is disabled and
+files should be placed manually in the directory configured in
+``dist_path``.
 
 Default: ``python setup.py sdist bdist_wheel``
 
diff --git a/semantic_release/__init__.py b/semantic_release/__init__.py
index bec5d4f..0637574 100644
--- a/semantic_release/__init__.py
+++ b/semantic_release/__init__.py
@@ -1,6 +1,6 @@
 """Semantic Release
 """
-__version__ = "7.9.0"
+__version__ = "7.10.0"
 
 
 from .errors import UnknownCommitMessageStyleError  # noqa; noqa
diff --git a/semantic_release/__main__.py b/semantic_release/__main__.py
new file mode 100644
index 0000000..55da025
--- /dev/null
+++ b/semantic_release/__main__.py
@@ -0,0 +1,5 @@
+
+from .cli import entry
+
+if __name__ == "__main__":
+    entry()
diff --git a/semantic_release/cli.py b/semantic_release/cli.py
index ff25d3b..528cee6 100644
--- a/semantic_release/cli.py
+++ b/semantic_release/cli.py
@@ -11,7 +11,7 @@ from semantic_release import ci_checks
 from semantic_release.errors import GitError, ImproperConfigurationError
 
 from .changelog import markdown_changelog
-from .dist import build_dists, remove_dists
+from .dist import build_dists, remove_dists, should_build, should_remove_dist
 from .history import (
     evaluate_version_bump,
     get_current_version,
@@ -71,7 +71,7 @@ COMMON_OPTIONS = [
         "--define",
         "-D",
         multiple=True,
-        help='setting="value", override a configuration value',
+        help='setting="value", override a configuration value.',
     ),
     overload_configuration,
 ]
@@ -86,6 +86,30 @@ def common_options(func):
     return func
 
 
+def print_version(*, current=False, force_level=None, **kwargs):
+    """
+    Print the current or new version to standard output.
+    """
+    try:
+        current_version = get_current_version()
+    except GitError as e:
+        print(str(e), file=sys.stderr)
+        return False
+    if current:
+        print(current_version, end="")
+        return True
+
+    # Find what the new version number should be
+    level_bump = evaluate_version_bump(current_version, force_level)
+    new_version = get_new_version(current_version, level_bump)
+    if should_bump_version(current_version=current_version, new_version=new_version):
+        print(new_version, end="")
+        return True
+
+    print("No release will be made.", file=sys.stderr)
+    return False
+
+
 def version(*, retry=False, noop=False, force_level=None, **kwargs):
     """
     Detect the new version according to git log and semver.
@@ -258,7 +282,6 @@ def publish(**kwargs):
 
         # Get config options for uploads
         dist_path = config.get("dist_path")
-        remove_dist = config.get("remove_dist")
         upload_pypi = config.get("upload_to_pypi")
         upload_to_pypi_glob_patterns = config.get("upload_to_pypi_glob_patterns")
         upload_release = config.get("upload_to_release")
@@ -266,10 +289,10 @@ def publish(**kwargs):
         if upload_to_pypi_glob_patterns:
             upload_to_pypi_glob_patterns = upload_to_pypi_glob_patterns.split(",")
 
-        if upload_pypi or upload_release:
+        if should_build():
             # We need to run the command to build wheels for releasing
             logger.info("Building distributions")
-            if remove_dist:
+            if should_remove_dist():
                 # Remove old distributions before building
                 remove_dists(dist_path)
             build_dists()
@@ -298,7 +321,7 @@ def publish(**kwargs):
             logger.info("Uploading to HVCS release")
             upload_to_release(owner, name, new_version, dist_path)
         # Remove distribution files as they are no longer needed
-        if (upload_pypi or upload_release) and remove_dist:
+        if should_remove_dist():
             remove_dists(dist_path)
 
         logger.info("New release published")
@@ -318,10 +341,13 @@ def filter_output_for_secrets(message):
 
 
 def entry():
-    click_log.basic_config(logger)
-
     # Move flags to after the command
     ARGS = sorted(sys.argv[1:], key=lambda x: 1 if x.startswith("--") else -1)
+
+    if ARGS and not ARGS[0].startswith('print-'):
+        # print-* command output should not be polluted with logging.
+        click_log.basic_config()
+
     main(args=ARGS)
 
 
@@ -390,12 +416,12 @@ def cmd_version(**kwargs):
         exit(1)
 
 
-if __name__ == "__main__":
-    #
-    # Allow options to come BEFORE commands,
-    # we simply sort them behind the command instead.
-    #
-    # This will have to be removed if there are ever global options
-    # that are not valid for a subcommand.
-    #
-    entry()
+@main.command(name="print-version", help=print_version.__doc__)
+@common_options
+@click.option('--current/--next', default=False, help="Choose to output next version (default) or current one.")
+def cmd_print_version(**kwargs):
+    try:
+        return print_version(**kwargs)
+    except Exception as error:
+        print(filter_output_for_secrets(str(error)), file=sys.stderr)
+        exit(1)
diff --git a/semantic_release/dist.py b/semantic_release/dist.py
index bc5f597..ef38703 100644
--- a/semantic_release/dist.py
+++ b/semantic_release/dist.py
@@ -9,6 +9,18 @@ from .settings import config
 logger = logging.getLogger(__name__)
 
 
+def should_build():
+    upload_pypi = config.get("upload_to_pypi")
+    upload_release = config.get("upload_to_release")
+    build_command = config.get("build_command")
+    return bool(build_command and (upload_pypi or upload_release))
+
+
+def should_remove_dist():
+    remove_dist = config.get("remove_dist")
+    return bool(remove_dist and should_build())
+
+
 def build_dists():
     command = config.get("build_command")
     logger.info(f"Running {command}")
diff --git a/semantic_release/history/logs.py b/semantic_release/history/logs.py
index d6cdebd..2d0980e 100644
--- a/semantic_release/history/logs.py
+++ b/semantic_release/history/logs.py
@@ -12,6 +12,7 @@ from .parser_helpers import re_breaking
 logger = logging.getLogger(__name__)
 
 LEVELS = {
+    0: None,
     1: "patch",
     2: "minor",
     3: "major",


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ed97e9c..c69e03b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,7 +1,7 @@
 from click.testing import CliRunner
 
 import semantic_release
-from semantic_release.cli import changelog, main, publish, version
+from semantic_release.cli import changelog, main, publish, version, print_version
 from semantic_release.errors import GitError, ImproperConfigurationError
 
 from . import mock, pytest, reset_config, wrapped_config_get
@@ -190,6 +190,116 @@ def test_noop_mode(mocker):
     assert not mock_tag_new_version.called
 
 
+def test_cli_print_version(mocker, runner):
+    mock_print_version = mocker.patch("semantic_release.cli.print_version")
+    result = runner.invoke(main, ["print-version"])
+    mock_print_version.assert_called_once_with(
+        current=False,
+        force_level=None,
+        noop=False,
+        post=False,
+        retry=False,
+        define=(),
+    )
+    assert result.exit_code == 0
+
+
+def test_cli_print_version_force_major(mocker, runner):
+    mock_print_version = mocker.patch("semantic_release.cli.print_version")
+    result = runner.invoke(main, ["print-version", "--major"])
+    mock_print_version.assert_called_once_with(
+        current=False,
+        force_level="major",
+        noop=False,
+        post=False,
+        retry=False,
+        define=(),
+    )
+    assert result.exit_code == 0
+
+
+def test_cli_print_version_current(mocker, runner):
+    mock_print_version = mocker.patch("semantic_release.cli.print_version")
+    result = runner.invoke(main, ["print-version", "--current"])
+    mock_print_version.assert_called_once_with(
+        current=True,
+        force_level=None,
+        noop=False,
+        post=False,
+        retry=False,
+        define=(),
+    )
+    assert result.exit_code == 0
+
+
+def test_cli_print_version_next(mocker, runner):
+    mock_print_version = mocker.patch("semantic_release.cli.print_version")
+    result = runner.invoke(main, ["print-version", "--next"])
+    mock_print_version.assert_called_once_with(
+        current=False,
+        force_level=None,
+        noop=False,
+        post=False,
+        retry=False,
+        define=(),
+    )
+    assert result.exit_code == 0
+
+
+def test_print_version_no_change(mocker, runner, capsys):
+    mock_new_version = mocker.patch(
+        "semantic_release.cli.get_new_version", return_value="1.2.3"
+    )
+    mock_evaluate_bump = mocker.patch(
+        "semantic_release.cli.evaluate_version_bump", return_value=None
+    )
+    mock_current_version = mocker.patch(
+        "semantic_release.cli.get_current_version", return_value="1.2.3"
+    )
+
+    print_version()
+    outerr = capsys.readouterr()
+    assert outerr.out == ""
+    assert outerr.err == "No release will be made.\n"
+
+    mock_current_version.assert_called_once_with()
+    mock_evaluate_bump.assert_called_once_with("1.2.3", None)
+    mock_new_version.assert_called_once_with("1.2.3", None)
+
+
+def test_print_version_change(mocker, runner, capsys):
+    mock_current_version = mocker.patch(
+        "semantic_release.cli.get_current_version", return_value="1.2.3"
+    )
+    mock_evaluate_bump = mocker.patch(
+        "semantic_release.cli.evaluate_version_bump", return_value="minor"
+    )
+
+    print_version()
+    outerr = capsys.readouterr()
+    assert outerr.out == "1.3.0"
+    assert outerr.err == ""
+
+    mock_current_version.assert_called_once_with()
+    mock_evaluate_bump.assert_called_once_with("1.2.3", None)
+
+
+def test_print_version_force_major(mocker, runner, capsys):
+    mock_current_version = mocker.patch(
+        "semantic_release.cli.get_current_version", return_value="1.2.3"
+    )
+    mock_evaluate_bump = mocker.patch(
+        "semantic_release.cli.evaluate_version_bump", return_value="major"
+    )
+
+    print_version(force_level="major")
+    outerr = capsys.readouterr()
+    assert outerr.out == "2.0.0"
+    assert outerr.err == ""
+
+    mock_current_version.assert_called_once_with()
+    mock_evaluate_bump.assert_called_once_with("1.2.3", "major")
+
 def test_version_no_change(mocker, runner):
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
     mock_commit_new_version = mocker.patch("semantic_release.cli.commit_new_version")
@@ -408,6 +518,52 @@ def test_publish_should_call_functions(mocker):
     mock_checkout.assert_called_once_with("master")
 
 
+def test_publish_should_skip_build_when_command_is_empty(mocker):
+    mock_push = mocker.patch("semantic_release.cli.push_new_version")
+    mock_checkout = mocker.patch("semantic_release.cli.checkout")
+    mock_should_bump_version = mocker.patch(
+        "semantic_release.cli.should_bump_version", return_value=True
+    )
+    mock_log = mocker.patch("semantic_release.cli.post_changelog")
+    mock_ci_check = mocker.patch("semantic_release.ci_checks.check")
+    mock_pypi = mocker.patch("semantic_release.cli.upload_to_pypi")
+    mock_release = mocker.patch("semantic_release.cli.upload_to_release")
+    mock_build_dists = mocker.patch("semantic_release.cli.build_dists")
+    mock_remove_dists = mocker.patch("semantic_release.cli.remove_dists")
+    mocker.patch(
+        "semantic_release.cli.get_repository_owner_and_name",
+        return_value=("relekang", "python-semantic-release"),
+    )
+    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "feature")
+    mocker.patch("semantic_release.cli.generate_changelog")
+    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.update_changelog_file")
+    mocker.patch("semantic_release.cli.bump_version")
+    mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
+    mocker.patch("semantic_release.cli.check_token", lambda: True)
+
+    mocker.patch(
+        "semantic_release.cli.config.get",
+        wrapped_config_get(
+            build_command="",
+        ),
+    )
+
+    publish()
+
+    assert mock_ci_check.called
+    assert mock_push.called
+    assert not mock_remove_dists.called
+    assert not mock_build_dists.called
+    assert mock_pypi.called
+    assert mock_release.called
+    assert mock_should_bump_version.called
+    mock_log.assert_called_once_with(
+        u"relekang", "python-semantic-release", "2.0.0", "CHANGES"
+    )
+    mock_checkout.assert_called_once_with("master")
+
+
 def test_publish_should_call_functions_with_custom_pypi_glob_patterns(mocker):
     mock_push = mocker.patch("semantic_release.cli.push_new_version")
     mock_checkout = mocker.patch("semantic_release.cli.checkout")
diff --git a/tests/test_dist.py b/tests/test_dist.py
index a9e562f..ed9fb94 100644
--- a/tests/test_dist.py
+++ b/tests/test_dist.py
@@ -1,4 +1,4 @@
-from semantic_release.dist import build_dists
+from semantic_release.dist import build_dists, should_build, should_remove_dist
 
 from . import pytest
 
@@ -12,3 +12,94 @@ def test_build_command(mocker, commands):
     mock_run = mocker.patch("semantic_release.dist.run")
     build_dists()
     mock_run.assert_called_once_with(commands)
+
+
+@pytest.mark.parametrize(
+    "config,expected",
+    [
+        (
+            {
+                "upload_to_pypi": True,
+                "upload_to_release": True,
+                "build_command": "python setup.py build",
+            },
+            True,
+        ),
+        (
+            {"upload_to_pypi": True, "upload_to_release": True, "build_command": False},
+            False,
+        ),
+        (
+            {"upload_to_pypi": True, "upload_to_release": True, "build_command": None},
+            False,
+        ),
+        (
+            {"upload_to_pypi": True, "upload_to_release": True, "build_command": ""},
+            False,
+        ),
+        (
+            {
+                "upload_to_pypi": False,
+                "upload_to_release": True,
+                "build_command": "python setup.py build",
+            },
+            True,
+        ),
+        (
+            {
+                "upload_to_pypi": True,
+                "upload_to_release": False,
+                "build_command": "python setup.py build",
+            },
+            True,
+        ),
+        (
+            {
+                "upload_to_pypi": False,
+                "upload_to_release": False,
+                "build_command": "python setup.py build",
+            },
+            False,
+        ),
+    ],
+)
+def test_should_build(config, expected, mocker):
+    mocker.patch("semantic_release.cli.config.get", lambda key: config.get(key))
+    assert should_build() == expected
+
+
+@pytest.mark.parametrize(
+    "config,expected",
+    [
+        (
+            {
+                "upload_to_pypi": True,
+                "upload_to_release": True,
+                "build_command": "python setup.py build",
+                "remove_dist": True,
+            },
+            True,
+        ),
+        (
+            {
+                "upload_to_pypi": True,
+                "upload_to_release": True,
+                "build_command": "python setup.py build",
+                "remove_dist": False,
+            },
+            False,
+        ),
+        (
+            {
+                "upload_to_pypi": False,
+                "upload_to_release": False,
+                "build_command": False,
+                "remove_dist": True,
+            },
+            False,
+        ),
+    ],
+)
+def test_should_remove_dist(config, expected, mocker):
+    mocker.patch("semantic_release.cli.config.get", lambda key: config.get(key))
+    assert should_remove_dist() == expected