Track upload progress in parser and display

517bbc0033ef8b6e4c3107a9903132a62598da76
nandi committed 1 months ago
D result -1
diff --git a/result b/resultdeleted file mode 120000index 9bc6505..0000000--- a/result+++ /dev/null@@ -1 +0,0 @@-/nix/store/7qs31js7dw2ayj0q807v9apmwbw00jvb-cowsay-3.8.4\ No newline at end of file
diff --git a/result-man b/result-mandeleted file mode 120000index 9ef1188..0000000--- a/result-man+++ /dev/null@@ -1 +0,0 @@-/nix/store/ps0aal328bh5xlf861vx3dx3lcm2qibn-cowsay-3.8.4-man\ No newline at end of file
diff --git a/src/pynom/display.py b/src/pynom/display.pyindex 8c26235..3dee591 100644--- a/src/pynom/display.py+++ b/src/pynom/display.py@@ -47,6 +47,24 @@ class BuildDisplay:                 return f"{bytes_val:.1f}{unit}"             bytes_val /= 1024         return f"{bytes_val:.1f}TB"++    def _transfer_label(+        self,+        color: str,+        prefix: str,+        name: str,+        downloaded: int,+        total: Optional[int],+    ) -> str:+        """Build a transfer label that includes byte counters when known."""+        if total and total > 0:+            return (+                f"  [{color}]{prefix} {name[:20]} "+                f"({self.format_size(downloaded)}/{self.format_size(total)})[/]"+            )+        if downloaded > 0:+            return f"  [{color}]{prefix} {name[:20]} ({self.format_size(downloaded)})[/]"+        return f"  [{color}]{prefix} {name[:25]}[/]"          def render_state(         self,@@ -65,9 +83,9 @@ class BuildDisplay:         )                  # Overall build progress-        total_tasks = state.total_builds + state.total_downloads-        done_tasks = state.completed_builds + state.completed_downloads-        running_tasks = len(state.running_builds) + len(state.running_downloads)+        total_tasks = state.total_builds + state.total_downloads + state.total_uploads+        done_tasks = state.completed_builds + state.completed_downloads + state.completed_uploads+        running_tasks = len(state.running_builds) + len(state.running_downloads) + len(state.running_uploads)                  if total_tasks > 0:             overall_task = progress.add_task(@@ -109,13 +127,38 @@ class BuildDisplay:             if dep:                 if dep.size and dep.size > 0:                     progress.add_task(-                        f"  [blue]DL {name[:25]}[/]",+                        self._transfer_label("blue", "DL", name, dep.downloaded, dep.size),                         total=dep.size,                         completed=dep.downloaded,                     )                 else:                     progress.add_task(-                        f"  [blue]DL {name[:25]}[/]",+                        self._transfer_label("blue", "DL", name, dep.downloaded, dep.size),+                        total=None,+                    )+        +        # Running uploads (copying source)+        for name in sorted(state.running_uploads):+            dep = state.dependencies.get(name)+            if dep:+                if dep.size and dep.size > 0:+                    # For path-based uploads, show path count+                    if 'paths' in name:+                        progress.add_task(+                            f"  [magenta]UP {name[:25]}[/]",+                            total=dep.size,+                            completed=dep.downloaded,+                        )+                    else:+                        # For byte-based uploads, show size in the label+                        progress.add_task(+                            self._transfer_label("magenta", "UP", name, dep.downloaded, dep.size),+                            total=dep.size,+                            completed=dep.downloaded,+                        )+                else:+                    progress.add_task(+                        self._transfer_label("magenta", "UP", name, dep.downloaded, dep.size),                         total=None,                     )         @@ -139,6 +182,35 @@ class BuildDisplay:             lines.append(f"[dim]{state.status_message}[/]")             lines.append("") +        # Show active upload/download progress summary+        if state.running_downloads:+            for name in sorted(state.running_downloads):+                dep = state.dependencies.get(name)+                if dep and dep.downloaded > 0:+                    if dep.size and dep.size > 0:+                        lines.append(+                            f"  [blue]↓ {self.format_size(dep.downloaded)}/{self.format_size(dep.size)}[/]"+                        )+                    else:+                        lines.append(f"  [blue]↓ {self.format_size(dep.downloaded)}[/]")+            lines.append("")++        if state.running_uploads:+            for name in sorted(state.running_uploads):+                dep = state.dependencies.get(name)+                if dep:+                    if dep.size and dep.downloaded > 0:+                        # For path-based uploads, show "X/Y paths"+                        if 'paths' in name:+                            lines.append(f"  [magenta]↑ {dep.downloaded}/{dep.size} paths[/]")+                        else:+                            # For byte-based uploads, show size progress+                            lines.append(f"  [magenta]↑ {self.format_size(dep.downloaded)}/{self.format_size(dep.size)}[/]")+                    elif dep.downloaded > 0:+                        # Known transferred but unknown total+                        lines.append(f"  [magenta]↑ {self.format_size(dep.downloaded)}[/]")+            lines.append("")+         recent_events = state.recent_events[-8:]         if show_recent_events and recent_events:             lines.append("[dim]Recent activity:[/]")
diff --git a/src/pynom/models.py b/src/pynom/models.pyindex f55f0d1..4f03d38 100644--- a/src/pynom/models.py+++ b/src/pynom/models.py@@ -91,6 +91,7 @@ class BuildState:     # Completed counts     completed_builds: int = 0     completed_downloads: int = 0+    completed_uploads: int = 0     failed_builds: int = 0          @property@@ -111,6 +112,12 @@ class BuildState:         return len([d for d in self.dependencies.values()                     if d.activity_type == ActivityType.DOWNLOAD])     +    @property+    def total_uploads(self) -> int:+        """Total number of uploads."""+        return len([d for d in self.dependencies.values() +                   if d.activity_type == ActivityType.UPLOAD])+         def add_dependency(self, dep: Dependency) -> None:         """Add a dependency to tracking."""         self.dependencies[dep.name] = dep@@ -153,6 +160,8 @@ class BuildState:                 self.completed_builds += 1             elif dep.activity_type == ActivityType.DOWNLOAD:                 self.completed_downloads += 1+            elif dep.activity_type == ActivityType.UPLOAD:+                self.completed_uploads += 1         elif status == BuildStatus.FAILED:             self.failed_builds += 1     
diff --git a/src/pynom/parser.py b/src/pynom/parser.pyindex 5b63d46..39fe038 100644--- a/src/pynom/parser.py+++ b/src/pynom/parser.py@@ -21,7 +21,8 @@ class NixParser:     DOWNLOAD_RE = re.compile(         r"(downloading|fetching).*'(/nix/store/[^']+)'"     )-    COPYING_RE = re.compile(r"copying (path|signal).*'(/nix/store/[^']+)'")+    COPYING_RE = re.compile(r"copying (path|signal|source).*'([^']+)'")+    COPYING_TO_RE = re.compile(r"copying.*to ['\"]?([^'\">\s]+)")          def __init__(self, use_json: bool = False):         self.use_json = use_json@@ -32,6 +33,68 @@ class NixParser:         self._result_data: dict[int, dict] = {}  # activity id -> result data for downloads         self._pending_builders: dict[str, str] = {}  # dep name -> builder host/name +    def _coerce_transfer_int(self, value: object) -> Optional[int]:+        """Return a non-negative transfer counter from a JSON field."""+        if isinstance(value, bool) or not isinstance(value, int):+            return None+        return max(value, 0)++    def _mark_failed_builds(self, message: str) -> None:+        """Mark builds as failed based on an error message."""+        matched = False++        for path in re.findall(r"(/nix/store/[^'\s]+\.drv)", message):+            name = self._extract_name(path)+            if name in self.state.dependencies:+                self.state.update_status(name, BuildStatus.FAILED, datetime.now())+                matched = True++        if matched:+            return++        for name in list(self.state.running_builds):+            self.state.update_status(name, BuildStatus.FAILED, datetime.now())++    def _track_transfer_start(+        self,+        activity_id: int,+        source: str,+        destination: str,+        text: str = "",+    ) -> Optional[str]:+        """Create a tracked upload/download from generic transfer metadata."""+        if not source or not destination:+            return None++        is_upload = "://" in destination+        activity = ActivityType.UPLOAD if is_upload else ActivityType.DOWNLOAD++        if source.startswith("/nix/store/"):+            name = self._extract_name(source)+            out_path = source+        else:+            name = source.split("/")[-1][:40] if "/" in source else source[:40]+            out_path = source++        if not self._should_track_activity(name, activity, out_path):+            return None++        dep = Dependency(+            name=name,+            out_path=out_path,+            status=BuildStatus.RUNNING,+            activity_type=activity,+            started_at=datetime.now(),+        )+        self.state.add_dependency(dep)+        self._activity_map[activity_id] = name+        self._activity_type_map[activity_id] = activity++        direction = "up" if is_upload else "down"+        if "source" in text.lower() and not source.startswith("/nix/store/"):+            return f"Copying {direction} {name}"+        return f"Copying {direction} {name}"+     def _should_track_activity(         self,         name: str,@@ -101,6 +164,7 @@ class NixParser:                     r"connecting to",                     r"copying \d+ paths?",                     r"copying path",+                    r"copying source",                     r"this derivation will be built",                 ]                 for pattern in status_patterns:@@ -108,7 +172,40 @@ class NixParser:                         clean = line.strip()                         if clean and len(clean) < 100:                             self.state.status_message = clean++                            # Track aggregate copy progress (e.g., "copying 23 paths to 'ssh://...'")+                            paths_match = re.search(r"copying (\d+) paths? to", line.lower())+                            if paths_match:+                                total_paths = int(paths_match.group(1))+                                # Create or update an aggregate upload tracker+                                agg_name = f"upload ({total_paths} paths)"+                                if agg_name not in self.state.dependencies:+                                    dep = Dependency(+                                        name=agg_name,+                                        status=BuildStatus.RUNNING,+                                        activity_type=ActivityType.UPLOAD,+                                        started_at=datetime.now(),+                                        size=total_paths,  # Use size field to track total paths+                                        downloaded=0,  # Use downloaded to track completed paths+                                    )+                                    self.state.add_dependency(dep)+                                self.state.running_uploads.add(agg_name)                         break++                # Track individual path completion during aggregate copy+                # Nix outputs: "copied 1 path" or "copied 5/23 paths"+                copied_match = re.search(r"copied (\d+)(?:/(\d+))? paths?", line.lower())+                if copied_match:+                    completed = int(copied_match.group(1))+                    total = int(copied_match.group(2)) if copied_match.group(2) else None+                    # Update aggregate upload tracker (named "upload (N paths)")+                    for name in list(self.state.running_uploads):+                        if 'paths' in name:+                            dep = self.state.dependencies.get(name)+                            if dep and dep.activity_type == ActivityType.UPLOAD:+                                dep.downloaded = completed+                                if total:+                                    dep.size = total                  if self.use_json:             result = self._parse_json_line(line)@@ -161,6 +258,32 @@ class NixParser:                          return f"Downloading {name}"         +        # Check for copying operations (uploads/downloads)+        match = self.COPYING_RE.search(line)+        if match:+            copy_type, path = match.groups()+            # Determine if upload (to remote) or download+            is_upload = "to " in line.lower() or "ssh://" in line.lower() or "uploading" in line.lower()+            +            # For source copying, use the source path as name+            if copy_type == "source":+                name = path.split('/')[-1][:40] if '/' in path else path[:40]+            else:+                name = self._extract_name(path)+            +            activity = ActivityType.UPLOAD if is_upload else ActivityType.DOWNLOAD+            dep = Dependency(+                name=name,+                out_path=path if path.startswith('/nix/store') else None,+                status=BuildStatus.RUNNING,+                activity_type=activity,+                started_at=datetime.now(),+            )+            self.state.add_dependency(dep)+            +            direction = "up" if is_upload else "down"+            return f"Copying {direction} {name}"+                 # Check for built outputs         match = self.BUILT_RE.match(line)         if match:@@ -221,6 +344,16 @@ class NixParser:         text = data.get("text", "")         fields = data.get("fields", [])         level = data.get("level", 4)++        if (+            activity_type >= 100+            and len(fields) >= 2+            and isinstance(fields[0], str)+            and isinstance(fields[1], str)+        ):+            tracked = self._track_transfer_start(activity_id, fields[0], fields[1], text)+            if tracked:+                return tracked                  # Track activity type for file transfers         if activity_type >= 100:@@ -328,14 +461,15 @@ class NixParser:                 return None                  elif "copying" in text.lower():+            # Try to extract path from text first             match = re.search(r"'(/nix/store/[^']+)'", text)             if match:                 path = match.group(1)                 name = self._extract_name(path)-                +                 # Check if upload or download                 is_upload = "to " in text.lower() or "uploading" in text.lower()-                +                 activity = ActivityType.UPLOAD if is_upload else ActivityType.DOWNLOAD                 if self._should_track_activity(name, activity, path):                     dep = Dependency(@@ -347,9 +481,14 @@ class NixParser:                     )                     self.state.add_dependency(dep)                     self._activity_map[activity_id] = name+                    self._activity_type_map[activity_id] = activity                     direction = "up" if is_upload else "down"                     return f"Copying {direction} {name}"                 return None++            # Handle "copying source" with paths in fields+            elif "source" in text.lower() and fields and len(fields) >= 2:+                return self._track_transfer_start(activity_id, fields[0], fields[1], text)                  # Track evaluation activities         if "evaluating" in text.lower():@@ -390,11 +529,8 @@ class NixParser:                  if name in self.state.dependencies:             dep = self.state.dependencies[name]-            -            # Check for errors in the activity-            has_error = False-            status = BuildStatus.FAILED if has_error else BuildStatus.DONE-            ++            status = dep.status if dep.status == BuildStatus.FAILED else BuildStatus.DONE             self.state.update_status(name, status, datetime.now())                          # Record build time@@ -436,8 +572,11 @@ class NixParser:             return None                  # Type 106 = file transfer progress-        # fields[0] = status (100=started, 101=progress, etc)+        # fields[0] = transfer status code         # fields[1] = bytes transferred+        # fields[2] may contain total size+        if result_type != 106:+            return None                  if activity_id not in self._activity_map:             return None@@ -447,16 +586,15 @@ class NixParser:         if name in self.state.dependencies:             dep = self.state.dependencies[name]             -            # Update download progress-            if dep.activity_type == ActivityType.DOWNLOAD and len(fields) >= 2:-                status_code = fields[0]-                bytes_transferred = fields[1] if len(fields) > 1 else 0-                -                if status_code == 101:  # Progress-                    dep.downloaded = bytes_transferred-                    # We don't know total size, so can't compute progress-                elif status_code == 100:  # Done+            # Update download/upload progress+            if dep.activity_type in (ActivityType.DOWNLOAD, ActivityType.UPLOAD) and len(fields) >= 2:+                bytes_transferred = self._coerce_transfer_int(fields[1])+                total_size = self._coerce_transfer_int(fields[2]) if len(fields) > 2 else None++                if bytes_transferred is not None:                     dep.downloaded = bytes_transferred+                if total_size is not None:+                    dep.size = total_size                  return None     @@ -474,6 +612,7 @@ class NixParser:         # Only set error if it's a real error message         if level <= 0 and msg and ("error:" in msg.lower() or "failed" in msg.lower()):             self.state.error = msg+            self._mark_failed_builds(msg)             return f"ERROR: {msg}"                  # Level 3 = info about paths
An unhandled error has occurred. Reload 🗙