diff --git a/src/pkgmgr/actions/publish/__init__.py b/src/pkgmgr/actions/publish/__init__.py new file mode 100644 index 0000000..c885ab9 --- /dev/null +++ b/src/pkgmgr/actions/publish/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .workflow import publish + +__all__ = ["publish"] diff --git a/src/pkgmgr/actions/publish/git_tags.py b/src/pkgmgr/actions/publish/git_tags.py new file mode 100644 index 0000000..6f7fa09 --- /dev/null +++ b/src/pkgmgr/actions/publish/git_tags.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from pkgmgr.core.git import run_git +from pkgmgr.core.version.semver import SemVer, is_semver_tag + + +def head_semver_tags(cwd: str = ".") -> list[str]: + out = run_git(["tag", "--points-at", "HEAD"], cwd=cwd) + if not out: + return [] + + tags = [t.strip() for t in out.splitlines() if t.strip()] + tags = [t for t in tags if is_semver_tag(t) and t.startswith("v")] + if not tags: + return [] + + return sorted(tags, key=SemVer.parse) diff --git a/src/pkgmgr/actions/publish/pypi_url.py b/src/pkgmgr/actions/publish/pypi_url.py new file mode 100644 index 0000000..ab450cd --- /dev/null +++ b/src/pkgmgr/actions/publish/pypi_url.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from urllib.parse import urlparse + +from .types import PyPITarget + + +def parse_pypi_project_url(url: str) -> PyPITarget | None: + u = (url or "").strip() + if not u: + return None + + parsed = urlparse(u) + host = (parsed.netloc or "").lower() + path = (parsed.path or "").strip("/") + + if host not in ("pypi.org", "test.pypi.org"): + return None + + parts = [p for p in path.split("/") if p] + if len(parts) >= 2 and parts[0] == "project": + return PyPITarget(host=host, project=parts[1]) + + return None diff --git a/src/pkgmgr/actions/publish/types.py b/src/pkgmgr/actions/publish/types.py new file mode 100644 index 0000000..d275a0f --- /dev/null +++ b/src/pkgmgr/actions/publish/types.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PyPITarget: + host: str + project: str diff --git a/src/pkgmgr/actions/publish/workflow.py b/src/pkgmgr/actions/publish/workflow.py new file mode 100644 index 0000000..b35aca8 --- /dev/null +++ b/src/pkgmgr/actions/publish/workflow.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import glob +import os +import shutil +import subprocess + +from pkgmgr.actions.mirror.io import read_mirrors_file +from pkgmgr.actions.mirror.types import Repository +from pkgmgr.core.credentials.resolver import ResolutionOptions, TokenResolver +from pkgmgr.core.version.semver import SemVer + +from .git_tags import head_semver_tags +from .pypi_url import parse_pypi_project_url + + +def _require_tool(module: str) -> None: + try: + subprocess.run( + ["python", "-m", module, "--help"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + except Exception as exc: + raise RuntimeError( + f"Required Python module '{module}' is not available. " + f"Install it via: pip install {module}" + ) from exc + + +def publish( + repo: Repository, + repo_dir: str, + *, + preview: bool = False, + interactive: bool = True, + allow_prompt: bool = True, +) -> None: + mirrors = read_mirrors_file(repo_dir) + + targets = [] + for url in mirrors.values(): + t = parse_pypi_project_url(url) + if t: + targets.append(t) + + if not targets: + print("[INFO] No PyPI mirror found. Skipping publish.") + return + + if len(targets) > 1: + raise RuntimeError("Multiple PyPI mirrors found; refusing to publish.") + + tags = head_semver_tags(cwd=repo_dir) + if not tags: + print("[INFO] No version tag on HEAD. Skipping publish.") + return + + tag = max(tags, key=SemVer.parse) + target = targets[0] + + print(f"[INFO] Publishing {target.project} for tag {tag}") + + if preview: + print("[PREVIEW] Would build and upload to PyPI.") + return + + _require_tool("build") + _require_tool("twine") + + dist_dir = os.path.join(repo_dir, "dist") + if os.path.isdir(dist_dir): + shutil.rmtree(dist_dir, ignore_errors=True) + + subprocess.run( + ["python", "-m", "build"], + cwd=repo_dir, + check=True, + ) + + artifacts = sorted(glob.glob(os.path.join(dist_dir, "*"))) + if not artifacts: + raise RuntimeError("No build artifacts found in dist/.") + + resolver = TokenResolver() + token = resolver.get_token( + provider_kind="pypi", + host=target.host, + owner=target.project, + options=ResolutionOptions( + interactive=interactive, + allow_prompt=allow_prompt, + save_prompt_token_to_keyring=True, + ), + ).token + + env = dict(os.environ) + env["TWINE_USERNAME"] = "__token__" + env["TWINE_PASSWORD"] = token + + subprocess.run( + ["python", "-m", "twine", "upload", *artifacts], + cwd=repo_dir, + env=env, + check=True, + ) + + print("[INFO] Publish completed.") diff --git a/src/pkgmgr/cli/commands/__init__.py b/src/pkgmgr/cli/commands/__init__.py index df7ea76..b8a723a 100644 --- a/src/pkgmgr/cli/commands/__init__.py +++ b/src/pkgmgr/cli/commands/__init__.py @@ -2,6 +2,7 @@ from .repos import handle_repos_command from .config import handle_config from .tools import handle_tools_command from .release import handle_release +from .publish import handle_publish from .version import handle_version from .make import handle_make from .changelog import handle_changelog @@ -13,6 +14,7 @@ __all__ = [ "handle_config", "handle_tools_command", "handle_release", + "handle_publish", "handle_version", "handle_make", "handle_changelog", diff --git a/src/pkgmgr/cli/commands/publish.py b/src/pkgmgr/cli/commands/publish.py new file mode 100644 index 0000000..fa71b00 --- /dev/null +++ b/src/pkgmgr/cli/commands/publish.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, List + +from pkgmgr.actions.publish import publish +from pkgmgr.cli.context import CLIContext +from pkgmgr.core.repository.dir import get_repo_dir +from pkgmgr.core.repository.identifier import get_repo_identifier + +Repository = Dict[str, Any] + + +def handle_publish(args, ctx: CLIContext, selected: List[Repository]) -> None: + if not selected: + print("[pkgmgr] No repositories selected for publish.") + return + + for repo in selected: + identifier = get_repo_identifier(repo, ctx.all_repositories) + repo_dir = repo.get("directory") or get_repo_dir(ctx.repositories_base_dir, repo) + + if not os.path.isdir(repo_dir): + print(f"[WARN] Skipping {identifier}: directory missing.") + continue + + print(f"[pkgmgr] Publishing repository {identifier}...") + publish( + repo=repo, + repo_dir=repo_dir, + preview=getattr(args, "preview", False), + interactive=not getattr(args, "non_interactive", False), + allow_prompt=not getattr(args, "non_interactive", False), + ) diff --git a/src/pkgmgr/cli/dispatch.py b/src/pkgmgr/cli/dispatch.py index 285c826..20a6381 100644 --- a/src/pkgmgr/cli/dispatch.py +++ b/src/pkgmgr/cli/dispatch.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - from __future__ import annotations import os @@ -16,6 +13,7 @@ from pkgmgr.cli.commands import ( handle_repos_command, handle_tools_command, handle_release, + handle_publish, handle_version, handle_config, handle_make, @@ -24,40 +22,20 @@ from pkgmgr.cli.commands import ( handle_mirror_command, ) -def _has_explicit_selection(args) -> bool: - """ - Return True if the user explicitly selected repositories via - identifiers / --all / --category / --tag / --string. - """ - identifiers = getattr(args, "identifiers", []) or [] - use_all = getattr(args, "all", False) - categories = getattr(args, "category", []) or [] - tags = getattr(args, "tag", []) or [] - string_filter = getattr(args, "string", "") or "" +def _has_explicit_selection(args) -> bool: return bool( - use_all - or identifiers - or categories - or tags - or string_filter + getattr(args, "all", False) + or getattr(args, "identifiers", []) + or getattr(args, "category", []) + or getattr(args, "tag", []) + or getattr(args, "string", "") ) -def _select_repo_for_current_directory( - ctx: CLIContext, -) -> List[Dict[str, Any]]: - """ - Heuristic: find the repository whose local directory matches the - current working directory or is the closest parent. - - Example: - - Repo directory: /home/kevin/Repositories/foo - - CWD: /home/kevin/Repositories/foo/subdir - → 'foo' is selected. - """ +def _select_repo_for_current_directory(ctx: CLIContext) -> List[Dict[str, Any]]: cwd = os.path.abspath(os.getcwd()) - candidates: List[tuple[str, Dict[str, Any]]] = [] + matches = [] for repo in ctx.all_repositories: repo_dir = repo.get("directory") @@ -65,33 +43,24 @@ def _select_repo_for_current_directory( try: repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) except Exception: - repo_dir = None - if not repo_dir: - continue + continue - repo_dir_abs = os.path.abspath(os.path.expanduser(repo_dir)) - if cwd == repo_dir_abs or cwd.startswith(repo_dir_abs + os.sep): - candidates.append((repo_dir_abs, repo)) + repo_dir = os.path.abspath(os.path.expanduser(repo_dir)) + if cwd == repo_dir or cwd.startswith(repo_dir + os.sep): + matches.append((repo_dir, repo)) - if not candidates: + if not matches: return [] - # Pick the repo with the longest (most specific) path. - candidates.sort(key=lambda item: len(item[0]), reverse=True) - return [candidates[0][1]] + matches.sort(key=lambda x: len(x[0]), reverse=True) + return [matches[0][1]] def dispatch_command(args, ctx: CLIContext) -> None: - """ - Dispatch the parsed arguments to the appropriate command handler. - """ - - # First: proxy commands (git / docker / docker compose / make wrapper etc.) if maybe_handle_proxy(args, ctx): return - # Commands that operate on repository selections - commands_with_selection: List[str] = [ + commands_with_selection = { "install", "update", "deinstall", @@ -103,31 +72,25 @@ def dispatch_command(args, ctx: CLIContext) -> None: "list", "make", "release", + "publish", "version", "changelog", "explore", "terminal", "code", "mirror", - ] + } - if getattr(args, "command", None) in commands_with_selection: - if _has_explicit_selection(args): - # Classic selection logic (identifiers / --all / filters) - selected = get_selected_repos(args, ctx.all_repositories) - else: - # Default per help text: repository of current folder. - selected = _select_repo_for_current_directory(ctx) - # If none is found, leave 'selected' empty. - # Individual handlers will then emit a clear message instead - # of silently picking an unrelated repository. + if args.command in commands_with_selection: + selected = ( + get_selected_repos(args, ctx.all_repositories) + if _has_explicit_selection(args) + else _select_repo_for_current_directory(ctx) + ) else: selected = [] - # ------------------------------------------------------------------ # - # Repos-related commands - # ------------------------------------------------------------------ # - if args.command in ( + if args.command in { "install", "deinstall", "delete", @@ -136,13 +99,10 @@ def dispatch_command(args, ctx: CLIContext) -> None: "shell", "create", "list", - ): + }: handle_repos_command(args, ctx, selected) return - # ------------------------------------------------------------ - # update - # ------------------------------------------------------------ if args.command == "update": from pkgmgr.actions.update import UpdateManager UpdateManager().run( @@ -160,21 +120,18 @@ def dispatch_command(args, ctx: CLIContext) -> None: ) return - - # ------------------------------------------------------------------ # - # Tools (explore / terminal / code) - # ------------------------------------------------------------------ # if args.command in ("explore", "terminal", "code"): handle_tools_command(args, ctx, selected) return - # ------------------------------------------------------------------ # - # Release / Version / Changelog / Config / Make / Branch - # ------------------------------------------------------------------ # if args.command == "release": handle_release(args, ctx, selected) return + if args.command == "publish": + handle_publish(args, ctx, selected) + return + if args.command == "version": handle_version(args, ctx, selected) return diff --git a/src/pkgmgr/cli/parser/__init__.py b/src/pkgmgr/cli/parser/__init__.py index ee974c1..ede8a00 100644 --- a/src/pkgmgr/cli/parser/__init__.py +++ b/src/pkgmgr/cli/parser/__init__.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - from __future__ import annotations import argparse @@ -13,6 +10,7 @@ from .config_cmd import add_config_subparsers from .navigation_cmd import add_navigation_subparsers from .branch_cmd import add_branch_subparsers from .release_cmd import add_release_subparser +from .publish_cmd import add_publish_subparser from .version_cmd import add_version_subparser from .changelog_cmd import add_changelog_subparser from .list_cmd import add_list_subparser @@ -21,9 +19,6 @@ from .mirror_cmd import add_mirror_subparsers def create_parser(description_text: str) -> argparse.ArgumentParser: - """ - Create the top-level argument parser for pkgmgr. - """ parser = argparse.ArgumentParser( description=description_text, formatter_class=argparse.RawTextHelpFormatter, @@ -34,35 +29,23 @@ def create_parser(description_text: str) -> argparse.ArgumentParser: action=SortedSubParsersAction, ) - # Core repo operations add_install_update_subparsers(subparsers) add_config_subparsers(subparsers) - - # Navigation / tooling around repos add_navigation_subparsers(subparsers) - # Branch & release workflow add_branch_subparsers(subparsers) add_release_subparser(subparsers) + add_publish_subparser(subparsers) - # Info commands add_version_subparser(subparsers) add_changelog_subparser(subparsers) add_list_subparser(subparsers) - # Make wrapper add_make_subparsers(subparsers) - - # Mirror management add_mirror_subparsers(subparsers) - # Proxy commands (git, docker, docker compose, ...) register_proxy_commands(subparsers) - return parser -__all__ = [ - "create_parser", - "SortedSubParsersAction", -] +__all__ = ["create_parser", "SortedSubParsersAction"] diff --git a/src/pkgmgr/cli/parser/publish_cmd.py b/src/pkgmgr/cli/parser/publish_cmd.py new file mode 100644 index 0000000..38ab7ee --- /dev/null +++ b/src/pkgmgr/cli/parser/publish_cmd.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_publish_subparser(subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser( + "publish", + help="Publish repository artifacts (e.g. PyPI) based on MIRRORS.", + ) + add_identifier_arguments(parser) + + parser.add_argument( + "--non-interactive", + action="store_true", + help="Disable interactive credential prompts (CI mode).", + ) diff --git a/tests/e2e/test_publish_commands.py b/tests/e2e/test_publish_commands.py new file mode 100644 index 0000000..b7230db --- /dev/null +++ b/tests/e2e/test_publish_commands.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import io +import os +import shutil +import subprocess +import tempfile +import unittest +from contextlib import redirect_stdout +from types import SimpleNamespace + +from pkgmgr.cli.commands.publish import handle_publish + + +def _run(cmd: list[str], cwd: str) -> None: + subprocess.run( + cmd, + cwd=cwd, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +class TestIntegrationPublish(unittest.TestCase): + def setUp(self) -> None: + if shutil.which("git") is None: + self.skipTest("git is required for this integration test") + + self.tmp = tempfile.TemporaryDirectory() + self.repo_dir = self.tmp.name + + # Initialize git repository + _run(["git", "init"], cwd=self.repo_dir) + _run(["git", "config", "user.email", "ci@example.invalid"], cwd=self.repo_dir) + _run(["git", "config", "user.name", "CI"], cwd=self.repo_dir) + + with open(os.path.join(self.repo_dir, "README.md"), "w", encoding="utf-8") as f: + f.write("test\n") + + _run(["git", "add", "README.md"], cwd=self.repo_dir) + _run(["git", "commit", "-m", "init"], cwd=self.repo_dir) + _run(["git", "tag", "-a", "v1.2.3", "-m", "v1.2.3"], cwd=self.repo_dir) + + # Create MIRRORS file with PyPI target + with open(os.path.join(self.repo_dir, "MIRRORS"), "w", encoding="utf-8") as f: + f.write("https://pypi.org/project/pkgmgr/\n") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_publish_preview_end_to_end(self) -> None: + ctx = SimpleNamespace( + repositories_base_dir=self.repo_dir, + all_repositories=[ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ], + ) + + selected = [ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ] + + args = SimpleNamespace( + preview=True, + non_interactive=False, + ) + + buf = io.StringIO() + with redirect_stdout(buf): + handle_publish(args=args, ctx=ctx, selected=selected) + + out = buf.getvalue() + + self.assertIn("[pkgmgr] Publishing repository", out) + self.assertIn("[INFO] Publishing pkgmgr for tag v1.2.3", out) + self.assertIn("[PREVIEW] Would build and upload to PyPI.", out) + + # Preview must not create dist/ + self.assertFalse(os.path.isdir(os.path.join(self.repo_dir, "dist"))) + + def test_publish_skips_without_pypi_mirror(self) -> None: + with open(os.path.join(self.repo_dir, "MIRRORS"), "w", encoding="utf-8") as f: + f.write("git@github.com:example/example.git\n") + + ctx = SimpleNamespace( + repositories_base_dir=self.repo_dir, + all_repositories=[ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ], + ) + + selected = [ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ] + + args = SimpleNamespace( + preview=True, + non_interactive=False, + ) + + buf = io.StringIO() + with redirect_stdout(buf): + handle_publish(args=args, ctx=ctx, selected=selected) + + out = buf.getvalue() + self.assertIn("[INFO] No PyPI mirror found. Skipping publish.", out) diff --git a/tests/integration/test_publish_integration.py b/tests/integration/test_publish_integration.py new file mode 100644 index 0000000..b7230db --- /dev/null +++ b/tests/integration/test_publish_integration.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import io +import os +import shutil +import subprocess +import tempfile +import unittest +from contextlib import redirect_stdout +from types import SimpleNamespace + +from pkgmgr.cli.commands.publish import handle_publish + + +def _run(cmd: list[str], cwd: str) -> None: + subprocess.run( + cmd, + cwd=cwd, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +class TestIntegrationPublish(unittest.TestCase): + def setUp(self) -> None: + if shutil.which("git") is None: + self.skipTest("git is required for this integration test") + + self.tmp = tempfile.TemporaryDirectory() + self.repo_dir = self.tmp.name + + # Initialize git repository + _run(["git", "init"], cwd=self.repo_dir) + _run(["git", "config", "user.email", "ci@example.invalid"], cwd=self.repo_dir) + _run(["git", "config", "user.name", "CI"], cwd=self.repo_dir) + + with open(os.path.join(self.repo_dir, "README.md"), "w", encoding="utf-8") as f: + f.write("test\n") + + _run(["git", "add", "README.md"], cwd=self.repo_dir) + _run(["git", "commit", "-m", "init"], cwd=self.repo_dir) + _run(["git", "tag", "-a", "v1.2.3", "-m", "v1.2.3"], cwd=self.repo_dir) + + # Create MIRRORS file with PyPI target + with open(os.path.join(self.repo_dir, "MIRRORS"), "w", encoding="utf-8") as f: + f.write("https://pypi.org/project/pkgmgr/\n") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_publish_preview_end_to_end(self) -> None: + ctx = SimpleNamespace( + repositories_base_dir=self.repo_dir, + all_repositories=[ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ], + ) + + selected = [ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ] + + args = SimpleNamespace( + preview=True, + non_interactive=False, + ) + + buf = io.StringIO() + with redirect_stdout(buf): + handle_publish(args=args, ctx=ctx, selected=selected) + + out = buf.getvalue() + + self.assertIn("[pkgmgr] Publishing repository", out) + self.assertIn("[INFO] Publishing pkgmgr for tag v1.2.3", out) + self.assertIn("[PREVIEW] Would build and upload to PyPI.", out) + + # Preview must not create dist/ + self.assertFalse(os.path.isdir(os.path.join(self.repo_dir, "dist"))) + + def test_publish_skips_without_pypi_mirror(self) -> None: + with open(os.path.join(self.repo_dir, "MIRRORS"), "w", encoding="utf-8") as f: + f.write("git@github.com:example/example.git\n") + + ctx = SimpleNamespace( + repositories_base_dir=self.repo_dir, + all_repositories=[ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ], + ) + + selected = [ + { + "name": "pkgmgr", + "directory": self.repo_dir, + } + ] + + args = SimpleNamespace( + preview=True, + non_interactive=False, + ) + + buf = io.StringIO() + with redirect_stdout(buf): + handle_publish(args=args, ctx=ctx, selected=selected) + + out = buf.getvalue() + self.assertIn("[INFO] No PyPI mirror found. Skipping publish.", out) diff --git a/tests/unit/pkgmgr/actions/publish/__init__.py b/tests/unit/pkgmgr/actions/publish/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/actions/publish/test_git_tags.py b/tests/unit/pkgmgr/actions/publish/test_git_tags.py new file mode 100644 index 0000000..95323ae --- /dev/null +++ b/tests/unit/pkgmgr/actions/publish/test_git_tags.py @@ -0,0 +1,20 @@ + +import unittest +from unittest.mock import patch + +from pkgmgr.actions.publish.git_tags import head_semver_tags + + +class TestHeadSemverTags(unittest.TestCase): + @patch("pkgmgr.actions.publish.git_tags.run_git") + def test_no_tags(self, mock_run_git): + mock_run_git.return_value = "" + self.assertEqual(head_semver_tags(), []) + + @patch("pkgmgr.actions.publish.git_tags.run_git") + def test_filters_and_sorts_semver(self, mock_run_git): + mock_run_git.return_value = "v1.0.0\nv2.0.0\nfoo\n" + self.assertEqual( + head_semver_tags(), + ["v1.0.0", "v2.0.0"], + ) diff --git a/tests/unit/pkgmgr/actions/publish/test_pypi_url.py b/tests/unit/pkgmgr/actions/publish/test_pypi_url.py new file mode 100644 index 0000000..376df3c --- /dev/null +++ b/tests/unit/pkgmgr/actions/publish/test_pypi_url.py @@ -0,0 +1,13 @@ + +import unittest +from pkgmgr.actions.publish.pypi_url import parse_pypi_project_url + + +class TestParsePyPIUrl(unittest.TestCase): + def test_valid_pypi_url(self): + t = parse_pypi_project_url("https://pypi.org/project/example/") + self.assertIsNotNone(t) + self.assertEqual(t.project, "example") + + def test_invalid_url(self): + self.assertIsNone(parse_pypi_project_url("https://example.com/foo")) diff --git a/tests/unit/pkgmgr/actions/publish/test_workflow_preview.py b/tests/unit/pkgmgr/actions/publish/test_workflow_preview.py new file mode 100644 index 0000000..cb367bb --- /dev/null +++ b/tests/unit/pkgmgr/actions/publish/test_workflow_preview.py @@ -0,0 +1,21 @@ + +import unittest +from unittest.mock import patch + +from pkgmgr.actions.publish.workflow import publish + + +class TestPublishWorkflowPreview(unittest.TestCase): + @patch("pkgmgr.actions.publish.workflow.read_mirrors_file") + @patch("pkgmgr.actions.publish.workflow.head_semver_tags") + def test_preview_does_not_build(self, mock_tags, mock_mirrors): + mock_mirrors.return_value = { + "pypi": "https://pypi.org/project/example/" + } + mock_tags.return_value = ["v1.0.0"] + + publish( + repo={}, + repo_dir=".", + preview=True, + ) diff --git a/tests/unit/pkgmgr/cli/commands/test_publish.py b/tests/unit/pkgmgr/cli/commands/test_publish.py new file mode 100644 index 0000000..ee2d4a9 --- /dev/null +++ b/tests/unit/pkgmgr/cli/commands/test_publish.py @@ -0,0 +1,12 @@ + +import unittest +from unittest.mock import patch + +from pkgmgr.cli.commands.publish import handle_publish + + +class TestHandlePublish(unittest.TestCase): + @patch("pkgmgr.cli.commands.publish.publish") + def test_no_selected_repos(self, mock_publish): + handle_publish(args=object(), ctx=None, selected=[]) + mock_publish.assert_not_called()