df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
148
models.py
148
models.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user