df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
2025-12-29 17:05:03 -08:00
parent 226de9316a
commit c019c00aed
104 changed files with 19669 additions and 12954 deletions

148
models.py
View File

@@ -33,10 +33,10 @@ from rich.progress import (
@dataclass(slots=True)
class PipeObject:
"""Unified pipeline object for tracking files, metadata, tag values, and relationships through the pipeline.
This is the single source of truth for all result data in the pipeline. Uses the hash+store
canonical pattern for file identification.
Attributes:
hash: SHA-256 hash of the file (canonical identifier)
store: Storage backend name (e.g., 'default', 'hydrus', 'test', 'home')
@@ -53,6 +53,7 @@ class PipeObject:
parent_hash: Hash of the parent file in the pipeline chain (for tracking provenance/lineage)
extra: Additional fields not covered above
"""
hash: str
store: str
provider: Optional[str] = None
@@ -72,21 +73,21 @@ class PipeObject:
def add_relationship(self, rel_type: str, rel_hash: str) -> None:
"""Add a relationship hash.
Args:
rel_type: Relationship type ('king', 'alt', 'related')
rel_hash: Hash to add to the relationship
"""
if rel_type not in self.relationships:
self.relationships[rel_type] = []
if isinstance(self.relationships[rel_type], list):
if rel_hash not in self.relationships[rel_type]:
self.relationships[rel_type].append(rel_hash)
else:
# Single value (e.g., king), convert to that value
self.relationships[rel_type] = rel_hash
def get_relationships(self) -> Dict[str, Any]:
"""Get all relationships for this object."""
return self.relationships.copy() if self.relationships else {}
@@ -114,7 +115,10 @@ class PipeObject:
cmdlet_name = "PipeObject"
try:
import pipeline as ctx
current = ctx.get_current_cmdlet_name("") if hasattr(ctx, "get_current_cmdlet_name") else ""
current = (
ctx.get_current_cmdlet_name("") if hasattr(ctx, "get_current_cmdlet_name") else ""
)
if current:
cmdlet_name = current
else:
@@ -145,7 +149,7 @@ class PipeObject:
if self.provider:
data["provider"] = self.provider
if self.tag:
data["tag"] = self.tag
if self.title:
@@ -170,7 +174,7 @@ class PipeObject:
data["action"] = self.action
if self.parent_hash:
data["parent_hash"] = self.parent_hash
# Add extra fields
data.update({k: v for k, v in self.extra.items() if v is not None})
return data
@@ -178,22 +182,22 @@ class PipeObject:
class FileRelationshipTracker:
"""Track relationships between files for sidecar creation.
Allows tagging files with their relationships to other files:
- king: The primary/master version of a file
- alt: Alternate versions of the same content
- related: Related files (e.g., screenshots of a book)
"""
def __init__(self) -> None:
self.relationships: Dict[str, Dict[str, Any]] = {}
def register_king(self, file_path: str, file_hash: str) -> None:
"""Register a file as the king (primary) version."""
if file_path not in self.relationships:
self.relationships[file_path] = {}
self.relationships[file_path]["king"] = file_hash
def add_alt(self, file_path: str, alt_hash: str) -> None:
"""Add an alternate version of a file."""
if file_path not in self.relationships:
@@ -202,7 +206,7 @@ class FileRelationshipTracker:
self.relationships[file_path]["alt"] = []
if alt_hash not in self.relationships[file_path]["alt"]:
self.relationships[file_path]["alt"].append(alt_hash)
def add_related(self, file_path: str, related_hash: str) -> None:
"""Add a related file."""
if file_path not in self.relationships:
@@ -211,14 +215,14 @@ class FileRelationshipTracker:
self.relationships[file_path]["related"] = []
if related_hash not in self.relationships[file_path]["related"]:
self.relationships[file_path]["related"].append(related_hash)
def get_relationships(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Get relationships for a file."""
return self.relationships.get(file_path)
def link_files(self, primary_path: str, king_hash: str, *alt_paths: str) -> None:
"""Link files together with primary as king and others as alternates.
Args:
primary_path: Path to the primary file (will be marked as 'king')
king_hash: Hash of the primary file
@@ -231,6 +235,7 @@ class FileRelationshipTracker:
self.add_alt(primary_path, alt_hash)
except Exception as e:
import sys
print(f"Error hashing {alt_path}: {e}", file=sys.stderr)
@@ -245,6 +250,7 @@ def _get_file_hash(filepath: str) -> str:
# ============= Download Module Classes =============
class DownloadError(RuntimeError):
"""Raised when the download or Hydrus import fails."""
@@ -252,9 +258,10 @@ class DownloadError(RuntimeError):
@dataclass(slots=True)
class DownloadOptions:
"""Configuration for downloading media.
Use the add-file cmdlet separately for Hydrus import.
"""
url: str
mode: str # "audio" or "video"
output_dir: Path
@@ -273,13 +280,14 @@ class DownloadOptions:
class SendFunc(Protocol):
"""Protocol for event sender function."""
def __call__(self, event: str, **payload: Any) -> None:
...
def __call__(self, event: str, **payload: Any) -> None: ...
@dataclass(slots=True)
class DownloadMediaResult:
"""Result of a successful media download."""
path: Path
info: Dict[str, Any]
tag: List[str]
@@ -291,6 +299,7 @@ class DownloadMediaResult:
@dataclass(slots=True)
class DebugLogger:
"""Logs events to a JSON debug file for troubleshooting downloads."""
path: Path
file: Optional[TextIO] = None
session_started: bool = False
@@ -383,8 +392,7 @@ def _sanitise_for_json(value: Any, *, max_depth: int = 8, _seen: Optional[set[in
if isinstance(value, (list, tuple, set)):
iterable = value if not isinstance(value, set) else list(value)
return [
_sanitise_for_json(item, max_depth=max_depth - 1, _seen=_seen)
for item in iterable
_sanitise_for_json(item, max_depth=max_depth - 1, _seen=_seen) for item in iterable
]
if is_dataclass(value) and not isinstance(value, type):
return _sanitise_for_json(asdict(value), max_depth=max_depth - 1, _seen=_seen)
@@ -393,6 +401,7 @@ def _sanitise_for_json(value: Any, *, max_depth: int = 8, _seen: Optional[set[in
return repr(value)
class ProgressBar:
"""Rich progress helper for byte-based transfers.
@@ -419,7 +428,9 @@ class ProgressBar:
# Pipeline-backed transfer task is already registered; update its total if needed.
try:
if total is not None and total > 0:
self._pipeline_ui.update_transfer(label=self._pipeline_label, completed=None, total=int(total))
self._pipeline_ui.update_transfer(
label=self._pipeline_label, completed=None, total=int(total)
)
except Exception:
pass
return
@@ -438,7 +449,10 @@ class ProgressBar:
self._pipeline_ui = ui
self._pipeline_label = str(label or "download")
try:
ui.begin_transfer(label=self._pipeline_label, total=int(total) if isinstance(total, int) and total > 0 else None)
ui.begin_transfer(
label=self._pipeline_label,
total=int(total) if isinstance(total, int) and total > 0 else None,
)
except Exception:
# If pipeline integration fails, fall back to standalone progress.
self._pipeline_ui = None
@@ -503,7 +517,9 @@ class ProgressBar:
if self._progress is None or self._task_id is None:
return
if total is not None and total > 0:
self._progress.update(self._task_id, completed=int(downloaded or 0), total=int(total), refresh=True)
self._progress.update(
self._task_id, completed=int(downloaded or 0), total=int(total), refresh=True
)
else:
self._progress.update(self._task_id, completed=int(downloaded or 0), refresh=True)
@@ -528,21 +544,21 @@ class ProgressBar:
def format_bytes(self, bytes_val: Optional[float]) -> str:
"""Format bytes to human-readable size.
Args:
bytes_val: Number of bytes or None.
Returns:
Formatted string (e.g., "123.4 MB", "1.2 GB").
"""
if bytes_val is None or bytes_val <= 0:
return "?.? B"
for unit in ("B", "KB", "MB", "GB", "TB"):
if bytes_val < 1024:
return f"{bytes_val:.1f} {unit}"
bytes_val /= 1024
return f"{bytes_val:.1f} PB"
# NOTE: rich.Progress handles the visual formatting; format_bytes remains as a general utility.
@@ -555,7 +571,14 @@ class ProgressFileReader:
Progress is written to stderr (so pipelines remain clean).
"""
def __init__(self, fileobj: Any, *, total_bytes: Optional[int], label: str = "upload", min_interval_s: float = 0.25):
def __init__(
self,
fileobj: Any,
*,
total_bytes: Optional[int],
label: str = "upload",
min_interval_s: float = 0.25,
):
self._f = fileobj
self._total = int(total_bytes) if total_bytes not in (None, 0, "") else 0
self._label = str(label or "upload")
@@ -574,7 +597,12 @@ class ProgressFileReader:
now = time.time()
if now - self._last < self._min_interval_s:
return
self._bar.update(downloaded=int(self._read), total=int(self._total), label=str(self._label or "upload"), file=sys.stderr)
self._bar.update(
downloaded=int(self._read),
total=int(self._total),
label=str(self._label or "upload"),
file=sys.stderr,
)
self._last = now
def _finish(self) -> None:
@@ -868,7 +896,13 @@ class PipelineLiveProgress:
return
if self._live is not None:
return
if self._console is None or self._pipe_progress is None or self._subtasks is None or self._transfers is None or self._overall is None:
if (
self._console is None
or self._pipe_progress is None
or self._subtasks is None
or self._transfers is None
or self._overall is None
):
# Not initialized yet; start fresh.
self.start()
return
@@ -1081,7 +1115,9 @@ class PipelineLiveProgress:
except Exception:
pass
def update_transfer(self, *, label: str, completed: Optional[int], total: Optional[int] = None) -> None:
def update_transfer(
self, *, label: str, completed: Optional[int], total: Optional[int] = None
) -> None:
if not self._enabled:
return
if self._transfers is None:
@@ -1123,7 +1159,9 @@ class PipelineLiveProgress:
return False
return True
def begin_pipe(self, pipe_index: int, *, total_items: int, items_preview: Optional[List[Any]] = None) -> None:
def begin_pipe(
self, pipe_index: int, *, total_items: int, items_preview: Optional[List[Any]] = None
) -> None:
if not self._ensure_pipe(pipe_index):
return
pipe_progress = self._pipe_progress
@@ -1321,7 +1359,11 @@ class PipelineLiveProgress:
if self._overall_task is not None:
completed = 0
try:
completed = sum(1 for i in range(len(self._pipe_labels)) if self._pipe_done[i] >= max(1, self._pipe_totals[i]))
completed = sum(
1
for i in range(len(self._pipe_labels))
if self._pipe_done[i] >= max(1, self._pipe_totals[i])
)
except Exception:
completed = 0
overall.update(
@@ -1330,6 +1372,7 @@ class PipelineLiveProgress:
description=f"Pipeline: {completed}/{len(self._pipe_labels)} pipes completed",
)
class PipelineStageContext:
"""Context information for the current pipeline stage."""
@@ -1343,7 +1386,7 @@ class PipelineStageContext:
):
self.stage_index = stage_index
self.total_stages = total_stages
self.is_last_stage = (stage_index == total_stages - 1)
self.is_last_stage = stage_index == total_stages - 1
self.pipe_index = int(pipe_index) if pipe_index is not None else None
self.worker_id = worker_id
self._on_emit = on_emit
@@ -1377,13 +1420,14 @@ class PipelineStageContext:
# Consolidated from result_table.py
# ============================================================================
@dataclass
class InputOption:
"""Represents an interactive input option (cmdlet argument) in a table.
Allows users to select options that translate to cmdlet arguments,
enabling interactive configuration right from the result table.
Example:
# Create an option for location selection
location_opt = InputOption(
@@ -1392,11 +1436,12 @@ class InputOption:
choices=["local", "hydrus", "0x0"],
description="Download destination"
)
# Use in result table
table.add_input_option(location_opt)
selected = table.select_option("location") # Returns user choice
"""
name: str
"""Option name (maps to cmdlet argument)"""
type: str = "string"
@@ -1409,7 +1454,7 @@ class InputOption:
"""Description of what this option does"""
validator: Optional[Callable[[str], bool]] = None
"""Optional validator function: takes value, returns True if valid"""
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -1424,10 +1469,11 @@ class InputOption:
@dataclass
class TUIResultCard:
"""Represents a result as a UI card with title, metadata, and actions.
Used in hub-ui and TUI contexts to render individual search results
Used in hub-ui and TUI contexts to render individual search results
as grouped components with visual structure.
"""
title: str
subtitle: Optional[str] = None
metadata: Optional[Dict[str, str]] = None
@@ -1436,7 +1482,7 @@ class TUIResultCard:
file_hash: Optional[str] = None
file_size: Optional[str] = None
duration: Optional[str] = None
def __post_init__(self):
"""Initialize default values."""
if self.metadata is None:
@@ -1448,14 +1494,15 @@ class TUIResultCard:
@dataclass
class ResultColumn:
"""Represents a single column in a result table."""
name: str
value: str
width: Optional[int] = None
def __str__(self) -> str:
"""String representation of the column."""
return f"{self.name}: {self.value}"
def to_dict(self) -> Dict[str, str]:
"""Convert to dictionary."""
return {"name": self.name, "value": self.value}
@@ -1464,28 +1511,29 @@ class ResultColumn:
@dataclass
class ResultRow:
"""Represents a single row in a result table."""
columns: List[ResultColumn] = field(default_factory=list)
def add_column(self, name: str, value: Any) -> None:
"""Add a column to this row."""
str_value = str(value) if value is not None else ""
self.columns.append(ResultColumn(name, str_value))
def get_column(self, name: str) -> Optional[str]:
"""Get column value by name."""
for col in self.columns:
if col.name.lower() == name.lower():
return col.value
return None
def to_dict(self) -> List[Dict[str, str]]:
"""Convert to list of column dicts."""
return [col.to_dict() for col in self.columns]
def to_list(self) -> List[tuple[str, str]]:
"""Convert to list of (name, value) tuples."""
return [(col.name, col.value) for col in self.columns]
def __str__(self) -> str:
"""String representation of the row."""
return " | ".join(str(col) for col in self.columns)
return " | ".join(str(col) for col in self.columns)