Coverage for src/ezcompiler/services/publish_service.py: 94.94%

61 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 16:37 +0000

1# /////////////////////////////////////////////////////////////// 

2# PUBLISH_SERVICE - Publication orchestration (update tree & release) 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Publish service - Orchestrates the two publication paths. 

8 

9``publish_update`` transfers the signed TUF tree; ``publish_release`` 

10either creates a platform release (github) or copies the assets to a file 

11destination (disk/server/r2). This module owns the routing between the two 

12and is the only place that decides it. 

13 

14The human-facing confirmation lives in ``interfaces/``, never here: a 

15service must not read stdin. 

16""" 

17 

18from __future__ import annotations 

19 

20# /////////////////////////////////////////////////////////////// 

21# IMPORTS 

22# /////////////////////////////////////////////////////////////// 

23# Standard library imports 

24import logging 

25from pathlib import Path 

26from typing import TYPE_CHECKING, Any 

27 

28# Third-party imports 

29# Local imports 

30from .._types import PublisherPort 

31from ..adapters import PublisherFactory 

32from ..shared import ReleasePreflight 

33from ..shared._compiler_config import _OWNER_REPO_RE 

34from ..shared.exceptions import PublishError, UploadError 

35from .pipeline_service import PipelineService 

36from .tuf_service import TufService 

37from .uploader_service import UploaderService 

38 

39if TYPE_CHECKING: 39 ↛ 40line 39 didn't jump to line 40 because the condition on line 39 was never true

40 from ..shared import CompilerConfig 

41 

42# /////////////////////////////////////////////////////////////// 

43# CONSTANTS 

44# /////////////////////////////////////////////////////////////// 

45 

46_PLATFORMS: tuple[str, ...] = ("github", "gitlab") 

47 

48logger = logging.getLogger(__name__) 

49 

50# /////////////////////////////////////////////////////////////// 

51# CLASSES 

52# /////////////////////////////////////////////////////////////// 

53 

54 

55class PublishService: 

56 """Orchestrates TUF-tree and release publication.""" 

57 

58 # //////////////////////////////////////////////// 

59 # ROUTING 

60 # //////////////////////////////////////////////// 

61 

62 @staticmethod 

63 def resolve_publisher( 

64 config: CompilerConfig, release_destination: str | None = None 

65 ) -> PublisherPort | None: 

66 """Return a publisher for platform destinations, else ``None``. 

67 

68 Args: 

69 config: Current configuration. 

70 release_destination: Override for ``config.release_destination``. 

71 

72 Returns: 

73 PublisherPort | None: ``None`` when the destination is a file 

74 backend (disk/server/r2) and the uploader path applies. 

75 

76 Raises: 

77 PublisherTypeError: If the platform is not supported. 

78 PublishError: If ``release_endpoint`` is not an ``owner/repo``. 

79 """ 

80 dest = release_destination or config.release_destination 

81 if dest not in _PLATFORMS: 

82 return None 

83 endpoint = config.release_endpoint 

84 # The config only validates the owner/repo form when 

85 # release_destination already names a platform: an override 

86 # (-rld github) on a disk/r2 config would bring a path or a bucket 

87 # through --repo. 

88 if endpoint and not _OWNER_REPO_RE.fullmatch(endpoint): 

89 raise PublishError( 

90 f"release_endpoint '{endpoint}' is not an 'owner/repo' " 

91 f"repository usable for '{dest}' publication." 

92 ) 

93 return PublisherFactory.create_publisher( 

94 dest, {"repo": endpoint} if endpoint else None 

95 ) 

96 

97 @staticmethod 

98 def preflight_release( 

99 config: CompilerConfig, 

100 *, 

101 tag: str, 

102 release_destination: str | None = None, 

103 ) -> ReleasePreflight: 

104 """Run every pre-publication check and return the recap to display. 

105 

106 The routing decision and the ``PublisherPort`` lifecycle both stay 

107 here: the interfaces layer receives a value object, shows it and asks 

108 for confirmation. Every costly check runs before that confirmation, so 

109 once the operator says yes only a network failure remains. 

110 

111 Args: 

112 config: Current configuration. 

113 tag: Release tag that would be created (platform path only). 

114 release_destination: Override for ``config.release_destination``. 

115 

116 Returns: 

117 ReleasePreflight: Recap of what the publication would do. 

118 

119 Raises: 

120 PublishError: If the CLI is missing or unauthenticated, or if a 

121 release already exists for ``tag``. 

122 PublisherTypeError: If the platform is not supported. 

123 ReleaseError: If an enabled installer is missing, or nothing was 

124 built. 

125 """ 

126 dest = release_destination or config.release_destination 

127 publisher = PublishService.resolve_publisher(config, release_destination) 

128 if publisher is None: 

129 return ReleasePreflight(destination=dest, is_platform=False) 

130 

131 publisher.preflight() 

132 if publisher.exists(tag): 

133 raise PublishError( 

134 f"Release {tag} already exists. Delete it or change version." 

135 ) 

136 assets = PipelineService.stage_versioned_assets(config) 

137 return ReleasePreflight( 

138 destination=dest, 

139 is_platform=True, 

140 publisher_name=publisher.get_publisher_name(), 

141 repo=config.release_endpoint or None, 

142 assets=tuple(assets), 

143 ) 

144 

145 # //////////////////////////////////////////////// 

146 # PUBLICATION 

147 # //////////////////////////////////////////////// 

148 

149 @staticmethod 

150 def publish_update( 

151 config: CompilerConfig, 

152 *, 

153 destination: str | None = None, 

154 repo_destination: str | None = None, 

155 upload_config: dict[str, Any] | None = None, 

156 ) -> None: 

157 """Transfer the signed TUF tree to the update backend. 

158 

159 Never routed to a platform release: a TUF tree must be served over 

160 HTTP on stable paths, which release assets do not provide. 

161 

162 Args: 

163 config: Current configuration. 

164 destination: Override for the resolved repo destination. 

165 repo_destination: Override for ``config.repo_destination``. 

166 upload_config: Extra options forwarded to the uploader. 

167 

168 Raises: 

169 UploadError: If the transfer fails. 

170 """ 

171 repo_dir = TufService.repo_dir(config) 

172 repo_dest = repo_destination or config.repo_destination 

173 logger.info("Publishing TUF update tree (%s)", repo_dest) 

174 UploaderService.upload_tuf_repo( 

175 config, repo_dir, repo_dest, destination, upload_config 

176 ) 

177 

178 @staticmethod 

179 def publish_release( 

180 config: CompilerConfig, 

181 assets: list[Path], 

182 *, 

183 tag: str, 

184 title: str, 

185 notes: str | None = None, 

186 prerelease: bool = False, 

187 draft: bool = False, 

188 destination: str | None = None, 

189 release_destination: str | None = None, 

190 upload_config: dict[str, Any] | None = None, 

191 ) -> str | None: 

192 """Publish the release assets. 

193 

194 Args: 

195 config: Current configuration. 

196 assets: Files to attach (platform path only). The file path 

197 re-assembles ``release/`` exactly as before and ignores it. 

198 tag: Release tag (platform path only). 

199 title: Release title (platform path only). 

200 notes: Release body; ``None`` requests generated notes. 

201 prerelease: Mark as pre-release. 

202 draft: Create unpublished. 

203 destination: Override for the resolved release destination. 

204 release_destination: Override for ``config.release_destination``. 

205 upload_config: Extra options forwarded to the uploader. 

206 

207 Returns: 

208 str | None: The release URL on the platform path, ``None`` on the 

209 file-destination path. 

210 

211 Raises: 

212 PublishError: If platform publication fails. 

213 UploadError: If the file transfer fails, or nothing was built. 

214 """ 

215 # Routing resolved here and nowhere else: the CLI never handles a 

216 # PublisherPort (it goes through preflight_release()). 

217 publisher = PublishService.resolve_publisher(config, release_destination) 

218 if publisher is None: 

219 # File path: delegated as is to the existing code, which puts 

220 # the artifacts under <dest>/release/ and handles the r2/server 

221 # variants. Do NOT reimplement the upload loop here: the 

222 # `release/` subdirectory and the destination branches would be 

223 # lost. 

224 rel_dest = release_destination or config.release_destination 

225 logger.info("Uploading release assets (%s)", rel_dest) 

226 release_root = PipelineService.assemble_release_dir(config) 

227 # assemble_release_dir only copies what exists: without a 

228 # build, an empty directory would be transferred as a success. 

229 if not any(release_root.iterdir()): 

230 raise UploadError( 

231 f"No artifact to publish in {release_root} " 

232 "(zip or installer): run the compilation first." 

233 ) 

234 UploaderService.upload_release_zip( 

235 config, release_root, rel_dest, destination, upload_config 

236 ) 

237 return None 

238 

239 if not assets: 

240 raise PublishError(f"No asset to publish for {tag}.") 

241 

242 logger.info("Publishing release %s via %s", tag, publisher.get_publisher_name()) 

243 url = publisher.publish( 

244 assets, 

245 tag=tag, 

246 title=title, 

247 notes=notes, 

248 prerelease=prerelease, 

249 draft=draft, 

250 ) 

251 if not url: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 raise PublishError( 

253 f"Publishing {tag} returned no URL — cannot confirm that it succeeded." 

254 ) 

255 return url