Coverage for tests/unit_tests/test_profile.py: 100%

67 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 01:55 +0000

1""" 

2Tests of apio_profile.py 

3""" 

4 

5import json 

6from datetime import datetime, timedelta 

7from pytest import LogCaptureFixture, raises 

8from tests.conftest import ApioRunner 

9from apio.profile import ( 

10 Profile, 

11 get_datetime_stamp, 

12 days_between_datetime_stamps, 

13) 

14from apio.utils import util 

15from apio.apio_context import ( 

16 ApioContext, 

17 PackagesPolicy, 

18 ProjectPolicy, 

19 RemoteConfigPolicy, 

20) 

21 

22 

23def get_test_data( 

24 apio_ctx: ApioContext, 

25 loaded_by_apio_version: str, 

26 loaded_at_days: int, 

27): 

28 """Returns a fake profile.json content. 'loaded_at_days' is the value of 

29 the remote config "loaded-at" relative to today in days.""" 

30 loaded_at_datetime = datetime.now() + timedelta(days=loaded_at_days) 

31 loaded_at_stamp = get_datetime_stamp(loaded_at_datetime) 

32 assert isinstance(loaded_at_stamp, str) 

33 

34 return { 

35 "preferences": {"theme": "light"}, 

36 "remote-config": { 

37 "metadata": { 

38 "loaded-at": loaded_at_stamp, 

39 "loaded-by": loaded_by_apio_version, 

40 "loaded-from": apio_ctx.profile.remote_config_url, 

41 }, 

42 "packages": { 

43 "drivers": { 

44 "release": { 

45 "package-file": ( 

46 "apio-drivers-${PLATFORM}-${YYYYMMDD}.tgz" 

47 ), 

48 "release-tag": "${YYYY-MM-DD}", 

49 "version": "2025.06.13", 

50 }, 

51 "repository": { 

52 "name": "tools-drivers", 

53 "organization": "fpgawars", 

54 }, 

55 }, 

56 "examples": { 

57 "release": { 

58 "package-file": "apio-examples-${YYYYMMDD}.tgz", 

59 "release-tag": "${YYYY-MM-DD}", 

60 "version": "2025.06.21", 

61 }, 

62 "repository": { 

63 "name": "apio-examples", 

64 "organization": "fpgawars", 

65 }, 

66 }, 

67 }, 

68 }, 

69 } 

70 

71 

72def test_profile_loading_config_ok(apio_runner: ApioRunner): 

73 """Tests the loading and validation of a profile file with no need 

74 to fetch a remote config.""" 

75 

76 with apio_runner.in_sandbox() as sb: 

77 

78 apio_ctx = ApioContext( 

79 project_policy=ProjectPolicy.NO_PROJECT, 

80 remote_config_policy=RemoteConfigPolicy.CACHED_OK, 

81 packages_policy=PackagesPolicy.ENSURE_PACKAGES, 

82 ) 

83 

84 # -- Write a test profile.json file. 

85 path = sb.home_dir / "profile.json" 

86 test_data = get_test_data(apio_ctx, util.get_apio_version_str(), 0) 

87 sb.write_file( 

88 path, 

89 json.dumps( 

90 test_data, 

91 indent=2, 

92 ), 

93 exists_ok=True, 

94 ) 

95 

96 # -- Read back the content. 

97 profile = Profile( 

98 sb.home_dir, 

99 sb.packages_dir, 

100 apio_ctx.profile.remote_config_url, 

101 5, # TTL in days 

102 60, # Remote config retry mins. 

103 RemoteConfigPolicy.CACHED_OK, 

104 ) 

105 

106 # -- Verify 

107 assert profile.preferences == test_data["preferences"] 

108 assert profile.remote_config == test_data["remote-config"] 

109 

110 

111def test_profile_loading_config_stale_version(apio_runner: ApioRunner): 

112 """Tests the loading and validation of a profile file that include 

113 an old config that needs to be refreshed.""" 

114 

115 with apio_runner.in_sandbox() as sb: 

116 

117 apio_ctx = ApioContext( 

118 project_policy=ProjectPolicy.NO_PROJECT, 

119 remote_config_policy=RemoteConfigPolicy.CACHED_OK, 

120 packages_policy=PackagesPolicy.ENSURE_PACKAGES, 

121 ) 

122 

123 # -- Write a test profile.json file. We set an old apio version 

124 # -- to cause the cached remote config to be classified as stale. 

125 path = sb.home_dir / "profile.json" 

126 original_loaded_by = "0.9.6" 

127 assert original_loaded_by != util.get_apio_version_str() 

128 test_data = get_test_data(apio_ctx, original_loaded_by, 0) 

129 

130 sb.write_file( 

131 path, 

132 json.dumps( 

133 test_data, 

134 indent=2, 

135 ), 

136 exists_ok=True, 

137 ) 

138 

139 # -- Read back the content. 

140 profile = Profile( 

141 sb.home_dir, 

142 sb.packages_dir, 

143 apio_ctx.profile.remote_config_url, 

144 5, # TTL in days 

145 60, # Remote config retry mins. 

146 RemoteConfigPolicy.CACHED_OK, 

147 ) 

148 

149 # -- Verify. Remote config should be a fresh one, loaded by this 

150 # -- apio version. 

151 assert profile.preferences == test_data["preferences"] 

152 # assert profile.installed_packages == test_data["installed-packages"] 

153 assert ( 

154 profile.remote_config["metadata"]["loaded-by"] 

155 == util.get_apio_version_str() 

156 ) 

157 

158 

159def test_profile_with_corrupt_profile_file( 

160 apio_runner: ApioRunner, capsys: LogCaptureFixture 

161): 

162 """Tests that a corrupt profile.json results in a clean error message 

163 instead of an unhandled JSONDecodeError on every command.""" 

164 

165 bad_contents = [ 

166 "{ corrupt json", # -- Not a valid json. 

167 "[1, 2, 3]", # -- Not a json dict. 

168 '{"remote-config": "corrupt"}', # -- Field is not a dict. 

169 '{"preferences": "corrupt"}', # -- Field is not a dict. 

170 '{"installed-packages": {"examples": 5}}', # -- Package not a dict. 

171 ] 

172 

173 with apio_runner.in_sandbox() as sb: 

174 

175 # -- A packages dir that is private to this test. We don't use 

176 # -- sb.packages_dir since it may point to the shared packages cache. 

177 packages_dir = sb.sandbox_dir / "packages" 

178 

179 for bad_content in bad_contents: 

180 

181 # -- Write a corrupt profile.json file. 

182 sb.write_file( 

183 sb.home_dir / "profile.json", bad_content, exists_ok=True 

184 ) 

185 

186 # -- The theme reader should quietly fall back to the default. 

187 assert Profile.read_preferences_theme(default="dark") == "dark" 

188 

189 # -- Loading the profile should exit with a clean error message. 

190 with raises(SystemExit) as e: 

191 Profile( 

192 sb.home_dir, 

193 packages_dir, 

194 "http://localhost/apio-{major}.{minor}.jsonc", 

195 5, # TTL in days 

196 60, # Remote config retry mins. 

197 RemoteConfigPolicy.CACHED_OK, 

198 ) 

199 assert e.value.code == 1, bad_content 

200 assert "Invalid profile file" in capsys.readouterr().out 

201 

202 

203def test_profile_with_unknown_theme(apio_runner: ApioRunner): 

204 """Tests that an unknown theme name in profile.json falls back to the 

205 default theme instead of crashing every command with an 

206 AssertionError.""" 

207 

208 with apio_runner.in_sandbox() as sb: 

209 for bogus_theme in ["no-such-theme", ["dark"], 5, None]: 

210 

211 # -- Write a profile.json file with a bogus theme value. 

212 sb.write_file( 

213 sb.home_dir / "profile.json", 

214 json.dumps({"preferences": {"theme": bogus_theme}}), 

215 exists_ok=True, 

216 ) 

217 

218 # -- The theme reader should quietly fall back to the default. 

219 assert Profile.read_preferences_theme(default="light") == "light" 

220 

221 

222def test_profile_with_corrupt_packages_index( 

223 apio_runner: ApioRunner, capsys: LogCaptureFixture 

224): 

225 """Tests that a corrupt installed_packages.json results in a clean 

226 error message instead of an unhandled exception on every command.""" 

227 

228 bad_contents = [ 

229 "not json", # -- Not a valid json. 

230 "[1, 2, 3]", # -- Not a json dict. 

231 '{"examples": "boom"}', # -- Package info is not a dict. 

232 ] 

233 

234 with apio_runner.in_sandbox() as sb: 

235 

236 # -- A packages dir that is private to this test. We don't use 

237 # -- sb.packages_dir since it may point to the shared packages cache. 

238 packages_dir = sb.sandbox_dir / "packages" 

239 

240 for bad_content in bad_contents: 

241 

242 # -- Write a corrupt installed_packages.json file. 

243 sb.write_file( 

244 packages_dir / "installed_packages.json", 

245 bad_content, 

246 exists_ok=True, 

247 ) 

248 

249 # -- Loading the profile should exit with a clean error message. 

250 with raises(SystemExit) as e: 

251 Profile( 

252 sb.home_dir, 

253 packages_dir, 

254 "http://localhost/apio-{major}.{minor}.jsonc", 

255 5, # TTL in days 

256 60, # Remote config retry mins. 

257 RemoteConfigPolicy.CACHED_OK, 

258 ) 

259 assert e.value.code == 1, bad_content 

260 assert "Invalid packages index file" in capsys.readouterr().out 

261 

262 

263def test_datetime_stamp_diff_days(): 

264 """Test the datetime timestamp diff.""" 

265 

266 assert ( 

267 get_datetime_stamp( 

268 datetime(year=2025, month=6, day=30, hour=14, minute=45) 

269 ) 

270 == "2025-06-30-14-45" 

271 ) 

272 

273 ts_now = get_datetime_stamp() 

274 assert ( 

275 days_between_datetime_stamps( 

276 ts_now, 

277 ts_now, 

278 default=9999, 

279 ) 

280 == 0 

281 ) 

282 

283 assert ( 

284 days_between_datetime_stamps( 

285 "2025-06-15-07-30", 

286 "2025-06-16-00-01", 

287 default=9999, 

288 ) 

289 == 1 

290 ) 

291 

292 assert ( 

293 days_between_datetime_stamps( 

294 "2025-06-16-00-01", 

295 "2025-06-15-07-30", 

296 default=9999, 

297 ) 

298 == -1 

299 ) 

300 

301 assert ( 

302 days_between_datetime_stamps( 

303 "2025-06-15-00-00", 

304 "2025-06-15-23-59", 

305 default=9999, 

306 ) 

307 == 0 

308 ) 

309 

310 assert ( 

311 days_between_datetime_stamps( 

312 "2025-06-15-00-0x", 

313 "2025-06-15-23-59", 

314 default=9999, 

315 ) 

316 == 9999 

317 ) 

318 

319 assert ( 

320 days_between_datetime_stamps( 

321 "2025-06-15-20-15", 

322 "2025-06-20-00-01", 

323 default=9999, 

324 ) 

325 == 5 

326 )