feat(publish): add PyPI publish workflow, CLI command, parser integration, and tests
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / linter-shell (push) Has been cancelled
Mark stable commit / linter-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / linter-shell (push) Has been cancelled
Mark stable commit / linter-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Introduce publish action with PyPI target detection via MIRRORS * Resolve version from SemVer git tags on HEAD * Support preview mode and non-interactive CI usage * Build and upload artifacts using build + twine with token resolution * Add CLI wiring (dispatch, command handler, parser) * Add E2E publish help tests for pkgmgr and nix run * Add integration tests for publish preview and mirror handling * Add unit tests for git tag parsing, PyPI URL parsing, workflow preview, and CLI handler * Clean up dispatch and parser structure while integrating publish https://chatgpt.com/share/693f0f00-af68-800f-8846-193dca69bd2e
This commit is contained in:
5
src/pkgmgr/actions/publish/__init__.py
Normal file
5
src/pkgmgr/actions/publish/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .workflow import publish
|
||||||
|
|
||||||
|
__all__ = ["publish"]
|
||||||
17
src/pkgmgr/actions/publish/git_tags.py
Normal file
17
src/pkgmgr/actions/publish/git_tags.py
Normal file
@@ -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)
|
||||||
24
src/pkgmgr/actions/publish/pypi_url.py
Normal file
24
src/pkgmgr/actions/publish/pypi_url.py
Normal file
@@ -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
|
||||||
9
src/pkgmgr/actions/publish/types.py
Normal file
9
src/pkgmgr/actions/publish/types.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PyPITarget:
|
||||||
|
host: str
|
||||||
|
project: str
|
||||||
109
src/pkgmgr/actions/publish/workflow.py
Normal file
109
src/pkgmgr/actions/publish/workflow.py
Normal file
@@ -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.")
|
||||||
@@ -2,6 +2,7 @@ from .repos import handle_repos_command
|
|||||||
from .config import handle_config
|
from .config import handle_config
|
||||||
from .tools import handle_tools_command
|
from .tools import handle_tools_command
|
||||||
from .release import handle_release
|
from .release import handle_release
|
||||||
|
from .publish import handle_publish
|
||||||
from .version import handle_version
|
from .version import handle_version
|
||||||
from .make import handle_make
|
from .make import handle_make
|
||||||
from .changelog import handle_changelog
|
from .changelog import handle_changelog
|
||||||
@@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"handle_config",
|
"handle_config",
|
||||||
"handle_tools_command",
|
"handle_tools_command",
|
||||||
"handle_release",
|
"handle_release",
|
||||||
|
"handle_publish",
|
||||||
"handle_version",
|
"handle_version",
|
||||||
"handle_make",
|
"handle_make",
|
||||||
"handle_changelog",
|
"handle_changelog",
|
||||||
|
|||||||
34
src/pkgmgr/cli/commands/publish.py
Normal file
34
src/pkgmgr/cli/commands/publish.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -16,6 +13,7 @@ from pkgmgr.cli.commands import (
|
|||||||
handle_repos_command,
|
handle_repos_command,
|
||||||
handle_tools_command,
|
handle_tools_command,
|
||||||
handle_release,
|
handle_release,
|
||||||
|
handle_publish,
|
||||||
handle_version,
|
handle_version,
|
||||||
handle_config,
|
handle_config,
|
||||||
handle_make,
|
handle_make,
|
||||||
@@ -24,40 +22,20 @@ from pkgmgr.cli.commands import (
|
|||||||
handle_mirror_command,
|
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(
|
return bool(
|
||||||
use_all
|
getattr(args, "all", False)
|
||||||
or identifiers
|
or getattr(args, "identifiers", [])
|
||||||
or categories
|
or getattr(args, "category", [])
|
||||||
or tags
|
or getattr(args, "tag", [])
|
||||||
or string_filter
|
or getattr(args, "string", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _select_repo_for_current_directory(
|
def _select_repo_for_current_directory(ctx: CLIContext) -> List[Dict[str, Any]]:
|
||||||
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.
|
|
||||||
"""
|
|
||||||
cwd = os.path.abspath(os.getcwd())
|
cwd = os.path.abspath(os.getcwd())
|
||||||
candidates: List[tuple[str, Dict[str, Any]]] = []
|
matches = []
|
||||||
|
|
||||||
for repo in ctx.all_repositories:
|
for repo in ctx.all_repositories:
|
||||||
repo_dir = repo.get("directory")
|
repo_dir = repo.get("directory")
|
||||||
@@ -65,33 +43,24 @@ def _select_repo_for_current_directory(
|
|||||||
try:
|
try:
|
||||||
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
|
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
|
||||||
except Exception:
|
except Exception:
|
||||||
repo_dir = None
|
continue
|
||||||
if not repo_dir:
|
|
||||||
continue
|
|
||||||
|
|
||||||
repo_dir_abs = os.path.abspath(os.path.expanduser(repo_dir))
|
repo_dir = os.path.abspath(os.path.expanduser(repo_dir))
|
||||||
if cwd == repo_dir_abs or cwd.startswith(repo_dir_abs + os.sep):
|
if cwd == repo_dir or cwd.startswith(repo_dir + os.sep):
|
||||||
candidates.append((repo_dir_abs, repo))
|
matches.append((repo_dir, repo))
|
||||||
|
|
||||||
if not candidates:
|
if not matches:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Pick the repo with the longest (most specific) path.
|
matches.sort(key=lambda x: len(x[0]), reverse=True)
|
||||||
candidates.sort(key=lambda item: len(item[0]), reverse=True)
|
return [matches[0][1]]
|
||||||
return [candidates[0][1]]
|
|
||||||
|
|
||||||
|
|
||||||
def dispatch_command(args, ctx: CLIContext) -> None:
|
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):
|
if maybe_handle_proxy(args, ctx):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Commands that operate on repository selections
|
commands_with_selection = {
|
||||||
commands_with_selection: List[str] = [
|
|
||||||
"install",
|
"install",
|
||||||
"update",
|
"update",
|
||||||
"deinstall",
|
"deinstall",
|
||||||
@@ -103,31 +72,25 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
"list",
|
"list",
|
||||||
"make",
|
"make",
|
||||||
"release",
|
"release",
|
||||||
|
"publish",
|
||||||
"version",
|
"version",
|
||||||
"changelog",
|
"changelog",
|
||||||
"explore",
|
"explore",
|
||||||
"terminal",
|
"terminal",
|
||||||
"code",
|
"code",
|
||||||
"mirror",
|
"mirror",
|
||||||
]
|
}
|
||||||
|
|
||||||
if getattr(args, "command", None) in commands_with_selection:
|
if args.command in commands_with_selection:
|
||||||
if _has_explicit_selection(args):
|
selected = (
|
||||||
# Classic selection logic (identifiers / --all / filters)
|
get_selected_repos(args, ctx.all_repositories)
|
||||||
selected = get_selected_repos(args, ctx.all_repositories)
|
if _has_explicit_selection(args)
|
||||||
else:
|
else _select_repo_for_current_directory(ctx)
|
||||||
# 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.
|
|
||||||
else:
|
else:
|
||||||
selected = []
|
selected = []
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
if args.command in {
|
||||||
# Repos-related commands
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
if args.command in (
|
|
||||||
"install",
|
"install",
|
||||||
"deinstall",
|
"deinstall",
|
||||||
"delete",
|
"delete",
|
||||||
@@ -136,13 +99,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
"shell",
|
"shell",
|
||||||
"create",
|
"create",
|
||||||
"list",
|
"list",
|
||||||
):
|
}:
|
||||||
handle_repos_command(args, ctx, selected)
|
handle_repos_command(args, ctx, selected)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# update
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if args.command == "update":
|
if args.command == "update":
|
||||||
from pkgmgr.actions.update import UpdateManager
|
from pkgmgr.actions.update import UpdateManager
|
||||||
UpdateManager().run(
|
UpdateManager().run(
|
||||||
@@ -160,21 +120,18 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Tools (explore / terminal / code)
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
if args.command in ("explore", "terminal", "code"):
|
if args.command in ("explore", "terminal", "code"):
|
||||||
handle_tools_command(args, ctx, selected)
|
handle_tools_command(args, ctx, selected)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Release / Version / Changelog / Config / Make / Branch
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
if args.command == "release":
|
if args.command == "release":
|
||||||
handle_release(args, ctx, selected)
|
handle_release(args, ctx, selected)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.command == "publish":
|
||||||
|
handle_publish(args, ctx, selected)
|
||||||
|
return
|
||||||
|
|
||||||
if args.command == "version":
|
if args.command == "version":
|
||||||
handle_version(args, ctx, selected)
|
handle_version(args, ctx, selected)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -13,6 +10,7 @@ from .config_cmd import add_config_subparsers
|
|||||||
from .navigation_cmd import add_navigation_subparsers
|
from .navigation_cmd import add_navigation_subparsers
|
||||||
from .branch_cmd import add_branch_subparsers
|
from .branch_cmd import add_branch_subparsers
|
||||||
from .release_cmd import add_release_subparser
|
from .release_cmd import add_release_subparser
|
||||||
|
from .publish_cmd import add_publish_subparser
|
||||||
from .version_cmd import add_version_subparser
|
from .version_cmd import add_version_subparser
|
||||||
from .changelog_cmd import add_changelog_subparser
|
from .changelog_cmd import add_changelog_subparser
|
||||||
from .list_cmd import add_list_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:
|
def create_parser(description_text: str) -> argparse.ArgumentParser:
|
||||||
"""
|
|
||||||
Create the top-level argument parser for pkgmgr.
|
|
||||||
"""
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=description_text,
|
description=description_text,
|
||||||
formatter_class=argparse.RawTextHelpFormatter,
|
formatter_class=argparse.RawTextHelpFormatter,
|
||||||
@@ -34,35 +29,23 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
|||||||
action=SortedSubParsersAction,
|
action=SortedSubParsersAction,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Core repo operations
|
|
||||||
add_install_update_subparsers(subparsers)
|
add_install_update_subparsers(subparsers)
|
||||||
add_config_subparsers(subparsers)
|
add_config_subparsers(subparsers)
|
||||||
|
|
||||||
# Navigation / tooling around repos
|
|
||||||
add_navigation_subparsers(subparsers)
|
add_navigation_subparsers(subparsers)
|
||||||
|
|
||||||
# Branch & release workflow
|
|
||||||
add_branch_subparsers(subparsers)
|
add_branch_subparsers(subparsers)
|
||||||
add_release_subparser(subparsers)
|
add_release_subparser(subparsers)
|
||||||
|
add_publish_subparser(subparsers)
|
||||||
|
|
||||||
# Info commands
|
|
||||||
add_version_subparser(subparsers)
|
add_version_subparser(subparsers)
|
||||||
add_changelog_subparser(subparsers)
|
add_changelog_subparser(subparsers)
|
||||||
add_list_subparser(subparsers)
|
add_list_subparser(subparsers)
|
||||||
|
|
||||||
# Make wrapper
|
|
||||||
add_make_subparsers(subparsers)
|
add_make_subparsers(subparsers)
|
||||||
|
|
||||||
# Mirror management
|
|
||||||
add_mirror_subparsers(subparsers)
|
add_mirror_subparsers(subparsers)
|
||||||
|
|
||||||
# Proxy commands (git, docker, docker compose, ...)
|
|
||||||
register_proxy_commands(subparsers)
|
register_proxy_commands(subparsers)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["create_parser", "SortedSubParsersAction"]
|
||||||
"create_parser",
|
|
||||||
"SortedSubParsersAction",
|
|
||||||
]
|
|
||||||
|
|||||||
19
src/pkgmgr/cli/parser/publish_cmd.py
Normal file
19
src/pkgmgr/cli/parser/publish_cmd.py
Normal file
@@ -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).",
|
||||||
|
)
|
||||||
119
tests/e2e/test_publish_commands.py
Normal file
119
tests/e2e/test_publish_commands.py
Normal file
@@ -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)
|
||||||
119
tests/integration/test_publish_integration.py
Normal file
119
tests/integration/test_publish_integration.py
Normal file
@@ -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)
|
||||||
0
tests/unit/pkgmgr/actions/publish/__init__.py
Normal file
0
tests/unit/pkgmgr/actions/publish/__init__.py
Normal file
20
tests/unit/pkgmgr/actions/publish/test_git_tags.py
Normal file
20
tests/unit/pkgmgr/actions/publish/test_git_tags.py
Normal file
@@ -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"],
|
||||||
|
)
|
||||||
13
tests/unit/pkgmgr/actions/publish/test_pypi_url.py
Normal file
13
tests/unit/pkgmgr/actions/publish/test_pypi_url.py
Normal file
@@ -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"))
|
||||||
21
tests/unit/pkgmgr/actions/publish/test_workflow_preview.py
Normal file
21
tests/unit/pkgmgr/actions/publish/test_workflow_preview.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
12
tests/unit/pkgmgr/cli/commands/test_publish.py
Normal file
12
tests/unit/pkgmgr/cli/commands/test_publish.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user