Public Access
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 48s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 44s
Post-merge / badges (push) Successful in 1m3s
390 lines
17 KiB
Python
390 lines
17 KiB
Python
"""Unit tests for devx.ci.publish."""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import click
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from devx.ci.publish import (
|
|
_default_gitea_registry_url,
|
|
build_package,
|
|
generate_release_notes,
|
|
main,
|
|
publish_to_gitea_registry,
|
|
publish_to_pypi,
|
|
)
|
|
from devx.gitea_cli import TeaCLIError
|
|
|
|
|
|
class TestGenerateReleaseNotes:
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x")
|
|
result = generate_release_notes("v1.0.0")
|
|
assert "## v1.0.0" in result
|
|
assert "feat: x" in result
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n")
|
|
result = generate_release_notes("v1.0.0")
|
|
assert result == "changelog"
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
|
result = generate_release_notes("v1.0.0")
|
|
assert "Release v1.0.0" in result
|
|
assert "CHANGELOG.md" in result
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
|
|
result = generate_release_notes("v1.0.0")
|
|
assert "Release v1.0.0" in result
|
|
|
|
@patch("devx.ci.publish.subprocess.run", side_effect=FileNotFoundError)
|
|
@patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
result = generate_release_notes("v1.0.0")
|
|
assert "Release v1.0.0" in result
|
|
assert "CHANGELOG.md" in result
|
|
|
|
@patch("devx.ci.publish.shutil.which", return_value=None)
|
|
def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None:
|
|
result = generate_release_notes("v1.0.0")
|
|
assert "Release v1.0.0" in result
|
|
assert "CHANGELOG.md" in result
|
|
|
|
|
|
class TestBuildPackage:
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.Path.exists", return_value=False)
|
|
def test_success(self, mock_exists: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
build_package()
|
|
args, _ = mock_run.call_args
|
|
assert args[0][1] == "-m"
|
|
assert args[0][2] == "build"
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.Path.exists", return_value=False)
|
|
def test_failure_raises(self, mock_exists: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
|
|
with pytest.raises(click.ClickException) as exc:
|
|
build_package()
|
|
assert "build" in str(exc.value)
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.shutil.rmtree")
|
|
@patch("devx.ci.publish.Path.exists", return_value=True)
|
|
def test_cleans_dist_before_build(
|
|
self, mock_exists: MagicMock, mock_rmtree: MagicMock, mock_run: MagicMock
|
|
) -> None:
|
|
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
build_package()
|
|
mock_rmtree.assert_called_once_with(Path("dist"))
|
|
|
|
|
|
class TestPublishToPypi:
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
def test_success(self, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
publish_to_pypi("pypi-tok")
|
|
args, _ = mock_run.call_args
|
|
assert "twine" in args[0]
|
|
assert "pypi-tok" in args[0]
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=1, stderr="upload failed")
|
|
with pytest.raises(click.ClickException) as exc:
|
|
publish_to_pypi("pypi-tok")
|
|
assert "PyPI" in str(exc.value)
|
|
|
|
|
|
class TestPublishToGiteaRegistry:
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
def test_success(self, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
|
args, _ = mock_run.call_args
|
|
assert "twine" in args[0]
|
|
assert "--repository-url" in args[0]
|
|
assert "https://git.example.com/api/packages/owner/pypi" in args[0]
|
|
assert "gitea-tok" in args[0]
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed")
|
|
with pytest.raises(click.ClickException) as exc:
|
|
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
|
assert "Gitea PyPI registry" in str(exc.value)
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None:
|
|
"""409 Conflict (already published) should not raise — just continue."""
|
|
mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="")
|
|
# Should not raise
|
|
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
|
|
|
|
|
class TestDefaultGiteaRegistryUrl:
|
|
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
|
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1")
|
|
def test_derives_from_api_url(self) -> None:
|
|
url = _default_gitea_registry_url()
|
|
assert url == "https://git.example.com/api/packages/myorg/pypi"
|
|
|
|
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
|
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api")
|
|
def test_derives_from_api_url_no_v1(self) -> None:
|
|
url = _default_gitea_registry_url()
|
|
assert url == "https://git.example.com/api/packages/myorg/pypi"
|
|
|
|
@patch.dict("os.environ", {}, clear=True)
|
|
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1")
|
|
def test_default_owner(self) -> None:
|
|
url = _default_gitea_registry_url()
|
|
assert "oblachno-oss" in url
|
|
|
|
|
|
class TestMain:
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_full_flow_with_pypi(
|
|
self,
|
|
mock_build: MagicMock,
|
|
mock_publish: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
) -> None:
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 0
|
|
assert "Gitea release v1.0.0 created" in result.output
|
|
mock_build.assert_called_once()
|
|
mock_publish.assert_called_once_with("pypi-tok")
|
|
mock_tea.create_release.assert_called_once_with(
|
|
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
|
)
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_without_pypi_uses_gitea_registry(
|
|
self,
|
|
mock_build: MagicMock,
|
|
mock_gitea_publish: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
) -> None:
|
|
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 0
|
|
mock_build.assert_called_once()
|
|
mock_gitea_publish.assert_called_once()
|
|
mock_tea.create_release.assert_called_once()
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_with_registry_url_flag(
|
|
self,
|
|
mock_build: MagicMock,
|
|
mock_gitea_publish: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
) -> None:
|
|
"""--registry-url flag publishes to the specified Gitea registry."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
["v1.0.0", "owner/repo", "--registry-url", "https://custom.registry.com/pypi"],
|
|
)
|
|
assert result.exit_code == 0
|
|
mock_gitea_publish.assert_called_once_with("https://custom.registry.com/pypi", "gitea-tok")
|
|
|
|
@patch.dict(
|
|
"os.environ",
|
|
{"REPO_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"},
|
|
clear=True,
|
|
)
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_registry_url_from_env(
|
|
self,
|
|
mock_build: MagicMock,
|
|
mock_gitea_publish: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
) -> None:
|
|
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 0
|
|
mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok")
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.build_package")
|
|
@patch("devx.ci.publish._default_gitea_registry_url", return_value="")
|
|
def test_no_pypi_no_registry_skips_publish(
|
|
self,
|
|
mock_default_url: MagicMock,
|
|
mock_build: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
) -> None:
|
|
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
|
|
assert result.exit_code == 0
|
|
assert "PYPI_TOKEN not set" in result.output
|
|
mock_tea.create_release.assert_called_once()
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
|
def test_missing_repo_token_exits(self) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 1
|
|
assert "REPO_TOKEN" in result.output
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_build_failure_raises_click(
|
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
) -> None:
|
|
mock_build.side_effect = click.ClickException("build failed")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 1
|
|
assert "build" in result.output
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_publish_failure_continues_to_gitea_release(
|
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
) -> None:
|
|
"""PyPI publish failure is non-fatal — Gitea release is still created."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea_cls.return_value = mock_tea
|
|
mock_publish.side_effect = click.ClickException("publish failed")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 0
|
|
assert "non-fatal" in result.output
|
|
mock_tea.create_release.assert_called_once_with(
|
|
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
|
)
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_release_failure_raises_click(
|
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
) -> None:
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 1
|
|
assert "Release creation failed" in result.output
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_skip_build_skips_build_and_publish(
|
|
self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
) -> None:
|
|
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
|
assert result.exit_code == 0
|
|
assert "skip" in result.output.lower()
|
|
mock_build.assert_not_called()
|
|
mock_tea.create_release.assert_called_once()
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_skips_release_creation_when_already_exists(
|
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
) -> None:
|
|
"""If the Gitea release already exists, skip creation (idempotent)."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}]
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 0
|
|
assert "already exists" in result.output
|
|
mock_tea.create_release.assert_not_called()
|
|
|
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.build_package")
|
|
def test_proceeds_to_create_when_list_releases_fails(
|
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
) -> None:
|
|
"""If list_releases raises TeaCLIError, proceed to create the release."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 0
|
|
assert "Gitea release v1.0.0 created" in result.output
|
|
mock_tea.create_release.assert_called_once()
|