From 0ef3b97f4955248fbed4f3c7e0eeee881b1e10a0 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Wed, 29 Apr 2026 01:16:19 +0530 Subject: [PATCH 1/7] refactor: split marketplace commands into package modules --- .../__init__.py} | 1096 ++--------------- src/apm_cli/commands/marketplace/build.py | 66 + src/apm_cli/commands/marketplace/check.py | 129 ++ src/apm_cli/commands/marketplace/doctor.py | 170 +++ src/apm_cli/commands/marketplace/init.py | 74 ++ src/apm_cli/commands/marketplace/outdated.py | 159 +++ .../commands/marketplace/plugin/__init__.py | 177 +++ .../commands/marketplace/plugin/add.py | 82 ++ .../commands/marketplace/plugin/remove.py | 46 + .../commands/marketplace/plugin/set.py | 98 ++ src/apm_cli/commands/marketplace/publish.py | 229 ++++ src/apm_cli/commands/marketplace/validate.py | 93 ++ src/apm_cli/commands/marketplace_plugin.py | 418 +------ 13 files changed, 1466 insertions(+), 1371 deletions(-) rename src/apm_cli/commands/{marketplace.py => marketplace/__init__.py} (51%) create mode 100644 src/apm_cli/commands/marketplace/build.py create mode 100644 src/apm_cli/commands/marketplace/check.py create mode 100644 src/apm_cli/commands/marketplace/doctor.py create mode 100644 src/apm_cli/commands/marketplace/init.py create mode 100644 src/apm_cli/commands/marketplace/outdated.py create mode 100644 src/apm_cli/commands/marketplace/plugin/__init__.py create mode 100644 src/apm_cli/commands/marketplace/plugin/add.py create mode 100644 src/apm_cli/commands/marketplace/plugin/remove.py create mode 100644 src/apm_cli/commands/marketplace/plugin/set.py create mode 100644 src/apm_cli/commands/marketplace/publish.py create mode 100644 src/apm_cli/commands/marketplace/validate.py diff --git a/src/apm_cli/commands/marketplace.py b/src/apm_cli/commands/marketplace/__init__.py similarity index 51% rename from src/apm_cli/commands/marketplace.py rename to src/apm_cli/commands/marketplace/__init__.py index 1c62c0c6..b8e3398d 100644 --- a/src/apm_cli/commands/marketplace.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -1,9 +1,11 @@ -"""APM marketplace command group. +"""Marketplace CLI package. -Manages marketplace discovery and governance. Follows the same -Click group pattern as ``mcp.py``. +This package keeps click group wiring, shared helpers, and compatibility +exports for the marketplace command surface. """ +from __future__ import annotations + import builtins import json import os @@ -15,9 +17,9 @@ import click import yaml -from ..core.command_logger import CommandLogger -from ..marketplace.builder import BuildOptions, BuildReport, MarketplaceBuilder, ResolvedPackage -from ..marketplace.errors import ( +from ...core.command_logger import CommandLogger +from ...marketplace.builder import BuildOptions, BuildReport, MarketplaceBuilder, ResolvedPackage +from ...marketplace.errors import ( BuildError, GitLsRemoteError, HeadNotAllowedError, @@ -27,26 +29,24 @@ OfflineMissError, RefNotFoundError, ) -from ..marketplace.git_stderr import translate_git_stderr -from ..marketplace.pr_integration import PrIntegrator, PrResult, PrState -from ..marketplace.publisher import ( +from ...marketplace.git_stderr import translate_git_stderr +from ...marketplace.pr_integration import PrIntegrator, PrResult, PrState +from ...marketplace.publisher import ( ConsumerTarget, MarketplacePublisher, PublishOutcome, PublishPlan, TargetResult, ) -from ..marketplace.ref_resolver import RefResolver, RemoteRef -from ..marketplace.semver import SemVer, parse_semver, satisfies_range -from ..marketplace.yml_schema import load_marketplace_yml -from ..utils.path_security import PathTraversalError, validate_path_segments -from ..utils.console import _rich_info, _rich_warning -from ._helpers import _get_console, _is_interactive - +from ...marketplace.ref_resolver import RefResolver, RemoteRef +from ...marketplace.semver import SemVer, parse_semver, satisfies_range +from ...marketplace.yml_schema import load_marketplace_yml +from ...utils.console import _rich_info, _rich_warning +from ...utils.path_security import PathTraversalError, validate_path_segments +from .._helpers import _get_console, _is_interactive -# --------------------------------------------------------------------------- -# Custom group for organised --help output -# --------------------------------------------------------------------------- +# Restore builtins shadowed by subcommand names +list = builtins.list class MarketplaceGroup(click.Group): @@ -59,7 +59,7 @@ class MarketplaceGroup(click.Group): def _authoring_visible() -> bool: """Return True when authoring commands should appear in ``--help``.""" try: - from ..core.experimental import is_enabled + from ...core.experimental import is_enabled return is_enabled("marketplace_authoring") except Exception: # noqa: BLE001 -- fail-open UI visibility check @@ -82,15 +82,6 @@ def format_commands(self, ctx, formatter): with formatter.section(section_name): formatter.write_dl(commands) -# Restore builtins shadowed by subcommand names -list = builtins.list - - -# --------------------------------------------------------------------------- -# Module-private helpers -# --------------------------------------------------------------------------- - - def _load_yml_or_exit(logger): """Load ``./marketplace.yml`` from CWD or exit with an appropriate code. @@ -111,7 +102,6 @@ def _load_yml_or_exit(logger): logger.error(f"marketplace.yml schema error: {exc}", symbol="error") sys.exit(2) - def _warn_duplicate_names(logger, yml): """Emit a warning for each duplicate package name in *yml*.""" seen: dict[str, int] = {} @@ -127,7 +117,6 @@ def _warn_duplicate_names(logger, yml): else: seen[lower] = idx - def _find_duplicate_names(yml): """Return a diagnostic string if *yml* contains duplicate package names.""" seen: dict[str, int] = {} @@ -146,7 +135,7 @@ def _find_duplicate_names(yml): def _require_authoring_flag(): """Exit with enablement hint if marketplace-authoring flag is disabled.""" - from ..core.experimental import is_enabled + from ...core.experimental import is_enabled if not is_enabled("marketplace_authoring"): _rich_warning( @@ -167,85 +156,16 @@ def _require_authoring_flag(): ) sys.exit(1) - @click.group(cls=MarketplaceGroup, help="Manage marketplaces for discovery and governance") @click.pass_context def marketplace(ctx): """Register, browse, and search marketplaces.""" -from .marketplace_plugin import package # noqa: E402 - -marketplace.add_command(package) - - -# --------------------------------------------------------------------------- -# marketplace init -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Scaffold a new marketplace.yml in the current directory") -@click.option("--force", is_flag=True, help="Overwrite existing marketplace.yml") -@click.option( - "--no-gitignore-check", - is_flag=True, - help="Skip the .gitignore staleness check", -) -@click.option("--name", default=None, help="Marketplace name (default: my-marketplace)") -@click.option("--owner", default=None, help="Owner name for the marketplace") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def init(force, no_gitignore_check, name, owner, verbose): - """Create a richly-commented marketplace.yml scaffold.""" - _require_authoring_flag() - from ..marketplace.init_template import render_marketplace_yml_template - - logger = CommandLogger("marketplace-init", verbose=verbose) - yml_path = Path.cwd() / "marketplace.yml" - - # Guard: file already exists - if yml_path.exists() and not force: - logger.error( - "marketplace.yml already exists. Use --force to overwrite.", - symbol="error", - ) - sys.exit(1) - - # Write template - template_text = render_marketplace_yml_template(name=name, owner=owner) - try: - yml_path.write_text(template_text, encoding="utf-8") - except OSError as exc: - logger.error(f"Failed to write marketplace.yml: {exc}", symbol="error") - sys.exit(1) - - logger.success("Created marketplace.yml", symbol="check") - - if verbose: - logger.verbose_detail(f" Path: {yml_path}") - - # .gitignore staleness check - if not no_gitignore_check: - _check_gitignore_for_marketplace_json(logger) - - # Next steps panel - next_steps = [ - "Edit marketplace.yml to add your packages", - "Run 'apm marketplace build' to generate marketplace.json", - "Commit BOTH marketplace.yml and marketplace.json", - ] - try: - from ..utils.console import _rich_panel +from .plugin import package # noqa: E402 - _rich_panel( - "\n".join(f" {i}. {step}" for i, step in enumerate(next_steps, 1)), - title=" Next Steps", - style="cyan", - ) - except (ImportError, NameError): - logger.progress("Next steps:") - for i, step in enumerate(next_steps, 1): - logger.tree_item(f" {i}. {step}") +marketplace.add_command(package) def _check_gitignore_for_marketplace_json(logger): @@ -274,12 +194,6 @@ def _check_gitignore_for_marketplace_json(logger): ) return - -# --------------------------------------------------------------------------- -# marketplace add -# --------------------------------------------------------------------------- - - @marketplace.command(help="Register a marketplace") @click.argument("repo", required=True) @click.option("--name", "-n", default=None, help="Display name (defaults to repo name)") @@ -290,9 +204,9 @@ def add(repo, name, branch, host, verbose): """Register a marketplace from OWNER/REPO or HOST/OWNER/REPO.""" logger = CommandLogger("marketplace-add", verbose=verbose) try: - from ..marketplace.client import _auto_detect_path, fetch_marketplace - from ..marketplace.models import MarketplaceSource - from ..marketplace.registry import add_marketplace + from ...marketplace.client import _auto_detect_path, fetch_marketplace + from ...marketplace.models import MarketplaceSource + from ...marketplace.registry import add_marketplace # Parse OWNER/REPO or HOST/OWNER/REPO if "/" not in repo: @@ -302,7 +216,7 @@ def add(repo, name, branch, host, verbose): ) sys.exit(1) - from ..utils.github_host import default_host, is_valid_fqdn + from ...utils.github_host import default_host, is_valid_fqdn parts = repo.split("/") if len(parts) == 3 and parts[0] and parts[1] and parts[2]: @@ -405,19 +319,13 @@ def add(repo, name, branch, host, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace list -# --------------------------------------------------------------------------- - - @marketplace.command(name="list", help="List registered marketplaces") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def list_cmd(verbose): """Show all registered marketplaces.""" logger = CommandLogger("marketplace-list", verbose=verbose) try: - from ..marketplace.registry import get_registered_marketplaces + from ...marketplace.registry import get_registered_marketplaces sources = get_registered_marketplaces() @@ -468,12 +376,6 @@ def list_cmd(verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace browse -# --------------------------------------------------------------------------- - - @marketplace.command(help="Browse plugins in a marketplace") @click.argument("name", required=True) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") @@ -481,8 +383,8 @@ def browse(name, verbose): """Show available plugins in a marketplace.""" logger = CommandLogger("marketplace-browse", verbose=verbose) try: - from ..marketplace.client import fetch_marketplace - from ..marketplace.registry import get_marketplace_by_name + from ...marketplace.client import fetch_marketplace + from ...marketplace.registry import get_marketplace_by_name source = get_marketplace_by_name(name) logger.start(f"Fetching plugins from '{name}'...", symbol="search") @@ -538,12 +440,6 @@ def browse(name, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace update -# --------------------------------------------------------------------------- - - @marketplace.command(help="Refresh marketplace cache") @click.argument("name", required=False, default=None) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") @@ -551,8 +447,8 @@ def update(name, verbose): """Refresh cached marketplace data (one or all).""" logger = CommandLogger("marketplace-update", verbose=verbose) try: - from ..marketplace.client import clear_marketplace_cache, fetch_marketplace - from ..marketplace.registry import ( + from ...marketplace.client import clear_marketplace_cache, fetch_marketplace + from ...marketplace.registry import ( get_marketplace_by_name, get_registered_marketplaces, ) @@ -595,12 +491,6 @@ def update(name, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace remove -# --------------------------------------------------------------------------- - - @marketplace.command(help="Remove a registered marketplace") @click.argument("name", required=True) @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") @@ -609,8 +499,8 @@ def remove(name, yes, verbose): """Unregister a marketplace.""" logger = CommandLogger("marketplace-remove", verbose=verbose) try: - from ..marketplace.client import clear_marketplace_cache - from ..marketplace.registry import get_marketplace_by_name, remove_marketplace + from ...marketplace.client import clear_marketplace_cache + from ...marketplace.registry import get_marketplace_by_name, remove_marketplace # Verify it exists first source = get_marketplace_by_name(name) @@ -640,154 +530,6 @@ def remove(name, yes, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace validate -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Validate a marketplace manifest") -@click.argument("name", required=True) -@click.option( - "--check-refs", is_flag=True, hidden=True, help="Verify version refs are reachable (network)" -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def validate(name, check_refs, verbose): - """Validate the manifest of a registered marketplace.""" - logger = CommandLogger("marketplace-validate", verbose=verbose) - try: - from ..marketplace.client import fetch_marketplace - from ..marketplace.registry import get_marketplace_by_name - from ..marketplace.validator import validate_marketplace - - source = get_marketplace_by_name(name) - logger.start(f"Validating marketplace '{name}'...", symbol="gear") - - manifest = fetch_marketplace(source, force_refresh=True) - - logger.progress( - f"Found {len(manifest.plugins)} plugins", - symbol="info", - ) - - # Verbose: per-plugin details - if verbose: - for p in manifest.plugins: - source_type = "dict" if isinstance(p.source, dict) else "string" - logger.verbose_detail( - f" {p.name}: source type: {source_type}" - ) - - # Run validation - results = validate_marketplace(manifest) - - # Check-refs placeholder - if check_refs: - logger.warning( - "Ref checking not yet implemented -- skipping ref " - "reachability checks", - symbol="warning", - ) - - # Render results - passed = 0 - warning_count = 0 - error_count = 0 - click.echo() - logger.progress("Validation Results:", symbol="info") - for r in results: - if r.passed and not r.warnings: - logger.success( - f" {r.check_name}: all plugins valid", symbol="check" - ) - passed += 1 - elif r.warnings and not r.errors: - for w in r.warnings: - logger.warning(f" {r.check_name}: {w}", symbol="warning") - warning_count += len(r.warnings) - else: - for e in r.errors: - logger.error(f" {r.check_name}: {e}", symbol="error") - for w in r.warnings: - logger.warning(f" {r.check_name}: {w}", symbol="warning") - error_count += len(r.errors) - warning_count += len(r.warnings) - - click.echo() - logger.progress( - f"Summary: {passed} passed, {warning_count} warnings, " - f"{error_count} errors", - symbol="info", - ) - - if error_count > 0: - sys.exit(1) - - except Exception as e: # noqa: BLE001 -- top-level command catch-all - logger.error(f"Failed to validate marketplace: {e}") - logger.verbose_detail(traceback.format_exc()) - sys.exit(1) - - -# --------------------------------------------------------------------------- -# marketplace build -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Build marketplace.json from marketplace.yml") -@click.option("--dry-run", is_flag=True, help="Preview without writing marketplace.json") -@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") -@click.option( - "--include-prerelease", is_flag=True, help="Include prerelease versions" -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def build(dry_run, offline, include_prerelease, verbose): - """Resolve packages and compile marketplace.json.""" - _require_authoring_flag() - logger = CommandLogger("marketplace-build", verbose=verbose) - yml_path = Path.cwd() / "marketplace.yml" - - # Load yml (exit 1 on missing, exit 2 on schema error) - _load_yml_or_exit(logger) - - try: - opts = BuildOptions( - dry_run=dry_run, - offline=offline, - include_prerelease=include_prerelease, - ) - builder = MarketplaceBuilder(yml_path, options=opts) - report = builder.build() - except MarketplaceYmlError as exc: - logger.error(f"marketplace.yml schema error: {exc}", symbol="error") - sys.exit(2) - except BuildError as exc: - _render_build_error(logger, exc) - logger.verbose_detail(traceback.format_exc()) - sys.exit(1) - except Exception as e: # noqa: BLE001 -- top-level command catch-all - logger.error(f"Build failed: {e}", symbol="error") - logger.verbose_detail(traceback.format_exc()) - sys.exit(1) - - # Render results table - _render_build_table(logger, report) - - # Surface duplicate-name warnings from the builder - for warn_msg in report.warnings: - logger.warning(warn_msg, symbol="warning") - - if dry_run: - logger.progress( - "Dry run -- marketplace.json not written", symbol="info" - ) - else: - logger.success( - f"Built marketplace.json ({len(report.resolved)} packages)", - symbol="check", - ) - - def _render_build_error(logger, exc): """Render a BuildError with actionable hints.""" if isinstance(exc, GitLsRemoteError): @@ -817,7 +559,6 @@ def _render_build_error(logger, exc): else: logger.error(f"Build failed: {exc}", symbol="error") - def _render_build_table(logger, report): """Render the resolved-packages table (Rich with colorama fallback).""" console = _get_console() @@ -857,160 +598,6 @@ def _render_build_table(logger, report): console.print() console.print(table) - -# --------------------------------------------------------------------------- -# marketplace outdated -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Show packages with available upgrades") -@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") -@click.option( - "--include-prerelease", is_flag=True, help="Include prerelease versions" -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def outdated(offline, include_prerelease, verbose): - """Compare installed versions against latest available tags.""" - _require_authoring_flag() - logger = CommandLogger("marketplace-outdated", verbose=verbose) - - yml = _load_yml_or_exit(logger) - - # Load current marketplace.json for "Current" column - current_versions = _load_current_versions() - - resolver = RefResolver(offline=offline) - try: - rows = [] - upgradable = 0 - up_to_date = 0 - for entry in yml.packages: - # Entries with explicit ref (no range) are skipped - if entry.ref is not None: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec="--", - latest_in_range="--", - latest_overall="--", - status="[i]", - note="Pinned to ref; skipped", - )) - continue - - version_range = entry.version or "" - if not version_range: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec="--", - latest_in_range="--", - latest_overall="--", - status="[i]", - note="No version range", - )) - continue - - try: - refs = resolver.list_remote_refs(entry.source) - except (BuildError, Exception) as exc: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec=version_range, - latest_in_range="--", - latest_overall="--", - status="[x]", - note=str(exc)[:60], - )) - continue - - # Parse tags into semvers - tag_versions = _extract_tag_versions( - refs, entry, yml, include_prerelease - ) - - if not tag_versions: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec=version_range, - latest_in_range="--", - latest_overall="--", - status="[!]", - note="No matching tags found", - )) - continue - - # Find highest in-range and highest overall - in_range = [ - (sv, tag) for sv, tag in tag_versions - if satisfies_range(sv, version_range) - ] - latest_overall_sv, latest_overall_tag = max( - tag_versions, key=lambda x: x[0] - ) - latest_in_range_tag = "--" - if in_range: - _, latest_in_range_tag = max(in_range, key=lambda x: x[0]) - - current = current_versions.get(entry.name, "--") - - # Determine status - if current == latest_in_range_tag: - status = "[+]" - up_to_date += 1 - elif latest_in_range_tag != "--" and current != latest_in_range_tag: - status = "[!]" - upgradable += 1 - else: - status = "[!]" - upgradable += 1 - - # Check if major upgrade available outside range - if latest_overall_tag != latest_in_range_tag: - status = "[*]" - - rows.append(_OutdatedRow( - name=entry.name, - current=current, - range_spec=version_range, - latest_in_range=latest_in_range_tag, - latest_overall=latest_overall_tag, - status=status, - note="", - )) - - _render_outdated_table(logger, rows) - - if upgradable > 0: - logger.progress( - f"{upgradable} package(s) can be updated", - symbol="info", - ) - else: - logger.progress( - "All packages are up to date", - symbol="info", - ) - - if verbose: - logger.verbose_detail(f" {upgradable} upgradable entries") - - if upgradable > 0: - sys.exit(1) - sys.exit(0) - - except SystemExit: - raise - except Exception as e: # noqa: BLE001 -- top-level command catch-all - logger.error(f"Failed to check outdated packages: {e}", symbol="error") - logger.verbose_detail(traceback.format_exc()) - sys.exit(1) - finally: - resolver.close() - - class _OutdatedRow: """Simple container for outdated table row data.""" @@ -1029,7 +616,6 @@ def __init__(self, name, current, range_spec, latest_in_range, self.status = status self.note = note - def _load_current_versions(): """Load current ref versions from marketplace.json if present.""" mkt_path = Path.cwd() / "marketplace.json" @@ -1047,10 +633,9 @@ def _load_current_versions(): except (json.JSONDecodeError, OSError): return {} - def _extract_tag_versions(refs, entry, yml, include_prerelease): """Extract (SemVer, tag_name) pairs from remote refs for a package entry.""" - from ..marketplace.tag_pattern import build_tag_regex + from ...marketplace.tag_pattern import build_tag_regex pattern = entry.tag_pattern or yml.build.tag_pattern tag_rx = build_tag_regex(pattern) @@ -1071,7 +656,6 @@ def _extract_tag_versions(refs, entry, yml, include_prerelease): results.append((sv, tag_name)) return results - def _render_outdated_table(logger, rows): """Render the outdated-packages table.""" console = _get_console() @@ -1117,130 +701,6 @@ def _render_outdated_table(logger, rows): console.print() console.print(table) - -# --------------------------------------------------------------------------- -# marketplace check -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Validate marketplace.yml entries are resolvable") -@click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def check(offline, verbose): - """Validate marketplace.yml and check each entry is resolvable.""" - _require_authoring_flag() - logger = CommandLogger("marketplace-check", verbose=verbose) - - yml = _load_yml_or_exit(logger) - - # Defence-in-depth: flag duplicate package names (yml_schema - # also rejects them, but an extra check keeps diagnostics visible). - _warn_duplicate_names(logger, yml) - - if offline: - logger.progress( - "Offline mode -- only schema and cached-ref checks", - symbol="info", - ) - - resolver = RefResolver(offline=offline) - results = [] - failure_count = 0 - - try: - for entry in yml.packages: - try: - # Attempt to resolve each entry - refs = resolver.list_remote_refs(entry.source) - - # Check version/ref resolution - ref_ok = False - if entry.ref is not None: - # Check the explicit ref exists - for r in refs: - tag_name = r.name - if tag_name.startswith("refs/tags/"): - tag_name = tag_name[len("refs/tags/"):] - elif tag_name.startswith("refs/heads/"): - tag_name = tag_name[len("refs/heads/"):] - if tag_name == entry.ref or r.name == entry.ref: - ref_ok = True - break - if not ref_ok: - results.append(_CheckResult( - name=entry.name, reachable=True, - version_found=False, ref_ok=False, - error=f"Ref '{entry.ref}' not found", - )) - failure_count += 1 - continue - else: - # Version range -- check at least one tag satisfies - tag_versions = _extract_tag_versions( - refs, entry, yml, False - ) - version_range = entry.version or "" - matching = [ - (sv, tag) for sv, tag in tag_versions - if satisfies_range(sv, version_range) - ] - if matching: - ref_ok = True - else: - results.append(_CheckResult( - name=entry.name, reachable=True, - version_found=len(tag_versions) > 0, - ref_ok=False, - error=f"No tag matching '{version_range}'", - )) - failure_count += 1 - continue - - results.append(_CheckResult( - name=entry.name, reachable=True, - version_found=True, ref_ok=True, error="", - )) - - except OfflineMissError: - results.append(_CheckResult( - name=entry.name, reachable=False, - version_found=False, ref_ok=False, - error="No cached refs (offline)", - )) - failure_count += 1 - except GitLsRemoteError as exc: - results.append(_CheckResult( - name=entry.name, reachable=False, - version_found=False, ref_ok=False, - error=exc.summary_text[:60], - )) - failure_count += 1 - except Exception as exc: # noqa: BLE001 -- per-entry diagnostic catch-all - results.append(_CheckResult( - name=entry.name, reachable=False, - version_found=False, ref_ok=False, - error=str(exc)[:60], - )) - failure_count += 1 - logger.verbose_detail(traceback.format_exc()) - - _render_check_table(logger, results) - - total = len(results) - if failure_count > 0: - logger.error( - f"{failure_count} entries have issues", symbol="error" - ) - sys.exit(1) - else: - logger.success( - f"All {total} entries OK", symbol="check" - ) - - finally: - resolver.close() - - class _CheckResult: """Container for per-entry check results.""" @@ -1253,7 +713,6 @@ def __init__(self, name, reachable, version_found, ref_ok, error): self.ref_ok = ref_ok self.error = error - def _render_check_table(logger, results): """Render the check-results table.""" console = _get_console() @@ -1297,171 +756,6 @@ def _render_check_table(logger, results): console.print() console.print(table) - -# --------------------------------------------------------------------------- -# marketplace doctor -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Run environment diagnostics for marketplace builds") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def doctor(verbose): - """Check git, network, auth, and marketplace.yml readiness.""" - _require_authoring_flag() - logger = CommandLogger("marketplace-doctor", verbose=verbose) - checks = [] - - # Check 1: git on PATH - git_ok = False - git_detail = "" - try: - result = subprocess.run( - ["git", "--version"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - git_ok = True - git_detail = result.stdout.strip() - else: - git_detail = "git returned non-zero exit code" - except FileNotFoundError: - git_detail = "git not found on PATH" - except subprocess.TimeoutExpired: - git_detail = "git --version timed out" - except (subprocess.SubprocessError, OSError) as exc: - git_detail = str(exc)[:60] - - checks.append(_DoctorCheck( - name="git", - passed=git_ok, - detail=git_detail, - )) - - # Check 2: network reachability - net_ok = False - net_detail = "" - try: - result = subprocess.run( - ["git", "ls-remote", "https://github.com/git/git.git", "HEAD"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - net_ok = True - net_detail = "github.com reachable" - else: - translated = translate_git_stderr( - result.stderr, - exit_code=result.returncode, - operation="ls-remote", - remote="github.com", - ) - net_detail = translated.hint[:80] - except subprocess.TimeoutExpired: - net_detail = "Network check timed out (5s)" - except FileNotFoundError: - net_detail = "git not found; cannot test network" - except (subprocess.SubprocessError, OSError) as exc: - net_detail = str(exc)[:60] - - checks.append(_DoctorCheck( - name="network", - passed=net_ok, - detail=net_detail, - )) - - # Check 3: auth tokens (delegate to AuthResolver for full coverage) - try: - from ..core.auth import AuthResolver - resolver = AuthResolver() - # Try to get a token for github.com as a representative check - token = resolver.resolve("github.com").token - has_token = bool(token) - except Exception: # noqa: BLE001 -- best-effort auth probe - has_token = False - auth_detail = "Token detected" if has_token else "No token; unauthenticated rate limits apply" - checks.append(_DoctorCheck( - name="auth", - passed=True, # informational; never fails - detail=auth_detail, - informational=True, - )) - - # Check 4: gh CLI availability (informational; only needed for publish) - gh_ok = False - gh_detail = "" - try: - result = subprocess.run( - ["gh", "--version"], - capture_output=True, text=True, timeout=10, - ) - if result.returncode == 0: - gh_ok = True - gh_detail = result.stdout.strip().split("\n")[0] - else: - gh_detail = "gh CLI returned non-zero exit code" - except FileNotFoundError: - gh_detail = "gh CLI not found (install: https://cli.github.com/)" - except subprocess.TimeoutExpired: - gh_detail = "gh --version timed out" - except (subprocess.SubprocessError, OSError) as exc: - gh_detail = str(exc)[:60] - - checks.append(_DoctorCheck( - name="gh CLI", - passed=gh_ok, - detail=gh_detail, - informational=True, - )) - - # Check 5: marketplace.yml presence + parsability - yml_path = Path.cwd() / "marketplace.yml" - yml_found = yml_path.exists() - yml_detail = "" - yml_parsed = False - yml_obj = None - if yml_found: - try: - yml_obj = load_marketplace_yml(yml_path) - yml_parsed = True - yml_detail = "marketplace.yml found and valid" - except MarketplaceYmlError as exc: - yml_detail = f"marketplace.yml has errors: {str(exc)[:60]}" - else: - yml_detail = "No marketplace.yml in current directory" - - checks.append(_DoctorCheck( - name="marketplace.yml", - passed=yml_parsed if yml_found else True, # informational if absent - detail=yml_detail, - informational=True, - )) - - # Check 6: duplicate package names (defence-in-depth) - if yml_obj is not None: - dup_detail = _find_duplicate_names(yml_obj) - if dup_detail: - checks.append(_DoctorCheck( - name="duplicate names", - passed=False, - detail=dup_detail, - informational=True, - )) - else: - checks.append(_DoctorCheck( - name="duplicate names", - passed=True, - detail="No duplicate package names", - informational=True, - )) - - _render_doctor_table(logger, checks) - - # Exit: 0 if checks 1-2 pass; checks 3-6 are informational - critical_checks = [c for c in checks if not c.informational] - if any(not c.passed for c in critical_checks): - sys.exit(1) - - class _DoctorCheck: """Container for a single doctor check result.""" @@ -1473,7 +767,6 @@ def __init__(self, name, passed, detail, informational=False): self.detail = detail self.informational = informational - def _render_doctor_table(logger, checks): """Render the doctor results table.""" console = _get_console() @@ -1513,12 +806,6 @@ def _render_doctor_table(logger, checks): console.print() console.print(table) - -# --------------------------------------------------------------------------- -# marketplace publish -# --------------------------------------------------------------------------- - - def _load_targets_file(path): """Load and validate a consumer-targets YAML file. @@ -1579,225 +866,6 @@ def _load_targets_file(path): return targets, None - -@marketplace.command(help="Publish marketplace updates to consumer repositories") -@click.option( - "--targets", - "targets_file", - default=None, - type=click.Path(exists=False), - help="Path to consumer-targets YAML file (default: ./consumer-targets.yml)", -) -@click.option("--dry-run", is_flag=True, help="Preview without pushing or opening PRs") -@click.option("--no-pr", is_flag=True, help="Push branches but skip PR creation") -@click.option("--draft", is_flag=True, help="Create PRs as drafts") -@click.option("--allow-downgrade", is_flag=True, help="Allow version downgrades") -@click.option("--allow-ref-change", is_flag=True, help="Allow switching ref types") -@click.option( - "--parallel", - default=4, - show_default=True, - type=int, - help="Maximum number of concurrent target updates", -) -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def publish( - targets_file, - dry_run, - no_pr, - draft, - allow_downgrade, - allow_ref_change, - parallel, - yes, - verbose, -): - """Publish marketplace updates to consumer repositories.""" - _require_authoring_flag() - logger = CommandLogger("marketplace-publish", verbose=verbose) - - # ------------------------------------------------------------------ - # 1. Pre-flight checks - # ------------------------------------------------------------------ - - # 1a. Load marketplace.yml - yml = _load_yml_or_exit(logger) - - # 1b. Load marketplace.json - mkt_json_path = Path.cwd() / "marketplace.json" - if not mkt_json_path.exists(): - logger.error( - "marketplace.json not found. Run 'apm marketplace build' first.", - symbol="error", - ) - sys.exit(1) - - # 1c. Load targets - if targets_file: - targets_path = Path(targets_file) - if not targets_path.exists(): - logger.error( - f"Targets file not found: {targets_file}", - symbol="error", - ) - sys.exit(1) - else: - targets_path = Path.cwd() / "consumer-targets.yml" - if not targets_path.exists(): - logger.error( - "No consumer-targets.yml found. " - "Create one or pass --targets .\n" - "\n" - "Example consumer-targets.yml:\n" - " targets:\n" - " - repo: acme-org/service-a\n" - " branch: main\n" - " - repo: acme-org/service-b\n" - " branch: develop", - symbol="error", - ) - sys.exit(1) - - targets, error = _load_targets_file(targets_path) - if error: - logger.error(error, symbol="error") - sys.exit(1) - - # 1d. Check gh availability (unless --no-pr) - pr = None - if not no_pr: - pr = PrIntegrator() - available, hint = pr.check_available() - if not available: - logger.error(hint, symbol="error") - sys.exit(1) - - # ------------------------------------------------------------------ - # 2. Plan and confirm - # ------------------------------------------------------------------ - - publisher = MarketplacePublisher(Path.cwd()) - plan = publisher.plan( - targets, - allow_downgrade=allow_downgrade, - allow_ref_change=allow_ref_change, - ) - - # Render publish plan - _render_publish_plan(logger, plan) - - # Confirmation logic - if not yes: - if not _is_interactive(): - logger.error( - "Non-interactive session: pass --yes to confirm the publish.", - symbol="error", - ) - sys.exit(1) - try: - if not click.confirm( - f"Confirm publish to {len(targets)} repositories?", - default=False, - ): - logger.progress("Publish cancelled.", symbol="info") - sys.exit(0) - except click.Abort: - logger.progress("Publish cancelled.", symbol="info") - sys.exit(0) - - if dry_run: - logger.progress( - "Dry run: no branches will be pushed and no PRs will be opened.", - symbol="info", - ) - - # ------------------------------------------------------------------ - # 3. Execute publish - # ------------------------------------------------------------------ - - results = publisher.execute(plan, dry_run=dry_run, parallel=parallel) - - # PR integration - pr_results = [] - if not no_pr: - if pr is None: - pr = PrIntegrator() - - for result in results: - if dry_run: - # In dry-run, preview what PR would do for UPDATED targets - if result.outcome == PublishOutcome.UPDATED: - pr_result = pr.open_or_update( - plan, - result.target, - result, - no_pr=False, - draft=draft, - dry_run=True, - ) - pr_results.append(pr_result) - else: - pr_results.append(PrResult( - target=result.target, - state=PrState.SKIPPED, - pr_number=None, - pr_url=None, - message=f"No PR needed: {result.outcome.value}", - )) - else: - if result.outcome == PublishOutcome.UPDATED: - pr_result = pr.open_or_update( - plan, - result.target, - result, - no_pr=False, - draft=draft, - dry_run=False, - ) - pr_results.append(pr_result) - else: - pr_results.append(PrResult( - target=result.target, - state=PrState.SKIPPED, - pr_number=None, - pr_url=None, - message=f"No PR needed: {result.outcome.value}", - )) - - # ------------------------------------------------------------------ - # 4. Summary rendering - # ------------------------------------------------------------------ - - _render_publish_summary(logger, results, pr_results, no_pr, dry_run) - - # State file path -- use soft_wrap so the path is never split mid-word - # in narrow terminals (Rich would otherwise break at hyphens). - state_path = Path.cwd() / ".apm" / "publish-state.json" - try: - from rich.text import Text - - console = _get_console() - if console is not None: - console.print( - Text(f"[i] State file: {state_path}", no_wrap=True), - style="blue", - highlight=False, - soft_wrap=True, - ) - else: - logger.progress(f"State file: {state_path}", symbol="info") - except Exception: # noqa: BLE001 -- best-effort Rich rendering fallback - logger.progress(f"State file: {state_path}", symbol="info") - - # Exit code - failed_count = sum( - 1 for r in results if r.outcome == PublishOutcome.FAILED - ) - if failed_count > 0: - sys.exit(1) - - def _render_publish_plan(logger, plan): """Render the publish plan as a Rich panel + target table.""" console = _get_console() @@ -1848,7 +916,6 @@ def _render_publish_plan(logger, plan): console.print(table) console.print() - def _render_publish_summary(logger, results, pr_results, no_pr, dry_run): """Render the final publish summary table.""" console = _get_console() @@ -1926,7 +993,6 @@ def _render_publish_summary(logger, results, pr_results, no_pr, dry_run): _render_publish_footer(logger, updated_count, failed_count, total, dry_run) - def _outcome_symbol(outcome): """Map a ``PublishOutcome`` to a bracket symbol.""" if outcome == PublishOutcome.UPDATED: @@ -1942,7 +1008,6 @@ def _outcome_symbol(outcome): return "[*]" return "[*]" - def _render_publish_footer(logger, updated, failed, total, dry_run): """Render the footer success/warning line.""" suffix = " (dry-run)" if dry_run else "" @@ -1958,12 +1023,6 @@ def _render_publish_footer(logger, updated, failed, total, dry_run): symbol="warning", ) - -# --------------------------------------------------------------------------- -# Top-level search command (registered separately in cli.py) -# --------------------------------------------------------------------------- - - @click.command( name="search", help="Search plugins in a marketplace (QUERY@MARKETPLACE)", @@ -1978,8 +1037,8 @@ def search(expression, limit, verbose): """ logger = CommandLogger("marketplace-search", verbose=verbose) try: - from ..marketplace.client import search_marketplace - from ..marketplace.registry import get_marketplace_by_name + from ...marketplace.client import search_marketplace + from ...marketplace.registry import get_marketplace_by_name if "@" not in expression: logger.error( @@ -2062,3 +1121,84 @@ def search(expression, limit, verbose): logger.verbose_detail(traceback.format_exc()) sys.exit(1) + + +from .build import build # noqa: E402 +from .check import check # noqa: E402 +from .doctor import doctor # noqa: E402 +from .init import init # noqa: E402 +from .outdated import outdated # noqa: E402 +from .publish import publish # noqa: E402 +from .validate import validate # noqa: E402 + +__all__ = [ + "MarketplaceGroup", + "marketplace", + "package", + "init", + "add", + "list_cmd", + "browse", + "update", + "remove", + "validate", + "build", + "outdated", + "check", + "doctor", + "publish", + "search", + "_load_yml_or_exit", + "_warn_duplicate_names", + "_find_duplicate_names", + "_require_authoring_flag", + "_check_gitignore_for_marketplace_json", + "_render_build_error", + "_render_build_table", + "_OutdatedRow", + "_load_current_versions", + "_extract_tag_versions", + "_render_outdated_table", + "_CheckResult", + "_render_check_table", + "_DoctorCheck", + "_render_doctor_table", + "_load_targets_file", + "_render_publish_plan", + "_render_publish_summary", + "_outcome_symbol", + "_render_publish_footer", + "BuildOptions", + "BuildReport", + "MarketplaceBuilder", + "ResolvedPackage", + "BuildError", + "GitLsRemoteError", + "HeadNotAllowedError", + "MarketplaceNotFoundError", + "MarketplaceYmlError", + "NoMatchingVersionError", + "OfflineMissError", + "RefNotFoundError", + "translate_git_stderr", + "PrIntegrator", + "PrResult", + "PrState", + "ConsumerTarget", + "MarketplacePublisher", + "PublishOutcome", + "PublishPlan", + "TargetResult", + "RefResolver", + "RemoteRef", + "SemVer", + "parse_semver", + "satisfies_range", + "load_marketplace_yml", + "PathTraversalError", + "validate_path_segments", + "_get_console", + "_is_interactive", + "subprocess", +] + diff --git a/src/apm_cli/commands/marketplace/build.py b/src/apm_cli/commands/marketplace/build.py new file mode 100644 index 00000000..9a4fa974 --- /dev/null +++ b/src/apm_cli/commands/marketplace/build.py @@ -0,0 +1,66 @@ +"""``apm marketplace build`` command.""" + +from __future__ import annotations + +import sys +import traceback +from pathlib import Path + +import click + + +from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _render_build_error, _render_build_table, CommandLogger, BuildOptions, MarketplaceYmlError, BuildError) + +@marketplace.command(help="Build marketplace.json from marketplace.yml") +@click.option("--dry-run", is_flag=True, help="Preview without writing marketplace.json") +@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def build(dry_run, offline, include_prerelease, verbose): + """Resolve packages and compile marketplace.json.""" + from . import MarketplaceBuilder + _require_authoring_flag() + logger = CommandLogger("marketplace-build", verbose=verbose) + yml_path = Path.cwd() / "marketplace.yml" + + # Load yml (exit 1 on missing, exit 2 on schema error) + _load_yml_or_exit(logger) + + try: + opts = BuildOptions( + dry_run=dry_run, + offline=offline, + include_prerelease=include_prerelease, + ) + builder = MarketplaceBuilder(yml_path, options=opts) + report = builder.build() + except MarketplaceYmlError as exc: + logger.error(f"marketplace.yml schema error: {exc}", symbol="error") + sys.exit(2) + except BuildError as exc: + _render_build_error(logger, exc) + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + except Exception as e: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Build failed: {e}", symbol="error") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + + # Render results table + _render_build_table(logger, report) + + # Surface duplicate-name warnings from the builder + for warn_msg in report.warnings: + logger.warning(warn_msg, symbol="warning") + + if dry_run: + logger.progress( + "Dry run -- marketplace.json not written", symbol="info" + ) + else: + logger.success( + f"Built marketplace.json ({len(report.resolved)} packages)", + symbol="check", + ) diff --git a/src/apm_cli/commands/marketplace/check.py b/src/apm_cli/commands/marketplace/check.py new file mode 100644 index 00000000..44039140 --- /dev/null +++ b/src/apm_cli/commands/marketplace/check.py @@ -0,0 +1,129 @@ +"""``apm marketplace check`` command.""" + +from __future__ import annotations + +import sys +import traceback + +import click + + +from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _warn_duplicate_names, _CheckResult, _extract_tag_versions, _render_check_table, CommandLogger, OfflineMissError, GitLsRemoteError, satisfies_range) + +@marketplace.command(help="Validate marketplace.yml entries are resolvable") +@click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def check(offline, verbose): + """Validate marketplace.yml and check each entry is resolvable.""" + from . import RefResolver + _require_authoring_flag() + logger = CommandLogger("marketplace-check", verbose=verbose) + + yml = _load_yml_or_exit(logger) + + # Defence-in-depth: flag duplicate package names (yml_schema + # also rejects them, but an extra check keeps diagnostics visible). + _warn_duplicate_names(logger, yml) + + if offline: + logger.progress( + "Offline mode -- only schema and cached-ref checks", + symbol="info", + ) + + resolver = RefResolver(offline=offline) + results = [] + failure_count = 0 + + try: + for entry in yml.packages: + try: + # Attempt to resolve each entry + refs = resolver.list_remote_refs(entry.source) + + # Check version/ref resolution + ref_ok = False + if entry.ref is not None: + # Check the explicit ref exists + for r in refs: + tag_name = r.name + if tag_name.startswith("refs/tags/"): + tag_name = tag_name[len("refs/tags/"):] + elif tag_name.startswith("refs/heads/"): + tag_name = tag_name[len("refs/heads/"):] + if tag_name == entry.ref or r.name == entry.ref: + ref_ok = True + break + if not ref_ok: + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=False, ref_ok=False, + error=f"Ref '{entry.ref}' not found", + )) + failure_count += 1 + continue + else: + # Version range -- check at least one tag satisfies + tag_versions = _extract_tag_versions( + refs, entry, yml, False + ) + version_range = entry.version or "" + matching = [ + (sv, tag) for sv, tag in tag_versions + if satisfies_range(sv, version_range) + ] + if matching: + ref_ok = True + else: + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=len(tag_versions) > 0, + ref_ok=False, + error=f"No tag matching '{version_range}'", + )) + failure_count += 1 + continue + + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=True, ref_ok=True, error="", + )) + + except OfflineMissError: + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error="No cached refs (offline)", + )) + failure_count += 1 + except GitLsRemoteError as exc: + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error=exc.summary_text[:60], + )) + failure_count += 1 + except Exception as exc: # noqa: BLE001 -- per-entry diagnostic catch-all + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error=str(exc)[:60], + )) + failure_count += 1 + logger.verbose_detail(traceback.format_exc()) + + _render_check_table(logger, results) + + total = len(results) + if failure_count > 0: + logger.error( + f"{failure_count} entries have issues", symbol="error" + ) + sys.exit(1) + else: + logger.success( + f"All {total} entries OK", symbol="check" + ) + + finally: + resolver.close() diff --git a/src/apm_cli/commands/marketplace/doctor.py b/src/apm_cli/commands/marketplace/doctor.py new file mode 100644 index 00000000..8ad454fb --- /dev/null +++ b/src/apm_cli/commands/marketplace/doctor.py @@ -0,0 +1,170 @@ +"""``apm marketplace doctor`` command.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + + +from . import (marketplace, _require_authoring_flag, _find_duplicate_names, _DoctorCheck, _render_doctor_table, CommandLogger, MarketplaceYmlError, translate_git_stderr, subprocess) + +@marketplace.command(help="Run environment diagnostics for marketplace builds") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def doctor(verbose): + """Check git, network, auth, and marketplace.yml readiness.""" + from . import load_marketplace_yml + _require_authoring_flag() + logger = CommandLogger("marketplace-doctor", verbose=verbose) + checks = [] + + # Check 1: git on PATH + git_ok = False + git_detail = "" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + git_ok = True + git_detail = result.stdout.strip() + else: + git_detail = "git returned non-zero exit code" + except FileNotFoundError: + git_detail = "git not found on PATH" + except subprocess.TimeoutExpired: + git_detail = "git --version timed out" + except (subprocess.SubprocessError, OSError) as exc: + git_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="git", + passed=git_ok, + detail=git_detail, + )) + + # Check 2: network reachability + net_ok = False + net_detail = "" + try: + result = subprocess.run( + ["git", "ls-remote", "https://github.com/git/git.git", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + net_ok = True + net_detail = "github.com reachable" + else: + translated = translate_git_stderr( + result.stderr, + exit_code=result.returncode, + operation="ls-remote", + remote="github.com", + ) + net_detail = translated.hint[:80] + except subprocess.TimeoutExpired: + net_detail = "Network check timed out (5s)" + except FileNotFoundError: + net_detail = "git not found; cannot test network" + except (subprocess.SubprocessError, OSError) as exc: + net_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="network", + passed=net_ok, + detail=net_detail, + )) + + # Check 3: auth tokens (delegate to AuthResolver for full coverage) + try: + from ...core.auth import AuthResolver + resolver = AuthResolver() + # Try to get a token for github.com as a representative check + token = resolver.resolve("github.com").token + has_token = bool(token) + except Exception: # noqa: BLE001 -- best-effort auth probe + has_token = False + auth_detail = "Token detected" if has_token else "No token; unauthenticated rate limits apply" + checks.append(_DoctorCheck( + name="auth", + passed=True, # informational; never fails + detail=auth_detail, + informational=True, + )) + + # Check 4: gh CLI availability (informational; only needed for publish) + gh_ok = False + gh_detail = "" + try: + result = subprocess.run( + ["gh", "--version"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + gh_ok = True + gh_detail = result.stdout.strip().split("\n")[0] + else: + gh_detail = "gh CLI returned non-zero exit code" + except FileNotFoundError: + gh_detail = "gh CLI not found (install: https://cli.github.com/)" + except subprocess.TimeoutExpired: + gh_detail = "gh --version timed out" + except (subprocess.SubprocessError, OSError) as exc: + gh_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="gh CLI", + passed=gh_ok, + detail=gh_detail, + informational=True, + )) + + # Check 5: marketplace.yml presence + parsability + yml_path = Path.cwd() / "marketplace.yml" + yml_found = yml_path.exists() + yml_detail = "" + yml_parsed = False + yml_obj = None + if yml_found: + try: + yml_obj = load_marketplace_yml(yml_path) + yml_parsed = True + yml_detail = "marketplace.yml found and valid" + except MarketplaceYmlError as exc: + yml_detail = f"marketplace.yml has errors: {str(exc)[:60]}" + else: + yml_detail = "No marketplace.yml in current directory" + + checks.append(_DoctorCheck( + name="marketplace.yml", + passed=yml_parsed if yml_found else True, # informational if absent + detail=yml_detail, + informational=True, + )) + + # Check 6: duplicate package names (defence-in-depth) + if yml_obj is not None: + dup_detail = _find_duplicate_names(yml_obj) + if dup_detail: + checks.append(_DoctorCheck( + name="duplicate names", + passed=False, + detail=dup_detail, + informational=True, + )) + else: + checks.append(_DoctorCheck( + name="duplicate names", + passed=True, + detail="No duplicate package names", + informational=True, + )) + + _render_doctor_table(logger, checks) + + # Exit: 0 if checks 1-2 pass; checks 3-6 are informational + critical_checks = [c for c in checks if not c.informational] + if any(not c.passed for c in critical_checks): + sys.exit(1) diff --git a/src/apm_cli/commands/marketplace/init.py b/src/apm_cli/commands/marketplace/init.py new file mode 100644 index 00000000..bb6f2e59 --- /dev/null +++ b/src/apm_cli/commands/marketplace/init.py @@ -0,0 +1,74 @@ +"""``apm marketplace init`` command.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + + +from . import (marketplace, _require_authoring_flag, _check_gitignore_for_marketplace_json, CommandLogger) + +@marketplace.command(help="Scaffold a new marketplace.yml in the current directory") +@click.option("--force", is_flag=True, help="Overwrite existing marketplace.yml") +@click.option( + "--no-gitignore-check", + is_flag=True, + help="Skip the .gitignore staleness check", +) +@click.option("--name", default=None, help="Marketplace name (default: my-marketplace)") +@click.option("--owner", default=None, help="Owner name for the marketplace") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def init(force, no_gitignore_check, name, owner, verbose): + """Create a richly-commented marketplace.yml scaffold.""" + _require_authoring_flag() + from ...marketplace.init_template import render_marketplace_yml_template + + logger = CommandLogger("marketplace-init", verbose=verbose) + yml_path = Path.cwd() / "marketplace.yml" + + # Guard: file already exists + if yml_path.exists() and not force: + logger.error( + "marketplace.yml already exists. Use --force to overwrite.", + symbol="error", + ) + sys.exit(1) + + # Write template + template_text = render_marketplace_yml_template(name=name, owner=owner) + try: + yml_path.write_text(template_text, encoding="utf-8") + except OSError as exc: + logger.error(f"Failed to write marketplace.yml: {exc}", symbol="error") + sys.exit(1) + + logger.success("Created marketplace.yml", symbol="check") + + if verbose: + logger.verbose_detail(f" Path: {yml_path}") + + # .gitignore staleness check + if not no_gitignore_check: + _check_gitignore_for_marketplace_json(logger) + + # Next steps panel + next_steps = [ + "Edit marketplace.yml to add your packages", + "Run 'apm marketplace build' to generate marketplace.json", + "Commit BOTH marketplace.yml and marketplace.json", + ] + + try: + from ...utils.console import _rich_panel + + _rich_panel( + "\n".join(f" {i}. {step}" for i, step in enumerate(next_steps, 1)), + title=" Next Steps", + style="cyan", + ) + except (ImportError, NameError): + logger.progress("Next steps:") + for i, step in enumerate(next_steps, 1): + logger.tree_item(f" {i}. {step}") diff --git a/src/apm_cli/commands/marketplace/outdated.py b/src/apm_cli/commands/marketplace/outdated.py new file mode 100644 index 00000000..6fb2a4a1 --- /dev/null +++ b/src/apm_cli/commands/marketplace/outdated.py @@ -0,0 +1,159 @@ +"""``apm marketplace outdated`` command.""" + +from __future__ import annotations + +import sys +import traceback + +import click + + +from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _load_current_versions, _OutdatedRow, _extract_tag_versions, _render_outdated_table, CommandLogger, BuildError, satisfies_range) + +@marketplace.command(help="Show packages with available upgrades") +@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def outdated(offline, include_prerelease, verbose): + """Compare installed versions against latest available tags.""" + from . import RefResolver + _require_authoring_flag() + logger = CommandLogger("marketplace-outdated", verbose=verbose) + + yml = _load_yml_or_exit(logger) + + # Load current marketplace.json for "Current" column + current_versions = _load_current_versions() + + resolver = RefResolver(offline=offline) + try: + rows = [] + upgradable = 0 + up_to_date = 0 + for entry in yml.packages: + # Entries with explicit ref (no range) are skipped + if entry.ref is not None: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec="--", + latest_in_range="--", + latest_overall="--", + status="[i]", + note="Pinned to ref; skipped", + )) + continue + + version_range = entry.version or "" + if not version_range: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec="--", + latest_in_range="--", + latest_overall="--", + status="[i]", + note="No version range", + )) + continue + + try: + refs = resolver.list_remote_refs(entry.source) + except (BuildError, Exception) as exc: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec=version_range, + latest_in_range="--", + latest_overall="--", + status="[x]", + note=str(exc)[:60], + )) + continue + + # Parse tags into semvers + tag_versions = _extract_tag_versions( + refs, entry, yml, include_prerelease + ) + + if not tag_versions: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec=version_range, + latest_in_range="--", + latest_overall="--", + status="[!]", + note="No matching tags found", + )) + continue + + # Find highest in-range and highest overall + in_range = [ + (sv, tag) for sv, tag in tag_versions + if satisfies_range(sv, version_range) + ] + latest_overall_sv, latest_overall_tag = max( + tag_versions, key=lambda x: x[0] + ) + latest_in_range_tag = "--" + if in_range: + _, latest_in_range_tag = max(in_range, key=lambda x: x[0]) + + current = current_versions.get(entry.name, "--") + + # Determine status + if current == latest_in_range_tag: + status = "[+]" + up_to_date += 1 + elif latest_in_range_tag != "--" and current != latest_in_range_tag: + status = "[!]" + upgradable += 1 + else: + status = "[!]" + upgradable += 1 + + # Check if major upgrade available outside range + if latest_overall_tag != latest_in_range_tag: + status = "[*]" + + rows.append(_OutdatedRow( + name=entry.name, + current=current, + range_spec=version_range, + latest_in_range=latest_in_range_tag, + latest_overall=latest_overall_tag, + status=status, + note="", + )) + + _render_outdated_table(logger, rows) + + if upgradable > 0: + logger.progress( + f"{upgradable} package(s) can be updated", + symbol="info", + ) + else: + logger.progress( + "All packages are up to date", + symbol="info", + ) + + if verbose: + logger.verbose_detail(f" {upgradable} upgradable entries") + + if upgradable > 0: + sys.exit(1) + sys.exit(0) + + except SystemExit: + raise + except Exception as e: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Failed to check outdated packages: {e}", symbol="error") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + finally: + resolver.close() diff --git a/src/apm_cli/commands/marketplace/plugin/__init__.py b/src/apm_cli/commands/marketplace/plugin/__init__.py new file mode 100644 index 00000000..11c35e04 --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/__init__.py @@ -0,0 +1,177 @@ +"""Marketplace package subgroup helpers and click wiring.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import click + +from ....core.command_logger import CommandLogger +from ....marketplace.errors import ( + GitLsRemoteError, + MarketplaceYmlError, + OfflineMissError, +) +from ..._helpers import _is_interactive + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def _yml_path() -> Path: + """Return the canonical ``marketplace.yml`` path in CWD.""" + return Path.cwd() / "marketplace.yml" + +def _ensure_yml_exists(logger: CommandLogger) -> Path: + """Return the yml path or exit with guidance if it does not exist.""" + path = _yml_path() + if not path.exists(): + logger.error( + "No marketplace.yml found. " + "Run 'apm marketplace init' to scaffold one.", + symbol="error", + ) + sys.exit(1) + return path + +def _parse_tags(raw: str | None) -> list[str] | None: + """Split a comma-separated tag string into a list, or return None.""" + if raw is None: + return None + parts = [t.strip() for t in raw.split(",") if t.strip()] + return parts if parts else None + +def _verify_source(logger: CommandLogger, source: str) -> None: + """Run ``git ls-remote`` against *source* to verify reachability.""" + from ....marketplace.ref_resolver import RefResolver + + resolver = RefResolver() + try: + resolver.list_remote_refs(source) + except GitLsRemoteError as exc: + logger.error( + f"Source '{source}' is not reachable: {exc}", + symbol="error", + ) + sys.exit(2) + except OfflineMissError: + logger.warning( + f"Cannot verify source '{source}' (offline / no cache).", + symbol="warning", + ) + +def _resolve_ref( + logger: CommandLogger, + source: str, + ref: str | None, + version: str | None, + no_verify: bool, +) -> str | None: + """Resolve *ref* to a concrete SHA when it is mutable. + + Returns the (possibly resolved) ref string, or ``None`` when + *version* is set (version-based pinning, no ref needed). + """ + from ....marketplace.ref_resolver import RefResolver + + # Version-based — no ref resolution needed. + if version is not None: + return None + + # Already a concrete SHA — store as-is. + if ref is not None and _SHA_RE.match(ref): + return ref + + # HEAD (explicit or implicit) requires network access. + is_head = ref is None or ref.upper() == "HEAD" + if is_head: + if no_verify: + logger.error( + "Cannot resolve HEAD ref without network access. " + "Provide an explicit --ref SHA.", + symbol="error", + ) + sys.exit(2) + if ref is not None: + logger.warning( + "'HEAD' is a mutable ref. Resolving to current SHA for safety.", + symbol="warning", + ) + resolver = RefResolver() + try: + sha = resolver.resolve_ref_sha(source, "HEAD") + except GitLsRemoteError as exc: + logger.error( + f"Failed to resolve HEAD for '{source}': {exc}", + symbol="error", + ) + sys.exit(2) + logger.progress( + f"Resolved HEAD to {sha[:12]}", + symbol="info", + ) + return sha + + # Non-HEAD, non-SHA ref — check whether it is a branch name. + resolver = RefResolver() + try: + remote_refs = resolver.list_remote_refs(source) + except (GitLsRemoteError, OfflineMissError): + # Cannot verify — store as-is but warn the user. + logger.warning( + f"Could not verify ref '{ref}' for '{source}' (network unavailable). " + "Storing unresolved -- run with network access to pin a concrete SHA.", + symbol="warning", + ) + return ref + + for remote_ref in remote_refs: + if remote_ref.name == f"refs/heads/{ref}": + if no_verify: + logger.error( + "Cannot resolve branch ref without network access. " + "Provide an explicit --ref SHA.", + symbol="error", + ) + sys.exit(2) + logger.warning( + f"'{ref}' is a branch (mutable ref). " + "Resolving to current SHA for safety.", + symbol="warning", + ) + logger.progress( + f"Resolved {ref} to {remote_ref.sha[:12]}", + symbol="info", + ) + return remote_ref.sha + + # Not a branch — tag or unknown ref; store as-is. + return ref + +@click.group(help="Manage packages in marketplace.yml (add, set, remove)") +def package(): + """Add, update, or remove packages in marketplace.yml.""" + from .. import _require_authoring_flag + + _require_authoring_flag() + + + +from .add import add # noqa: E402 +from .remove import remove # noqa: E402 +from .set import set_cmd # noqa: E402 + +__all__ = [ + "package", + "add", + "set_cmd", + "remove", + "_SHA_RE", + "_yml_path", + "_ensure_yml_exists", + "_parse_tags", + "_verify_source", + "_resolve_ref", +] + diff --git a/src/apm_cli/commands/marketplace/plugin/add.py b/src/apm_cli/commands/marketplace/plugin/add.py new file mode 100644 index 00000000..be3b3d7c --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/add.py @@ -0,0 +1,82 @@ +"""``apm marketplace package add`` command.""" + +from __future__ import annotations + +import sys + +import click + + +from . import (package, _ensure_yml_exists, _parse_tags, _resolve_ref, _verify_source, CommandLogger, MarketplaceYmlError) + +@package.command(help="Add a package to marketplace.yml") +@click.argument("source") +@click.option("--name", default=None, help="Package name (default: repo name)") +@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") +@click.option( + "--ref", + default=None, + help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", +) +@click.option("-s", "--subdir", default=None, help="Subdirectory inside source repo") +@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") +@click.option("--tags", default=None, help="Comma-separated tags") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--no-verify", is_flag=True, help="Skip remote reachability check") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def add( + source, + name, + version, + ref, + subdir, + tag_pattern, + tags, + include_prerelease, + no_verify, + verbose, +): + """Add a package entry to marketplace.yml.""" + from ....marketplace.yml_editor import add_plugin_entry + + logger = CommandLogger("marketplace-package-add", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # --version and --ref are mutually exclusive. + if version and ref: + raise click.UsageError( + "--version and --ref are mutually exclusive. " + "Use --version for semver ranges or --ref for git refs." + ) + + parsed_tags = _parse_tags(tags) + + # Verify source reachability unless skipped. + if not no_verify: + _verify_source(logger, source) + + # Resolve mutable refs to concrete SHAs. + ref = _resolve_ref(logger, source, ref, version, no_verify) + + try: + resolved_name = add_plugin_entry( + yml, + source=source, + name=name, + version=version, + ref=ref, + subdir=subdir, + tag_pattern=tag_pattern, + tags=parsed_tags, + include_prerelease=include_prerelease, + ) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success( + f"Added package '{resolved_name}' from {source}", + symbol="check", + ) diff --git a/src/apm_cli/commands/marketplace/plugin/remove.py b/src/apm_cli/commands/marketplace/plugin/remove.py new file mode 100644 index 00000000..3fd3181d --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/remove.py @@ -0,0 +1,46 @@ +"""``apm marketplace package remove`` command.""" + +from __future__ import annotations + +import sys + +import click + + +from . import (package, _ensure_yml_exists, _is_interactive, CommandLogger, MarketplaceYmlError) + +@package.command(help="Remove a package from marketplace.yml") +@click.argument("name") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def remove(name, yes, verbose): + """Remove a package entry from marketplace.yml.""" + from ....marketplace.yml_editor import remove_plugin_entry + + logger = CommandLogger("marketplace-package-remove", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # Confirmation gate. + if not yes: + if not _is_interactive(): + logger.error( + "Use --yes to skip confirmation in non-interactive mode", + symbol="error", + ) + sys.exit(1) + try: + click.confirm( + f"Remove package '{name}' from marketplace.yml?", + abort=True, + ) + except click.Abort: + logger.progress("Cancelled.", symbol="info") + return + + try: + remove_plugin_entry(yml, name) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success(f"Removed package '{name}'", symbol="check") diff --git a/src/apm_cli/commands/marketplace/plugin/set.py b/src/apm_cli/commands/marketplace/plugin/set.py new file mode 100644 index 00000000..e324c60c --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/set.py @@ -0,0 +1,98 @@ +"""``apm marketplace package set`` command.""" + +from __future__ import annotations + +import sys + +import click + + +from . import (package, _ensure_yml_exists, _parse_tags, _resolve_ref, _SHA_RE, CommandLogger, MarketplaceYmlError) + +@package.command("set", help="Update a package entry in marketplace.yml") +@click.argument("name") +@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") +@click.option( + "--ref", + default=None, + help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", +) +@click.option("--subdir", default=None, help="Subdirectory inside source repo") +@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") +@click.option("--tags", default=None, help="Comma-separated tags") +@click.option( + "--include-prerelease", + is_flag=True, + default=None, + help="Include prerelease versions", +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def set_cmd( + name, + version, + ref, + subdir, + tag_pattern, + tags, + include_prerelease, + verbose, +): + """Update fields on an existing package entry.""" + from ....marketplace.yml_editor import update_plugin_entry + + logger = CommandLogger("marketplace-package-set", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # --version and --ref are mutually exclusive. + if version and ref: + raise click.UsageError( + "--version and --ref are mutually exclusive. " + "Use --version for semver ranges or --ref for git refs." + ) + + # Resolve mutable refs to concrete SHAs. + if ref is not None and not _SHA_RE.match(ref): + from ....marketplace.yml_schema import load_marketplace_yml + + yml_data = load_marketplace_yml(yml) + source = None + for pkg in yml_data.packages: + if pkg.name.lower() == name.lower(): + source = pkg.source + break + if source is None: + logger.error(f"Package '{name}' not found", symbol="error") + sys.exit(2) + ref = _resolve_ref(logger, source, ref, version, no_verify=False) + + parsed_tags = _parse_tags(tags) + + fields = {} + if version is not None: + fields["version"] = version + if ref is not None: + fields["ref"] = ref + if subdir is not None: + fields["subdir"] = subdir + if tag_pattern is not None: + fields["tag_pattern"] = tag_pattern + if parsed_tags is not None: + fields["tags"] = parsed_tags + if include_prerelease is not None: + fields["include_prerelease"] = include_prerelease + + if not fields: + logger.error( + "No fields specified. Pass at least one option " + "(e.g. --version, --ref, --subdir).", + symbol="error", + ) + sys.exit(1) + + try: + update_plugin_entry(yml, name, **fields) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success(f"Updated package '{name}'", symbol="check") diff --git a/src/apm_cli/commands/marketplace/publish.py b/src/apm_cli/commands/marketplace/publish.py new file mode 100644 index 00000000..514d4290 --- /dev/null +++ b/src/apm_cli/commands/marketplace/publish.py @@ -0,0 +1,229 @@ +"""``apm marketplace publish`` command.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + + +from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _load_targets_file, _render_publish_plan, _render_publish_summary, _get_console, CommandLogger, PublishOutcome, PrResult, PrState) + +@marketplace.command(help="Publish marketplace updates to consumer repositories") +@click.option( + "--targets", + "targets_file", + default=None, + type=click.Path(exists=False), + help="Path to consumer-targets YAML file (default: ./consumer-targets.yml)", +) +@click.option("--dry-run", is_flag=True, help="Preview without pushing or opening PRs") +@click.option("--no-pr", is_flag=True, help="Push branches but skip PR creation") +@click.option("--draft", is_flag=True, help="Create PRs as drafts") +@click.option("--allow-downgrade", is_flag=True, help="Allow version downgrades") +@click.option("--allow-ref-change", is_flag=True, help="Allow switching ref types") +@click.option( + "--parallel", + default=4, + show_default=True, + type=int, + help="Maximum number of concurrent target updates", +) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def publish( + targets_file, + dry_run, + no_pr, + draft, + allow_downgrade, + allow_ref_change, + parallel, + yes, + verbose, +): + """Publish marketplace updates to consumer repositories.""" + from . import MarketplacePublisher, PrIntegrator, _is_interactive + _require_authoring_flag() + logger = CommandLogger("marketplace-publish", verbose=verbose) + + # ------------------------------------------------------------------ + # 1. Pre-flight checks + # ------------------------------------------------------------------ + + # 1a. Load marketplace.yml + yml = _load_yml_or_exit(logger) + + # 1b. Load marketplace.json + mkt_json_path = Path.cwd() / "marketplace.json" + if not mkt_json_path.exists(): + logger.error( + "marketplace.json not found. Run 'apm marketplace build' first.", + symbol="error", + ) + sys.exit(1) + + # 1c. Load targets + if targets_file: + targets_path = Path(targets_file) + if not targets_path.exists(): + logger.error( + f"Targets file not found: {targets_file}", + symbol="error", + ) + sys.exit(1) + else: + targets_path = Path.cwd() / "consumer-targets.yml" + if not targets_path.exists(): + logger.error( + "No consumer-targets.yml found. " + "Create one or pass --targets .\n" + "\n" + "Example consumer-targets.yml:\n" + " targets:\n" + " - repo: acme-org/service-a\n" + " branch: main\n" + " - repo: acme-org/service-b\n" + " branch: develop", + symbol="error", + ) + sys.exit(1) + + targets, error = _load_targets_file(targets_path) + if error: + logger.error(error, symbol="error") + sys.exit(1) + + # 1d. Check gh availability (unless --no-pr) + pr = None + if not no_pr: + pr = PrIntegrator() + available, hint = pr.check_available() + if not available: + logger.error(hint, symbol="error") + sys.exit(1) + + # ------------------------------------------------------------------ + # 2. Plan and confirm + # ------------------------------------------------------------------ + + publisher = MarketplacePublisher(Path.cwd()) + plan = publisher.plan( + targets, + allow_downgrade=allow_downgrade, + allow_ref_change=allow_ref_change, + ) + + # Render publish plan + _render_publish_plan(logger, plan) + + # Confirmation logic + if not yes: + if not _is_interactive(): + logger.error( + "Non-interactive session: pass --yes to confirm the publish.", + symbol="error", + ) + sys.exit(1) + try: + if not click.confirm( + f"Confirm publish to {len(targets)} repositories?", + default=False, + ): + logger.progress("Publish cancelled.", symbol="info") + sys.exit(0) + except click.Abort: + logger.progress("Publish cancelled.", symbol="info") + sys.exit(0) + + if dry_run: + logger.progress( + "Dry run: no branches will be pushed and no PRs will be opened.", + symbol="info", + ) + + # ------------------------------------------------------------------ + # 3. Execute publish + # ------------------------------------------------------------------ + + results = publisher.execute(plan, dry_run=dry_run, parallel=parallel) + + # PR integration + pr_results = [] + if not no_pr: + if pr is None: + pr = PrIntegrator() + + for result in results: + if dry_run: + # In dry-run, preview what PR would do for UPDATED targets + if result.outcome == PublishOutcome.UPDATED: + pr_result = pr.open_or_update( + plan, + result.target, + result, + no_pr=False, + draft=draft, + dry_run=True, + ) + pr_results.append(pr_result) + else: + pr_results.append(PrResult( + target=result.target, + state=PrState.SKIPPED, + pr_number=None, + pr_url=None, + message=f"No PR needed: {result.outcome.value}", + )) + else: + if result.outcome == PublishOutcome.UPDATED: + pr_result = pr.open_or_update( + plan, + result.target, + result, + no_pr=False, + draft=draft, + dry_run=False, + ) + pr_results.append(pr_result) + else: + pr_results.append(PrResult( + target=result.target, + state=PrState.SKIPPED, + pr_number=None, + pr_url=None, + message=f"No PR needed: {result.outcome.value}", + )) + + # ------------------------------------------------------------------ + # 4. Summary rendering + # ------------------------------------------------------------------ + + _render_publish_summary(logger, results, pr_results, no_pr, dry_run) + + # State file path -- use soft_wrap so the path is never split mid-word + # in narrow terminals (Rich would otherwise break at hyphens). + state_path = Path.cwd() / ".apm" / "publish-state.json" + try: + from rich.text import Text + + console = _get_console() + if console is not None: + console.print( + Text(f"[i] State file: {state_path}", no_wrap=True), + style="blue", + highlight=False, + soft_wrap=True, + ) + else: + logger.progress(f"State file: {state_path}", symbol="info") + except Exception: # noqa: BLE001 -- best-effort Rich rendering fallback + logger.progress(f"State file: {state_path}", symbol="info") + + # Exit code + failed_count = sum( + 1 for r in results if r.outcome == PublishOutcome.FAILED + ) + if failed_count > 0: + sys.exit(1) diff --git a/src/apm_cli/commands/marketplace/validate.py b/src/apm_cli/commands/marketplace/validate.py new file mode 100644 index 00000000..fb8ee7cc --- /dev/null +++ b/src/apm_cli/commands/marketplace/validate.py @@ -0,0 +1,93 @@ +"""``apm marketplace validate`` command.""" + +from __future__ import annotations + +import sys +import traceback + +import click + + +from . import (marketplace, CommandLogger) + +@marketplace.command(help="Validate a marketplace manifest") +@click.argument("name", required=True) +@click.option( + "--check-refs", is_flag=True, hidden=True, help="Verify version refs are reachable (network)" +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def validate(name, check_refs, verbose): + """Validate the manifest of a registered marketplace.""" + logger = CommandLogger("marketplace-validate", verbose=verbose) + try: + from ...marketplace.client import fetch_marketplace + from ...marketplace.registry import get_marketplace_by_name + from ...marketplace.validator import validate_marketplace + + source = get_marketplace_by_name(name) + logger.start(f"Validating marketplace '{name}'...", symbol="gear") + + manifest = fetch_marketplace(source, force_refresh=True) + + logger.progress( + f"Found {len(manifest.plugins)} plugins", + symbol="info", + ) + + # Verbose: per-plugin details + if verbose: + for p in manifest.plugins: + source_type = "dict" if isinstance(p.source, dict) else "string" + logger.verbose_detail( + f" {p.name}: source type: {source_type}" + ) + + # Run validation + results = validate_marketplace(manifest) + + # Check-refs placeholder + if check_refs: + logger.warning( + "Ref checking not yet implemented -- skipping ref " + "reachability checks", + symbol="warning", + ) + + # Render results + passed = 0 + warning_count = 0 + error_count = 0 + click.echo() + logger.progress("Validation Results:", symbol="info") + for r in results: + if r.passed and not r.warnings: + logger.success( + f" {r.check_name}: all plugins valid", symbol="check" + ) + passed += 1 + elif r.warnings and not r.errors: + for w in r.warnings: + logger.warning(f" {r.check_name}: {w}", symbol="warning") + warning_count += len(r.warnings) + else: + for e in r.errors: + logger.error(f" {r.check_name}: {e}", symbol="error") + for w in r.warnings: + logger.warning(f" {r.check_name}: {w}", symbol="warning") + error_count += len(r.errors) + warning_count += len(r.warnings) + + click.echo() + logger.progress( + f"Summary: {passed} passed, {warning_count} warnings, " + f"{error_count} errors", + symbol="info", + ) + + if error_count > 0: + sys.exit(1) + + except Exception as e: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Failed to validate marketplace: {e}") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) diff --git a/src/apm_cli/commands/marketplace_plugin.py b/src/apm_cli/commands/marketplace_plugin.py index 26eafc8e..598e89f9 100644 --- a/src/apm_cli/commands/marketplace_plugin.py +++ b/src/apm_cli/commands/marketplace_plugin.py @@ -1,395 +1,27 @@ -"""``apm marketplace package {add,set,remove}`` subgroup. - -Lets maintainers programmatically manage package entries in -``marketplace.yml`` instead of hand-editing YAML. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -import click - -from ..core.command_logger import CommandLogger -from ..marketplace.errors import ( - GitLsRemoteError, - MarketplaceYmlError, - OfflineMissError, -) -from ._helpers import _is_interactive - - -# ------------------------------------------------------------------- -# Constants -# ------------------------------------------------------------------- - -_SHA_RE = re.compile(r"^[0-9a-f]{40}$") - - -# ------------------------------------------------------------------- -# Helpers -# ------------------------------------------------------------------- - - -def _yml_path() -> Path: - """Return the canonical ``marketplace.yml`` path in CWD.""" - return Path.cwd() / "marketplace.yml" - - -def _ensure_yml_exists(logger: CommandLogger) -> Path: - """Return the yml path or exit with guidance if it does not exist.""" - path = _yml_path() - if not path.exists(): - logger.error( - "No marketplace.yml found. " - "Run 'apm marketplace init' to scaffold one.", - symbol="error", - ) - sys.exit(1) - return path - - -def _parse_tags(raw: str | None) -> list[str] | None: - """Split a comma-separated tag string into a list, or return None.""" - if raw is None: - return None - parts = [t.strip() for t in raw.split(",") if t.strip()] - return parts if parts else None - - -def _verify_source(logger: CommandLogger, source: str) -> None: - """Run ``git ls-remote`` against *source* to verify reachability.""" - from ..marketplace.ref_resolver import RefResolver - - resolver = RefResolver() - try: - resolver.list_remote_refs(source) - except GitLsRemoteError as exc: - logger.error( - f"Source '{source}' is not reachable: {exc}", - symbol="error", - ) - sys.exit(2) - except OfflineMissError: - logger.warning( - f"Cannot verify source '{source}' (offline / no cache).", - symbol="warning", - ) - - -def _resolve_ref( - logger: CommandLogger, - source: str, - ref: str | None, - version: str | None, - no_verify: bool, -) -> str | None: - """Resolve *ref* to a concrete SHA when it is mutable. - - Returns the (possibly resolved) ref string, or ``None`` when - *version* is set (version-based pinning, no ref needed). - """ - from ..marketplace.ref_resolver import RefResolver - - # Version-based — no ref resolution needed. - if version is not None: - return None - - # Already a concrete SHA — store as-is. - if ref is not None and _SHA_RE.match(ref): - return ref - - # HEAD (explicit or implicit) requires network access. - is_head = ref is None or ref.upper() == "HEAD" - if is_head: - if no_verify: - logger.error( - "Cannot resolve HEAD ref without network access. " - "Provide an explicit --ref SHA.", - symbol="error", - ) - sys.exit(2) - if ref is not None: - logger.warning( - "'HEAD' is a mutable ref. Resolving to current SHA for safety.", - symbol="warning", - ) - resolver = RefResolver() - try: - sha = resolver.resolve_ref_sha(source, "HEAD") - except GitLsRemoteError as exc: - logger.error( - f"Failed to resolve HEAD for '{source}': {exc}", - symbol="error", - ) - sys.exit(2) - logger.progress( - f"Resolved HEAD to {sha[:12]}", - symbol="info", - ) - return sha - - # Non-HEAD, non-SHA ref — check whether it is a branch name. - resolver = RefResolver() - try: - remote_refs = resolver.list_remote_refs(source) - except (GitLsRemoteError, OfflineMissError): - # Cannot verify — store as-is but warn the user. - logger.warning( - f"Could not verify ref '{ref}' for '{source}' (network unavailable). " - "Storing unresolved -- run with network access to pin a concrete SHA.", - symbol="warning", - ) - return ref - - for remote_ref in remote_refs: - if remote_ref.name == f"refs/heads/{ref}": - if no_verify: - logger.error( - "Cannot resolve branch ref without network access. " - "Provide an explicit --ref SHA.", - symbol="error", - ) - sys.exit(2) - logger.warning( - f"'{ref}' is a branch (mutable ref). " - "Resolving to current SHA for safety.", - symbol="warning", - ) - logger.progress( - f"Resolved {ref} to {remote_ref.sha[:12]}", - symbol="info", - ) - return remote_ref.sha - - # Not a branch — tag or unknown ref; store as-is. - return ref - - -# ------------------------------------------------------------------- -# Click group -# ------------------------------------------------------------------- - - -@click.group(help="Manage packages in marketplace.yml (add, set, remove)") -def package(): - """Add, update, or remove packages in marketplace.yml.""" - from ..commands.marketplace import _require_authoring_flag - - _require_authoring_flag() - - -# ------------------------------------------------------------------- -# package add -# ------------------------------------------------------------------- - - -@package.command(help="Add a package to marketplace.yml") -@click.argument("source") -@click.option("--name", default=None, help="Package name (default: repo name)") -@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") -@click.option( - "--ref", - default=None, - help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", -) -@click.option("-s", "--subdir", default=None, help="Subdirectory inside source repo") -@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") -@click.option("--tags", default=None, help="Comma-separated tags") -@click.option( - "--include-prerelease", is_flag=True, help="Include prerelease versions" -) -@click.option("--no-verify", is_flag=True, help="Skip remote reachability check") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def add( - source, - name, - version, - ref, - subdir, - tag_pattern, - tags, - include_prerelease, - no_verify, - verbose, -): - """Add a package entry to marketplace.yml.""" - from ..marketplace.yml_editor import add_plugin_entry - - logger = CommandLogger("marketplace-package-add", verbose=verbose) - yml = _ensure_yml_exists(logger) - - # --version and --ref are mutually exclusive. - if version and ref: - raise click.UsageError( - "--version and --ref are mutually exclusive. " - "Use --version for semver ranges or --ref for git refs." - ) - - parsed_tags = _parse_tags(tags) - - # Verify source reachability unless skipped. - if not no_verify: - _verify_source(logger, source) - - # Resolve mutable refs to concrete SHAs. - ref = _resolve_ref(logger, source, ref, version, no_verify) - - try: - resolved_name = add_plugin_entry( - yml, - source=source, - name=name, - version=version, - ref=ref, - subdir=subdir, - tag_pattern=tag_pattern, - tags=parsed_tags, - include_prerelease=include_prerelease, - ) - except MarketplaceYmlError as exc: - logger.error(str(exc), symbol="error") - sys.exit(2) - - logger.success( - f"Added package '{resolved_name}' from {source}", - symbol="check", - ) - - -# ------------------------------------------------------------------- -# package set -# ------------------------------------------------------------------- - - -@package.command("set", help="Update a package entry in marketplace.yml") -@click.argument("name") -@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") -@click.option( - "--ref", - default=None, - help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", +"""Compatibility wrapper for the marketplace package command group.""" + +from .marketplace.plugin import ( + _SHA_RE, + _ensure_yml_exists, + _parse_tags, + _resolve_ref, + _verify_source, + _yml_path, + add, + package, + remove, + set_cmd, ) -@click.option("--subdir", default=None, help="Subdirectory inside source repo") -@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") -@click.option("--tags", default=None, help="Comma-separated tags") -@click.option( - "--include-prerelease", - is_flag=True, - default=None, - help="Include prerelease versions", -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def set_cmd( - name, - version, - ref, - subdir, - tag_pattern, - tags, - include_prerelease, - verbose, -): - """Update fields on an existing package entry.""" - from ..marketplace.yml_editor import update_plugin_entry - - logger = CommandLogger("marketplace-package-set", verbose=verbose) - yml = _ensure_yml_exists(logger) - - # --version and --ref are mutually exclusive. - if version and ref: - raise click.UsageError( - "--version and --ref are mutually exclusive. " - "Use --version for semver ranges or --ref for git refs." - ) - - # Resolve mutable refs to concrete SHAs. - if ref is not None and not _SHA_RE.match(ref): - from ..marketplace.yml_schema import load_marketplace_yml - - yml_data = load_marketplace_yml(yml) - source = None - for pkg in yml_data.packages: - if pkg.name.lower() == name.lower(): - source = pkg.source - break - if source is None: - logger.error(f"Package '{name}' not found", symbol="error") - sys.exit(2) - ref = _resolve_ref(logger, source, ref, version, no_verify=False) - - parsed_tags = _parse_tags(tags) - - fields = {} - if version is not None: - fields["version"] = version - if ref is not None: - fields["ref"] = ref - if subdir is not None: - fields["subdir"] = subdir - if tag_pattern is not None: - fields["tag_pattern"] = tag_pattern - if parsed_tags is not None: - fields["tags"] = parsed_tags - if include_prerelease is not None: - fields["include_prerelease"] = include_prerelease - - if not fields: - logger.error( - "No fields specified. Pass at least one option " - "(e.g. --version, --ref, --subdir).", - symbol="error", - ) - sys.exit(1) - - try: - update_plugin_entry(yml, name, **fields) - except MarketplaceYmlError as exc: - logger.error(str(exc), symbol="error") - sys.exit(2) - - logger.success(f"Updated package '{name}'", symbol="check") - - -# ------------------------------------------------------------------- -# package remove -# ------------------------------------------------------------------- - - -@package.command(help="Remove a package from marketplace.yml") -@click.argument("name") -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def remove(name, yes, verbose): - """Remove a package entry from marketplace.yml.""" - from ..marketplace.yml_editor import remove_plugin_entry - - logger = CommandLogger("marketplace-package-remove", verbose=verbose) - yml = _ensure_yml_exists(logger) - - # Confirmation gate. - if not yes: - if not _is_interactive(): - logger.error( - "Use --yes to skip confirmation in non-interactive mode", - symbol="error", - ) - sys.exit(1) - try: - click.confirm( - f"Remove package '{name}' from marketplace.yml?", - abort=True, - ) - except click.Abort: - logger.progress("Cancelled.", symbol="info") - return - - try: - remove_plugin_entry(yml, name) - except MarketplaceYmlError as exc: - logger.error(str(exc), symbol="error") - sys.exit(2) - logger.success(f"Removed package '{name}'", symbol="check") +__all__ = [ + "package", + "add", + "set_cmd", + "remove", + "_SHA_RE", + "_yml_path", + "_ensure_yml_exists", + "_parse_tags", + "_verify_source", + "_resolve_ref", +] From 138a8a567981e1215295d61c9eba7663d5d56bbc Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Wed, 29 Apr 2026 01:26:35 +0530 Subject: [PATCH 2/7] updated tests/integration/marketplace/README.md --- tests/integration/marketplace/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/marketplace/README.md b/tests/integration/marketplace/README.md index 40f772d9..00e880cd 100644 --- a/tests/integration/marketplace/README.md +++ b/tests/integration/marketplace/README.md @@ -146,7 +146,7 @@ APM-only keys (`subdir`, `version`, `ref` in yml, `tag_pattern`, | Unit tests fail | Library logic | Check the specific unit test file in tests/unit/marketplace/. | | Integration tests fail on yml parse | yml_schema.py | Confirm the test fixture YAML is valid. | | Integration tests fail on JSON content | builder.compose_marketplace_json | Check key order and golden fixture. | -| Integration tests fail on exit code | CLI command handler | Inspect the sys.exit() paths in commands/marketplace.py. | +| Integration tests fail on exit code | CLI command handler | Inspect the sys.exit() paths in the relevant module under src/apm_cli/commands/marketplace/. | | Integration tests fail on mock | conftest.py fixture | Confirm mock_ref_resolver patches the right import path. | | Live tests fail on resolution | Real remote | Check that APM_E2E_MARKETPLACE points to a valid repo with tags. | | Live tests fail on timeout | Network or rate limit | Increase timeout or set GITHUB_TOKEN to raise rate limit. | From 305e07011c70883f4c9381566cc9d02ab8852b6c Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Wed, 29 Apr 2026 06:13:20 +0530 Subject: [PATCH 3/7] Fix(marketplace): doctor subprocess import boundary --- src/apm_cli/commands/marketplace/__init__.py | 1 - src/apm_cli/commands/marketplace/doctor.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index b8e3398d..860d0972 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -1199,6 +1199,5 @@ def search(expression, limit, verbose): "validate_path_segments", "_get_console", "_is_interactive", - "subprocess", ] diff --git a/src/apm_cli/commands/marketplace/doctor.py b/src/apm_cli/commands/marketplace/doctor.py index 8ad454fb..962b0120 100644 --- a/src/apm_cli/commands/marketplace/doctor.py +++ b/src/apm_cli/commands/marketplace/doctor.py @@ -3,12 +3,13 @@ from __future__ import annotations import sys +import subprocess from pathlib import Path import click -from . import (marketplace, _require_authoring_flag, _find_duplicate_names, _DoctorCheck, _render_doctor_table, CommandLogger, MarketplaceYmlError, translate_git_stderr, subprocess) +from . import (marketplace, _require_authoring_flag, _find_duplicate_names, _DoctorCheck, _render_doctor_table, CommandLogger, MarketplaceYmlError, translate_git_stderr) @marketplace.command(help="Run environment diagnostics for marketplace builds") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") From 98fae0f100ee374a34ad7fd3c3c8dd406b299fa6 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Wed, 29 Apr 2026 06:22:10 +0530 Subject: [PATCH 4/7] Replace Unicode comment dashes with ASCII hyphens --- src/apm_cli/commands/marketplace/plugin/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/apm_cli/commands/marketplace/plugin/__init__.py b/src/apm_cli/commands/marketplace/plugin/__init__.py index 11c35e04..e4cc712c 100644 --- a/src/apm_cli/commands/marketplace/plugin/__init__.py +++ b/src/apm_cli/commands/marketplace/plugin/__init__.py @@ -75,11 +75,11 @@ def _resolve_ref( """ from ....marketplace.ref_resolver import RefResolver - # Version-based — no ref resolution needed. + # Version-based - no ref resolution needed. if version is not None: return None - # Already a concrete SHA — store as-is. + # Already a concrete SHA - store as-is. if ref is not None and _SHA_RE.match(ref): return ref @@ -113,12 +113,12 @@ def _resolve_ref( ) return sha - # Non-HEAD, non-SHA ref — check whether it is a branch name. + # Non-HEAD, non-SHA ref - check whether it is a branch name. resolver = RefResolver() try: remote_refs = resolver.list_remote_refs(source) except (GitLsRemoteError, OfflineMissError): - # Cannot verify — store as-is but warn the user. + # Cannot verify - store as-is but warn the user. logger.warning( f"Could not verify ref '{ref}' for '{source}' (network unavailable). " "Storing unresolved -- run with network access to pin a concrete SHA.", @@ -146,7 +146,7 @@ def _resolve_ref( ) return remote_ref.sha - # Not a branch — tag or unknown ref; store as-is. + # Not a branch - tag or unknown ref; store as-is. return ref @click.group(help="Manage packages in marketplace.yml (add, set, remove)") From 8f8a130876d4149c90ee1233b96230df77183ace Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Wed, 29 Apr 2026 06:35:28 +0530 Subject: [PATCH 5/7] Fix: marketplace command imports, help, and logging polish --- src/apm_cli/commands/marketplace/__init__.py | 12 +++++++++++- src/apm_cli/commands/marketplace/validate.py | 6 +++--- src/apm_cli/core/command_logger.py | 4 ++++ tests/unit/commands/test_marketplace_gating.py | 5 +++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index 860d0972..0c1e4633 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -52,7 +52,15 @@ class MarketplaceGroup(click.Group): """Custom group that organises commands by audience.""" - _consumer_commands = ["add", "list", "browse", "update", "remove", "validate"] + _consumer_commands = [ + "add", + "list", + "browse", + "search", + "update", + "remove", + "validate", + ] _authoring_commands = ["init", "build", "check", "outdated", "doctor", "publish", "package"] @staticmethod @@ -1123,6 +1131,8 @@ def search(expression, limit, verbose): +marketplace.add_command(search) + from .build import build # noqa: E402 from .check import check # noqa: E402 from .doctor import doctor # noqa: E402 diff --git a/src/apm_cli/commands/marketplace/validate.py b/src/apm_cli/commands/marketplace/validate.py index fb8ee7cc..96e13ab7 100644 --- a/src/apm_cli/commands/marketplace/validate.py +++ b/src/apm_cli/commands/marketplace/validate.py @@ -57,7 +57,7 @@ def validate(name, check_refs, verbose): passed = 0 warning_count = 0 error_count = 0 - click.echo() + logger.blank_line() logger.progress("Validation Results:", symbol="info") for r in results: if r.passed and not r.warnings: @@ -77,7 +77,7 @@ def validate(name, check_refs, verbose): error_count += len(r.errors) warning_count += len(r.warnings) - click.echo() + logger.blank_line() logger.progress( f"Summary: {passed} passed, {warning_count} warnings, " f"{error_count} errors", @@ -88,6 +88,6 @@ def validate(name, check_refs, verbose): sys.exit(1) except Exception as e: # noqa: BLE001 -- top-level command catch-all - logger.error(f"Failed to validate marketplace: {e}") + logger.error(f"Failed to validate marketplace: {e}", symbol="error") logger.verbose_detail(traceback.format_exc()) sys.exit(1) diff --git a/src/apm_cli/core/command_logger.py b/src/apm_cli/core/command_logger.py index 5a95d0eb..581622b9 100644 --- a/src/apm_cli/core/command_logger.py +++ b/src/apm_cli/core/command_logger.py @@ -111,6 +111,10 @@ def tree_item(self, message: str): """ _rich_echo(message, color="green") + def blank_line(self): + """Log a blank line through the shared console output path.""" + _rich_echo("") + def package_inline_warning(self, message: str): """Log an inline warning under a package block (verbose only). diff --git a/tests/unit/commands/test_marketplace_gating.py b/tests/unit/commands/test_marketplace_gating.py index 6705f9e2..a6d6af3f 100644 --- a/tests/unit/commands/test_marketplace_gating.py +++ b/tests/unit/commands/test_marketplace_gating.py @@ -2,7 +2,7 @@ Verifies: - ``marketplace_authoring`` flag is registered in the ``FLAGS`` registry - - Consumer commands (add, list, browse, update, remove, validate) work + - Consumer commands (add, list, browse, search, update, remove, validate) work WITHOUT the flag enabled - Authoring commands (init, build, check, outdated, doctor, publish, package) are blocked when the flag is disabled, with an enablement message @@ -77,7 +77,7 @@ def test_flag_description_mentions_authoring(self) -> None: class TestConsumerCommandsUngated: """Consumer commands must work without marketplace_authoring enabled.""" - @pytest.mark.parametrize("subcmd", ["add", "list", "browse", "update", "remove", "validate"]) + @pytest.mark.parametrize("subcmd", ["add", "list", "browse", "search", "update", "remove", "validate"]) def test_consumer_command_reachable_when_flag_disabled(self, subcmd: str) -> None: """Consumer subcommands are not blocked by the authoring flag.""" from apm_cli.commands.marketplace import marketplace @@ -107,6 +107,7 @@ def test_marketplace_help_works_when_flag_disabled(self) -> None: assert result.exit_code == 0 assert "Consumer commands" in result.output + assert "search" in result.output def test_marketplace_help_hides_authoring_when_flag_disabled(self) -> None: """``marketplace --help`` omits authoring section when flag is off.""" From 7299f5572f72c55407721410463b8839575147a8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 29 Apr 2026 13:53:09 +0200 Subject: [PATCH 6/7] fix(marketplace): revert search alias regression + clean import discipline Address review-panel REQUIRED finding plus architect feedback: REQUIRED FIX (search alias regression): - Remove 'search' from MarketplaceGroup._consumer_commands and drop the marketplace.add_command(search) call. 'apm search' remains the canonical top-level command (registered in cli.py); the new 'apm marketplace search' alias was an unintentional surface added by the refactor split. - Update tests/unit/commands/test_marketplace_gating.py to reflect that 'search' is not part of the marketplace group's consumer command set. IMPORT DISCIPLINE (architect feedback): - Submodules (build, check, doctor, init, outdated, publish, validate, plugin/{add,remove,set}) now import domain types from their canonical source modules (...core.command_logger, ...marketplace.builder, ...marketplace.errors, etc.) instead of re-importing them via the package __init__. Drop redundant lazy 'from . import X' calls inside command bodies. - Package __init__ keeps eager re-exports (used by test mock.patch and by the helpers that still live in __init__) but the submodule code paths no longer rely on them. - Remove dead 'import subprocess' from package __init__. CLEANUP: - Delete src/apm_cli/commands/marketplace_plugin.py compatibility shim. Its sole consumer (tests/unit/commands/test_marketplace_plugin.py) now imports from apm_cli.commands.marketplace.plugin directly. - Update mock.patch paths in unit + integration tests to point at the canonical submodule namespace (e.g. marketplace.build.MarketplaceBuilder instead of marketplace.MarketplaceBuilder). Verified: 6702 unit tests pass; 'apm marketplace --help' lists the correct consumer set (no 'search'); 'from apm_cli.commands.marketplace import marketplace, search' still works. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/marketplace/__init__.py | 30 +----- src/apm_cli/commands/marketplace/build.py | 12 ++- src/apm_cli/commands/marketplace/check.py | 15 ++- src/apm_cli/commands/marketplace/doctor.py | 15 ++- src/apm_cli/commands/marketplace/init.py | 8 +- src/apm_cli/commands/marketplace/outdated.py | 15 ++- .../commands/marketplace/plugin/add.py | 10 +- .../commands/marketplace/plugin/remove.py | 8 +- .../commands/marketplace/plugin/set.py | 10 +- src/apm_cli/commands/marketplace/publish.py | 14 ++- src/apm_cli/commands/marketplace/validate.py | 4 +- src/apm_cli/commands/marketplace_plugin.py | 27 ----- .../marketplace/test_publish_integration.py | 28 ++--- tests/unit/commands/test_marketplace_build.py | 42 ++++---- tests/unit/commands/test_marketplace_check.py | 36 +++---- .../unit/commands/test_marketplace_doctor.py | 70 ++++++------ .../unit/commands/test_marketplace_gating.py | 10 +- .../commands/test_marketplace_outdated.py | 34 +++--- .../unit/commands/test_marketplace_plugin.py | 2 +- .../unit/commands/test_marketplace_publish.py | 102 +++++++++--------- 20 files changed, 261 insertions(+), 231 deletions(-) delete mode 100644 src/apm_cli/commands/marketplace_plugin.py diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index d71f8272..02a7be0c 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -10,7 +10,6 @@ import json import os import re -import subprocess import sys import traceback from pathlib import Path @@ -72,7 +71,6 @@ class MarketplaceGroup(click.Group): "add", "list", "browse", - "search", "update", "remove", "validate", @@ -1190,8 +1188,6 @@ def search(expression, limit, verbose): -marketplace.add_command(search) - from .build import build # noqa: E402 from .check import check # noqa: E402 from .doctor import doctor # noqa: E402 @@ -1200,6 +1196,10 @@ def search(expression, limit, verbose): from .publish import publish # noqa: E402 from .validate import validate # noqa: E402 +# Public surface: the click group + per-command callables. Domain types are +# re-exported from canonical sources for backward compatibility with tests +# and external consumers that patch via this package path. Submodules import +# their domain types from the canonical sources directly, not from here. __all__ = [ "MarketplaceGroup", "marketplace", @@ -1217,26 +1217,6 @@ def search(expression, limit, verbose): "doctor", "publish", "search", - "_load_yml_or_exit", - "_warn_duplicate_names", - "_find_duplicate_names", - "_require_authoring_flag", - "_check_gitignore_for_marketplace_json", - "_render_build_error", - "_render_build_table", - "_OutdatedRow", - "_load_current_versions", - "_extract_tag_versions", - "_render_outdated_table", - "_CheckResult", - "_render_check_table", - "_DoctorCheck", - "_render_doctor_table", - "_load_targets_file", - "_render_publish_plan", - "_render_publish_summary", - "_outcome_symbol", - "_render_publish_footer", "BuildOptions", "BuildReport", "MarketplaceBuilder", @@ -1266,7 +1246,5 @@ def search(expression, limit, verbose): "load_marketplace_yml", "PathTraversalError", "validate_path_segments", - "_get_console", - "_is_interactive", ] diff --git a/src/apm_cli/commands/marketplace/build.py b/src/apm_cli/commands/marketplace/build.py index 9a4fa974..d962c8c6 100644 --- a/src/apm_cli/commands/marketplace/build.py +++ b/src/apm_cli/commands/marketplace/build.py @@ -8,8 +8,17 @@ import click +from ...core.command_logger import CommandLogger +from ...marketplace.builder import BuildOptions, MarketplaceBuilder +from ...marketplace.errors import BuildError, MarketplaceYmlError +from . import ( + marketplace, + _load_yml_or_exit, + _render_build_error, + _render_build_table, + _require_authoring_flag, +) -from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _render_build_error, _render_build_table, CommandLogger, BuildOptions, MarketplaceYmlError, BuildError) @marketplace.command(help="Build marketplace.json from marketplace.yml") @click.option("--dry-run", is_flag=True, help="Preview without writing marketplace.json") @@ -20,7 +29,6 @@ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def build(dry_run, offline, include_prerelease, verbose): """Resolve packages and compile marketplace.json.""" - from . import MarketplaceBuilder _require_authoring_flag() logger = CommandLogger("marketplace-build", verbose=verbose) yml_path = Path.cwd() / "marketplace.yml" diff --git a/src/apm_cli/commands/marketplace/check.py b/src/apm_cli/commands/marketplace/check.py index 44039140..9d504548 100644 --- a/src/apm_cli/commands/marketplace/check.py +++ b/src/apm_cli/commands/marketplace/check.py @@ -7,15 +7,26 @@ import click +from ...core.command_logger import CommandLogger +from ...marketplace.errors import GitLsRemoteError, OfflineMissError +from ...marketplace.ref_resolver import RefResolver +from ...marketplace.semver import satisfies_range +from . import ( + marketplace, + _CheckResult, + _extract_tag_versions, + _load_yml_or_exit, + _render_check_table, + _require_authoring_flag, + _warn_duplicate_names, +) -from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _warn_duplicate_names, _CheckResult, _extract_tag_versions, _render_check_table, CommandLogger, OfflineMissError, GitLsRemoteError, satisfies_range) @marketplace.command(help="Validate marketplace.yml entries are resolvable") @click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def check(offline, verbose): """Validate marketplace.yml and check each entry is resolvable.""" - from . import RefResolver _require_authoring_flag() logger = CommandLogger("marketplace-check", verbose=verbose) diff --git a/src/apm_cli/commands/marketplace/doctor.py b/src/apm_cli/commands/marketplace/doctor.py index 962b0120..b7df7cbf 100644 --- a/src/apm_cli/commands/marketplace/doctor.py +++ b/src/apm_cli/commands/marketplace/doctor.py @@ -2,20 +2,29 @@ from __future__ import annotations -import sys import subprocess +import sys from pathlib import Path import click +from ...core.command_logger import CommandLogger +from ...marketplace.errors import MarketplaceYmlError +from ...marketplace.git_stderr import translate_git_stderr +from ...marketplace.yml_schema import load_marketplace_yml +from . import ( + marketplace, + _DoctorCheck, + _find_duplicate_names, + _render_doctor_table, + _require_authoring_flag, +) -from . import (marketplace, _require_authoring_flag, _find_duplicate_names, _DoctorCheck, _render_doctor_table, CommandLogger, MarketplaceYmlError, translate_git_stderr) @marketplace.command(help="Run environment diagnostics for marketplace builds") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def doctor(verbose): """Check git, network, auth, and marketplace.yml readiness.""" - from . import load_marketplace_yml _require_authoring_flag() logger = CommandLogger("marketplace-doctor", verbose=verbose) checks = [] diff --git a/src/apm_cli/commands/marketplace/init.py b/src/apm_cli/commands/marketplace/init.py index bb6f2e59..f6acc583 100644 --- a/src/apm_cli/commands/marketplace/init.py +++ b/src/apm_cli/commands/marketplace/init.py @@ -7,8 +7,12 @@ import click - -from . import (marketplace, _require_authoring_flag, _check_gitignore_for_marketplace_json, CommandLogger) +from ...core.command_logger import CommandLogger +from . import ( + marketplace, + _check_gitignore_for_marketplace_json, + _require_authoring_flag, +) @marketplace.command(help="Scaffold a new marketplace.yml in the current directory") @click.option("--force", is_flag=True, help="Overwrite existing marketplace.yml") diff --git a/src/apm_cli/commands/marketplace/outdated.py b/src/apm_cli/commands/marketplace/outdated.py index 6fb2a4a1..3f4e61a2 100644 --- a/src/apm_cli/commands/marketplace/outdated.py +++ b/src/apm_cli/commands/marketplace/outdated.py @@ -7,8 +7,20 @@ import click +from ...core.command_logger import CommandLogger +from ...marketplace.errors import BuildError +from ...marketplace.ref_resolver import RefResolver +from ...marketplace.semver import satisfies_range +from . import ( + marketplace, + _OutdatedRow, + _extract_tag_versions, + _load_current_versions, + _load_yml_or_exit, + _render_outdated_table, + _require_authoring_flag, +) -from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _load_current_versions, _OutdatedRow, _extract_tag_versions, _render_outdated_table, CommandLogger, BuildError, satisfies_range) @marketplace.command(help="Show packages with available upgrades") @click.option("--offline", is_flag=True, help="Use cached refs only (no network)") @@ -18,7 +30,6 @@ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def outdated(offline, include_prerelease, verbose): """Compare installed versions against latest available tags.""" - from . import RefResolver _require_authoring_flag() logger = CommandLogger("marketplace-outdated", verbose=verbose) diff --git a/src/apm_cli/commands/marketplace/plugin/add.py b/src/apm_cli/commands/marketplace/plugin/add.py index be3b3d7c..a8189e8e 100644 --- a/src/apm_cli/commands/marketplace/plugin/add.py +++ b/src/apm_cli/commands/marketplace/plugin/add.py @@ -7,7 +7,15 @@ import click -from . import (package, _ensure_yml_exists, _parse_tags, _resolve_ref, _verify_source, CommandLogger, MarketplaceYmlError) +from ....core.command_logger import CommandLogger +from ....marketplace.errors import MarketplaceYmlError +from . import ( + package, + _ensure_yml_exists, + _parse_tags, + _resolve_ref, + _verify_source, +) @package.command(help="Add a package to marketplace.yml") @click.argument("source") diff --git a/src/apm_cli/commands/marketplace/plugin/remove.py b/src/apm_cli/commands/marketplace/plugin/remove.py index 3fd3181d..cdf34aef 100644 --- a/src/apm_cli/commands/marketplace/plugin/remove.py +++ b/src/apm_cli/commands/marketplace/plugin/remove.py @@ -7,7 +7,13 @@ import click -from . import (package, _ensure_yml_exists, _is_interactive, CommandLogger, MarketplaceYmlError) +from ....core.command_logger import CommandLogger +from ....marketplace.errors import MarketplaceYmlError +from . import ( + package, + _ensure_yml_exists, + _is_interactive, +) @package.command(help="Remove a package from marketplace.yml") @click.argument("name") diff --git a/src/apm_cli/commands/marketplace/plugin/set.py b/src/apm_cli/commands/marketplace/plugin/set.py index e324c60c..00d1280a 100644 --- a/src/apm_cli/commands/marketplace/plugin/set.py +++ b/src/apm_cli/commands/marketplace/plugin/set.py @@ -7,7 +7,15 @@ import click -from . import (package, _ensure_yml_exists, _parse_tags, _resolve_ref, _SHA_RE, CommandLogger, MarketplaceYmlError) +from ....core.command_logger import CommandLogger +from ....marketplace.errors import MarketplaceYmlError +from . import ( + package, + _ensure_yml_exists, + _parse_tags, + _resolve_ref, + _SHA_RE, +) @package.command("set", help="Update a package entry in marketplace.yml") @click.argument("name") diff --git a/src/apm_cli/commands/marketplace/publish.py b/src/apm_cli/commands/marketplace/publish.py index 514d4290..45720613 100644 --- a/src/apm_cli/commands/marketplace/publish.py +++ b/src/apm_cli/commands/marketplace/publish.py @@ -7,8 +7,19 @@ import click +from ...core.command_logger import CommandLogger +from ...marketplace.pr_integration import PrIntegrator, PrResult, PrState +from ...marketplace.publisher import MarketplacePublisher, PublishOutcome +from .._helpers import _get_console, _is_interactive +from . import ( + marketplace, + _load_targets_file, + _load_yml_or_exit, + _render_publish_plan, + _render_publish_summary, + _require_authoring_flag, +) -from . import (marketplace, _require_authoring_flag, _load_yml_or_exit, _load_targets_file, _render_publish_plan, _render_publish_summary, _get_console, CommandLogger, PublishOutcome, PrResult, PrState) @marketplace.command(help="Publish marketplace updates to consumer repositories") @click.option( @@ -44,7 +55,6 @@ def publish( verbose, ): """Publish marketplace updates to consumer repositories.""" - from . import MarketplacePublisher, PrIntegrator, _is_interactive _require_authoring_flag() logger = CommandLogger("marketplace-publish", verbose=verbose) diff --git a/src/apm_cli/commands/marketplace/validate.py b/src/apm_cli/commands/marketplace/validate.py index 96e13ab7..6f3bb5dd 100644 --- a/src/apm_cli/commands/marketplace/validate.py +++ b/src/apm_cli/commands/marketplace/validate.py @@ -7,8 +7,8 @@ import click - -from . import (marketplace, CommandLogger) +from ...core.command_logger import CommandLogger +from . import marketplace @marketplace.command(help="Validate a marketplace manifest") @click.argument("name", required=True) diff --git a/src/apm_cli/commands/marketplace_plugin.py b/src/apm_cli/commands/marketplace_plugin.py deleted file mode 100644 index 598e89f9..00000000 --- a/src/apm_cli/commands/marketplace_plugin.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Compatibility wrapper for the marketplace package command group.""" - -from .marketplace.plugin import ( - _SHA_RE, - _ensure_yml_exists, - _parse_tags, - _resolve_ref, - _verify_source, - _yml_path, - add, - package, - remove, - set_cmd, -) - -__all__ = [ - "package", - "add", - "set_cmd", - "remove", - "_SHA_RE", - "_yml_path", - "_ensure_yml_exists", - "_parse_tags", - "_verify_source", - "_resolve_ref", -] diff --git a/tests/integration/marketplace/test_publish_integration.py b/tests/integration/marketplace/test_publish_integration.py index ed974d6b..bd930309 100644 --- a/tests/integration/marketplace/test_publish_integration.py +++ b/tests/integration/marketplace/test_publish_integration.py @@ -168,23 +168,23 @@ def _run_publish(tmp_path: Path, extra_args=(), mock_plan=None, mock_results=Non with ( patch( - "apm_cli.commands.marketplace.MarketplacePublisher.plan", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", return_value=plan, ), patch( - "apm_cli.commands.marketplace.MarketplacePublisher.execute", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", return_value=results, ), patch( - "apm_cli.commands.marketplace.PrIntegrator.check_available", + "apm_cli.commands.marketplace.publish.PrIntegrator.check_available", return_value=(mock_pr_available, "gh available"), ), patch( - "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", side_effect=pr_results, ), patch( - "apm_cli.commands.marketplace._is_interactive", + "apm_cli.commands.marketplace.publish._is_interactive", return_value=False, ), patch.dict(os.environ, env, clear=False), @@ -258,23 +258,23 @@ def test_dry_run_forwarded_to_execute(self, tmp_path: Path): with ( patch( - "apm_cli.commands.marketplace.MarketplacePublisher.plan", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", return_value=plan, ), patch( - "apm_cli.commands.marketplace.MarketplacePublisher.execute", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", execute_mock, ), patch( - "apm_cli.commands.marketplace.PrIntegrator.check_available", + "apm_cli.commands.marketplace.publish.PrIntegrator.check_available", return_value=(True, "ok"), ), patch( - "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", return_value=_make_pr_result("consumer-org/service-a", PrState.SKIPPED), ), patch( - "apm_cli.commands.marketplace._is_interactive", + "apm_cli.commands.marketplace.publish._is_interactive", return_value=False, ), ): @@ -384,21 +384,21 @@ def test_no_pr_skips_pr_integrator(self, tmp_path: Path): with ( patch( - "apm_cli.commands.marketplace.MarketplacePublisher.plan", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", return_value=plan, ), patch( - "apm_cli.commands.marketplace.MarketplacePublisher.execute", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", return_value=[ _make_target_result("consumer-org/service-a", PublishOutcome.UPDATED) ], ), patch( - "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", open_or_update_mock, ), patch( - "apm_cli.commands.marketplace._is_interactive", + "apm_cli.commands.marketplace.publish._is_interactive", return_value=False, ), ): diff --git a/tests/unit/commands/test_marketplace_build.py b/tests/unit/commands/test_marketplace_build.py index 501fb3fe..64366b85 100644 --- a/tests/unit/commands/test_marketplace_build.py +++ b/tests/unit/commands/test_marketplace_build.py @@ -111,7 +111,7 @@ def yml_cwd(tmp_path, monkeypatch): class TestBuildHappyPath: """build command -- success scenarios.""" - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_basic_build_success(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report() @@ -121,7 +121,7 @@ def test_basic_build_success(self, MockBuilder, runner, yml_cwd): assert "Built marketplace.json" in result.output assert "2 packages" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_build_table_contains_package_names(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report() @@ -131,7 +131,7 @@ def test_build_table_contains_package_names(self, MockBuilder, runner, yml_cwd): assert "pkg-alpha" in result.output assert "pkg-beta" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_build_table_contains_version_refs(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report() @@ -140,7 +140,7 @@ def test_build_table_contains_version_refs(self, MockBuilder, runner, yml_cwd): assert "v1.2.0" in result.output assert "v2.0.1" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_build_table_shows_sha_prefix(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report() @@ -158,7 +158,7 @@ def test_build_table_shows_sha_prefix(self, MockBuilder, runner, yml_cwd): class TestBuildDryRun: """build --dry-run scenarios.""" - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_dry_run_message(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report(dry_run=True) @@ -168,7 +168,7 @@ def test_dry_run_message(self, MockBuilder, runner, yml_cwd): assert "Dry run" in result.output assert "not written" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_dry_run_no_built_message(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report(dry_run=True) @@ -176,7 +176,7 @@ def test_dry_run_no_built_message(self, MockBuilder, runner, yml_cwd): result = runner.invoke(marketplace, ["build", "--dry-run"]) assert "Built marketplace.json" not in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_dry_run_passes_option_to_builder(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report(dry_run=True) @@ -194,7 +194,7 @@ def test_dry_run_passes_option_to_builder(self, MockBuilder, runner, yml_cwd): class TestBuildFlags: """Verify CLI flags are forwarded to BuildOptions.""" - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_offline_flag(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report() @@ -203,7 +203,7 @@ def test_offline_flag(self, MockBuilder, runner, yml_cwd): opts = MockBuilder.call_args[1].get("options") or MockBuilder.call_args[0][1] assert opts.offline is True - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_include_prerelease_flag(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report() @@ -212,7 +212,7 @@ def test_include_prerelease_flag(self, MockBuilder, runner, yml_cwd): opts = MockBuilder.call_args[1].get("options") or MockBuilder.call_args[0][1] assert opts.include_prerelease is True - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_verbose_flag(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report() @@ -266,7 +266,7 @@ def test_bad_yaml_syntax_exits_2(self, runner, tmp_path, monkeypatch): class TestBuildErrors: """build command -- BuildError subclass handling.""" - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_no_matching_version_error(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.side_effect = NoMatchingVersionError( @@ -277,7 +277,7 @@ def test_no_matching_version_error(self, MockBuilder, runner, yml_cwd): assert result.exit_code == 1 assert "pkg-alpha" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_ref_not_found_error(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.side_effect = RefNotFoundError( @@ -288,7 +288,7 @@ def test_ref_not_found_error(self, MockBuilder, runner, yml_cwd): assert result.exit_code == 1 assert "not found" in result.output.lower() - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_git_ls_remote_error(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.side_effect = GitLsRemoteError( @@ -302,7 +302,7 @@ def test_git_ls_remote_error(self, MockBuilder, runner, yml_cwd): assert "Authentication failed" in result.output assert "GITHUB_TOKEN" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_offline_miss_error(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.side_effect = OfflineMissError( @@ -313,7 +313,7 @@ def test_offline_miss_error(self, MockBuilder, runner, yml_cwd): assert result.exit_code == 1 assert "offline" in result.output.lower() or "cache" in result.output.lower() - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_head_not_allowed_error(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.side_effect = HeadNotAllowedError( @@ -324,7 +324,7 @@ def test_head_not_allowed_error(self, MockBuilder, runner, yml_cwd): assert result.exit_code == 1 assert "main" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_generic_build_error(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.side_effect = BuildError( @@ -343,7 +343,7 @@ def test_generic_build_error(self, MockBuilder, runner, yml_cwd): class TestBuildEdgeCases: """Edge cases for the build command.""" - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_empty_packages_list(self, MockBuilder, runner, yml_cwd): mock_inst = MockBuilder.return_value mock_inst.build.return_value = _make_report(resolved=(), added=0) @@ -352,7 +352,7 @@ def test_empty_packages_list(self, MockBuilder, runner, yml_cwd): assert result.exit_code == 0 assert "0 packages" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_single_package(self, MockBuilder, runner, yml_cwd): single = ( ResolvedPackage( @@ -373,7 +373,7 @@ def test_single_package(self, MockBuilder, runner, yml_cwd): assert result.exit_code == 0 assert "only-one" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_prerelease_package(self, MockBuilder, runner, yml_cwd): pre = ( ResolvedPackage( @@ -403,7 +403,7 @@ def test_prerelease_package(self, MockBuilder, runner, yml_cwd): class TestBuildVerboseTraceback: """build --verbose -- traceback on unexpected failure.""" - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_verbose_shows_traceback_on_unexpected_error( self, MockBuilder, runner, yml_cwd ): @@ -417,7 +417,7 @@ def test_verbose_shows_traceback_on_unexpected_error( assert "Traceback" in result.output assert "unexpected internal failure" in result.output - @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + @patch("apm_cli.commands.marketplace.build.MarketplaceBuilder") def test_no_traceback_without_verbose(self, MockBuilder, runner, yml_cwd): """Without --verbose the traceback is suppressed.""" mock_inst = MockBuilder.return_value diff --git a/tests/unit/commands/test_marketplace_check.py b/tests/unit/commands/test_marketplace_check.py index a04bc6f7..759da6b7 100644 --- a/tests/unit/commands/test_marketplace_check.py +++ b/tests/unit/commands/test_marketplace_check.py @@ -100,7 +100,7 @@ def yml_cwd(tmp_path, monkeypatch): class TestCheckAllOK: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_all_entries_pass(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -110,7 +110,7 @@ def test_all_entries_pass(self, MockResolver, runner, yml_cwd): assert result.exit_code == 0 assert "All 2 entries OK" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_shows_package_names(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -120,7 +120,7 @@ def test_shows_package_names(self, MockResolver, runner, yml_cwd): assert "pkg-alpha" in result.output assert "pkg-beta" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_success_icon_shown(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -136,7 +136,7 @@ def test_success_icon_shown(self, MockResolver, runner, yml_cwd): class TestCheckExplicitRef: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_ref_found(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_WITH_REF, encoding="utf-8") @@ -150,7 +150,7 @@ def test_ref_found(self, MockResolver, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "pinned-pkg" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_ref_not_found(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "200") @@ -173,7 +173,7 @@ def test_ref_not_found(self, MockResolver, runner, tmp_path, monkeypatch): class TestCheckFailures: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_one_failure_exits_1(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value # First package OK, second fails @@ -189,7 +189,7 @@ def test_one_failure_exits_1(self, MockResolver, runner, yml_cwd): assert result.exit_code == 1 assert "1 entries have issues" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_all_failures_exits_1(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -201,7 +201,7 @@ def test_all_failures_exits_1(self, MockResolver, runner, yml_cwd): assert result.exit_code == 1 assert "2 entries have issues" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_failure_icon_shown(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -212,7 +212,7 @@ def test_failure_icon_shown(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["check"]) assert "[x]" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_no_matching_version(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value # Return tags that don't match the version range @@ -251,7 +251,7 @@ def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): class TestCheckOffline: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_offline_label_shown(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = OfflineMissError( @@ -263,7 +263,7 @@ def test_offline_label_shown(self, MockResolver, runner, yml_cwd): assert "Offline mode" in result.output or "offline" in result.output.lower() MockResolver.assert_called_once_with(offline=True) - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_offline_cache_miss_fails_entry(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = OfflineMissError( @@ -281,7 +281,7 @@ def test_offline_cache_miss_fails_entry(self, MockResolver, runner, yml_cwd): class TestCheckVerbose: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_verbose_no_crash(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -297,7 +297,7 @@ def test_verbose_no_crash(self, MockResolver, runner, yml_cwd): class TestCheckResolverCleanup: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_resolver_close_called(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -306,7 +306,7 @@ def test_resolver_close_called(self, MockResolver, runner, yml_cwd): runner.invoke(marketplace, ["check"]) mock_inst.close.assert_called_once() - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_resolver_close_on_failure(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -324,7 +324,7 @@ def test_resolver_close_on_failure(self, MockResolver, runner, yml_cwd): class TestCheckEdgeCases: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_single_entry_all_ok(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_SINGLE, encoding="utf-8") @@ -336,7 +336,7 @@ def test_single_entry_all_ok(self, MockResolver, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "All 1 entries OK" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_generic_exception_handled(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = RuntimeError("Unexpected") @@ -355,7 +355,7 @@ def test_generic_exception_handled(self, MockResolver, runner, yml_cwd): class TestCheckDuplicateNames: """Defence-in-depth duplicate name check in the check command.""" - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") @patch("apm_cli.commands.marketplace.load_marketplace_yml") def test_duplicate_names_warned( self, mock_load, MockResolver, runner, tmp_path, monkeypatch, @@ -390,7 +390,7 @@ def test_duplicate_names_warned( result = runner.invoke(marketplace, ["check"]) assert "Duplicate package name 'learning'" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") @patch("apm_cli.commands.marketplace.load_marketplace_yml") def test_no_warning_when_unique( self, mock_load, MockResolver, runner, tmp_path, monkeypatch, diff --git a/tests/unit/commands/test_marketplace_doctor.py b/tests/unit/commands/test_marketplace_doctor.py index d3e92b5f..f0f40449 100644 --- a/tests/unit/commands/test_marketplace_doctor.py +++ b/tests/unit/commands/test_marketplace_doctor.py @@ -82,7 +82,7 @@ def _make_run_result(returncode=0, stdout="", stderr=""): class TestDoctorAllPass: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_all_pass_exit_0(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("GITHUB_TOKEN", "test-token") @@ -97,7 +97,7 @@ def test_all_pass_exit_0(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert result.exit_code == 0 - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_version_shown(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -109,7 +109,7 @@ def test_git_version_shown(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert "git version" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_reachable_shown(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -128,7 +128,7 @@ def test_network_reachable_shown(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorGitCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_missing_exits_1(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = FileNotFoundError("git not found") @@ -137,7 +137,7 @@ def test_git_missing_exits_1(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 1 assert "not found" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_timeout(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5) @@ -146,7 +146,7 @@ def test_git_timeout(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 1 assert "timed out" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -165,7 +165,7 @@ def test_git_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorNetworkCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_failure_exits_1(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -177,7 +177,7 @@ def test_network_failure_exits_1(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert result.exit_code == 1 - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_timeout(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -190,7 +190,7 @@ def test_network_timeout(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 1 assert "timed out" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_auth_error(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -209,7 +209,7 @@ def test_network_auth_error(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorAuthCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_github_token_detected(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123") @@ -224,7 +224,7 @@ def test_github_token_detected(self, mock_run, runner, tmp_path, monkeypatch): # Must NOT print the actual token assert "ghp_test123" not in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_token_detected(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.delenv("GITHUB_TOKEN", raising=False) @@ -239,7 +239,7 @@ def test_gh_token_detected(self, mock_run, runner, tmp_path, monkeypatch): assert "Token detected" in result.output assert "gho_test456" not in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_no_token_informational(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # Override the autouse mock so AuthResolver reports no token. @@ -265,7 +265,7 @@ def test_no_token_informational(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorGhCliCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_found_shows_version(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -278,7 +278,7 @@ def test_gh_found_shows_version(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "gh version" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_missing_is_warning_not_error(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -292,7 +292,7 @@ def test_gh_missing_is_warning_not_error(self, mock_run, runner, tmp_path, monke assert "not found" in result.output.lower() assert "cli.github.com" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -305,7 +305,7 @@ def test_gh_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 # informational assert "non-zero" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_timeout(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -318,7 +318,7 @@ def test_gh_timeout(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 # informational assert "timed out" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_general_exception(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -331,7 +331,7 @@ def test_gh_general_exception(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 # informational assert "Permission denied" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_shown_in_table(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -350,7 +350,7 @@ def test_gh_shown_in_table(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorYmlCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_present_and_valid(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") @@ -364,7 +364,7 @@ def test_yml_present_and_valid(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "valid" in result.output.lower() or "found" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_present_but_invalid(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text("bad: true\n", encoding="utf-8") @@ -379,7 +379,7 @@ def test_yml_present_but_invalid(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "error" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -399,7 +399,7 @@ def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorExitCodes: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_invalid_does_not_cause_exit_1(self, mock_run, runner, tmp_path, monkeypatch): """Check 5 is informational; invalid yml alone should not exit 1.""" monkeypatch.chdir(tmp_path) @@ -413,7 +413,7 @@ def test_yml_invalid_does_not_cause_exit_1(self, mock_run, runner, tmp_path, mon result = runner.invoke(marketplace, ["doctor"]) assert result.exit_code == 0 - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_fail_plus_valid_yml_exits_1(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") @@ -429,7 +429,7 @@ def test_git_fail_plus_valid_yml_exits_1(self, mock_run, runner, tmp_path, monke class TestDoctorVerbose: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_verbose_no_crash(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -448,7 +448,7 @@ def test_verbose_no_crash(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorTable: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_table_has_check_column(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -463,7 +463,7 @@ def test_table_has_check_column(self, mock_run, runner, tmp_path, monkeypatch): assert "network" in result.output.lower() assert "auth" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_info_icon_for_auth(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.delenv("GITHUB_TOKEN", raising=False) @@ -477,7 +477,7 @@ def test_info_icon_for_auth(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert "[i]" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_pass_icon_for_git(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -489,7 +489,7 @@ def test_pass_icon_for_git(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert "[+]" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_fail_icon_for_git_missing(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = FileNotFoundError("not found") @@ -504,7 +504,7 @@ def test_fail_icon_for_git_missing(self, mock_run, runner, tmp_path, monkeypatch class TestDoctorEdgeCases: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_general_exception_in_git_check(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = OSError("Permission denied") @@ -513,7 +513,7 @@ def test_general_exception_in_git_check(self, mock_run, runner, tmp_path, monkey assert result.exit_code == 1 assert "Permission denied" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_ok_network_file_not_found(self, mock_run, runner, tmp_path, monkeypatch): """When git works but network check raises FileNotFoundError.""" monkeypatch.chdir(tmp_path) @@ -535,8 +535,8 @@ def test_git_ok_network_file_not_found(self, mock_run, runner, tmp_path, monkeyp class TestDoctorDuplicateNames: """Defence-in-depth duplicate name check in the doctor command.""" - @patch("apm_cli.commands.marketplace.subprocess.run") - @patch("apm_cli.commands.marketplace.load_marketplace_yml") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.load_marketplace_yml") def test_duplicate_names_flagged( self, mock_load, mock_run, runner, tmp_path, monkeypatch, ): @@ -568,8 +568,8 @@ def test_duplicate_names_flagged( assert "duplicate" in result.output.lower() assert "learning" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") - @patch("apm_cli.commands.marketplace.load_marketplace_yml") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.load_marketplace_yml") def test_no_duplicate_names_shows_pass( self, mock_load, mock_run, runner, tmp_path, monkeypatch, ): @@ -599,7 +599,7 @@ def test_no_duplicate_names_shows_pass( assert result.exit_code == 0 assert "No duplicate package names" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_no_duplicate_check_when_yml_absent( self, mock_run, runner, tmp_path, monkeypatch, ): diff --git a/tests/unit/commands/test_marketplace_gating.py b/tests/unit/commands/test_marketplace_gating.py index a6d6af3f..adde95eb 100644 --- a/tests/unit/commands/test_marketplace_gating.py +++ b/tests/unit/commands/test_marketplace_gating.py @@ -2,12 +2,16 @@ Verifies: - ``marketplace_authoring`` flag is registered in the ``FLAGS`` registry - - Consumer commands (add, list, browse, search, update, remove, validate) work + - Consumer commands (add, list, browse, update, remove, validate) work WITHOUT the flag enabled - Authoring commands (init, build, check, outdated, doctor, publish, package) are blocked when the flag is disabled, with an enablement message - Authoring commands proceed when the flag is enabled +Note: ``search`` is registered at the top level (``apm search``), not as +``apm marketplace search`` -- see ``cli.py``. The marketplace-package help +output therefore does not list ``search``. + Note: The directory-level conftest patches ``is_enabled`` to return True for ``marketplace_authoring`` (so existing marketplace subcommand tests pass). Tests here that need the flag *disabled* wrap their assertions in an @@ -77,7 +81,7 @@ def test_flag_description_mentions_authoring(self) -> None: class TestConsumerCommandsUngated: """Consumer commands must work without marketplace_authoring enabled.""" - @pytest.mark.parametrize("subcmd", ["add", "list", "browse", "search", "update", "remove", "validate"]) + @pytest.mark.parametrize("subcmd", ["add", "list", "browse", "update", "remove", "validate"]) def test_consumer_command_reachable_when_flag_disabled(self, subcmd: str) -> None: """Consumer subcommands are not blocked by the authoring flag.""" from apm_cli.commands.marketplace import marketplace @@ -107,7 +111,7 @@ def test_marketplace_help_works_when_flag_disabled(self) -> None: assert result.exit_code == 0 assert "Consumer commands" in result.output - assert "search" in result.output + assert "add" in result.output def test_marketplace_help_hides_authoring_when_flag_disabled(self) -> None: """``marketplace --help`` omits authoring section when flag is off.""" diff --git a/tests/unit/commands/test_marketplace_outdated.py b/tests/unit/commands/test_marketplace_outdated.py index b1476f7e..6021af6b 100644 --- a/tests/unit/commands/test_marketplace_outdated.py +++ b/tests/unit/commands/test_marketplace_outdated.py @@ -103,7 +103,7 @@ def yml_cwd(tmp_path, monkeypatch): class TestOutdatedHappyPath: """outdated -- basic success.""" - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_shows_package_names(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -115,7 +115,7 @@ def test_shows_package_names(self, MockResolver, runner, yml_cwd): assert "pkg-alpha" in result.output assert "pkg-beta" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_shows_latest_in_range(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -127,7 +127,7 @@ def test_shows_latest_in_range(self, MockResolver, runner, yml_cwd): # v1.2.0 is highest in ^1.0.0 range assert "v1.2.0" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_shows_latest_overall(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -137,7 +137,7 @@ def test_shows_latest_overall(self, MockResolver, runner, yml_cwd): # v2.0.0 is highest overall for alpha assert "v2.0.0" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): """Exit code 1 when packages are outdated (CI-friendly).""" mock_inst = MockResolver.return_value @@ -147,7 +147,7 @@ def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert result.exit_code == 1 - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_with_marketplace_json_present(self, MockResolver, runner, yml_cwd): """Current versions read from marketplace.json.""" mkt = { @@ -177,7 +177,7 @@ def test_with_marketplace_json_present(self, MockResolver, runner, yml_cwd): class TestOutdatedRefPinned: """Entries with explicit ref: are skipped.""" - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_ref_pinned_shows_skip_note(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_WITH_REF, encoding="utf-8") @@ -214,7 +214,7 @@ def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): class TestOutdatedOffline: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_offline_passed_to_resolver(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = OfflineMissError( @@ -233,7 +233,7 @@ def test_offline_passed_to_resolver(self, MockResolver, runner, yml_cwd): class TestOutdatedErrors: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_resolver_error_shows_in_table(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -245,7 +245,7 @@ def test_resolver_error_shows_in_table(self, MockResolver, runner, yml_cwd): assert result.exit_code == 0 assert "pkg-alpha" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_no_matching_tags(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_SINGLE, encoding="utf-8") @@ -267,7 +267,7 @@ def test_no_matching_tags(self, MockResolver, runner, tmp_path, monkeypatch): class TestOutdatedVerbose: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_verbose_shows_upgradable_count(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -285,7 +285,7 @@ def test_verbose_shows_upgradable_count(self, MockResolver, runner, yml_cwd): class TestOutdatedStatusSymbols: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_up_to_date_status(self, MockResolver, runner, yml_cwd): """When current == latest-in-range, status is [+].""" mkt = { @@ -304,7 +304,7 @@ def test_up_to_date_status(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert result.exit_code == 0 - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_major_upgrade_status(self, MockResolver, runner, yml_cwd): """When latest-overall differs from latest-in-range, status is [*].""" mock_inst = MockResolver.return_value @@ -324,7 +324,7 @@ def test_major_upgrade_status(self, MockResolver, runner, yml_cwd): class TestOutdatedResolverCleanup: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_resolver_close_called(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -342,7 +342,7 @@ def test_resolver_close_called(self, MockResolver, runner, yml_cwd): class TestOutdatedSummaryLine: """outdated -- summary line and CI exit code.""" - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_summary_line_when_outdated(self, MockResolver, runner, yml_cwd): """Summary line reports outdated and up-to-date counts.""" mock_inst = MockResolver.return_value @@ -352,7 +352,7 @@ def test_summary_line_when_outdated(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert "package(s) can be updated" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_exit_code_zero_when_up_to_date(self, MockResolver, runner, yml_cwd): """Exit code 0 when all packages are up to date.""" mkt = { @@ -372,7 +372,7 @@ def test_exit_code_zero_when_up_to_date(self, MockResolver, runner, yml_cwd): assert result.exit_code == 0 assert "All packages are up to date" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): """Exit code 1 when packages are outdated (CI-friendly).""" mock_inst = MockResolver.return_value @@ -382,7 +382,7 @@ def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert result.exit_code == 1 - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_summary_counts_up_to_date(self, MockResolver, runner, yml_cwd): """Up-to-date count reflects packages at latest in range.""" mkt = { diff --git a/tests/unit/commands/test_marketplace_plugin.py b/tests/unit/commands/test_marketplace_plugin.py index 6c07761a..118de3aa 100644 --- a/tests/unit/commands/test_marketplace_plugin.py +++ b/tests/unit/commands/test_marketplace_plugin.py @@ -10,7 +10,7 @@ from click.testing import CliRunner from apm_cli.commands.marketplace import marketplace -from apm_cli.commands.marketplace_plugin import _resolve_ref, _SHA_RE +from apm_cli.commands.marketplace.plugin import _resolve_ref, _SHA_RE from apm_cli.core.command_logger import CommandLogger from apm_cli.marketplace.ref_resolver import RemoteRef diff --git a/tests/unit/commands/test_marketplace_publish.py b/tests/unit/commands/test_marketplace_publish.py index 44a65ecd..6d9311d7 100644 --- a/tests/unit/commands/test_marketplace_publish.py +++ b/tests/unit/commands/test_marketplace_publish.py @@ -112,8 +112,8 @@ def runner(): class TestPublishHappyPath: """Happy path: publish to 2 targets with PRs opened.""" - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_happy_path_exit_0( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -142,8 +142,8 @@ def test_happy_path_exit_0( assert "Published 2/2 targets" in result.output assert "publish-state.json" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_pr_integrator_called_for_updated_targets( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -179,7 +179,7 @@ def test_pr_integrator_called_for_updated_targets( class TestPublishNoPr: """--no-pr: publisher runs but PR integrator is not called.""" - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_no_pr_skips_pr_integration( self, MockPublisher, runner, tmp_path, monkeypatch ): @@ -196,7 +196,7 @@ def test_no_pr_skips_pr_integration( _fake_result(targets[1]), ] - with patch("apm_cli.commands.marketplace.PrIntegrator") as MockPr: + with patch("apm_cli.commands.marketplace.publish.PrIntegrator") as MockPr: result = runner.invoke(marketplace, ["publish", "--yes", "--no-pr"]) assert result.exit_code == 0, result.output # PrIntegrator should not have been instantiated for operations @@ -212,8 +212,8 @@ def test_no_pr_skips_pr_integration( class TestPublishDryRun: """--dry-run: publisher.execute with dry_run=True, PR with dry_run=True.""" - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_passes_flag_to_execute( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -242,8 +242,8 @@ def test_dry_run_passes_flag_to_execute( plan, dry_run=True, parallel=4, ) - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_passes_flag_to_pr_integration( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -270,8 +270,8 @@ def test_dry_run_passes_flag_to_pr_integration( for c in mock_pr.open_or_update.call_args_list: assert c.kwargs.get("dry_run") is True or c[1].get("dry_run") is True - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_shows_info_note( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -342,8 +342,8 @@ def test_missing_targets_file_exit_1_with_guidance( assert "consumer-targets.yml" in result.output assert "--targets" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_explicit_targets_file( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -435,7 +435,7 @@ def test_target_missing_branch(self, runner, tmp_path, monkeypatch): class TestPublishGhAvailability: - @patch("apm_cli.commands.marketplace.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") def test_gh_not_available_exit_1( self, MockPr, runner, tmp_path, monkeypatch ): @@ -452,7 +452,7 @@ def test_gh_not_available_exit_1( assert result.exit_code == 1 assert "gh" in result.output.lower() - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_gh_not_available_but_no_pr_proceeds( self, MockPublisher, runner, tmp_path, monkeypatch ): @@ -478,9 +478,9 @@ def test_gh_not_available_but_no_pr_proceeds( class TestPublishInteractive: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=False) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=False) def test_non_tty_without_yes_exit_1( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -498,9 +498,9 @@ def test_non_tty_without_yes_exit_1( assert result.exit_code == 1 assert "Non-interactive session" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=False) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=False) def test_non_tty_with_yes_proceeds( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -522,9 +522,9 @@ def test_non_tty_with_yes_proceeds( result = runner.invoke(marketplace, ["publish", "--yes"]) assert result.exit_code == 0, result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=True) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=True) def test_tty_user_types_n_aborts_gracefully( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -543,9 +543,9 @@ def test_tty_user_types_n_aborts_gracefully( assert "cancelled" in result.output.lower() mock_pub.execute.assert_not_called() - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=True) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=True) def test_tty_user_types_y_proceeds( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -577,8 +577,8 @@ def test_tty_user_types_y_proceeds( class TestPublishDraft: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_draft_passed_to_pr_integrator( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -611,8 +611,8 @@ def test_draft_passed_to_pr_integrator( class TestPublishPlanFlags: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_allow_downgrade_passed_to_plan( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -636,8 +636,8 @@ def test_allow_downgrade_passed_to_plan( _, kwargs = mock_pub.plan.call_args assert kwargs.get("allow_downgrade") is True - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_allow_ref_change_passed_to_plan( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -668,8 +668,8 @@ def test_allow_ref_change_passed_to_plan( class TestPublishParallel: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_parallel_passed_to_execute( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -701,8 +701,8 @@ def test_parallel_passed_to_execute( class TestPublishMixedOutcomes: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_mixed_outcomes_exit_1( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -744,8 +744,8 @@ def test_mixed_outcomes_exit_1( assert "acme-org/service-b" in result.output assert "acme-org/service-c" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_summary_table_has_all_outcomes( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -793,8 +793,8 @@ def test_summary_table_has_all_outcomes( class TestPublishVerbose: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_verbose_does_not_crash( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -823,8 +823,8 @@ def test_verbose_does_not_crash( class TestPublishStateFile: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_state_file_path_printed( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -853,8 +853,8 @@ def test_state_file_path_printed( class TestPublishPlanRendering: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_plan_shows_marketplace_name( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -884,8 +884,8 @@ def test_plan_shows_marketplace_name( class TestPublishNoChange: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_all_no_change_exit_0( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -915,7 +915,7 @@ def test_all_no_change_exit_0( class TestPublishDryRunNoPr: - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_no_pr_exit_0( self, MockPublisher, runner, tmp_path, monkeypatch ): @@ -977,8 +977,8 @@ def test_empty_targets_list(self, runner, tmp_path, monkeypatch): class TestPublishDefaultFlags: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_defaults_no_allow_downgrade_no_allow_ref_change( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): From ddb74e1d7478224c80d102cf449c7b28ee5a7a1d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:01:42 +0200 Subject: [PATCH 7/7] fix(marketplace): address review panel - delete no-op guard, restore help text and warning - Delete _require_authoring_flag() no-op and remove all 7 import+call sites (check, doctor, init, migrate, outdated, publish, plugin/__init__). - Restore 'apm marketplace init' help text scaffold affordance: '(scaffolds apm.yml if missing)'. - Fix gitignore warning to reference apm.yml instead of marketplace.yml, matching the post-migration canonical config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/marketplace/__init__.py | 8 ++------ src/apm_cli/commands/marketplace/check.py | 2 -- src/apm_cli/commands/marketplace/doctor.py | 2 -- src/apm_cli/commands/marketplace/init.py | 6 ++---- src/apm_cli/commands/marketplace/migrate.py | 3 +-- src/apm_cli/commands/marketplace/outdated.py | 2 -- src/apm_cli/commands/marketplace/plugin/__init__.py | 3 --- src/apm_cli/commands/marketplace/publish.py | 2 -- 8 files changed, 5 insertions(+), 23 deletions(-) diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index 4da028ce..a9a7f513 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -197,10 +197,6 @@ def _find_duplicate_names(yml): return f"Duplicate names: {', '.join(duplicates)}" return "" -def _require_authoring_flag(): - """Compatibility no-op for extracted command modules.""" - return None - @click.group(cls=MarketplaceGroup, help="Manage marketplaces for discovery and governance") @click.pass_context def marketplace(ctx): @@ -233,8 +229,8 @@ def _check_gitignore_for_marketplace_json(logger): if stripped in patterns: logger.warning( "Your .gitignore ignores marketplace.json. " - "Both marketplace.yml and marketplace.json must be tracked " - "in git. Remove the .gitignore rule.", + "Both apm.yml and the generated marketplace.json must be " + "tracked in git. Remove the .gitignore rule.", symbol="warning", ) return diff --git a/src/apm_cli/commands/marketplace/check.py b/src/apm_cli/commands/marketplace/check.py index 3755cf5a..734dcada 100644 --- a/src/apm_cli/commands/marketplace/check.py +++ b/src/apm_cli/commands/marketplace/check.py @@ -17,7 +17,6 @@ _extract_tag_versions, _load_config_or_exit, _render_check_table, - _require_authoring_flag, _warn_duplicate_names, ) @@ -27,7 +26,6 @@ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def check(offline, verbose): """Validate marketplace.yml and check each entry is resolvable.""" - _require_authoring_flag() logger = CommandLogger("marketplace-check", verbose=verbose) _, yml = _load_config_or_exit(logger) diff --git a/src/apm_cli/commands/marketplace/doctor.py b/src/apm_cli/commands/marketplace/doctor.py index f937c5ce..a4c018b9 100644 --- a/src/apm_cli/commands/marketplace/doctor.py +++ b/src/apm_cli/commands/marketplace/doctor.py @@ -21,7 +21,6 @@ _DoctorCheck, _find_duplicate_names, _render_doctor_table, - _require_authoring_flag, ) @@ -29,7 +28,6 @@ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def doctor(verbose): """Check git, network, auth, and marketplace config readiness.""" - _require_authoring_flag() logger = CommandLogger("marketplace-doctor", verbose=verbose) checks = [] diff --git a/src/apm_cli/commands/marketplace/init.py b/src/apm_cli/commands/marketplace/init.py index 14e81fb9..9934810f 100644 --- a/src/apm_cli/commands/marketplace/init.py +++ b/src/apm_cli/commands/marketplace/init.py @@ -12,10 +12,9 @@ from . import ( marketplace, _check_gitignore_for_marketplace_json, - _require_authoring_flag, ) -@marketplace.command(help="Add a 'marketplace:' block to apm.yml") +@marketplace.command(help="Add a 'marketplace:' block to apm.yml (scaffolds apm.yml if missing)") @click.option( "--force", is_flag=True, @@ -30,8 +29,7 @@ @click.option("--owner", default=None, help="Owner name for the marketplace") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def init(force, no_gitignore_check, name, owner, verbose): - """Scaffold a ``marketplace:`` block in apm.yml.""" - _require_authoring_flag() + """Scaffold a ``marketplace:`` block in apm.yml (creates apm.yml if absent).""" from ruamel.yaml import YAML from ...marketplace.init_template import render_marketplace_block diff --git a/src/apm_cli/commands/marketplace/migrate.py b/src/apm_cli/commands/marketplace/migrate.py index 7cf747d7..45bde7f9 100644 --- a/src/apm_cli/commands/marketplace/migrate.py +++ b/src/apm_cli/commands/marketplace/migrate.py @@ -11,7 +11,7 @@ from ...core.command_logger import CommandLogger from ...marketplace.errors import MarketplaceYmlError from ...marketplace.migration import migrate_marketplace_yml -from . import marketplace, _require_authoring_flag +from . import marketplace @marketplace.command(help="Fold marketplace.yml into apm.yml's 'marketplace:' block") @@ -31,7 +31,6 @@ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def migrate(force, dry_run, verbose): """Convert legacy marketplace.yml to an apm.yml marketplace block.""" - _require_authoring_flag() logger = CommandLogger("marketplace-migrate", verbose=verbose) project_root = Path.cwd() diff --git a/src/apm_cli/commands/marketplace/outdated.py b/src/apm_cli/commands/marketplace/outdated.py index 69973966..a7ab8593 100644 --- a/src/apm_cli/commands/marketplace/outdated.py +++ b/src/apm_cli/commands/marketplace/outdated.py @@ -18,7 +18,6 @@ _load_current_versions, _load_config_or_exit, _render_outdated_table, - _require_authoring_flag, ) @@ -30,7 +29,6 @@ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def outdated(offline, include_prerelease, verbose): """Compare installed versions against latest available tags.""" - _require_authoring_flag() logger = CommandLogger("marketplace-outdated", verbose=verbose) _, yml = _load_config_or_exit(logger) diff --git a/src/apm_cli/commands/marketplace/plugin/__init__.py b/src/apm_cli/commands/marketplace/plugin/__init__.py index dce0eb6a..e24015f2 100644 --- a/src/apm_cli/commands/marketplace/plugin/__init__.py +++ b/src/apm_cli/commands/marketplace/plugin/__init__.py @@ -188,9 +188,6 @@ def _resolve_ref( @click.group(help="Manage packages in marketplace authoring config") def package(): """Add, update, or remove packages in marketplace authoring config.""" - from .. import _require_authoring_flag - - _require_authoring_flag() diff --git a/src/apm_cli/commands/marketplace/publish.py b/src/apm_cli/commands/marketplace/publish.py index b542a316..7d4ef178 100644 --- a/src/apm_cli/commands/marketplace/publish.py +++ b/src/apm_cli/commands/marketplace/publish.py @@ -17,7 +17,6 @@ _load_config_or_exit, _render_publish_plan, _render_publish_summary, - _require_authoring_flag, ) @@ -55,7 +54,6 @@ def publish( verbose, ): """Publish marketplace updates to consumer repositories.""" - _require_authoring_flag() logger = CommandLogger("marketplace-publish", verbose=verbose) # ------------------------------------------------------------------