|
2 | 2 | import logging |
3 | 3 | import uuid |
4 | 4 | from datetime import datetime |
| 5 | +from importlib.metadata import packages_distributions |
5 | 6 | from pathlib import Path |
6 | | -from typing import TYPE_CHECKING, Any, Dict, Optional, Protocol, Union |
| 7 | +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union |
7 | 8 |
|
8 | 9 | from botocore.exceptions import ClientError |
9 | 10 | from pydantic import BaseModel, Field |
| 11 | +from rich.console import Console |
10 | 12 | from rich.pretty import pretty_repr |
| 13 | +from rich.table import Table |
11 | 14 |
|
12 | 15 | from redis_release.models import WorkflowConclusion, WorkflowStatus |
13 | 16 | from redis_release.state_manager import S3Backed, logger |
@@ -238,6 +241,7 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: |
238 | 241 | self.storage.release_lock(self.tag) |
239 | 242 | self._lock_acquired = False |
240 | 243 | logger.info(f"Lock released for tag: {self.tag}") |
| 244 | + print_state_table(self.state) |
241 | 245 |
|
242 | 246 | @property |
243 | 247 | def state(self) -> ReleaseState: |
@@ -508,3 +512,181 @@ def reset_model_to_defaults(target: BaseModel, default: BaseModel) -> None: |
508 | 512 | else: |
509 | 513 | # Simple value, copy directly |
510 | 514 | setattr(target, field_name, default_value) |
| 515 | + |
| 516 | + |
| 517 | +def print_state_table(state: ReleaseState, console: Optional[Console] = None) -> None: |
| 518 | + """Print table showing the release state. |
| 519 | +
|
| 520 | + Args: |
| 521 | + state: The ReleaseState to display |
| 522 | + console: Optional Rich Console instance (creates new one if not provided) |
| 523 | + """ |
| 524 | + if console is None: |
| 525 | + console = Console() |
| 526 | + |
| 527 | + # Create table with title |
| 528 | + table = Table( |
| 529 | + title=f"[bold cyan]Release State: {state.meta.tag or 'N/A'}[/bold cyan]", |
| 530 | + show_header=True, |
| 531 | + header_style="bold magenta", |
| 532 | + border_style="bright_blue", |
| 533 | + title_style="bold cyan", |
| 534 | + ) |
| 535 | + |
| 536 | + # Add columns |
| 537 | + table.add_column("Package", style="cyan", no_wrap=True, width=20) |
| 538 | + table.add_column("Build", justify="center", width=15) |
| 539 | + table.add_column("Publish", justify="center", width=15) |
| 540 | + table.add_column("Details", style="yellow", width=40) |
| 541 | + |
| 542 | + # Process each package |
| 543 | + for package_name, package in sorted(state.packages.items()): |
| 544 | + # Determine build status |
| 545 | + build_status = _get_workflow_status_display(package.build) |
| 546 | + |
| 547 | + # Determine publish status |
| 548 | + publish_status = _get_workflow_status_display(package.publish) |
| 549 | + |
| 550 | + # Collect details from workflows |
| 551 | + details = _collect_details(package) |
| 552 | + |
| 553 | + # Add row to table |
| 554 | + table.add_row( |
| 555 | + package_name, |
| 556 | + build_status, |
| 557 | + publish_status, |
| 558 | + details, |
| 559 | + ) |
| 560 | + |
| 561 | + # Print the table |
| 562 | + console.print() |
| 563 | + console.print(table) |
| 564 | + console.print() |
| 565 | + |
| 566 | + |
| 567 | +def _get_workflow_status_display(workflow: Workflow) -> str: |
| 568 | + """Get a rich-formatted status display for a workflow. |
| 569 | +
|
| 570 | + Args: |
| 571 | + workflow: The workflow to check |
| 572 | +
|
| 573 | + Returns: |
| 574 | + Rich-formatted status string |
| 575 | + """ |
| 576 | + # Check result field - if we have result, we succeeded |
| 577 | + if workflow.result is not None: |
| 578 | + return "[bold green]✓ Success[/bold green]" |
| 579 | + |
| 580 | + # Check if workflow was triggered |
| 581 | + if workflow.triggered_at is None: |
| 582 | + return "[dim]− Not Started[/dim]" |
| 583 | + |
| 584 | + # Workflow was triggered but no result - it failed |
| 585 | + return "[bold red]✗ Failed[/bold red]" |
| 586 | + |
| 587 | + |
| 588 | +def _collect_workflow_details(workflow: Workflow, prefix: str) -> List[str]: |
| 589 | + """Collect details from a workflow using bottom-up approach. |
| 590 | +
|
| 591 | + Shows successes until the first failure, then stops. |
| 592 | + Bottom-up means: trigger → identify → timeout → conclusion → artifacts → result |
| 593 | +
|
| 594 | + Args: |
| 595 | + workflow: The workflow to check |
| 596 | + prefix: Prefix for detail messages (e.g., "Build" or "Publish") |
| 597 | +
|
| 598 | + Returns: |
| 599 | + List of detail strings |
| 600 | + """ |
| 601 | + details: List[str] = [] |
| 602 | + |
| 603 | + # Stage 1: Trigger (earliest/bottom) |
| 604 | + if workflow.ephemeral.trigger_failed or workflow.triggered_at is None: |
| 605 | + details.append(f"[red]✗ Trigger {prefix} workflow failed[/red]") |
| 606 | + return details |
| 607 | + else: |
| 608 | + details.append(f"[green]✓ {prefix} workflow triggered[/green]") |
| 609 | + |
| 610 | + # Stage 2: Identify |
| 611 | + if workflow.ephemeral.identify_failed or workflow.run_id is None: |
| 612 | + details.append(f"[red]✗ {prefix} workflow not found[/red]") |
| 613 | + return details |
| 614 | + else: |
| 615 | + details.append(f"[green]✓ {prefix} workflow found[/green]") |
| 616 | + |
| 617 | + # Stage 3: Timeout (only ephemeral) |
| 618 | + if workflow.ephemeral.timed_out: |
| 619 | + details.append(f"[yellow]⏱ {prefix} timed out[/yellow]") |
| 620 | + return details |
| 621 | + |
| 622 | + # Stage 4: Workflow conclusion |
| 623 | + if workflow.conclusion == WorkflowConclusion.FAILURE: |
| 624 | + details.append(f"[red]✗ {prefix} workflow failed[/red]") |
| 625 | + return details |
| 626 | + |
| 627 | + # Stage 5: Artifacts download |
| 628 | + if workflow.ephemeral.artifacts_download_failed or workflow.artifacts is None: |
| 629 | + details.append(f"[red]✗ {prefix} artifacts download failed[/red]") |
| 630 | + return details |
| 631 | + else: |
| 632 | + details.append(f"[green]✓ {prefix} artifacts downloaded[/green]") |
| 633 | + |
| 634 | + # Stage 6: Result extraction (latest/top) |
| 635 | + if workflow.result is None or workflow.ephemeral.extract_result_failed: |
| 636 | + details.append(f"[red]✗ {prefix} failed to extract result[/red]") |
| 637 | + return details |
| 638 | + else: |
| 639 | + details.append(f"[green]✓ {prefix} result extracted[/green]") |
| 640 | + |
| 641 | + # Check for other workflow states |
| 642 | + if workflow.status == WorkflowStatus.IN_PROGRESS: |
| 643 | + details.append(f"[blue]⟳ {prefix} in progress[/blue]") |
| 644 | + elif workflow.status == WorkflowStatus.QUEUED: |
| 645 | + details.append(f"[cyan]⋯ {prefix} queued[/cyan]") |
| 646 | + elif workflow.status == WorkflowStatus.PENDING: |
| 647 | + details.append(f"[dim]○ {prefix} pending[/dim]") |
| 648 | + |
| 649 | + return details |
| 650 | + |
| 651 | + |
| 652 | +def _collect_package_details(package: Package) -> List[str]: |
| 653 | + """Collect details from package metadata. |
| 654 | +
|
| 655 | + Args: |
| 656 | + package: The package to check |
| 657 | +
|
| 658 | + Returns: |
| 659 | + List of detail strings (may be empty) |
| 660 | + """ |
| 661 | + details: List[str] = [] |
| 662 | + |
| 663 | + if package.meta.ephemeral.identify_ref_failed: |
| 664 | + details.append("[red]✗ Identify target ref to run workflow failed[/red]") |
| 665 | + elif package.meta.ref is not None: |
| 666 | + details.append(f"[green]✓ Target Ref identified: {package.meta.ref}[/green]") |
| 667 | + |
| 668 | + return details |
| 669 | + |
| 670 | + |
| 671 | +def _collect_details(package: Package) -> str: |
| 672 | + """Collect and format all details from package and workflows. |
| 673 | +
|
| 674 | + Args: |
| 675 | + package: The package to check |
| 676 | +
|
| 677 | + Returns: |
| 678 | + Formatted string of details |
| 679 | + """ |
| 680 | + details: List[str] = [] |
| 681 | + |
| 682 | + # Collect package-level details |
| 683 | + details.extend(_collect_package_details(package)) |
| 684 | + |
| 685 | + # Collect build workflow details |
| 686 | + details.extend(_collect_workflow_details(package.build, "Build")) |
| 687 | + |
| 688 | + # Only collect publish details if build succeeded (has result) |
| 689 | + if package.build.result is not None: |
| 690 | + details.extend(_collect_workflow_details(package.publish, "Publish")) |
| 691 | + |
| 692 | + return "\n".join(details) |
0 commit comments