Coverage for .tox/coverage/lib/python3.13/site-packages/wuttjamaican/upgrades.py: 100%

85 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-08 21:29 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Upgrade Handler 

25""" 

26 

27import logging 

28import os 

29import shutil 

30import subprocess 

31import sys 

32import warnings 

33from collections import OrderedDict 

34 

35from wuttjamaican.app import GenericHandler 

36 

37log = logging.getLogger(__name__) 

38 

39 

40class UpgradeHandler(GenericHandler): 

41 """ 

42 Base class and default implementation for the :term:`upgrade 

43 handler`. 

44 

45 See also 

46 :meth:`~wuttjamaican.app.AppHandler.get_upgrade_handler()`. 

47 """ 

48 

49 def get_all_target_keys(self): 

50 """ 

51 Retrieve a list of keys for all configured :term:`upgrade 

52 targets <upgrade target>`. 

53 

54 Note that a ``default`` target will always be included, first 

55 in the list. 

56 

57 :returns: List of string keys 

58 """ 

59 keys = self.config.get_list(f"{self.config.appname}.upgrades.keys", default=[]) 

60 

61 # make 'default' the first key 

62 if "default" in keys: 

63 keys.remove("default") 

64 keys.insert(0, "default") 

65 

66 return keys 

67 

68 def get_all_targets(self): 

69 """ 

70 Retrieve an ordered dict of all configured :term:`upgrade 

71 targets <upgrade target>`. 

72 

73 The result would be something like:: 

74 

75 OrderedDict([ 

76 ("default", { 

77 "key": "default", 

78 "label": "Poser", 

79 "command": "/srv/envs/poser/app/upgrade.sh" 

80 }), 

81 ("another", { 

82 "key": "another", 

83 "label": "My Other App", 

84 "command": "/srv/envs/poser/app/upgrade-another.sh" 

85 }), 

86 ]) 

87 

88 Note that a ``default`` target will always be included, first 

89 in the ordering. 

90 

91 :returns: :class:`~python:collections.OrderedDict` with all 

92 upgrade targets. 

93 """ 

94 targets = OrderedDict() 

95 for key in self.get_all_target_keys(): 

96 targets[key] = self.get_target(key) 

97 return targets 

98 

99 def get_target(self, key): 

100 """ 

101 Retrieve the configured :term:`upgrade target` for the given 

102 key. 

103 

104 The target is represented as a simple dict, e.g.:: 

105 

106 { 

107 "key": "default", 

108 "label": "Poser", 

109 "command": "/srv/envs/poser/app/upgrade.sh", 

110 } 

111 

112 :param key: Config key for the desired target. 

113 

114 :returns: Target as dict. 

115 """ 

116 if not key: 

117 key = "default" 

118 

119 default_label = key 

120 if key == "default": 

121 default_label = self.app.get_node_title() 

122 label = self.config.get( 

123 f"{self.config.appname}.upgrades.{key}.label", default=default_label 

124 ) 

125 

126 command = self.config.get(f"{self.config.appname}.upgrades.{key}.command") 

127 if not command and key == "default": 

128 if command := self.config.get(f"{self.config.appname}.upgrades.command"): 

129 warnings.warn( 

130 f"setting '{self.config.appname}.upgrades.command' " 

131 "is deprecated; please use setting " 

132 f"'{self.config.appname}.upgrades.default.command' instead", 

133 DeprecationWarning, 

134 ) 

135 

136 return {"key": key, "label": label, "command": command} 

137 

138 def do_execute(self, upgrade, user, **kwargs): 

139 """ 

140 Perform all steps required to fully execute an 

141 :term:`upgrade`. 

142 

143 This first calls :meth:`execute()` and then will update the 

144 given upgrade record to reflect the result. 

145 

146 For the ``default`` :term:`upgrade target` only, this will 

147 also call :meth:`capture_pip_freeze_output()` before and after 

148 the upgrade runs. This allows viewing the diff between them 

149 e.g. in web app. 

150 

151 :param upgrade: Reference to the 

152 :class:`~wuttjamaican.db.model.upgrades.Upgrade` record to 

153 be executed. 

154 

155 :param user: Reference to the in-app 

156 :class:`~wuttjamaican.db.model.auth.User` responsible for 

157 executing the upgrade. 

158 

159 :param \\**kwargs: Any additional kwargs are passed along to 

160 :meth:`execute()`. 

161 

162 :returns: Boolean indicating success of the upgrade. 

163 """ 

164 enum = self.app.enum 

165 

166 # record the fact that execution has begun for this upgrade 

167 # nb. this is done in separate session to ensure it sticks, 

168 # but also must update local object to reflect the change 

169 with self.app.short_session(commit=True) as s: 

170 alt = s.merge(upgrade) 

171 alt.executing = True 

172 alt.status = enum.UpgradeStatus.EXECUTING 

173 upgrade.executing = True 

174 upgrade.status = enum.UpgradeStatus.EXECUTING 

175 

176 # maybe capture pip freeze output 

177 if upgrade.target_key in ("default", None): 

178 self.capture_pip_freeze_output(upgrade, "before") 

179 

180 # execute proper 

181 success = self.execute(upgrade, user, **kwargs) 

182 

183 # maybe capture pip freeze output 

184 if upgrade.target_key in ("default", None): 

185 self.capture_pip_freeze_output(upgrade, "after") 

186 

187 # upgrade has finished executing 

188 upgrade.executing = False 

189 upgrade.executed = self.app.make_utc() 

190 upgrade.executed_by = user 

191 if success: 

192 upgrade.status = enum.UpgradeStatus.SUCCESS 

193 else: 

194 upgrade.status = enum.UpgradeStatus.FAILURE 

195 

196 return success 

197 

198 def capture_pip_freeze_output(self, upgrade, occurrence): 

199 """ 

200 Run the ``pip freeze`` command and capture its output to file, 

201 in the given upgrade's data folder. 

202 

203 This is called from :meth:`do_execute()` but only for the 

204 ``default`` :term:`upgrade target`. 

205 

206 See also :meth:`get_pip_path()`. 

207 

208 :param upgrade: Reference to the 

209 :class:`~wuttjamaican.db.model.upgrades.Upgrade` record 

210 being executed. 

211 

212 :param occurrence: String identifier, should be ``"before"`` 

213 or ``"after"`` only. 

214 

215 :returns: Output file path as string. 

216 """ 

217 # nb. specify create=True to ensure *folder* exists 

218 path = self.get_upgrade_filepath( 

219 upgrade, f"requirements.{occurrence}.txt", create=True 

220 ) 

221 

222 pip = self.get_pip_path() 

223 with open(path, "wb") as stdout: 

224 subprocess.call([pip, "freeze"], stdout=stdout) 

225 return path 

226 

227 def get_pip_path(self): 

228 """ 

229 Retrieve the path to ``pip`` executable. 

230 

231 This is called from :meth:`capture_pip_freeze_output()`. 

232 

233 :returns: File path as string 

234 """ 

235 # TODO: ugh how should we really do this 

236 return os.path.join(sys.prefix, "bin", "pip") 

237 

238 def execute(self, upgrade, user, **kwargs): # pylint: disable=unused-argument 

239 """ 

240 Execute the given upgrade as the given user. 

241 

242 This will run the :term:`upgrade target` command "as-is" via 

243 :func:`python:subprocess.call()` with ``shell=True``, and 

244 capture the STDOUT/STDERR streams to file. 

245 

246 Callers should generally avoid using this method directly and 

247 instead use :meth:`do_execute()`. 

248 

249 :param upgrade: Reference to the 

250 :class:`~wuttjamaican.db.model.upgrades.Upgrade` record to 

251 be executed. 

252 

253 :param user: Reference to the in-app 

254 :class:`~wuttjamaican.db.model.auth.User` responsible for 

255 executing the upgrade. 

256 

257 :param \\**kwargs: Any additional kwargs are ignored by 

258 default; subclass may override if needed. 

259 

260 :returns: Boolean indicating success of the command (i.e. if 

261 exit code was 0). 

262 """ 

263 # command 

264 cmd = self.get_target(upgrade.target_key)["command"] 

265 if not cmd: 

266 raise RuntimeError(f"could not determine command for upgrade: {upgrade}") 

267 

268 # output file paths 

269 # nb. specify create=True the first time to ensure *folder* exists 

270 stdout_path = self.get_upgrade_filepath(upgrade, "stdout.log", create=True) 

271 stderr_path = self.get_upgrade_filepath(upgrade, "stderr.log") 

272 

273 # run the command 

274 log.debug("running upgrade command: %s", cmd) 

275 with open(stdout_path, "wb") as stdout: 

276 with open(stderr_path, "wb") as stderr: 

277 upgrade.exit_code = subprocess.call( 

278 cmd, shell=True, text=True, stdout=stdout, stderr=stderr 

279 ) 

280 success = upgrade.exit_code == 0 

281 logger = log.debug if success else log.warning 

282 logger("upgrade command exited with code %s", upgrade.exit_code) 

283 

284 return success 

285 

286 def get_upgrade_filepath(self, upgrade, filename=None, create=False): 

287 """ 

288 Get the path to upgrade's data folder, or a file within it. 

289 

290 :param upgrade: Reference to the 

291 :class:`~wuttjamaican.db.model.upgrades.Upgrade` record. 

292 

293 :param filename: Optional filename to append as suffix to the 

294 data folder path. 

295 

296 :param create: Pass ``True`` here if you want to ensure the 

297 data *folder* exists, creating it if necessary. This does 

298 not auto-create any files. 

299 

300 :returns: Folder/file path as string. 

301 """ 

302 uuid = str(upgrade.uuid) 

303 path = self.app.get_appdir( 

304 "data", "upgrades", uuid[:2], uuid[2:], create=create 

305 ) 

306 if filename: 

307 path = os.path.join(path, filename) 

308 return path 

309 

310 def delete_files(self, upgrade): 

311 """ 

312 Delete all data files for the given upgrade. 

313 

314 Caller must use this method (first!) for full cleanup when 

315 deleting an upgrade record from the database. 

316 

317 :param upgrade: Reference to the 

318 :class:`~wuttjamaican.db.model.upgrades.Upgrade` record. 

319 """ 

320 path = self.get_upgrade_filepath(upgrade, create=False) 

321 if os.path.exists(path): 

322 shutil.rmtree(path)