Coverage for src/ezcompiler/adapters/base_publisher.py: 100.00%
38 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
1# ///////////////////////////////////////////////////////////////
2# BASE_PUBLISHER - Abstract base release publisher
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""Shared CLI behavior for release publishers."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13import shutil
15# CLI execution is isolated in _run_cli and always disables the shell.
16import subprocess # noqa: S404 # nosec B404
17from abc import ABC, abstractmethod
18from pathlib import Path
19from typing import Any
21from ..shared.exceptions import PublishAuthError, PublishCliError
23# ///////////////////////////////////////////////////////////////
24# CONSTANTS
25# ///////////////////////////////////////////////////////////////
27# Ceiling for each external CLI call. A `gh release create` uploading an
28# installer behind a corporate proxy that stalls would otherwise hang the
29# process indefinitely, with no output at all (capture_output=True).
30CLI_TIMEOUT_SECONDS = 300
32# ///////////////////////////////////////////////////////////////
33# CLASSES
34# ///////////////////////////////////////////////////////////////
37class BasePublisher(ABC):
38 """Abstract publisher with one mockable seam for external CLI calls."""
40 _cli_name: str = ""
41 _install_hint: str = ""
43 def __init__(self, config: dict[str, Any] | None = None) -> None:
44 self._config = config or {}
46 @property
47 def config(self) -> dict[str, Any]:
48 """Publisher configuration."""
49 return self._config
51 def preflight(self) -> None:
52 """Raise unless the CLI is installed and authenticated.
54 Lets callers run every check before showing a recap, so that an
55 unauthenticated CLI is reported as such rather than as an opaque
56 failure of a later call.
57 """
58 self._check_auth()
60 @abstractmethod
61 def exists(self, tag: str) -> bool:
62 """Whether a release exists for ``tag``."""
64 @abstractmethod
65 def publish(
66 self,
67 assets: list[Path],
68 *,
69 tag: str,
70 title: str,
71 notes: str | None = None,
72 prerelease: bool = False,
73 draft: bool = False,
74 ) -> str:
75 """Create a release with assets and return its URL."""
77 @abstractmethod
78 def get_publisher_name(self) -> str:
79 """Human-readable publisher name."""
81 def _resolve_cli(self) -> str:
82 """Return the CLI binary path or raise with an installation hint."""
83 exe = shutil.which(self._cli_name)
84 if exe is None:
85 raise PublishCliError(
86 f"'{self._cli_name}' not found in PATH. "
87 f"Installer depuis {self._install_hint}, "
88 f"puis lancer `{self._cli_name} auth login`."
89 )
90 return exe
92 def _check_auth(self) -> None:
93 """Raise when the external CLI reports no valid credentials."""
94 result = self._run_cli(["auth", "status"], check=False)
95 if result.returncode != 0:
96 raise PublishAuthError(
97 f"'{self._cli_name}' is not authenticated. "
98 f"Run `{self._cli_name} auth login`, or export the token "
99 "into the environment."
100 )
102 def _run_cli(
103 self,
104 args: list[str],
105 *,
106 check: bool = True,
107 timeout: float = CLI_TIMEOUT_SECONDS,
108 ) -> subprocess.CompletedProcess[str]:
109 """Run a CLI command without a shell and capture its output.
111 CLI stderr is retained in the returned result for callers using
112 ``check=False``. It is never copied into an exception, because the
113 external CLI can print credentials from outside this process.
115 Args:
116 args: Argument vector appended to the resolved CLI binary.
117 check: Raise ``PublishCliError`` on a non-zero return code.
118 timeout: Hard ceiling in seconds; expiry is reported as a
119 publication error rather than hanging the process.
121 Raises:
122 PublishCliError: If the CLI cannot be executed, exceeds
123 ``timeout``, or (with ``check=True``) returns non-zero.
124 """
125 exe = self._resolve_cli()
126 # Argument vector is passed directly; no shell interprets asset names.
127 try:
128 result = subprocess.run( # noqa: S603 # nosec B603
129 [exe, *args],
130 capture_output=True,
131 text=True,
132 check=False,
133 shell=False,
134 timeout=timeout,
135 )
136 except subprocess.TimeoutExpired:
137 raise PublishCliError(
138 f"`{self._cli_name}` did not answer within {timeout:g} s and was "
139 "interrupted. Check the network or the proxy, then review "
140 "the state of the release before retrying."
141 ) from None
142 except OSError:
143 raise PublishCliError(
144 f"'{self._cli_name}' could not be executed."
145 ) from None
146 if check and result.returncode != 0:
147 raise PublishCliError(
148 f"`{self._cli_name}` failed (code {result.returncode})."
149 )
150 return result