test_taler_repos.py (20789B)
1 # This file is in the public domain. 2 3 """Run from the repository root with python3 -m unittest discover -s tools -v.""" 4 5 import importlib.machinery 6 import importlib.util 7 import io 8 import json 9 import os 10 import subprocess 11 import sys 12 import tempfile 13 import unittest 14 from contextlib import redirect_stderr, redirect_stdout 15 from pathlib import Path 16 from unittest.mock import call, patch 17 18 SCRIPT = Path(__file__).with_name("taler-repos") 19 loader = importlib.machinery.SourceFileLoader("taler_repos", str(SCRIPT)) 20 spec = importlib.util.spec_from_loader(loader.name, loader) 21 taler_repos = importlib.util.module_from_spec(spec) 22 loader.exec_module(taler_repos) 23 24 25 class BuildOrderTests(unittest.TestCase): 26 EXPECTED = ( 27 "gnunet", 28 "exchange", 29 "donau", 30 "merchant", 31 "anastasis", 32 "challenger", 33 "paivana", 34 "taler-mdb", 35 "taler-typescript-core", 36 "libeufin", 37 "taler-merchant-demos", 38 "taler-rust", 39 ) 40 41 def setUp(self): 42 temporary = tempfile.TemporaryDirectory() 43 self.addCleanup(temporary.cleanup) 44 self.root = Path(temporary.name) 45 self.enterContext(patch.object(taler_repos, "ROOT", self.root)) 46 47 def test_order_without_deployment_checkout(self): 48 self.assertFalse((self.root / "taler-deployment").exists()) 49 self.assertEqual(taler_repos.build_order(), list(self.EXPECTED)) 50 51 def test_all_dependencies_precede_consumers_when_repository_order_changes(self): 52 components = tuple(reversed(taler_repos.REPOSITORIES)) 53 with patch.object(taler_repos, "REPOSITORIES", components): 54 order = taler_repos.build_order() 55 for name, dependencies in taler_repos.BUILD_DEPENDENCIES.items(): 56 for dependency in dependencies: 57 self.assertLess(order.index(dependency), order.index(name)) 58 self.assertEqual(order[0], "taler-rust") 59 60 def test_cycle_reports_the_components(self): 61 with ( 62 patch.dict(taler_repos.BUILD_DEPENDENCIES, {"gnunet": ("merchant",)}), 63 self.assertRaisesRegex( 64 taler_repos.BuildError, 65 "cycle: gnunet -> merchant -> gnunet", 66 ), 67 ): 68 taler_repos.build_order() 69 70 def test_unknown_repository_or_dependency(self): 71 for entry, error in ( 72 ({"missing": ()}, "unknown repository in build dependencies: missing"), 73 ({"gnunet": ("missing",)}, "gnunet: unknown build dependency missing"), 74 ): 75 with ( 76 self.subTest(entry=entry), 77 patch.dict(taler_repos.BUILD_DEPENDENCIES, entry), 78 self.assertRaisesRegex(taler_repos.BuildError, error), 79 ): 80 taler_repos.build_order() 81 82 def test_build_pulls_every_repository_before_building_in_order(self): 83 with ( 84 patch.object( 85 taler_repos, "repository", side_effect=lambda n: self.root / n 86 ), 87 patch.object(taler_repos, "preflight") as preflight, 88 patch.object(taler_repos, "run") as run, 89 redirect_stdout(io.StringIO()), 90 ): 91 taler_repos.build() 92 repos = [self.root / name for name in self.EXPECTED] 93 preflight.assert_called_once_with(repos) 94 calls = run.call_args_list 95 self.assertEqual( 96 calls[: len(repos)], [call(r, "git", "pull", "--ff-only") for r in repos] 97 ) 98 builds = calls[len(repos) :] 99 self.assertEqual(len(builds), 4 * len(repos)) 100 for index, repo in enumerate(repos): 101 commands = builds[4 * index : 4 * index + 4] 102 self.assertEqual(commands[0], call(repo, "./bootstrap")) 103 self.assertEqual(commands[1].args[:2], (repo, "./configure")) 104 self.assertEqual(commands[2].args[:2], (repo, "make")) 105 self.assertEqual(commands[3], call(repo, "make", "install")) 106 107 def test_invalid_dependencies_stop_before_repository_commands(self): 108 with ( 109 patch.dict(taler_repos.BUILD_DEPENDENCIES, {"gnunet": ("missing",)}), 110 patch.object(taler_repos, "repository") as repository, 111 self.assertRaises(taler_repos.BuildError), 112 ): 113 taler_repos.build() 114 repository.assert_not_called() 115 116 117 class WorkspaceTests(unittest.TestCase): 118 def test_default_workspace_is_the_current_directory(self): 119 with tempfile.TemporaryDirectory() as temporary: 120 result = subprocess.run( 121 [ 122 "python3", 123 "-B", 124 "-c", 125 "import runpy, sys; print(runpy.run_path(sys.argv[1])['ROOT'])", 126 str(SCRIPT), 127 ], 128 cwd=temporary, 129 check=True, 130 capture_output=True, 131 text=True, 132 ) 133 self.assertEqual(Path(result.stdout.strip()), Path(temporary)) 134 135 def test_explicit_workspace_is_used_from_another_directory(self): 136 with ( 137 tempfile.TemporaryDirectory() as temporary, 138 patch.object(taler_repos, "ROOT", Path.cwd()), 139 patch("sys.argv", [str(SCRIPT), "--root", temporary, "versions"]), 140 patch.object(taler_repos, "versions") as versions, 141 ): 142 self.assertEqual(taler_repos.main(), 0) 143 versions.assert_called_once_with() 144 self.assertEqual(taler_repos.ROOT, Path(temporary).resolve()) 145 146 147 class ForeachTests(unittest.TestCase): 148 def setUp(self): 149 temporary = tempfile.TemporaryDirectory() 150 self.addCleanup(temporary.cleanup) 151 self.root = Path(temporary.name) / "workspace with spaces" 152 self.root.mkdir() 153 self.enterContext( 154 patch.dict( 155 os.environ, 156 { 157 "GIT_CONFIG_NOSYSTEM": "1", 158 "GIT_CONFIG_GLOBAL": os.devnull, 159 }, 160 ) 161 ) 162 for name in taler_repos.REPOSITORIES: 163 repo = self.root / name 164 repo.mkdir() 165 subprocess.run( 166 ["git", "init", "--quiet", "--initial-branch=main", "--template="], 167 cwd=repo, 168 check=True, 169 capture_output=True, 170 ) 171 # These repositories have no upstream and contain untracked files. 172 (repo / "dirty").write_text("uncommitted") 173 174 def invoke(self, *args, explicit_root=True): 175 command = [sys.executable, "-B", str(SCRIPT.resolve())] 176 if explicit_root: 177 command.extend(["--root", str(self.root)]) 178 return subprocess.run( 179 [*command, "foreach", *args], 180 cwd=self.root.parent if explicit_root else self.root, 181 check=False, 182 capture_output=True, 183 text=True, 184 ) 185 186 def records(self, result): 187 return [ 188 json.loads(line) 189 for line in result.stdout.splitlines() 190 if line.startswith("{") 191 ] 192 193 def test_cwd_order_and_literal_arguments_with_optional_separator(self): 194 code = ( 195 "import json, os, sys; " 196 "print(json.dumps({'cwd': os.getcwd(), 'args': sys.argv[1:]}))" 197 ) 198 arguments = [ 199 "with spaces", "", "'quoted'", "$HOME", "$(pwd)", "*", ";", 200 "--help", "--root", "elsewhere", "--", 201 ] 202 expected = [ 203 {"cwd": str((self.root / name).resolve()), "args": arguments} 204 for name in taler_repos.REPOSITORIES 205 ] 206 for separator in ((), ("--",)): 207 with self.subTest(separator=separator): 208 result = self.invoke(*separator, sys.executable, "-c", code, *arguments) 209 self.assertEqual(result.returncode, 0, result.stderr) 210 self.assertEqual(self.records(result), expected) 211 for name in taler_repos.REPOSITORIES: 212 self.assertIn(f"[{name}] $ ", result.stdout) 213 214 def test_default_root_and_explicit_shell_command(self): 215 result = self.invoke("sh", "-c", "pwd && printf 'done\\n'", explicit_root=False) 216 self.assertEqual(result.returncode, 0, result.stderr) 217 lines = [ 218 line for line in result.stdout.splitlines() 219 if line and not line.startswith("[") 220 ] 221 expected = [] 222 for name in taler_repos.REPOSITORIES: 223 expected.extend([str((self.root / name).resolve()), "done"]) 224 self.assertEqual(lines, expected) 225 226 def test_command_failure_stops_at_the_failed_repository(self): 227 code = ( 228 "import json, os, sys; " 229 "name = os.path.basename(os.getcwd()); " 230 "print(json.dumps({'repo': name})); " 231 "sys.exit(7 if name == sys.argv[1] else 0)" 232 ) 233 for index in (0, 2): 234 name = taler_repos.REPOSITORIES[index] 235 with self.subTest(repository=name): 236 result = self.invoke(sys.executable, "-c", code, name) 237 self.assertEqual(result.returncode, 1) 238 self.assertEqual( 239 self.records(result), 240 [{"repo": n} for n in taler_repos.REPOSITORIES[:index + 1]], 241 ) 242 self.assertTrue(result.stderr.startswith(f"taler-repos: {name}:")) 243 self.assertIn("exited with status 7", result.stderr) 244 245 def test_missing_program_stops_after_first_repository(self): 246 result = self.invoke(str(self.root / "missing-program")) 247 self.assertEqual(result.returncode, 1) 248 self.assertIn("could not run", result.stderr) 249 self.assertIn(f"[{taler_repos.REPOSITORIES[0]}] $ ", result.stdout) 250 self.assertNotIn(f"[{taler_repos.REPOSITORIES[1]}] $ ", result.stdout) 251 252 def test_invalid_last_repository_prevents_all_execution(self): 253 name = taler_repos.REPOSITORIES[-1] 254 repo = self.root / name 255 repo.rename(self.root / "unused") 256 for kind in ("missing", "not a Git repository"): 257 with self.subTest(kind=kind): 258 if kind == "not a Git repository": 259 repo.mkdir() 260 result = self.invoke(sys.executable, "-c", "print('executed')") 261 self.assertEqual(result.returncode, 1) 262 self.assertTrue(result.stderr.startswith(f"taler-repos: {name}:")) 263 self.assertEqual(result.stdout, "") 264 265 def test_empty_command_is_a_usage_error(self): 266 for args in ((), ("--",)): 267 with self.subTest(args=args): 268 result = self.invoke(*args) 269 self.assertEqual(result.returncode, 2) 270 self.assertIn("a program is required", result.stderr) 271 self.assertEqual(result.stdout, "") 272 273 274 class NextTagTests(unittest.TestCase): 275 def test_semver_transitions(self): 276 cases = [ 277 (["v1.2.3"], "v1.2.4", "v1.2.4-dev.1"), 278 (["v1.2.4-dev.4"], "v1.2.4", "v1.2.4-dev.5"), 279 (["v1.2.4-dev.0"], "v1.2.4", "v1.2.4-dev.1"), 280 (["v1.2.4", "v1.2.4-dev.99"], "v1.2.5", "v1.2.5-dev.1"), 281 ( 282 ["v1.2.9", "v1.2.10-dev.9", "v1.2.10-dev.10"], 283 "v1.2.10", 284 "v1.2.10-dev.11", 285 ), 286 (["v1.9.99", "v1.10.0", "v0.99.99"], "v1.10.1", "v1.10.1-dev.1"), 287 (["v9.99.99", "v10.0.0"], "v10.0.1", "v10.0.1-dev.1"), 288 (["v0.0.0"], "v0.0.1", "v0.0.1-dev.1"), 289 ( 290 ["v1.2.3", "deb-v9.9.9-1", "v9.9.9-rc.1", "v2.0", "v3.0.0+build"], 291 "v1.2.4", 292 "v1.2.4-dev.1", 293 ), 294 ] 295 for tags, stable, dev in cases: 296 with self.subTest(tags=tags): 297 self.assertEqual(taler_repos.next_tag(tags, False), stable) 298 self.assertEqual(taler_repos.next_tag(list(reversed(tags)), True), dev) 299 300 def test_missing_supported_version(self): 301 for tags in ( 302 [], 303 [ 304 "release", 305 "deb-v1.2.3-1", 306 "v01.2.3", 307 "v1.02.3", 308 "v1.2.03", 309 "v1.2.3-dev.01", 310 ], 311 ): 312 with ( 313 self.subTest(tags=tags), 314 self.assertRaisesRegex( 315 taler_repos.BuildError, "no supported local version" 316 ), 317 ): 318 taler_repos.next_tag(tags, False) 319 320 321 class BumpTests(unittest.TestCase): 322 def setUp(self): 323 temporary = tempfile.TemporaryDirectory() 324 self.addCleanup(temporary.cleanup) 325 self.root = Path(temporary.name) 326 self.enterContext(patch.object(taler_repos, "ROOT", self.root)) 327 self.enterContext(patch.object(taler_repos, "REPOSITORIES", ("one", "two"))) 328 self.enterContext( 329 patch.dict( 330 os.environ, 331 { 332 "GIT_CONFIG_NOSYSTEM": "1", 333 "GIT_CONFIG_GLOBAL": os.devnull, 334 }, 335 ) 336 ) 337 for name in taler_repos.REPOSITORIES: 338 repo = self.root / name 339 repo.mkdir() 340 self.git(name, "init", "--quiet", "--initial-branch=main", "--template=") 341 self.git(name, "config", "user.name", "Taler Repos Test") 342 self.git(name, "config", "user.email", "test@example.invalid") 343 self.git(name, "config", "commit.gpgSign", "false") 344 self.git(name, "commit", "--quiet", "--allow-empty", "-m", "Initial commit") 345 self.git(name, "tag", "v1.2.3") 346 self.git(name, "commit", "--quiet", "--allow-empty", "-m", "Next commit") 347 348 def git(self, name, *args): 349 return subprocess.run( 350 ["git", *args], 351 cwd=self.root / name, 352 check=True, 353 capture_output=True, 354 text=True, 355 ).stdout.strip() 356 357 def bump(self, **kwargs): 358 output = io.StringIO() 359 with redirect_stdout(output): 360 taler_repos.bump(**kwargs) 361 return output.getvalue() 362 363 def refs(self): 364 return {name: self.git(name, "show-ref") for name in taler_repos.REPOSITORIES} 365 366 def test_creates_lightweight_tags_without_upstream_and_skips_on_repeat(self): 367 for name in taler_repos.REPOSITORIES: 368 self.git(name, "config", "tag.gpgSign", "true") 369 output = self.bump() 370 self.assertIn("Created 2 local tag(s)", output) 371 for name in taler_repos.REPOSITORIES: 372 self.assertEqual(self.git(name, "cat-file", "-t", "v1.2.4"), "commit") 373 self.assertEqual( 374 self.git(name, "rev-parse", "v1.2.4"), 375 self.git(name, "rev-parse", "HEAD"), 376 ) 377 before = self.refs() 378 self.assertIn("skip: HEAD tagged v1.2.4", self.bump()) 379 self.assertEqual(self.refs(), before) 380 381 def test_dev_bumps_each_components_own_version(self): 382 self.git("two", "tag", "v2.0.0-dev.9", "HEAD~") 383 self.bump(dev=True) 384 self.assertEqual(self.git("one", "tag", "--points-at", "HEAD"), "v1.2.4-dev.1") 385 self.assertEqual(self.git("two", "tag", "--points-at", "HEAD"), "v2.0.0-dev.10") 386 387 def test_both_modes_skip_all_v_tags_including_annotated_and_unsupported(self): 388 self.git("one", "tag", "v-unusual-format") 389 self.git("two", "tag", "-a", "v1.2.4-dev.1", "-m", "Development version") 390 (self.root / "two" / "untracked").write_text("dirty tagged HEAD is skipped") 391 before = self.refs() 392 for dev in (False, True): 393 with self.subTest(dev=dev): 394 output = self.bump(dev=dev) 395 self.assertIn("skip: HEAD tagged v-unusual-format", output) 396 self.assertIn("skip: HEAD tagged v1.2.4-dev.1", output) 397 self.assertEqual(self.refs(), before) 398 399 def test_non_version_tag_does_not_skip_head(self): 400 self.git("one", "tag", "checkpoint") 401 self.bump() 402 self.assertEqual( 403 self.git("one", "rev-parse", "v1.2.4"), self.git("one", "rev-parse", "HEAD") 404 ) 405 406 def test_highest_version_can_be_on_another_branch(self): 407 self.git("one", "checkout", "--quiet", "-b", "other") 408 self.git("one", "commit", "--quiet", "--allow-empty", "-m", "Other branch") 409 self.git("one", "tag", "-a", "v1.10.0-dev.9", "-m", "Other version") 410 self.git("one", "checkout", "--quiet", "main") 411 self.bump() 412 self.assertEqual( 413 self.git("one", "rev-parse", "v1.10.0"), 414 self.git("one", "rev-parse", "HEAD"), 415 ) 416 417 def test_dry_run_leaves_all_refs_unchanged(self): 418 before = self.refs() 419 for dev, expected in ((False, "v1.2.4"), (True, "v1.2.4-dev.1")): 420 with self.subTest(dev=dev): 421 output = self.bump(dev=dev, dry_run=True) 422 self.assertIn("as " + expected, output) 423 self.assertIn("Dry run complete; no tags created.", output) 424 self.assertEqual(self.refs(), before) 425 426 def test_dirty_repository_prevents_all_tagging_including_dry_run(self): 427 (self.root / "two" / "untracked").write_text("uncommitted") 428 before = self.refs() 429 for dry_run in (False, True): 430 with self.subTest(dry_run=dry_run): 431 with self.assertRaisesRegex( 432 taler_repos.BuildError, "two: worktree is not clean" 433 ): 434 self.bump(dry_run=dry_run) 435 self.assertEqual(self.refs(), before) 436 437 def test_missing_baseline_prevents_all_tagging(self): 438 self.git("two", "tag", "-d", "v1.2.3") 439 before = self.refs() 440 with self.assertRaisesRegex( 441 taler_repos.BuildError, "two: no supported local version" 442 ): 443 self.bump() 444 self.assertEqual(self.refs(), before) 445 446 def test_preflight_tag_collision_prevents_all_tagging(self): 447 # A normally calculated next tag is necessarily newer than all local 448 # versions; inject a collision to exercise the explicit safety check. 449 before = self.refs() 450 with ( 451 patch.object(taler_repos, "next_tag", side_effect=["v1.2.4", "v1.2.3"]), 452 self.assertRaisesRegex( 453 taler_repos.BuildError, "two: tag v1.2.3 already exists" 454 ), 455 ): 456 self.bump() 457 self.assertEqual(self.refs(), before) 458 459 def test_creation_collision_retains_prior_tags_and_never_overwrites(self): 460 original = self.git("two", "rev-parse", "HEAD~") 461 462 def race(repo, *command): 463 if repo.name == "two": 464 self.git("two", "tag", "v1.2.4", original) 465 # Capture the expected Git error while executing the real tag command. 466 taler_repos.capture(repo, *command) 467 468 with ( 469 patch.object(taler_repos, "run", side_effect=race), 470 self.assertRaisesRegex(taler_repos.BuildError, "retained: one:v1.2.4"), 471 ): 472 self.bump() 473 self.assertEqual( 474 self.git("one", "rev-parse", "v1.2.4"), self.git("one", "rev-parse", "HEAD") 475 ) 476 self.assertEqual(self.git("two", "rev-parse", "v1.2.4"), original) 477 478 def test_tags_the_inspected_commit_when_head_moves(self): 479 original = self.git("one", "rev-parse", "HEAD") 480 run = taler_repos.run 481 482 def advance(repo, *command): 483 if repo.name == "one": 484 self.git( 485 "one", 486 "commit", 487 "--quiet", 488 "--allow-empty", 489 "-m", 490 "Concurrent commit", 491 ) 492 run(repo, *command) 493 494 with patch.object(taler_repos, "run", side_effect=advance): 495 self.bump() 496 self.assertEqual(self.git("one", "rev-parse", "v1.2.4"), original) 497 self.assertNotEqual(self.git("one", "rev-parse", "HEAD"), original) 498 499 def test_cli_dispatch_and_error_prefix(self): 500 before = self.refs() 501 with ( 502 patch("sys.argv", [str(SCRIPT), "bump", "--dev", "--dry-run"]), 503 redirect_stdout(io.StringIO()) as output, 504 ): 505 self.assertEqual(taler_repos.main(), 0) 506 self.assertIn("v1.2.4-dev.1", output.getvalue()) 507 self.assertEqual(self.refs(), before) 508 (self.root / "two" / "dirty").touch() 509 with ( 510 patch("sys.argv", [str(SCRIPT), "bump"]), 511 redirect_stdout(io.StringIO()), 512 redirect_stderr(io.StringIO()) as error, 513 ): 514 self.assertEqual(taler_repos.main(), 1) 515 self.assertTrue( 516 error.getvalue().startswith("taler-repos: bump preflight failed") 517 ) 518 self.assertEqual(self.refs(), before) 519 520 521 if __name__ == "__main__": 522 unittest.main()