test_muse_release.py
python
| 1 | """Tests for ``muse release`` — export a tagged commit as release artifacts. |
| 2 | |
| 3 | Verifies: |
| 4 | - ``build_release`` writes a release-manifest.json when called with no flags. |
| 5 | - ``build_release --render-midi`` produces a zip archive of all MIDI files. |
| 6 | - ``build_release --render-audio`` copies the MIDI as an audio stub. |
| 7 | - ``build_release --export-stems`` produces per-track audio stubs. |
| 8 | - ``build_release`` raises ``ValueError`` when no MIDI files exist in snapshot. |
| 9 | - ``build_release`` raises ``StorpheusReleaseUnavailableError`` when Storpheus |
| 10 | is down and audio rendering is requested. |
| 11 | - ``_resolve_tag_to_commit`` resolves a tag to the most recent commit. |
| 12 | - ``_resolve_tag_to_commit`` falls back to prefix lookup when tag not found. |
| 13 | - ``_release_async`` (regression): resolves tag, fetches manifest, delegates. |
| 14 | - Boundary seal (AST): ``from __future__ import annotations`` present. |
| 15 | """ |
| 16 | from __future__ import annotations |
| 17 | |
| 18 | import ast |
| 19 | import datetime |
| 20 | import json |
| 21 | import pathlib |
| 22 | import uuid |
| 23 | import zipfile |
| 24 | from collections.abc import AsyncGenerator |
| 25 | from unittest.mock import patch |
| 26 | |
| 27 | import pytest |
| 28 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine |
| 29 | |
| 30 | from maestro.db.database import Base |
| 31 | from maestro.muse_cli import models as cli_models # noqa: F401 — register tables |
| 32 | from maestro.muse_cli.models import MuseCliCommit, MuseCliObject, MuseCliSnapshot, MuseCliTag |
| 33 | from maestro.services.muse_release import ( |
| 34 | ReleaseAudioFormat, |
| 35 | ReleaseResult, |
| 36 | StorpheusReleaseUnavailableError, |
| 37 | _collect_midi_paths, |
| 38 | _sha256_file, |
| 39 | build_release, |
| 40 | ) |
| 41 | |
| 42 | |
| 43 | # --------------------------------------------------------------------------- |
| 44 | # Fixtures |
| 45 | # --------------------------------------------------------------------------- |
| 46 | |
| 47 | |
| 48 | @pytest.fixture |
| 49 | async def async_session() -> AsyncGenerator[AsyncSession, None]: |
| 50 | """In-memory SQLite session with all CLI tables created.""" |
| 51 | engine = create_async_engine("sqlite+aiosqlite:///:memory:") |
| 52 | async with engine.begin() as conn: |
| 53 | await conn.run_sync(Base.metadata.create_all) |
| 54 | Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) |
| 55 | async with Session() as session: |
| 56 | yield session |
| 57 | await engine.dispose() |
| 58 | |
| 59 | |
| 60 | @pytest.fixture |
| 61 | def repo_root(tmp_path: pathlib.Path) -> pathlib.Path: |
| 62 | """Create a minimal Muse repo structure under *tmp_path*.""" |
| 63 | muse_dir = tmp_path / ".muse" |
| 64 | muse_dir.mkdir() |
| 65 | (muse_dir / "HEAD").write_text("refs/heads/main") |
| 66 | refs_dir = muse_dir / "refs" / "heads" |
| 67 | refs_dir.mkdir(parents=True) |
| 68 | return tmp_path |
| 69 | |
| 70 | |
| 71 | @pytest.fixture |
| 72 | def repo_id() -> str: |
| 73 | return str(uuid.uuid4()) |
| 74 | |
| 75 | |
| 76 | @pytest.fixture |
| 77 | def write_repo_json(repo_root: pathlib.Path, repo_id: str) -> None: |
| 78 | """Write .muse/repo.json with a stable repo_id.""" |
| 79 | (repo_root / ".muse" / "repo.json").write_text(json.dumps({"repo_id": repo_id})) |
| 80 | |
| 81 | |
| 82 | @pytest.fixture |
| 83 | def midi_repo(repo_root: pathlib.Path) -> dict[str, pathlib.Path]: |
| 84 | """Create a muse-work/ directory with two MIDI stub files. |
| 85 | |
| 86 | Returns a dict mapping relative path strings to absolute Path objects. |
| 87 | """ |
| 88 | workdir = repo_root / "muse-work" |
| 89 | workdir.mkdir() |
| 90 | paths: dict[str, pathlib.Path] = {} |
| 91 | for name in ("piano.mid", "bass.mid"): |
| 92 | p = workdir / name |
| 93 | p.write_bytes(b"MIDI") |
| 94 | paths[name] = p |
| 95 | return paths |
| 96 | |
| 97 | |
| 98 | async def _insert_commit_with_tag( |
| 99 | session: AsyncSession, |
| 100 | repo_id: str, |
| 101 | repo_root: pathlib.Path, |
| 102 | tag: str, |
| 103 | manifest: dict[str, str] | None = None, |
| 104 | commit_id_char: str = "b", |
| 105 | ) -> str: |
| 106 | """Insert a commit + snapshot + tag; return the commit_id.""" |
| 107 | object_id = "c" * 64 |
| 108 | snapshot_id = ("s" + commit_id_char) * 32 |
| 109 | commit_id = commit_id_char * 64 |
| 110 | |
| 111 | if not manifest: |
| 112 | manifest = {"piano.mid": object_id} |
| 113 | |
| 114 | session.add(MuseCliObject(object_id=object_id, size_bytes=4)) |
| 115 | session.add(MuseCliSnapshot(snapshot_id=snapshot_id, manifest=manifest)) |
| 116 | await session.flush() |
| 117 | |
| 118 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 119 | session.add( |
| 120 | MuseCliCommit( |
| 121 | commit_id=commit_id, |
| 122 | repo_id=repo_id, |
| 123 | branch="main", |
| 124 | parent_commit_id=None, |
| 125 | parent2_commit_id=None, |
| 126 | snapshot_id=snapshot_id, |
| 127 | message="tagged commit", |
| 128 | author="", |
| 129 | committed_at=committed_at, |
| 130 | ) |
| 131 | ) |
| 132 | await session.flush() |
| 133 | |
| 134 | session.add(MuseCliTag(repo_id=repo_id, commit_id=commit_id, tag=tag)) |
| 135 | await session.flush() |
| 136 | |
| 137 | # Update HEAD pointer |
| 138 | ref_path = repo_root / ".muse" / "refs" / "heads" / "main" |
| 139 | ref_path.write_text(commit_id) |
| 140 | return commit_id |
| 141 | |
| 142 | |
| 143 | # --------------------------------------------------------------------------- |
| 144 | # Unit tests — service layer (build_release) |
| 145 | # --------------------------------------------------------------------------- |
| 146 | |
| 147 | |
| 148 | def test_build_release_writes_manifest_only( |
| 149 | repo_root: pathlib.Path, |
| 150 | midi_repo: dict[str, pathlib.Path], |
| 151 | tmp_path: pathlib.Path, |
| 152 | ) -> None: |
| 153 | """build_release writes release-manifest.json even when no flags are set.""" |
| 154 | manifest = {name: "c" * 64 for name in midi_repo} |
| 155 | output_dir = tmp_path / "releases" / "v1.0" |
| 156 | |
| 157 | with patch( |
| 158 | "maestro.services.muse_release._check_storpheus_reachable" |
| 159 | ): # not called — no audio flags |
| 160 | result = build_release( |
| 161 | tag="v1.0", |
| 162 | commit_id="b" * 64, |
| 163 | manifest=manifest, |
| 164 | root=repo_root, |
| 165 | output_dir=output_dir, |
| 166 | render_audio=False, |
| 167 | render_midi=False, |
| 168 | export_stems=False, |
| 169 | ) |
| 170 | |
| 171 | assert result.manifest_path.exists() |
| 172 | data = json.loads(result.manifest_path.read_text()) |
| 173 | assert data["tag"] == "v1.0" |
| 174 | assert data["commit_id"] == "b" * 64 |
| 175 | assert data["commit_short"] == "b" * 8 |
| 176 | assert "released_at" in data |
| 177 | assert isinstance(data["files"], list) |
| 178 | |
| 179 | |
| 180 | def test_build_release_render_midi_produces_zip( |
| 181 | repo_root: pathlib.Path, |
| 182 | midi_repo: dict[str, pathlib.Path], |
| 183 | tmp_path: pathlib.Path, |
| 184 | ) -> None: |
| 185 | """build_release --render-midi produces a zip containing all MIDI files.""" |
| 186 | manifest = {name: "c" * 64 for name in midi_repo} |
| 187 | output_dir = tmp_path / "releases" / "v1.0" |
| 188 | |
| 189 | result = build_release( |
| 190 | tag="v1.0", |
| 191 | commit_id="b" * 64, |
| 192 | manifest=manifest, |
| 193 | root=repo_root, |
| 194 | output_dir=output_dir, |
| 195 | render_midi=True, |
| 196 | ) |
| 197 | |
| 198 | bundle_path = output_dir / "midi" / "midi-bundle.zip" |
| 199 | assert bundle_path.exists() |
| 200 | assert any(a.role == "midi-bundle" for a in result.artifacts) |
| 201 | |
| 202 | with zipfile.ZipFile(bundle_path) as zf: |
| 203 | names = zf.namelist() |
| 204 | assert "piano.mid" in names |
| 205 | assert "bass.mid" in names |
| 206 | |
| 207 | |
| 208 | def test_build_release_render_audio_produces_stub( |
| 209 | repo_root: pathlib.Path, |
| 210 | midi_repo: dict[str, pathlib.Path], |
| 211 | tmp_path: pathlib.Path, |
| 212 | ) -> None: |
| 213 | """build_release --render-audio copies MIDI as audio stub when /render not deployed.""" |
| 214 | manifest = {name: "c" * 64 for name in midi_repo} |
| 215 | output_dir = tmp_path / "releases" / "v1.0" |
| 216 | |
| 217 | with patch( |
| 218 | "maestro.services.muse_release._check_storpheus_reachable" |
| 219 | ): |
| 220 | result = build_release( |
| 221 | tag="v1.0", |
| 222 | commit_id="b" * 64, |
| 223 | manifest=manifest, |
| 224 | root=repo_root, |
| 225 | output_dir=output_dir, |
| 226 | render_audio=True, |
| 227 | ) |
| 228 | |
| 229 | audio_artifact = next(a for a in result.artifacts if a.role == "audio") |
| 230 | assert audio_artifact.path.exists() |
| 231 | assert audio_artifact.path.suffix == ".wav" |
| 232 | assert result.stubbed is True |
| 233 | |
| 234 | |
| 235 | def test_build_release_export_stems_produces_per_track_files( |
| 236 | repo_root: pathlib.Path, |
| 237 | midi_repo: dict[str, pathlib.Path], |
| 238 | tmp_path: pathlib.Path, |
| 239 | ) -> None: |
| 240 | """build_release --export-stems writes one audio file per MIDI track.""" |
| 241 | manifest = {name: "c" * 64 for name in midi_repo} |
| 242 | output_dir = tmp_path / "releases" / "v1.0" |
| 243 | |
| 244 | with patch( |
| 245 | "maestro.services.muse_release._check_storpheus_reachable" |
| 246 | ): |
| 247 | result = build_release( |
| 248 | tag="v1.0", |
| 249 | commit_id="b" * 64, |
| 250 | manifest=manifest, |
| 251 | root=repo_root, |
| 252 | output_dir=output_dir, |
| 253 | export_stems=True, |
| 254 | audio_format=ReleaseAudioFormat.FLAC, |
| 255 | ) |
| 256 | |
| 257 | stem_artifacts = [a for a in result.artifacts if a.role == "stem"] |
| 258 | assert len(stem_artifacts) == 2 |
| 259 | for a in stem_artifacts: |
| 260 | assert a.path.suffix == ".flac" |
| 261 | assert a.path.exists() |
| 262 | |
| 263 | |
| 264 | def test_build_release_manifest_contains_checksums( |
| 265 | repo_root: pathlib.Path, |
| 266 | midi_repo: dict[str, pathlib.Path], |
| 267 | tmp_path: pathlib.Path, |
| 268 | ) -> None: |
| 269 | """release-manifest.json includes sha256 checksums for every artifact.""" |
| 270 | manifest = {name: "c" * 64 for name in midi_repo} |
| 271 | output_dir = tmp_path / "releases" / "v1.0" |
| 272 | |
| 273 | result = build_release( |
| 274 | tag="v1.0", |
| 275 | commit_id="b" * 64, |
| 276 | manifest=manifest, |
| 277 | root=repo_root, |
| 278 | output_dir=output_dir, |
| 279 | render_midi=True, |
| 280 | ) |
| 281 | |
| 282 | data = json.loads(result.manifest_path.read_text()) |
| 283 | for file_entry in data["files"]: |
| 284 | assert "sha256" in file_entry |
| 285 | assert len(file_entry["sha256"]) == 64 |
| 286 | assert "size_bytes" in file_entry |
| 287 | assert "role" in file_entry |
| 288 | |
| 289 | |
| 290 | def test_build_release_raises_when_no_midi_files( |
| 291 | repo_root: pathlib.Path, |
| 292 | tmp_path: pathlib.Path, |
| 293 | ) -> None: |
| 294 | """build_release raises ValueError when no MIDI files exist in snapshot.""" |
| 295 | workdir = repo_root / "muse-work" |
| 296 | workdir.mkdir() |
| 297 | (workdir / "notes.json").write_text("{}") # not a MIDI file |
| 298 | |
| 299 | manifest = {"notes.json": "c" * 64} |
| 300 | output_dir = tmp_path / "releases" / "v1.0" |
| 301 | |
| 302 | with pytest.raises(ValueError, match="No MIDI files found"): |
| 303 | build_release( |
| 304 | tag="v1.0", |
| 305 | commit_id="b" * 64, |
| 306 | manifest=manifest, |
| 307 | root=repo_root, |
| 308 | output_dir=output_dir, |
| 309 | render_audio=True, |
| 310 | ) |
| 311 | |
| 312 | |
| 313 | def test_build_release_raises_when_storpheus_unreachable( |
| 314 | repo_root: pathlib.Path, |
| 315 | midi_repo: dict[str, pathlib.Path], |
| 316 | tmp_path: pathlib.Path, |
| 317 | ) -> None: |
| 318 | """build_release raises StorpheusReleaseUnavailableError when Storpheus is down.""" |
| 319 | manifest = {name: "c" * 64 for name in midi_repo} |
| 320 | output_dir = tmp_path / "releases" / "v1.0" |
| 321 | |
| 322 | with patch( |
| 323 | "maestro.services.muse_release._check_storpheus_reachable", |
| 324 | side_effect=StorpheusReleaseUnavailableError("Storpheus is down"), |
| 325 | ): |
| 326 | with pytest.raises(StorpheusReleaseUnavailableError): |
| 327 | build_release( |
| 328 | tag="v1.0", |
| 329 | commit_id="b" * 64, |
| 330 | manifest=manifest, |
| 331 | root=repo_root, |
| 332 | output_dir=output_dir, |
| 333 | render_audio=True, |
| 334 | ) |
| 335 | |
| 336 | |
| 337 | # --------------------------------------------------------------------------- |
| 338 | # Unit tests — SHA-256 helper |
| 339 | # --------------------------------------------------------------------------- |
| 340 | |
| 341 | |
| 342 | def test_sha256_file_matches_known_digest(tmp_path: pathlib.Path) -> None: |
| 343 | """_sha256_file computes the correct SHA-256 for a known byte sequence.""" |
| 344 | import hashlib |
| 345 | |
| 346 | content = b"MIDI content for hashing" |
| 347 | p = tmp_path / "test.mid" |
| 348 | p.write_bytes(content) |
| 349 | |
| 350 | expected = hashlib.sha256(content).hexdigest() |
| 351 | assert _sha256_file(p) == expected |
| 352 | |
| 353 | |
| 354 | # --------------------------------------------------------------------------- |
| 355 | # Unit tests — _collect_midi_paths |
| 356 | # --------------------------------------------------------------------------- |
| 357 | |
| 358 | |
| 359 | def test_collect_midi_paths_filters_by_track( |
| 360 | repo_root: pathlib.Path, |
| 361 | midi_repo: dict[str, pathlib.Path], |
| 362 | ) -> None: |
| 363 | """_collect_midi_paths returns only paths matching the track filter.""" |
| 364 | manifest = {name: "c" * 64 for name in midi_repo} |
| 365 | paths, skipped = _collect_midi_paths(manifest, repo_root, track="piano") |
| 366 | |
| 367 | assert len(paths) == 1 |
| 368 | assert paths[0].name == "piano.mid" |
| 369 | assert skipped == 1 |
| 370 | |
| 371 | |
| 372 | def test_collect_midi_paths_skips_missing_files( |
| 373 | repo_root: pathlib.Path, |
| 374 | ) -> None: |
| 375 | """_collect_midi_paths counts missing files in skipped_count.""" |
| 376 | workdir = repo_root / "muse-work" |
| 377 | workdir.mkdir() |
| 378 | manifest = {"missing.mid": "c" * 64} # file does not exist on disk |
| 379 | |
| 380 | paths, skipped = _collect_midi_paths(manifest, repo_root) |
| 381 | assert paths == [] |
| 382 | assert skipped == 1 |
| 383 | |
| 384 | |
| 385 | # --------------------------------------------------------------------------- |
| 386 | # Integration tests — _resolve_tag_to_commit |
| 387 | # --------------------------------------------------------------------------- |
| 388 | |
| 389 | |
| 390 | @pytest.mark.anyio |
| 391 | async def test_resolve_tag_to_commit_finds_tagged_commit( |
| 392 | async_session: AsyncSession, |
| 393 | repo_root: pathlib.Path, |
| 394 | repo_id: str, |
| 395 | write_repo_json: None, |
| 396 | ) -> None: |
| 397 | """_resolve_tag_to_commit resolves a tag string to the correct commit ID.""" |
| 398 | from maestro.muse_cli.commands.release import _resolve_tag_to_commit |
| 399 | |
| 400 | commit_id = await _insert_commit_with_tag(async_session, repo_id, repo_root, "v1.0") |
| 401 | |
| 402 | resolved = await _resolve_tag_to_commit(async_session, repo_root, "v1.0") |
| 403 | assert resolved == commit_id |
| 404 | |
| 405 | |
| 406 | @pytest.mark.anyio |
| 407 | async def test_resolve_tag_to_commit_uses_most_recent_when_ambiguous( |
| 408 | async_session: AsyncSession, |
| 409 | repo_root: pathlib.Path, |
| 410 | repo_id: str, |
| 411 | write_repo_json: None, |
| 412 | ) -> None: |
| 413 | """When multiple commits share a tag, the most recently committed is returned.""" |
| 414 | from maestro.muse_cli.commands.release import _resolve_tag_to_commit |
| 415 | |
| 416 | object_id = "c" * 64 |
| 417 | snap1_id = "s1" * 32 |
| 418 | snap2_id = "s2" * 32 |
| 419 | cid1 = "1" * 64 |
| 420 | cid2 = "2" * 64 |
| 421 | |
| 422 | async_session.add(MuseCliObject(object_id=object_id, size_bytes=4)) |
| 423 | async_session.add(MuseCliSnapshot(snapshot_id=snap1_id, manifest={"a.mid": object_id})) |
| 424 | async_session.add(MuseCliSnapshot(snapshot_id=snap2_id, manifest={"b.mid": object_id})) |
| 425 | await async_session.flush() |
| 426 | |
| 427 | t1 = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc) |
| 428 | t2 = datetime.datetime(2024, 6, 1, tzinfo=datetime.timezone.utc) # more recent |
| 429 | |
| 430 | async_session.add( |
| 431 | MuseCliCommit( |
| 432 | commit_id=cid1, repo_id=repo_id, branch="main", |
| 433 | parent_commit_id=None, parent2_commit_id=None, |
| 434 | snapshot_id=snap1_id, message="old", author="", committed_at=t1, |
| 435 | ) |
| 436 | ) |
| 437 | async_session.add( |
| 438 | MuseCliCommit( |
| 439 | commit_id=cid2, repo_id=repo_id, branch="main", |
| 440 | parent_commit_id=cid1, parent2_commit_id=None, |
| 441 | snapshot_id=snap2_id, message="newer", author="", committed_at=t2, |
| 442 | ) |
| 443 | ) |
| 444 | await async_session.flush() |
| 445 | |
| 446 | async_session.add(MuseCliTag(repo_id=repo_id, commit_id=cid1, tag="v1.0")) |
| 447 | async_session.add(MuseCliTag(repo_id=repo_id, commit_id=cid2, tag="v1.0")) |
| 448 | await async_session.flush() |
| 449 | |
| 450 | resolved = await _resolve_tag_to_commit(async_session, repo_root, "v1.0") |
| 451 | assert resolved == cid2 |
| 452 | |
| 453 | |
| 454 | @pytest.mark.anyio |
| 455 | async def test_resolve_tag_to_commit_falls_back_to_prefix( |
| 456 | async_session: AsyncSession, |
| 457 | repo_root: pathlib.Path, |
| 458 | repo_id: str, |
| 459 | write_repo_json: None, |
| 460 | ) -> None: |
| 461 | """_resolve_tag_to_commit falls back to commit prefix lookup when tag absent.""" |
| 462 | from maestro.muse_cli.commands.release import _resolve_tag_to_commit |
| 463 | |
| 464 | commit_id = await _insert_commit_with_tag(async_session, repo_id, repo_root, "v2.0") |
| 465 | # Use the commit ID prefix directly (not the tag) |
| 466 | resolved = await _resolve_tag_to_commit(async_session, repo_root, commit_id[:8]) |
| 467 | assert resolved == commit_id |
| 468 | |
| 469 | |
| 470 | # --------------------------------------------------------------------------- |
| 471 | # Regression test — test_release_resolves_tag_and_exports_manifest |
| 472 | # --------------------------------------------------------------------------- |
| 473 | |
| 474 | |
| 475 | @pytest.mark.anyio |
| 476 | async def test_release_resolves_tag_and_exports_manifest( |
| 477 | async_session: AsyncSession, |
| 478 | repo_root: pathlib.Path, |
| 479 | repo_id: str, |
| 480 | write_repo_json: None, |
| 481 | midi_repo: dict[str, pathlib.Path], |
| 482 | tmp_path: pathlib.Path, |
| 483 | ) -> None: |
| 484 | """Regression: _release_async resolves tag, fetches manifest, writes manifest.json. |
| 485 | |
| 486 | This is the primary acceptance criterion: a producer runs |
| 487 | 'muse release v1.0' and receives a release-manifest.json pinning the |
| 488 | tagged snapshot. |
| 489 | """ |
| 490 | from maestro.muse_cli.commands.release import _release_async |
| 491 | |
| 492 | manifest = {name: "c" * 64 for name in midi_repo} |
| 493 | commit_id = await _insert_commit_with_tag( |
| 494 | async_session, repo_id, repo_root, "v1.0", manifest=manifest |
| 495 | ) |
| 496 | |
| 497 | output_dir = tmp_path / "releases" / "v1.0" |
| 498 | |
| 499 | with patch("maestro.config.settings") as mock_settings: |
| 500 | mock_settings.storpheus_base_url = "http://storpheus:10002" |
| 501 | result = await _release_async( |
| 502 | tag="v1.0", |
| 503 | audio_format=ReleaseAudioFormat.WAV, |
| 504 | output_dir=output_dir, |
| 505 | render_audio=False, |
| 506 | render_midi=True, |
| 507 | export_stems=False, |
| 508 | root=repo_root, |
| 509 | session=async_session, |
| 510 | ) |
| 511 | |
| 512 | assert result.tag == "v1.0" |
| 513 | assert result.commit_id == commit_id |
| 514 | assert result.manifest_path.exists() |
| 515 | |
| 516 | data = json.loads(result.manifest_path.read_text()) |
| 517 | assert data["tag"] == "v1.0" |
| 518 | assert data["commit_id"] == commit_id |
| 519 | |
| 520 | # MIDI bundle should be present |
| 521 | bundle_artifact = next((a for a in result.artifacts if a.role == "midi-bundle"), None) |
| 522 | assert bundle_artifact is not None |
| 523 | assert bundle_artifact.path.exists() |
| 524 | |
| 525 | with zipfile.ZipFile(bundle_artifact.path) as zf: |
| 526 | names = zf.namelist() |
| 527 | for midi_name in midi_repo: |
| 528 | assert midi_name in names |
| 529 | |
| 530 | |
| 531 | # --------------------------------------------------------------------------- |
| 532 | # Boundary seal |
| 533 | # --------------------------------------------------------------------------- |
| 534 | |
| 535 | |
| 536 | def test_future_annotations_in_service() -> None: |
| 537 | """``from __future__ import annotations`` is present in muse_release.py.""" |
| 538 | import maestro.services.muse_release as mod |
| 539 | |
| 540 | src = pathlib.Path(mod.__file__).read_text() |
| 541 | tree = ast.parse(src) |
| 542 | future_imports = [ |
| 543 | node |
| 544 | for node in ast.walk(tree) |
| 545 | if isinstance(node, ast.ImportFrom) |
| 546 | and node.module == "__future__" |
| 547 | and any(alias.name == "annotations" for alias in node.names) |
| 548 | ] |
| 549 | assert future_imports, "from __future__ import annotations missing in muse_release.py" |
| 550 | |
| 551 | |
| 552 | def test_future_annotations_in_command() -> None: |
| 553 | """``from __future__ import annotations`` is present in commands/release.py.""" |
| 554 | import maestro.muse_cli.commands.release as mod |
| 555 | |
| 556 | src = pathlib.Path(mod.__file__).read_text() |
| 557 | tree = ast.parse(src) |
| 558 | future_imports = [ |
| 559 | node |
| 560 | for node in ast.walk(tree) |
| 561 | if isinstance(node, ast.ImportFrom) |
| 562 | and node.module == "__future__" |
| 563 | and any(alias.name == "annotations" for alias in node.names) |
| 564 | ] |
| 565 | assert future_imports, "from __future__ import annotations missing in commands/release.py" |